bantamkit-mcp 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- package/package.json +45 -0
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error base the Python runtime exposes from `bantamkit.client` as `BantamError`.
|
|
3
|
+
*
|
|
4
|
+
* It lives in its own module here rather than in a port of `client.py`, because the
|
|
5
|
+
* prep probe measured that `client.py` contributes exactly two things to the MCP
|
|
6
|
+
* surface — this base class and the `Tool` dataclass — and nothing else in that file
|
|
7
|
+
* is reachable from the ten tools.
|
|
8
|
+
*/
|
|
9
|
+
export declare class BantamError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The error base the Python runtime exposes from `bantamkit.client` as `BantamError`.
|
|
3
|
+
*
|
|
4
|
+
* It lives in its own module here rather than in a port of `client.py`, because the
|
|
5
|
+
* prep probe measured that `client.py` contributes exactly two things to the MCP
|
|
6
|
+
* surface — this base class and the `Tool` dataclass — and nothing else in that file
|
|
7
|
+
* is reachable from the ten tools.
|
|
8
|
+
*/
|
|
9
|
+
export class BantamError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
|
|
3
|
+
* `yes` -> the default file inside the memory store. Anything else is taken as the literal
|
|
4
|
+
* path of the log file.
|
|
5
|
+
*/
|
|
6
|
+
export declare const EVENT_LOG_ENV = "BANTAMKIT_EVENT_LOG";
|
|
7
|
+
/**
|
|
8
|
+
* Relative to the memory store root. NOT `facts/`, NOT `archive/`, NOT `index.md`, and not
|
|
9
|
+
* a `*.md` name anywhere — see `defaultPath` for the measured scan boundary.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_RELATIVE_PATH: readonly string[];
|
|
12
|
+
/**
|
|
13
|
+
* The live file is rotated before it would exceed this many bytes; exactly one previous
|
|
14
|
+
* generation is kept, at `<path>.1`. On-disk ceiling for the pair: 2 MiB, plus at most one
|
|
15
|
+
* oversized record (a record is never split).
|
|
16
|
+
*/
|
|
17
|
+
export declare const CAP_BYTES: number;
|
|
18
|
+
/**
|
|
19
|
+
* Record schema version. Bump only when a key is added, removed or renamed; both runtimes
|
|
20
|
+
* move together.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SCHEMA_VERSION = 1;
|
|
23
|
+
/** What a `detail` object is allowed to hold: a number, a bool, or a closed-set token. */
|
|
24
|
+
export type DetailValue = number | boolean | string;
|
|
25
|
+
/**
|
|
26
|
+
* `2026-08-24T09:12:33.412Z` — and it is `toISOString()` itself, not a format string.
|
|
27
|
+
*
|
|
28
|
+
* The reference builds this by hand out of an integer millisecond count precisely so that
|
|
29
|
+
* it agrees with THIS call, and `test_eventlog.py::test_the_timestamp_is_what_javascript_
|
|
30
|
+
* writes` spawns a real `node` to compare the two over seven samples including `0`, `1`,
|
|
31
|
+
* `999`, `1000` and `2000000000123`. Hand-building it here would drop the one anchor the
|
|
32
|
+
* Python side pinned itself to — a Node implementation that assembled the same digits and
|
|
33
|
+
* drifted at a boundary would leave both sides agreeing with each other's mistake.
|
|
34
|
+
* `test/eventlog.test.mjs` mirrors the seven samples against literals for that reason.
|
|
35
|
+
*/
|
|
36
|
+
export declare function formatTimestamp(epochMs: number): string;
|
|
37
|
+
/**
|
|
38
|
+
* One record as the bytes that go on disk, LF included.
|
|
39
|
+
*
|
|
40
|
+
* KEY ORDER IS FIXED AND IS PART OF THE CONTRACT: `v`, `ts`, `tool`, `outcome`, `detail`.
|
|
41
|
+
* Inside `detail` the keys are SORTED, so neither runtime needs a per-tool ordering table
|
|
42
|
+
* to agree. `detail` is always present, `{}` when there is nothing to say, because an
|
|
43
|
+
* optional key is a second shape to port.
|
|
44
|
+
*
|
|
45
|
+
* `JSON.stringify` is the serialiser the contract names: it already emits the compact
|
|
46
|
+
* separators Python needs `separators=(",", ":")` for, and it does not escape non-ASCII,
|
|
47
|
+
* which is what Python needs `ensure_ascii=False` for. The insertion order of a plain
|
|
48
|
+
* object with string keys is the emitted order, so building the object in contract order
|
|
49
|
+
* IS the contract. `cmpCodepoint` rather than a bare `sort()`: Python's `sorted()` orders
|
|
50
|
+
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
51
|
+
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
52
|
+
* that a fact about the data instead of a difference between the runtimes.
|
|
53
|
+
*/
|
|
54
|
+
export declare function encodeRecord(epochMs: number, tool: string, outcome: string, detail: Readonly<Record<string, DetailValue>>): Buffer;
|
|
55
|
+
/**
|
|
56
|
+
* `<store>/events/mcp.jsonl` — provably outside everything `MemoryStore` reads.
|
|
57
|
+
*
|
|
58
|
+
* THE SCAN BOUNDARY, MEASURED IN `memory/store.ts` RATHER THAN ASSUMED. A store reads
|
|
59
|
+
* exactly three things: `<store>/facts/` listed and filtered with `matchesMd`,
|
|
60
|
+
* `<store>/archive/` listed and filtered the same way, and `<store>/index.md`.
|
|
61
|
+
* `memory/layers.ts::countFacts` uses the same `facts/` filter.
|
|
62
|
+
*
|
|
63
|
+
* `events/mcp.jsonl` misses every one of them, and on three independent counts, not one:
|
|
64
|
+
* it is not in `facts/`, not in `archive/`, and its name does not match `*.md` under any
|
|
65
|
+
* case folding — which matters because `fnmatch` is CASE-INSENSITIVE ON WINDOWS, so an
|
|
66
|
+
* `events/MCP.MD` would have been found there and not here.
|
|
67
|
+
*/
|
|
68
|
+
export declare function defaultPath(storeRoot: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* Turn the environment's answer into a file path, or `null` for disabled.
|
|
71
|
+
*
|
|
72
|
+
* DISABLED IS THE DEFAULT, and the reason is measured rather than stylistic:
|
|
73
|
+
* `node tools/conformance/run.mjs --all` reads the operator's LIVE memory store, by design
|
|
74
|
+
* and read-only. A log that were on by default would turn that read into a write against
|
|
75
|
+
* real user data every time the suite runs. An operator diagnostic opts in; it does not
|
|
76
|
+
* arrive uninvited inside somebody's memory store.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolvePath(storeRoot: string, raw: string | undefined | null): string | null;
|
|
79
|
+
/** Append-only JSONL sink for tool outcomes. Disabled unless it was given a path. */
|
|
80
|
+
export declare class EventLog {
|
|
81
|
+
readonly path: string | null;
|
|
82
|
+
readonly capBytes: number;
|
|
83
|
+
private readonly clock;
|
|
84
|
+
/**
|
|
85
|
+
* Set the first time `record` swallows a system error, and never cleared.
|
|
86
|
+
*
|
|
87
|
+
* FAILING TO LOG STILL NEVER FAILS THE TOOL — that contract is unchanged and nothing here
|
|
88
|
+
* throws. What the flag buys is that the failure stops being INVISIBLE. An operator who
|
|
89
|
+
* turned the log on and is getting nothing is the one person who cannot tell "no records
|
|
90
|
+
* because nothing happened" from "no records because the path is unwritable", and the log
|
|
91
|
+
* is the one channel that cannot report its own silence. `mcp/status.ts`'s
|
|
92
|
+
* `eventLogCondition` reads this and says so on the tool surface instead.
|
|
93
|
+
*
|
|
94
|
+
* NOT CLEARED BY A LATER SUCCESS, deliberately: a log with a hole in it is not a log to
|
|
95
|
+
* read as complete, and the next write succeeding does not put the missing records back.
|
|
96
|
+
* It is a bool and not a count for the metadata rule's sake — a count would still be
|
|
97
|
+
* metadata, but nothing reads one, and an unused number is a second thing to keep true in
|
|
98
|
+
* two runtimes.
|
|
99
|
+
*/
|
|
100
|
+
writeFailed: boolean;
|
|
101
|
+
constructor(path: string | null, capBytes?: number, clock?: () => number);
|
|
102
|
+
static fromEnv(storeRoot: string, env?: Record<string, string | undefined>, clock?: () => number): EventLog;
|
|
103
|
+
get enabled(): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Append one record, or silently do nothing. NEVER throws, NEVER writes a stream.
|
|
106
|
+
*
|
|
107
|
+
* The swallowed class is the whole failure contract: an unwritable store, a full disk, a
|
|
108
|
+
* path whose parent is a file, a revoked permission. `encodeRecord` runs OUTSIDE it — a
|
|
109
|
+
* `TypeError` there is a bug in a caller's `detail`, not a disk that said no.
|
|
110
|
+
*/
|
|
111
|
+
record(tool: string, outcome: string, detail?: Readonly<Record<string, DetailValue>>): void;
|
|
112
|
+
/**
|
|
113
|
+
* Record that a handler threw, by exception TYPE ONLY.
|
|
114
|
+
*
|
|
115
|
+
* The constructor name and nothing else. `String(err)` is where the host already leaked
|
|
116
|
+
* an argument value to disk (see the module comment); a class name is a fact about the
|
|
117
|
+
* code, never about the call.
|
|
118
|
+
*/
|
|
119
|
+
raised(tool: string, error: unknown): void;
|
|
120
|
+
/**
|
|
121
|
+
* Rotate if this record would cross the cap, then append as BYTES.
|
|
122
|
+
*
|
|
123
|
+
* A `Buffer`, not a string: the reference opens the file in binary append mode because a
|
|
124
|
+
* text-mode write translates `\n` to `os.linesep`, which is `\r\n` on Windows, and a
|
|
125
|
+
* reader byte-comparing the file would see a different stream on a different platform.
|
|
126
|
+
* `appendFileSync` over a `Buffer` translates nothing on any platform, which is the same
|
|
127
|
+
* guarantee arrived at from the other side.
|
|
128
|
+
*
|
|
129
|
+
* THE ROTATION RULE, stated as a number: if the live file already holds bytes and
|
|
130
|
+
* appending this record would take it past `capBytes` (1048576), the live file is MOVED
|
|
131
|
+
* to `<path>.1`, replacing any previous generation, and a fresh file starts. So the live
|
|
132
|
+
* file never exceeds the cap and the pair never exceeds twice it. A single record larger
|
|
133
|
+
* than the cap is written whole rather than split — records here are a few hundred bytes
|
|
134
|
+
* by construction, so that branch is a statement of intent, not a live path.
|
|
135
|
+
*/
|
|
136
|
+
private append;
|
|
137
|
+
}
|
package/dist/eventlog.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A JSONL event log that records ONLY the outcomes the MCP host cannot see.
|
|
3
|
+
*
|
|
4
|
+
* The Node half of `runtime-py/src/bantamkit/eventlog.py`. The CONTRACT is
|
|
5
|
+
* `docs/eventlog.md`, not either implementation: record shape, key order, timestamp
|
|
6
|
+
* format, file location and rotation rule are specified there and a conformance case in
|
|
7
|
+
* `tools/conformance/suites/wire.mjs` byte-compares the two files a real session writes.
|
|
8
|
+
* Change the contract there and in both runtimes, or not at all.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS, MEASURED BEFORE IT WAS WRITTEN. Claude Code already persists an MCP log
|
|
11
|
+
* per project holding the tool NAME, the SUCCESS BIT, the DURATION and the `sessionId` on
|
|
12
|
+
* every line. None of those four is a field here, because a record that duplicated them
|
|
13
|
+
* would cost an operator disk and buy nothing. What the host cannot see is the outcome
|
|
14
|
+
* decided INSIDE a component and then flattened into one reply string it reports as
|
|
15
|
+
* "completed successfully": a `memory_save` that deduped, a `memory_save` the budget
|
|
16
|
+
* refused, a `shiftwork_clock_in` that answered `escalate`, a `memory_recall` that came
|
|
17
|
+
* back empty and WHY.
|
|
18
|
+
*
|
|
19
|
+
* THE PROPERTY THAT MAKES THIS NON-VACUOUS: **an `outcome` value is a value the code
|
|
20
|
+
* already computed as a decision — never a match against the reply text.**
|
|
21
|
+
* `SaveOutcome.status === 'duplicate'` is a decision; `reply.startsWith('similar memory')`
|
|
22
|
+
* is a re-derivation that breaks the day someone improves the wording, and it would make
|
|
23
|
+
* this file a second, worse copy of the string the host already stores. The gate is
|
|
24
|
+
* `test/eventlog.test.mjs`'s `the record does not move when the reply wording does`, and
|
|
25
|
+
* its Python mirror `test_eventlog.py::test_the_record_does_not_move_when_the_reply_
|
|
26
|
+
* wording_does`.
|
|
27
|
+
*
|
|
28
|
+
* THREE HARD RULES, each with the failure it prevents:
|
|
29
|
+
*
|
|
30
|
+
* * **File only, never a stream, not once.** `test/server.test.mjs` asserts a clean
|
|
31
|
+
* session writes nothing to stderr and `wire.mjs` byte-compares both runtimes' streams;
|
|
32
|
+
* one stray write breaks the wire suite. Nothing in this module names `process.stdout`,
|
|
33
|
+
* `process.stderr` or `console`.
|
|
34
|
+
* * **Metadata only.** Never a tool argument's value, never a memory body, never a
|
|
35
|
+
* validated output, never a query, never a document row. Six of the eleven tools take
|
|
36
|
+
* unbounded free text and five take absolute paths (`bantamkit_read`'s `path` is one and
|
|
37
|
+
* `skill_audit`'s `root` is another; each record carries counts and tokens from a closed
|
|
38
|
+
* set — never the path, never a part name, never a skill id).
|
|
39
|
+
* Every value written here is an ASCII token from a closed set, a number, or a boolean.
|
|
40
|
+
* * **Never `String(err)`.** Only the constructor name. This is not hypothetical: the host
|
|
41
|
+
* itself has already persisted `input_value={'schema_path': '/Users/k...
|
|
42
|
+
* e-loop/checkpoint.json'}` to disk from a `validate_json` pydantic failure — the
|
|
43
|
+
* argument value leaked through the EXCEPTION TEXT, truncated at 50 characters by
|
|
44
|
+
* pydantic rather than by any deliberate policy. bantamkit must not widen that hole.
|
|
45
|
+
*
|
|
46
|
+
* FAILING TO LOG NEVER FAILS THE TOOL. Every filesystem call is inside one catch that
|
|
47
|
+
* swallows the `OSError` class — a Node system error, the one carrying a string `code` —
|
|
48
|
+
* and returns. A read-only store, a full disk, a permission error, a path whose parent is
|
|
49
|
+
* a file: the record disappears, the call does not. `encodeRecord` is deliberately OUTSIDE
|
|
50
|
+
* it, and an error without a system `code` is RETHROWN: that would be a programming error
|
|
51
|
+
* in this module, and hiding it would leave the log silently empty forever with nothing to
|
|
52
|
+
* notice — the failure mode a diagnostic can least afford.
|
|
53
|
+
*/
|
|
54
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
|
|
55
|
+
import { cmpCodepoint, osErrorClassName, PyOSError, pyJoin, pyParent } from './memory/pyfs.js';
|
|
56
|
+
/**
|
|
57
|
+
* Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
|
|
58
|
+
* `yes` -> the default file inside the memory store. Anything else is taken as the literal
|
|
59
|
+
* path of the log file.
|
|
60
|
+
*/
|
|
61
|
+
export const EVENT_LOG_ENV = 'BANTAMKIT_EVENT_LOG';
|
|
62
|
+
/**
|
|
63
|
+
* Relative to the memory store root. NOT `facts/`, NOT `archive/`, NOT `index.md`, and not
|
|
64
|
+
* a `*.md` name anywhere — see `defaultPath` for the measured scan boundary.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_RELATIVE_PATH = ['events', 'mcp.jsonl'];
|
|
67
|
+
/**
|
|
68
|
+
* The live file is rotated before it would exceed this many bytes; exactly one previous
|
|
69
|
+
* generation is kept, at `<path>.1`. On-disk ceiling for the pair: 2 MiB, plus at most one
|
|
70
|
+
* oversized record (a record is never split).
|
|
71
|
+
*/
|
|
72
|
+
export const CAP_BYTES = 1 << 20; // 1048576
|
|
73
|
+
/**
|
|
74
|
+
* Record schema version. Bump only when a key is added, removed or renamed; both runtimes
|
|
75
|
+
* move together.
|
|
76
|
+
*/
|
|
77
|
+
export const SCHEMA_VERSION = 1;
|
|
78
|
+
const OFF = new Set(['', '0', 'off', 'false', 'no']);
|
|
79
|
+
const ON = new Set(['1', 'on', 'true', 'yes']);
|
|
80
|
+
/** Integer milliseconds since the epoch — the unit `Date.now()` returns, and Python's
|
|
81
|
+
* `time.time_ns() // 1_000_000`. Deliberately not a float seconds value: the two runtimes
|
|
82
|
+
* would round apart at the millisecond boundary and the timestamp is a compared field. */
|
|
83
|
+
function defaultClock() {
|
|
84
|
+
return Date.now();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `2026-08-24T09:12:33.412Z` — and it is `toISOString()` itself, not a format string.
|
|
88
|
+
*
|
|
89
|
+
* The reference builds this by hand out of an integer millisecond count precisely so that
|
|
90
|
+
* it agrees with THIS call, and `test_eventlog.py::test_the_timestamp_is_what_javascript_
|
|
91
|
+
* writes` spawns a real `node` to compare the two over seven samples including `0`, `1`,
|
|
92
|
+
* `999`, `1000` and `2000000000123`. Hand-building it here would drop the one anchor the
|
|
93
|
+
* Python side pinned itself to — a Node implementation that assembled the same digits and
|
|
94
|
+
* drifted at a boundary would leave both sides agreeing with each other's mistake.
|
|
95
|
+
* `test/eventlog.test.mjs` mirrors the seven samples against literals for that reason.
|
|
96
|
+
*/
|
|
97
|
+
export function formatTimestamp(epochMs) {
|
|
98
|
+
return new Date(epochMs).toISOString();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* One record as the bytes that go on disk, LF included.
|
|
102
|
+
*
|
|
103
|
+
* KEY ORDER IS FIXED AND IS PART OF THE CONTRACT: `v`, `ts`, `tool`, `outcome`, `detail`.
|
|
104
|
+
* Inside `detail` the keys are SORTED, so neither runtime needs a per-tool ordering table
|
|
105
|
+
* to agree. `detail` is always present, `{}` when there is nothing to say, because an
|
|
106
|
+
* optional key is a second shape to port.
|
|
107
|
+
*
|
|
108
|
+
* `JSON.stringify` is the serialiser the contract names: it already emits the compact
|
|
109
|
+
* separators Python needs `separators=(",", ":")` for, and it does not escape non-ASCII,
|
|
110
|
+
* which is what Python needs `ensure_ascii=False` for. The insertion order of a plain
|
|
111
|
+
* object with string keys is the emitted order, so building the object in contract order
|
|
112
|
+
* IS the contract. `cmpCodepoint` rather than a bare `sort()`: Python's `sorted()` orders
|
|
113
|
+
* by CODE POINT and JavaScript's default comparator by UTF-16 code UNIT, and those two
|
|
114
|
+
* disagree above the BMP. Every key written today is ASCII; the comparator is what keeps
|
|
115
|
+
* that a fact about the data instead of a difference between the runtimes.
|
|
116
|
+
*/
|
|
117
|
+
export function encodeRecord(epochMs, tool, outcome, detail) {
|
|
118
|
+
const sorted = {};
|
|
119
|
+
for (const key of Object.keys(detail).sort(cmpCodepoint))
|
|
120
|
+
sorted[key] = detail[key];
|
|
121
|
+
const record = { v: SCHEMA_VERSION, ts: formatTimestamp(epochMs), tool, outcome, detail: sorted };
|
|
122
|
+
return Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `<store>/events/mcp.jsonl` — provably outside everything `MemoryStore` reads.
|
|
126
|
+
*
|
|
127
|
+
* THE SCAN BOUNDARY, MEASURED IN `memory/store.ts` RATHER THAN ASSUMED. A store reads
|
|
128
|
+
* exactly three things: `<store>/facts/` listed and filtered with `matchesMd`,
|
|
129
|
+
* `<store>/archive/` listed and filtered the same way, and `<store>/index.md`.
|
|
130
|
+
* `memory/layers.ts::countFacts` uses the same `facts/` filter.
|
|
131
|
+
*
|
|
132
|
+
* `events/mcp.jsonl` misses every one of them, and on three independent counts, not one:
|
|
133
|
+
* it is not in `facts/`, not in `archive/`, and its name does not match `*.md` under any
|
|
134
|
+
* case folding — which matters because `fnmatch` is CASE-INSENSITIVE ON WINDOWS, so an
|
|
135
|
+
* `events/MCP.MD` would have been found there and not here.
|
|
136
|
+
*/
|
|
137
|
+
export function defaultPath(storeRoot) {
|
|
138
|
+
return pyJoin(storeRoot, ...DEFAULT_RELATIVE_PATH);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Turn the environment's answer into a file path, or `null` for disabled.
|
|
142
|
+
*
|
|
143
|
+
* DISABLED IS THE DEFAULT, and the reason is measured rather than stylistic:
|
|
144
|
+
* `node tools/conformance/run.mjs --all` reads the operator's LIVE memory store, by design
|
|
145
|
+
* and read-only. A log that were on by default would turn that read into a write against
|
|
146
|
+
* real user data every time the suite runs. An operator diagnostic opts in; it does not
|
|
147
|
+
* arrive uninvited inside somebody's memory store.
|
|
148
|
+
*/
|
|
149
|
+
export function resolvePath(storeRoot, raw) {
|
|
150
|
+
if (raw === undefined || raw === null)
|
|
151
|
+
return null;
|
|
152
|
+
const token = raw.trim();
|
|
153
|
+
if (OFF.has(token.toLowerCase()))
|
|
154
|
+
return null;
|
|
155
|
+
if (ON.has(token.toLowerCase()))
|
|
156
|
+
return defaultPath(storeRoot);
|
|
157
|
+
return token;
|
|
158
|
+
}
|
|
159
|
+
/** Append-only JSONL sink for tool outcomes. Disabled unless it was given a path. */
|
|
160
|
+
export class EventLog {
|
|
161
|
+
path;
|
|
162
|
+
capBytes;
|
|
163
|
+
clock;
|
|
164
|
+
/**
|
|
165
|
+
* Set the first time `record` swallows a system error, and never cleared.
|
|
166
|
+
*
|
|
167
|
+
* FAILING TO LOG STILL NEVER FAILS THE TOOL — that contract is unchanged and nothing here
|
|
168
|
+
* throws. What the flag buys is that the failure stops being INVISIBLE. An operator who
|
|
169
|
+
* turned the log on and is getting nothing is the one person who cannot tell "no records
|
|
170
|
+
* because nothing happened" from "no records because the path is unwritable", and the log
|
|
171
|
+
* is the one channel that cannot report its own silence. `mcp/status.ts`'s
|
|
172
|
+
* `eventLogCondition` reads this and says so on the tool surface instead.
|
|
173
|
+
*
|
|
174
|
+
* NOT CLEARED BY A LATER SUCCESS, deliberately: a log with a hole in it is not a log to
|
|
175
|
+
* read as complete, and the next write succeeding does not put the missing records back.
|
|
176
|
+
* It is a bool and not a count for the metadata rule's sake — a count would still be
|
|
177
|
+
* metadata, but nothing reads one, and an unused number is a second thing to keep true in
|
|
178
|
+
* two runtimes.
|
|
179
|
+
*/
|
|
180
|
+
writeFailed = false;
|
|
181
|
+
constructor(path, capBytes = CAP_BYTES, clock = defaultClock) {
|
|
182
|
+
this.path = path;
|
|
183
|
+
this.capBytes = capBytes;
|
|
184
|
+
this.clock = clock;
|
|
185
|
+
}
|
|
186
|
+
static fromEnv(storeRoot, env = process.env, clock = defaultClock) {
|
|
187
|
+
return new EventLog(resolvePath(storeRoot, env[EVENT_LOG_ENV]), CAP_BYTES, clock);
|
|
188
|
+
}
|
|
189
|
+
get enabled() {
|
|
190
|
+
return this.path !== null;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Append one record, or silently do nothing. NEVER throws, NEVER writes a stream.
|
|
194
|
+
*
|
|
195
|
+
* The swallowed class is the whole failure contract: an unwritable store, a full disk, a
|
|
196
|
+
* path whose parent is a file, a revoked permission. `encodeRecord` runs OUTSIDE it — a
|
|
197
|
+
* `TypeError` there is a bug in a caller's `detail`, not a disk that said no.
|
|
198
|
+
*/
|
|
199
|
+
record(tool, outcome, detail = {}) {
|
|
200
|
+
if (this.path === null)
|
|
201
|
+
return;
|
|
202
|
+
const payload = encodeRecord(this.clock(), tool, outcome, detail);
|
|
203
|
+
try {
|
|
204
|
+
this.append(this.path, payload);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
if (!isSystemError(e))
|
|
208
|
+
throw e;
|
|
209
|
+
// Set HERE and nowhere else, so the flag means exactly "a record was composed, offered
|
|
210
|
+
// to the filesystem, and lost" — never "the log is off" (which returns above, before
|
|
211
|
+
// any I/O) and never "nothing has been logged yet".
|
|
212
|
+
this.writeFailed = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Record that a handler threw, by exception TYPE ONLY.
|
|
217
|
+
*
|
|
218
|
+
* The constructor name and nothing else. `String(err)` is where the host already leaked
|
|
219
|
+
* an argument value to disk (see the module comment); a class name is a fact about the
|
|
220
|
+
* code, never about the call.
|
|
221
|
+
*/
|
|
222
|
+
raised(tool, error) {
|
|
223
|
+
this.record(tool, 'raised', { type: typeNameOf(error) });
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Rotate if this record would cross the cap, then append as BYTES.
|
|
227
|
+
*
|
|
228
|
+
* A `Buffer`, not a string: the reference opens the file in binary append mode because a
|
|
229
|
+
* text-mode write translates `\n` to `os.linesep`, which is `\r\n` on Windows, and a
|
|
230
|
+
* reader byte-comparing the file would see a different stream on a different platform.
|
|
231
|
+
* `appendFileSync` over a `Buffer` translates nothing on any platform, which is the same
|
|
232
|
+
* guarantee arrived at from the other side.
|
|
233
|
+
*
|
|
234
|
+
* THE ROTATION RULE, stated as a number: if the live file already holds bytes and
|
|
235
|
+
* appending this record would take it past `capBytes` (1048576), the live file is MOVED
|
|
236
|
+
* to `<path>.1`, replacing any previous generation, and a fresh file starts. So the live
|
|
237
|
+
* file never exceeds the cap and the pair never exceeds twice it. A single record larger
|
|
238
|
+
* than the cap is written whole rather than split — records here are a few hundred bytes
|
|
239
|
+
* by construction, so that branch is a statement of intent, not a live path.
|
|
240
|
+
*/
|
|
241
|
+
append(path, payload) {
|
|
242
|
+
// `pyParent` and not `node:path`'s `dirname`: this module is reached from a Windows
|
|
243
|
+
// host through the same code, and the reference's `Path(...).parent` is the spelling
|
|
244
|
+
// that understands a drive letter. `.`'s parent is `.` in both, so a bare filename
|
|
245
|
+
// makes the directory that already exists rather than reaching for the root.
|
|
246
|
+
mkdirSync(pyParent(path), { recursive: true });
|
|
247
|
+
let size = 0;
|
|
248
|
+
try {
|
|
249
|
+
size = statSync(path).size;
|
|
250
|
+
}
|
|
251
|
+
catch (e) {
|
|
252
|
+
if (!isSystemError(e) || e.code !== 'ENOENT')
|
|
253
|
+
throw e;
|
|
254
|
+
}
|
|
255
|
+
if (size && size + payload.length > this.capBytes) {
|
|
256
|
+
renameSync(path, `${path}.1`);
|
|
257
|
+
}
|
|
258
|
+
appendFileSync(path, payload);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* `type(exc).__name__`'s analogue, and it must never fall back to the message.
|
|
263
|
+
*
|
|
264
|
+
* `constructor.name` for anything constructed, the `name` property for a bare `Error`
|
|
265
|
+
* subclass whose constructor was minified away, and the literal `'thrown'` for a value
|
|
266
|
+
* that is not an object at all — `throw 'boom'` carries no type to name, and putting the
|
|
267
|
+
* VALUE there would be exactly the leak this function exists to prevent.
|
|
268
|
+
*
|
|
269
|
+
* `PyOSError` IS THE ONE CLASS WHOSE JAVASCRIPT NAME IS NOT THE ANSWER, and it is the
|
|
270
|
+
* commonest exception a handler here can throw. CPython has no `PyOSError`: `OSError.
|
|
271
|
+
* __new__` picks a subclass off the errno, so the reference records
|
|
272
|
+
* `NotADirectoryError` for an `ENOTDIR` where `constructor.name` records `PyOSError` for
|
|
273
|
+
* every errno there is. That is a difference in a byte-compared field with no reason
|
|
274
|
+
* behind it — and the port had already decided the question the other way in
|
|
275
|
+
* `memory/pyfs.ts`, which is why the answer comes from `osErrorClassName` and not from a
|
|
276
|
+
* second table here. An errno the table does not list is `OSError`, CPython's own default.
|
|
277
|
+
*/
|
|
278
|
+
function typeNameOf(error) {
|
|
279
|
+
if (error instanceof PyOSError)
|
|
280
|
+
return osErrorClassName(error.code);
|
|
281
|
+
if (typeof error === 'object' && error !== null) {
|
|
282
|
+
const ctor = error.constructor;
|
|
283
|
+
if (ctor && typeof ctor.name === 'string' && ctor.name !== '')
|
|
284
|
+
return ctor.name;
|
|
285
|
+
return 'Object';
|
|
286
|
+
}
|
|
287
|
+
return 'thrown';
|
|
288
|
+
}
|
|
289
|
+
/** The `OSError` class, as Node spells it: a system error carries a string `code`. */
|
|
290
|
+
function isSystemError(error) {
|
|
291
|
+
return (typeof error === 'object' &&
|
|
292
|
+
error !== null &&
|
|
293
|
+
typeof error.code === 'string');
|
|
294
|
+
}
|