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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. package/package.json +45 -0
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** name (with its `;` where the standard has one) -> replacement text. */
2
+ export declare const HTML5_ENTITIES: ReadonlyMap<string, string>;