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,758 @@
1
+ /**
2
+ * An analyst that reads BOTH MCP logs and says only what the pair can support.
3
+ *
4
+ * The Node half of `runtime-py/src/bantamkit/mcpreport.py`. THE CONTRACT IS
5
+ * `docs/mcpreport.md`, not either implementation: the section order, the field names, the
6
+ * ordering rules and the number formats are specified there, and
7
+ * `tools/conformance/suites/mcpreport.mjs` runs both CLIs over one synthetic fixture pair
8
+ * and compares the two reports byte for byte. Change the contract there and in both
9
+ * runtimes, or not at all. This file was written from that page.
10
+ *
11
+ * TWO SOURCES, NEITHER OF WHICH ANSWERS A USEFUL QUESTION ALONE.
12
+ *
13
+ * *Source A, the host's.* `<root>/<cwd-slug>/mcp-logs-bantamkit/<ISO>.jsonl`, where
14
+ * `<root>` is `~/Library/Caches/claude-cli-nodejs` ON macOS and `<cwd-slug>` is the launch
15
+ * cwd with `/` turned into `-`. It carries the tool NAME, the ok/fail BIT, the DURATION,
16
+ * the `sessionId` and the `cwd`, and it is the ONLY place a `sessionId` exists — the server
17
+ * process never sees one, so nothing bantamkit writes can carry it.
18
+ *
19
+ * *Source B, bantamkit's own.* `<store>/events/mcp.jsonl`, contract in `docs/eventlog.md`.
20
+ * It carries the OUTCOME the host flattens into the single word "success" — `saved` against
21
+ * `duplicate` against `refused-budget`, `escalate`, which layer answered — and deliberately
22
+ * no `sessionId`, no pid, no path.
23
+ *
24
+ * THE JOIN IS `(ts, tool)` AND IT IS APPROXIMATE. The host stamps when it DISPATCHED;
25
+ * bantamkit stamps when it DECIDED, and under concurrent in-flight calls the two files can
26
+ * disagree about ORDER, not merely about milliseconds. So a timestamp pair is EVIDENCE,
27
+ * never a key: candidate edges are treated as a bipartite graph and only a component of
28
+ * exactly one event and exactly one call is a pair. Everything else is reported with both
29
+ * of its group sizes, and CONSERVATION is the invariant — every event lands in exactly one
30
+ * of matched / ambiguous / unmatched, and so does every call.
31
+ *
32
+ * THREE HARD RULES, each with the failure it prevents:
33
+ *
34
+ * * **The host's log is READ-ONLY.** Nothing here opens a file for writing, creates a
35
+ * directory under the root, rotates or prunes. It belongs to another program.
36
+ * * **Only `mcp-logs-bantamkit`.** The slug directories are listed and each is asked for
37
+ * that directory BY NAME. No `mcp-logs-*` pattern is ever built — `mcp-logs-clickup` and
38
+ * sixteen other servers sit right beside it.
39
+ * * **No host text is ever re-emitted.** The guard is STRUCTURAL rather than a filter:
40
+ * `HostRecord` has no field that can hold host text. What survives parsing is a `kind`
41
+ * token from this module's own vocabulary, a validated tool name, a validated session id
42
+ * and integers, so there is no code path that could print a host message. Measured: the
43
+ * host has already persisted `input_value={'schema_path': '/Users/k...
44
+ * e-loop/checkpoint.json'}` from a pydantic failure — an argument value that leaked
45
+ * through EXCEPTION TEXT. `error` records are counted and classified, never quoted.
46
+ *
47
+ * NO CLOCK AND NO FLOAT. The report embeds no "now", so the same two files give the same
48
+ * bytes; and `p50` is the LOWER MEDIAN, an element of the input, so no float is ever
49
+ * formatted and the two runtimes cannot round apart at the edges. Both are what make the
50
+ * byte comparison in the conformance suite possible at all.
51
+ */
52
+ import { readFileSync } from 'node:fs';
53
+ import { formatTimestamp, resolvePath as resolveEventLogFile } from './eventlog.js';
54
+ import { pyStrip } from './memory/factfile.js';
55
+ import { discoverProjectStore } from './memory/layers.js';
56
+ import { cmpCodepoint, pyCwd, pyHome, pyIsDir, pyIsFile, pyJoin, pyParent, pyScandirNames, pySuffix, sortedPathNames, } from './memory/pyfs.js';
57
+ import { parseJson, toJs } from './pyjson.js';
58
+ /**
59
+ * Overrides the platform default for the host-log root. The default is only KNOWN on macOS
60
+ * (see `defaultHostLogRoot`), so this is what makes the analyst usable at all on Linux and
61
+ * Windows — and what lets a test run against a fixture tree.
62
+ */
63
+ export const HOST_LOG_ROOT_ENV = 'BANTAMKIT_HOST_LOG_ROOT';
64
+ /**
65
+ * The ONLY directory name read under a slug directory. `mcp-logs-clickup` and sixteen
66
+ * others sit beside it; globbing `mcp-logs-*` would read another program's log.
67
+ */
68
+ export const HOST_LOG_DIRNAME = 'mcp-logs-bantamkit';
69
+ /**
70
+ * How far outside the host's `[dispatch, completion]` interval an event's `ts` may fall and
71
+ * still be a CANDIDATE. Not a tolerance for clock skew — both stamps come from one machine
72
+ * clock — but for the lag between the component deciding and the host stamping the reply it
73
+ * eventually read. Widening it produces MORE ambiguity, not more matches.
74
+ */
75
+ export const DEFAULT_WINDOW_MS = 250;
76
+ /** Report format version. Bump when a section, a field or an order changes; both runtimes move together. */
77
+ export const REPORT_VERSION = 1;
78
+ /**
79
+ * Outcomes worth asking "and then what?" about. An `escalate` that ended a session and an
80
+ * `escalate` followed by nine more calls are different facts about the same word.
81
+ */
82
+ export const FOLLOW_UP_OUTCOMES = [
83
+ 'escalate',
84
+ 'raised',
85
+ 'refused-budget',
86
+ 'refused-validation',
87
+ ];
88
+ /**
89
+ * EVERY `$` HERE IS SPELLED `\n?$`, AND THAT IS NOT DECORATION.
90
+ *
91
+ * Python's `$` matches at the end of the string OR just before a newline that ends it;
92
+ * JavaScript's `$` without the `m` flag matches only at the very end. The reference
93
+ * therefore ACCEPTS `"memory_save\n"` as a tool token and `"...5ms\n"` as a completion,
94
+ * and a port that anchored strictly would classify the same host line differently. `.` is
95
+ * left alone: it excludes `\n` in both languages, which is what keeps a message with an
96
+ * embedded newline out of the capture on both sides.
97
+ */
98
+ const TOOL_TOKEN = /^[a-z0-9_]{1,64}\n?$/;
99
+ const SESSION_TOKEN = /^[A-Za-z0-9-]{1,64}\n?$/;
100
+ const OUTCOME_TOKEN = /^[a-z-]{1,32}\n?$/;
101
+ // The closed set. Each pattern deliberately stops before any free text: the `failed after`
102
+ // arm matches the colon and NOT what follows it, so the error string is never even
103
+ // captured, let alone stored.
104
+ const CALL_START = /^Calling MCP tool: (.+)\n?$/;
105
+ const CALL_OK = /^Tool '(.+)' completed successfully in (\d+)ms\n?$/;
106
+ const CALL_FAIL = /^Tool '(.+)' failed after (\d+)s:/;
107
+ const ERROR_TOOL = /^Error executing tool ([a-z0-9_]{1,64}):/;
108
+ const TS = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z\n?$/;
109
+ const DAYS_BEFORE_MONTH = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
110
+ /**
111
+ * `2026-08-23T17:38:47.243Z` -> integer epoch milliseconds, or `null`.
112
+ *
113
+ * Hand-rolled rather than `Date.parse` for the reason the reference is hand-rolled rather
114
+ * than `datetime.fromisoformat`: each stdlib accepts a DIFFERENT wider set (`+00:00`, no
115
+ * `Z`, six fractional digits, `2026-08-23` alone), and this is a compared field. A parser
116
+ * that accepted more would put records on the timeline the host never put there. One regex
117
+ * that admits exactly the host's spelling is the same function on both sides.
118
+ *
119
+ * The arithmetic is done in integers here: no `Date` object is constructed on the way IN,
120
+ * so no local time zone can reach the answer. `formatTimestamp` is the event log's, which
121
+ * is `toISOString()` and is UTC by definition — the two are inverses over the range that
122
+ * matters.
123
+ */
124
+ export function parseTimestamp(text) {
125
+ const m = TS.exec(text);
126
+ if (m === null)
127
+ return null;
128
+ const [year, month, day, hour, minute, second, milli] = m.slice(1).map((p) => Number(p));
129
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 60) {
130
+ return null;
131
+ }
132
+ const years = year - 1970;
133
+ const leaps = (Math.floor((year - 1) / 4) - 492) -
134
+ (Math.floor((year - 1) / 100) - 19) +
135
+ (Math.floor((year - 1) / 400) - 4);
136
+ let days = years * 365 + leaps + DAYS_BEFORE_MONTH[month - 1] + (day - 1);
137
+ if (month > 2 && year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0))
138
+ days += 1;
139
+ return ((days * 24 + hour) * 60 + minute) * 60000 + second * 1000 + milli;
140
+ }
141
+ /** The inverse, reusing the event log's formatter so one spelling exists in the repo. */
142
+ export { formatTimestamp };
143
+ function clean(token, pattern) {
144
+ if (typeof token !== 'string')
145
+ return null;
146
+ return pattern.test(token) ? token : null;
147
+ }
148
+ /** `json.loads`, not `JSON.parse`: the two accept different documents (`NaN`, a BOM). */
149
+ function loadsOrNull(text) {
150
+ try {
151
+ return toJs(parseJson(text));
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ function isRecord(value) {
158
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
159
+ }
160
+ /**
161
+ * One JSONL line -> a `HostRecord`, or `null` for anything unparseable.
162
+ *
163
+ * `null` covers a blank line, a truncated tail (the host is appending while this reads), a
164
+ * record with no usable timestamp, and a record with no `debug` and no `error` string. All
165
+ * of them are counted as `unclassified` by the caller and nothing else.
166
+ */
167
+ export function parseHostLine(line) {
168
+ const text = pyStrip(line);
169
+ if (text === '')
170
+ return null;
171
+ const record = loadsOrNull(text);
172
+ if (!isRecord(record))
173
+ return null;
174
+ const stamp = record['timestamp'];
175
+ const tsMs = typeof stamp === 'string' ? parseTimestamp(stamp) : null;
176
+ if (tsMs === null)
177
+ return null;
178
+ const session = clean(record['sessionId'], SESSION_TOKEN) ?? '-';
179
+ const error = record['error'];
180
+ if (typeof error === 'string') {
181
+ const hit = ERROR_TOOL.exec(error);
182
+ return { tsMs, session, kind: 'error', tool: hit ? hit[1] : null, durationMs: null };
183
+ }
184
+ const message = record['debug'];
185
+ if (typeof message !== 'string')
186
+ return null;
187
+ let hit = CALL_START.exec(message);
188
+ if (hit !== null) {
189
+ const tool = clean(hit[1], TOOL_TOKEN);
190
+ return { tsMs, session, kind: tool ? 'call-start' : 'other', tool, durationMs: null };
191
+ }
192
+ hit = CALL_OK.exec(message);
193
+ if (hit !== null) {
194
+ const tool = clean(hit[1], TOOL_TOKEN);
195
+ if (tool === null)
196
+ return { tsMs, session, kind: 'other', tool: null, durationMs: null };
197
+ return { tsMs, session, kind: 'call-ok', tool, durationMs: Number(hit[2]) };
198
+ }
199
+ hit = CALL_FAIL.exec(message);
200
+ if (hit !== null) {
201
+ const tool = clean(hit[1], TOOL_TOKEN);
202
+ if (tool === null)
203
+ return { tsMs, session, kind: 'other', tool: null, durationMs: null };
204
+ // SECONDS here, milliseconds on the success arm. That is the host's spelling, not a bug
205
+ // in this parser: `failed after 0s` is what it writes.
206
+ return { tsMs, session, kind: 'call-fail', tool, durationMs: Number(hit[2]) * 1000 };
207
+ }
208
+ return { tsMs, session, kind: 'other', tool: null, durationMs: null };
209
+ }
210
+ /** The one timestamp the join hangs on when only one end was logged. */
211
+ export function anchorMs(call) {
212
+ return call.startMs !== null ? call.startMs : (call.endMs ?? 0);
213
+ }
214
+ /** `sorted(key=...)` over tuples of numbers and strings — Python's, so codepoint order. */
215
+ function byKey(items, key) {
216
+ return [...items].sort((a, b) => {
217
+ const x = key(a);
218
+ const y = key(b);
219
+ for (let i = 0; i < x.length; i += 1) {
220
+ const p = x[i];
221
+ const q = y[i];
222
+ if (typeof p === 'number' && typeof q === 'number') {
223
+ if (p !== q)
224
+ return p < q ? -1 : 1;
225
+ }
226
+ else {
227
+ const order = cmpCodepoint(String(p), String(q));
228
+ if (order !== 0)
229
+ return order;
230
+ }
231
+ }
232
+ return 0;
233
+ });
234
+ }
235
+ /**
236
+ * Dispatches to completions, FIFO within one `(session, tool)`.
237
+ *
238
+ * FIFO and not nearest-time, because within one session and one tool the host's own file
239
+ * order IS the dispatch order — it is one writer appending. Across sessions or across tools
240
+ * no order is assumed, which is why the queue key carries both.
241
+ *
242
+ * Returns the calls plus two honesty counters: dispatches that never completed (the process
243
+ * was killed, or the file was rotated mid-call) and completions with no dispatch in view
244
+ * (the dispatch is in an earlier file that was rotated away).
245
+ */
246
+ export function pairCalls(records) {
247
+ const pending = new Map();
248
+ const calls = [];
249
+ let orphanCompletions = 0;
250
+ for (const record of byKey(records, (r) => [r.tsMs, r.session, r.kind])) {
251
+ if (record.tool === null)
252
+ continue;
253
+ const key = `${record.session}\u0000${record.tool}`;
254
+ if (record.kind === 'call-start') {
255
+ const queue = pending.get(key);
256
+ if (queue === undefined)
257
+ pending.set(key, [record]);
258
+ else
259
+ queue.push(record);
260
+ }
261
+ else if (record.kind === 'call-ok' || record.kind === 'call-fail') {
262
+ const queue = pending.get(key) ?? [];
263
+ const start = queue.length > 0 ? queue.shift() : null;
264
+ if (start === null)
265
+ orphanCompletions += 1;
266
+ calls.push({
267
+ tool: record.tool,
268
+ session: record.session,
269
+ startMs: start ? start.tsMs : null,
270
+ endMs: record.tsMs,
271
+ ok: record.kind === 'call-ok',
272
+ durationMs: record.durationMs,
273
+ });
274
+ }
275
+ }
276
+ let incomplete = 0;
277
+ for (const queue of pending.values()) {
278
+ for (const start of queue) {
279
+ incomplete += 1;
280
+ calls.push({
281
+ tool: start.tool ?? '',
282
+ session: start.session,
283
+ startMs: start.tsMs,
284
+ endMs: null,
285
+ ok: null,
286
+ durationMs: null,
287
+ });
288
+ }
289
+ }
290
+ return {
291
+ calls: byKey(calls, (c) => [anchorMs(c), c.session, c.tool]),
292
+ incomplete,
293
+ orphanCompletions,
294
+ };
295
+ }
296
+ /**
297
+ * `~/Library/Caches/claude-cli-nodejs` — ON macOS, AND NOWHERE ELSE.
298
+ *
299
+ * Where Claude Code writes this on Linux and on Windows has NOT been measured, and a guess
300
+ * dressed as a default would produce an empty report that reads like "nothing happened". So
301
+ * off darwin this returns `null`, the report says which platform it is on and names
302
+ * `BANTAMKIT_HOST_LOG_ROOT`, and source B is reported alone. `CLAUDE.md`'s parity rule is
303
+ * satisfied by the analyst RUNNING on Windows and saying what it cannot see, not by
304
+ * pretending it looked. Lifting the limitation is a MEASUREMENT, not a code change.
305
+ */
306
+ export function defaultHostLogRoot(platform, home) {
307
+ if (platform !== 'darwin')
308
+ return null;
309
+ return pyJoin(home, 'Library', 'Caches', 'claude-cli-nodejs');
310
+ }
311
+ export function resolveHostLogRoot(env, platform = process.platform, home = pyHome()) {
312
+ const override = pyStrip(env[HOST_LOG_ROOT_ENV] ?? '');
313
+ if (override !== '')
314
+ return pyJoin(override);
315
+ return defaultHostLogRoot(platform, home);
316
+ }
317
+ /**
318
+ * Every `<root>/*​/mcp-logs-bantamkit/*.jsonl`, sorted, and NOTHING else.
319
+ *
320
+ * The slug directories are listed and each is asked for `mcp-logs-bantamkit` BY NAME. No
321
+ * `mcp-logs-*` pattern is ever built, so a sibling server's directory cannot be reached by
322
+ * this function even by accident — `mcp-logs-clickup` is right there.
323
+ */
324
+ export function hostLogFiles(root) {
325
+ const found = [];
326
+ let slugs;
327
+ try {
328
+ slugs = sortedPathNames(pyScandirNames(root));
329
+ }
330
+ catch {
331
+ return found;
332
+ }
333
+ for (const slug of slugs) {
334
+ const ours = pyJoin(root, slug, HOST_LOG_DIRNAME);
335
+ if (!pyIsDir(ours))
336
+ continue;
337
+ try {
338
+ const names = pyScandirNames(ours).filter((name) => pySuffix(name) === '.jsonl' && pyIsFile(pyJoin(ours, name)));
339
+ for (const name of sortedPathNames(names))
340
+ found.push(pyJoin(ours, name));
341
+ }
342
+ catch {
343
+ continue;
344
+ }
345
+ }
346
+ return found;
347
+ }
348
+ /**
349
+ * `Path.read_text(encoding="utf-8", errors="replace")` — including its universal newlines.
350
+ *
351
+ * `errors="replace"` and not strict: this reads another program's file while that program
352
+ * is appending to it, so a torn multi-byte character at the tail is expected and must not
353
+ * end the report. A line that came back with replacement characters fails `json.loads` on
354
+ * both sides and is counted `unclassified`, which is the honest answer for it.
355
+ */
356
+ function readTextReplace(path) {
357
+ const raw = readFileSync(path);
358
+ return new TextDecoder('utf-8', { fatal: false, ignoreBOM: true })
359
+ .decode(raw)
360
+ .replace(/\r\n?/g, '\n');
361
+ }
362
+ const EMPTY_SCAN = {
363
+ dirs: 0,
364
+ files: 0,
365
+ records: 0,
366
+ lifecycle: 0,
367
+ unclassified: 0,
368
+ errors: 0,
369
+ errorsWithTool: 0,
370
+ calls: [],
371
+ incomplete: 0,
372
+ orphanCompletions: 0,
373
+ };
374
+ /**
375
+ * `platform` is carried into the scan, not read at render time, so an injected platform
376
+ * reaches the "unknown on this platform" line and a test can see it.
377
+ */
378
+ export function scanHostLog(root, platform = process.platform) {
379
+ if (root === null) {
380
+ return { root: null, missing: 'unknown-platform', platform, ...EMPTY_SCAN };
381
+ }
382
+ if (!pyIsDir(root)) {
383
+ return { root, missing: 'not-found', platform, ...EMPTY_SCAN };
384
+ }
385
+ const files = hostLogFiles(root);
386
+ const dirs = new Set(files.map((path) => pyParent(path))).size;
387
+ const records = [];
388
+ let total = 0;
389
+ let lifecycle = 0;
390
+ let unclassified = 0;
391
+ let errors = 0;
392
+ let errorsWithTool = 0;
393
+ for (const path of files) {
394
+ let text;
395
+ try {
396
+ text = readTextReplace(path);
397
+ }
398
+ catch {
399
+ continue;
400
+ }
401
+ for (const line of text.split('\n')) {
402
+ if (pyStrip(line) === '')
403
+ continue;
404
+ total += 1;
405
+ const parsed = parseHostLine(line);
406
+ if (parsed === null) {
407
+ unclassified += 1;
408
+ continue;
409
+ }
410
+ if (parsed.kind === 'error') {
411
+ errors += 1;
412
+ if (parsed.tool !== null)
413
+ errorsWithTool += 1;
414
+ continue;
415
+ }
416
+ if (parsed.kind === 'other') {
417
+ // A connection open, a SIGINT, a capability dump. Counted so the record total adds
418
+ // up, and separated from `unclassified` so a parser that stopped recognising tool
419
+ // calls cannot hide inside "lifecycle noise".
420
+ lifecycle += 1;
421
+ continue;
422
+ }
423
+ records.push(parsed);
424
+ }
425
+ }
426
+ const paired = pairCalls(records);
427
+ return {
428
+ root,
429
+ missing: null,
430
+ platform,
431
+ dirs,
432
+ files: files.length,
433
+ records: total,
434
+ lifecycle,
435
+ unclassified,
436
+ errors,
437
+ errorsWithTool,
438
+ calls: paired.calls,
439
+ incomplete: paired.incomplete,
440
+ orphanCompletions: paired.orphanCompletions,
441
+ };
442
+ }
443
+ /** One `docs/eventlog.md` record. Wrong shape or wrong `ts` -> `null`. */
444
+ export function parseEventLine(line) {
445
+ const text = pyStrip(line);
446
+ if (text === '')
447
+ return null;
448
+ const record = loadsOrNull(text);
449
+ if (!isRecord(record))
450
+ return null;
451
+ const tool = clean(record['tool'], TOOL_TOKEN);
452
+ const outcome = record['outcome'];
453
+ const stamp = record['ts'];
454
+ if (tool === null || typeof outcome !== 'string' || typeof stamp !== 'string')
455
+ return null;
456
+ if (!OUTCOME_TOKEN.test(outcome))
457
+ return null;
458
+ const tsMs = parseTimestamp(stamp);
459
+ return tsMs === null ? null : { tsMs, tool, outcome };
460
+ }
461
+ /**
462
+ * Reads the live file AND the one rotated generation, because the rotation rule keeps
463
+ * `<path>.1` and a report that ignored it would lose the older half of a session.
464
+ */
465
+ export function scanEventLog(path) {
466
+ if (path === null)
467
+ return { path: null, missing: 'off', records: 0, unreadable: 0, events: [] };
468
+ const parts = [`${path}.1`, path];
469
+ if (!parts.some((p) => pyIsFile(p))) {
470
+ return { path, missing: 'not-found', records: 0, unreadable: 0, events: [] };
471
+ }
472
+ const events = [];
473
+ let total = 0;
474
+ let unreadable = 0;
475
+ for (const part of parts) {
476
+ let text;
477
+ try {
478
+ text = readTextReplace(part);
479
+ }
480
+ catch {
481
+ continue;
482
+ }
483
+ for (const line of text.split('\n')) {
484
+ if (pyStrip(line) === '')
485
+ continue;
486
+ total += 1;
487
+ const parsed = parseEventLine(line);
488
+ if (parsed === null)
489
+ unreadable += 1;
490
+ else
491
+ events.push(parsed);
492
+ }
493
+ }
494
+ return {
495
+ path,
496
+ missing: null,
497
+ records: total,
498
+ unreadable,
499
+ events: byKey(events, (e) => [e.tsMs, e.tool, e.outcome]),
500
+ };
501
+ }
502
+ function candidates(event, calls, windowMs) {
503
+ const out = [];
504
+ for (let index = 0; index < calls.length; index += 1) {
505
+ const call = calls[index];
506
+ if (call.tool !== event.tool)
507
+ continue;
508
+ const ends = [call.startMs, call.endMs].filter((t) => t !== null);
509
+ if (ends.length === 0)
510
+ continue;
511
+ const lo = Math.min(...ends) - windowMs;
512
+ const hi = Math.max(...ends) + windowMs;
513
+ if (lo <= event.tsMs && event.tsMs <= hi)
514
+ out.push(index);
515
+ }
516
+ return out;
517
+ }
518
+ /**
519
+ * Mutual uniqueness or nothing.
520
+ *
521
+ * A greedy nearest-time pass would produce a bigger `matched` number and a report that lies
522
+ * about which session an outcome belongs to. The component rule cannot: an event whose
523
+ * candidate call is also some other event's candidate is not paired with it, and both land
524
+ * in one ambiguous group whose sizes are printed. The traversal is ITERATIVE — a busy day
525
+ * is thousands of calls and a recursive flood fill would blow the stack on the one input
526
+ * that matters.
527
+ */
528
+ export function join(events, calls, windowMs) {
529
+ const matched = [];
530
+ const ambiguousGroups = [];
531
+ const unmatchedEvents = [];
532
+ const unmatchedCalls = [];
533
+ let ambiguousEvents = 0;
534
+ let ambiguousCalls = 0;
535
+ const edges = events.map((event) => candidates(event, calls, windowMs));
536
+ const callEdges = new Map();
537
+ edges.forEach((indexes, ei) => {
538
+ for (const ci of indexes) {
539
+ const bucket = callEdges.get(ci);
540
+ if (bucket === undefined)
541
+ callEdges.set(ci, [ei]);
542
+ else
543
+ bucket.push(ei);
544
+ }
545
+ });
546
+ const seenEvents = new Set();
547
+ const seenCalls = new Set();
548
+ const groups = [];
549
+ for (let start = 0; start < events.length; start += 1) {
550
+ if (seenEvents.has(start))
551
+ continue;
552
+ const stack = [['e', start]];
553
+ seenEvents.add(start);
554
+ const groupEvents = [];
555
+ const groupCalls = [];
556
+ while (stack.length > 0) {
557
+ const [side, index] = stack.pop();
558
+ if (side === 'e') {
559
+ groupEvents.push(index);
560
+ for (const ci of edges[index]) {
561
+ if (!seenCalls.has(ci)) {
562
+ seenCalls.add(ci);
563
+ stack.push(['c', ci]);
564
+ }
565
+ }
566
+ }
567
+ else {
568
+ groupCalls.push(index);
569
+ for (const ei of callEdges.get(index) ?? []) {
570
+ if (!seenEvents.has(ei)) {
571
+ seenEvents.add(ei);
572
+ stack.push(['e', ei]);
573
+ }
574
+ }
575
+ }
576
+ }
577
+ groups.push([
578
+ groupEvents.sort((a, b) => a - b),
579
+ groupCalls.sort((a, b) => a - b),
580
+ ]);
581
+ }
582
+ for (const [groupEvents, groupCalls] of groups) {
583
+ if (groupEvents.length === 1 && groupCalls.length === 1) {
584
+ matched.push([events[groupEvents[0]], calls[groupCalls[0]]]);
585
+ }
586
+ else if (groupEvents.length === 1 && groupCalls.length === 0) {
587
+ unmatchedEvents.push(events[groupEvents[0]]);
588
+ }
589
+ else {
590
+ ambiguousGroups.push([groupEvents.length, groupCalls.length]);
591
+ ambiguousEvents += groupEvents.length;
592
+ ambiguousCalls += groupCalls.length;
593
+ }
594
+ }
595
+ for (let ci = 0; ci < calls.length; ci += 1) {
596
+ if (!seenCalls.has(ci))
597
+ unmatchedCalls.push(calls[ci]);
598
+ }
599
+ return {
600
+ windowMs,
601
+ matched: byKey(matched, ([event]) => [event.tsMs, event.tool, event.outcome]),
602
+ ambiguousGroups: byKey(ambiguousGroups, ([e, c]) => [e, c]),
603
+ ambiguousEvents,
604
+ ambiguousCalls,
605
+ unmatchedEvents,
606
+ unmatchedCalls,
607
+ };
608
+ }
609
+ // ---------------------------------------------------------------------------- report
610
+ /**
611
+ * The LOWER median: an element of the input, never a mean.
612
+ *
613
+ * A mean of two integers is a float, a float has to be formatted, and two runtimes format
614
+ * floats differently at the edges. `Array.prototype.sort` is LEXICOGRAPHIC by default, so
615
+ * the numeric comparator is not decoration: `[9, 51]` sorts to `[51, 9]` without it.
616
+ */
617
+ export function p50(values) {
618
+ const ordered = [...values].sort((a, b) => a - b);
619
+ return ordered[(ordered.length - 1) >> 1];
620
+ }
621
+ function linesOrNone(lines) {
622
+ return lines.length > 0 ? [...lines] : ['(none)'];
623
+ }
624
+ /** The report, as the bytes `docs/mcpreport.md` specifies. No clock is read here. */
625
+ export function render(host, events, joined) {
626
+ const out = [`bantamkit mcp report v${REPORT_VERSION}`, '', '[sources]'];
627
+ if (host.missing === 'unknown-platform') {
628
+ out.push(`host-log-root: unknown on this platform (${host.platform}); set ${HOST_LOG_ROOT_ENV}`);
629
+ }
630
+ else if (host.missing === 'not-found') {
631
+ out.push(`host-log-root: not found at ${host.root}`);
632
+ }
633
+ else {
634
+ out.push(`host-log-root: ${host.root}`);
635
+ }
636
+ 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
+ if (events.missing === 'off')
638
+ 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}`);
641
+ else
642
+ out.push(`event-log: ${events.path}`);
643
+ 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
+ ' and under concurrency the two files can disagree about order. (ts, tool)' +
645
+ ' is EVIDENCE, NOT A KEY.', `window-ms: ${joined.windowMs}`, `matched-pairs: ${joined.matched.length}`, `ambiguous-groups: ${joined.ambiguousGroups.length}`, `ambiguous-events: ${joined.ambiguousEvents}`, `ambiguous-calls: ${joined.ambiguousCalls}`, `unmatched-events: ${joined.unmatchedEvents.length}`, `unmatched-calls: ${joined.unmatchedCalls.length}`, `attributed-events: ${joined.matched.length} of ${events.events.length}`);
646
+ out.push(...linesOrNone(joined.ambiguousGroups.map(([e, c]) => `ambiguous-group: events=${e} calls=${c}`)));
647
+ // --- durations: the number NEITHER file holds. `ms` is source A, `outcome` is B.
648
+ const buckets = new Map();
649
+ for (const [event, call] of joined.matched) {
650
+ if (call.durationMs === null)
651
+ continue;
652
+ const key = `${event.tool}\u0000${event.outcome}`;
653
+ const bucket = buckets.get(key);
654
+ if (bucket === undefined) {
655
+ buckets.set(key, { tool: event.tool, outcome: event.outcome, values: [call.durationMs] });
656
+ }
657
+ else {
658
+ bucket.values.push(call.durationMs);
659
+ }
660
+ }
661
+ out.push('', '[durations]', 'matched pairs only; ms from the host, outcome from bantamkit');
662
+ out.push(...linesOrNone(byKey([...buckets.values()], (b) => [b.tool, b.outcome]).map(({ tool, outcome, values }) => `${tool} ${outcome} n=${values.length} min=${Math.min(...values)}` +
663
+ ` p50=${p50(values)} max=${Math.max(...values)}`)));
664
+ // --- sessions: the only place a sessionId exists, plus what B could attach to it.
665
+ const perSession = new Map();
666
+ for (const call of host.calls) {
667
+ const bucket = perSession.get(call.session);
668
+ if (bucket === undefined)
669
+ perSession.set(call.session, [call]);
670
+ else
671
+ bucket.push(call);
672
+ }
673
+ const attributed = new Map();
674
+ for (const [, call] of joined.matched) {
675
+ attributed.set(call.session, (attributed.get(call.session) ?? 0) + 1);
676
+ }
677
+ const orderedSessions = byKey([...perSession.entries()], ([name, calls]) => [
678
+ Math.min(...calls.map(anchorMs)),
679
+ name,
680
+ ]);
681
+ out.push('', '[sessions]');
682
+ out.push(...linesOrNone(orderedSessions.map(([name, calls]) => {
683
+ const anchors = calls.map(anchorMs);
684
+ const tools = [...new Set(calls.map((c) => c.tool))].sort(cmpCodepoint);
685
+ return (`${name} calls=${calls.length}` +
686
+ ` ok=${calls.filter((c) => c.ok === true).length}` +
687
+ ` fail=${calls.filter((c) => c.ok === false).length}` +
688
+ ` incomplete=${calls.filter((c) => c.ok === null).length}` +
689
+ ` first=${formatTimestamp(Math.min(...anchors))}` +
690
+ ` last=${formatTimestamp(Math.max(...anchors))}` +
691
+ ` attributed=${attributed.get(name) ?? 0}` +
692
+ ` tools=${tools.join(',')}`);
693
+ })));
694
+ // --- and then what? An `escalate` that ended a session is not an `escalate` that was
695
+ // retried, and only the pair can tell them apart.
696
+ out.push('', '[after-outcome]', `matched pairs only; ${FOLLOW_UP_OUTCOMES.join(',')}`);
697
+ const follow = [];
698
+ for (const [event, call] of joined.matched) {
699
+ if (!FOLLOW_UP_OUTCOMES.includes(event.outcome))
700
+ continue;
701
+ const later = (perSession.get(call.session) ?? []).filter((c) => anchorMs(c) > anchorMs(call) || (anchorMs(c) === anchorMs(call) && c !== call));
702
+ follow.push(`${event.tool} ${event.outcome} at=${formatTimestamp(event.tsMs)}` +
703
+ ` session=${call.session} later-calls=${later.length}` +
704
+ ` later-same-tool=${later.filter((c) => c.tool === event.tool).length}`);
705
+ }
706
+ out.push(...linesOrNone(follow));
707
+ // --- the host's errors, counted and never quoted.
708
+ out.push('', '[host-errors]', `records=${host.errors} with-tool=${host.errorsWithTool}` +
709
+ ` without-tool=${host.errors - host.errorsWithTool}`, 'text withheld by design: the host log has been measured to persist argument' +
710
+ ' values inside exception text');
711
+ out.push('', '[limits]', '- the join is a heuristic; only mutually unique candidates are paired, and' +
712
+ ' everything else is counted above rather than guessed', '- durations and sessions are reported for matched pairs only; unmatched and' +
713
+ ' ambiguous events have no session', '- the host log is read-only here: nothing under its root is written, created,' +
714
+ ` rotated or pruned, and only ${HOST_LOG_DIRNAME} directories are read`, '- the host-log location is measured on macOS only; elsewhere set' +
715
+ ` ${HOST_LOG_ROOT_ENV}`, '');
716
+ return out.join('\n');
717
+ }
718
+ export function buildReport(env, eventLogPath, options = {}) {
719
+ const platform = options.platform ?? process.platform;
720
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
721
+ const root = resolveHostLogRoot(env, platform, options.home ?? pyHome());
722
+ const host = scanHostLog(root, platform);
723
+ const events = scanEventLog(eventLogPath);
724
+ return render(host, events, join(events.events, host.calls, windowMs));
725
+ }
726
+ /**
727
+ * Where source B would be for THIS invocation, resolved exactly as the server resolves it.
728
+ *
729
+ * ONE VOCABULARY FOR `BANTAMKIT_EVENT_LOG`, NOT TWO: `eventlog.resolvePath` decides
730
+ * off / default / literal, and this only supplies the store root it needs. The root comes
731
+ * from `--store` when given and otherwise from `discoverProjectStore`, which walks up and
732
+ * DESIGNATES without creating anything — so asking for a report never brings a store into
733
+ * existence.
734
+ *
735
+ * A pinned `BANTAMKIT_MEMORY_DIR` that does not exist makes the walk throw. That is a real
736
+ * answer, not a crash to hide: the report then names the path the walk would have
737
+ * designated, and the `not found at` line says the rest.
738
+ *
739
+ * `pyJoin` on the way out because the reference returns a `Path` and prints `str(Path)`,
740
+ * which collapses `//` and `./` in a literal the operator typed. The file identity does not
741
+ * move; the printed line does, and the line is compared byte for byte.
742
+ */
743
+ export function resolveEventLogPath(env, store, start) {
744
+ let root;
745
+ if (store) {
746
+ root = store;
747
+ }
748
+ else {
749
+ try {
750
+ root = discoverProjectStore(start);
751
+ }
752
+ catch {
753
+ root = pyJoin(start ? start : pyCwd(), '.bantamkit', 'memory');
754
+ }
755
+ }
756
+ const path = resolveEventLogFile(root, env['BANTAMKIT_EVENT_LOG']);
757
+ return path === null ? null : pyJoin(path);
758
+ }