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
|
@@ -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
|
+
}
|