bantamkit 0.27.0__py3-none-any.whl
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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/mcpreport.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""An analyst that reads BOTH MCP logs and says only what the pair can support.
|
|
2
|
+
|
|
3
|
+
TWO SOURCES, NEITHER OF WHICH ANSWERS A USEFUL QUESTION ALONE.
|
|
4
|
+
|
|
5
|
+
*Source A, the host's.* Claude Code writes
|
|
6
|
+
`<root>/<cwd-slug>/mcp-logs-bantamkit/<ISO>.jsonl`, where `<root>` is
|
|
7
|
+
`~/Library/Caches/claude-cli-nodejs` on macOS and `<cwd-slug>` is the launch cwd with
|
|
8
|
+
`/` turned into `-`. Measured on the development machine 2026-08-24: 20 slug
|
|
9
|
+
directories, ~1533 records, key set exactly `[cwd, debug|error, sessionId, timestamp]`.
|
|
10
|
+
It carries the tool NAME, the ok/fail BIT, the DURATION, the `sessionId` and the `cwd`.
|
|
11
|
+
It is the ONLY place a `sessionId` exists -- the server process never sees one.
|
|
12
|
+
|
|
13
|
+
*Source B, bantamkit's own.* `<store>/events/mcp.jsonl`, contract in
|
|
14
|
+
`docs/eventlog.md`: `{v, ts, tool, outcome, detail}`. It carries the OUTCOME the host
|
|
15
|
+
flattens into the single word "success" -- `saved` against `duplicate` against
|
|
16
|
+
`refused-budget`, `escalate`, which layer answered. It deliberately carries no
|
|
17
|
+
`sessionId`, no pid, no path.
|
|
18
|
+
|
|
19
|
+
So "how long did a dedupe take, and did the session that hit the budget retry?" is a
|
|
20
|
+
question about a JOIN, and the join is the hard part of this module.
|
|
21
|
+
|
|
22
|
+
THE JOIN IS `(ts, tool)` AND IT IS APPROXIMATE. SAY SO, EVERY TIME.
|
|
23
|
+
|
|
24
|
+
The host stamps when it DISPATCHED; bantamkit stamps when it DECIDED. Under concurrent
|
|
25
|
+
in-flight calls the two files can disagree about ORDER, not just about milliseconds:
|
|
26
|
+
U5 measured two saves pipelined without waiting and a recall sent last logged first. A
|
|
27
|
+
timestamp pair is therefore EVIDENCE, never a key.
|
|
28
|
+
|
|
29
|
+
The rule this module enforces, and the reason it exists: **an event and a host call are
|
|
30
|
+
paired only when the pairing is MUTUALLY UNIQUE.** Candidate edges are built by
|
|
31
|
+
tool-equality plus interval containment widened by `window_ms`; the resulting bipartite
|
|
32
|
+
graph is split into connected components; a component of exactly one event and exactly
|
|
33
|
+
one call is a `matched` pair, and EVERY OTHER component is reported as `ambiguous` with
|
|
34
|
+
its two sizes. Nothing is broken by nearest-time, by file order, or by greedy first-fit.
|
|
35
|
+
Two same-tool calls close together stay two, and the report says how many.
|
|
36
|
+
|
|
37
|
+
An analyst that silently attributes an outcome to the wrong session is worse than one
|
|
38
|
+
that says it cannot tell. Every derived number here is labelled with the `n` of matched
|
|
39
|
+
pairs it came from, and the four counts -- matched, ambiguous, unmatched-event,
|
|
40
|
+
unmatched-call -- are printed before any of them.
|
|
41
|
+
|
|
42
|
+
THE HOST'S LOG IS READ-ONLY AND ITS TEXT IS UNTRUSTED.
|
|
43
|
+
|
|
44
|
+
Nothing in this module opens a host file for writing, creates a directory under the
|
|
45
|
+
root, or globs any `mcp-logs-*` name other than `mcp-logs-bantamkit` -- the other
|
|
46
|
+
servers' directories sit right beside it and are not ours to read.
|
|
47
|
+
|
|
48
|
+
The text is untrusted because it has already leaked: measured 2026-08-24, the host
|
|
49
|
+
persisted `input_value={'schema_path': '/Users/k...e-loop/checkpoint.json'}` from a
|
|
50
|
+
`validate_json` pydantic failure. That value came through the EXCEPTION TEXT. So the
|
|
51
|
+
guard here is STRUCTURAL, not a filter: `HostRecord` has no field that can hold host
|
|
52
|
+
text. The message is matched against a closed set of patterns at parse time and then
|
|
53
|
+
DROPPED; what survives is a `kind` token from this module's own vocabulary, a tool name
|
|
54
|
+
that had to match `[a-z0-9_]{1,64}`, a session id that had to match `[A-Za-z0-9-]{1,64}`,
|
|
55
|
+
and integers. A record that matches nothing is counted as `unclassified` and forgotten.
|
|
56
|
+
There is no code path that can print a host message, because after `parse_host_line`
|
|
57
|
+
no host message exists.
|
|
58
|
+
|
|
59
|
+
DETERMINISTIC BY CONSTRUCTION. No clock is read. Ordering is total and explicit:
|
|
60
|
+
sessions by `(first_ts, id)`, tools and outcomes lexicographically. `p50` is the LOWER
|
|
61
|
+
median (`values[(n - 1) // 2]`), an element of the input rather than a mean, so no
|
|
62
|
+
float is ever formatted and the two runtimes cannot round apart. Every line is LF, every
|
|
63
|
+
number is a bare ASCII integer, and no locale is consulted. Given the same two files,
|
|
64
|
+
the same bytes out.
|
|
65
|
+
|
|
66
|
+
THE OUTPUT SHAPE IS A CROSS-RUNTIME CONTRACT, specified in `docs/mcpreport.md`.
|
|
67
|
+
`runtime-ts` implements from that page, not from this file.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import json
|
|
73
|
+
import re
|
|
74
|
+
import sys
|
|
75
|
+
from collections.abc import Sequence
|
|
76
|
+
from dataclasses import dataclass, field
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
|
|
79
|
+
#: Overrides the platform default for the host-log root. The default is only KNOWN on
|
|
80
|
+
#: macOS (see `default_host_log_root`), so this is what makes the analyst usable at all
|
|
81
|
+
#: on Linux and Windows -- and what lets a test run against a fixture tree.
|
|
82
|
+
HOST_LOG_ROOT_ENV = "BANTAMKIT_HOST_LOG_ROOT"
|
|
83
|
+
|
|
84
|
+
#: The ONLY directory name read under a slug directory. `mcp-logs-clickup` and sixteen
|
|
85
|
+
#: others sit beside it; globbing `mcp-logs-*` would read another program's log.
|
|
86
|
+
HOST_LOG_DIRNAME = "mcp-logs-bantamkit"
|
|
87
|
+
|
|
88
|
+
#: How far outside the host's `[dispatch, completion]` interval an event's `ts` may fall
|
|
89
|
+
#: and still be a CANDIDATE. Not a tolerance for clock skew -- both stamps come from one
|
|
90
|
+
#: machine clock -- but for the lag between the component deciding and the host stamping
|
|
91
|
+
#: the reply it eventually read. Widening it produces MORE ambiguity, not more matches.
|
|
92
|
+
DEFAULT_WINDOW_MS = 250
|
|
93
|
+
|
|
94
|
+
#: Report format version. Bump when a section, a field or an order changes; both
|
|
95
|
+
#: runtimes move together, exactly like `eventlog.SCHEMA_VERSION`.
|
|
96
|
+
REPORT_VERSION = 1
|
|
97
|
+
|
|
98
|
+
#: Outcomes worth asking "and then what?" about. An `escalate` that ended a session and
|
|
99
|
+
#: an `escalate` followed by nine more calls are different facts about the same word.
|
|
100
|
+
FOLLOW_UP_OUTCOMES = ("escalate", "raised", "refused-budget", "refused-validation")
|
|
101
|
+
|
|
102
|
+
_TOOL_TOKEN = re.compile(r"^[a-z0-9_]{1,64}$")
|
|
103
|
+
_SESSION_TOKEN = re.compile(r"^[A-Za-z0-9-]{1,64}$")
|
|
104
|
+
|
|
105
|
+
# The closed set. Each pattern deliberately stops before any free text: the `failed
|
|
106
|
+
# after` arm matches the colon and NOT what follows it, so the error string is never
|
|
107
|
+
# even captured, let alone stored.
|
|
108
|
+
_CALL_START = re.compile(r"^Calling MCP tool: (.+)$")
|
|
109
|
+
_CALL_OK = re.compile(r"^Tool '(.+)' completed successfully in (\d+)ms$")
|
|
110
|
+
_CALL_FAIL = re.compile(r"^Tool '(.+)' failed after (\d+)s:")
|
|
111
|
+
_ERROR_TOOL = re.compile(r"^Error executing tool ([a-z0-9_]{1,64}):")
|
|
112
|
+
|
|
113
|
+
_TS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})Z$")
|
|
114
|
+
|
|
115
|
+
_DAYS_BEFORE_MONTH = (0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_timestamp(text: str) -> int | None:
|
|
119
|
+
"""`2026-08-23T17:38:47.243Z` -> integer epoch milliseconds, or `None`.
|
|
120
|
+
|
|
121
|
+
Hand-rolled rather than `datetime.fromisoformat` for one measured reason and one
|
|
122
|
+
portability reason. Measured: CPython's parser accepts spellings the host never
|
|
123
|
+
writes (`+00:00`, no `Z`, six fractional digits) and, before 3.11, rejected the `Z`
|
|
124
|
+
the host DOES write -- so it is both wider and narrower than the input. Portability:
|
|
125
|
+
`Date.parse` in Node accepts a different set again, and this is a compared field.
|
|
126
|
+
One regex that admits exactly the host's spelling is the same function on both sides.
|
|
127
|
+
"""
|
|
128
|
+
m = _TS.match(text)
|
|
129
|
+
if m is None:
|
|
130
|
+
return None
|
|
131
|
+
year, month, day, hour, minute, second, milli = (int(part) for part in m.groups())
|
|
132
|
+
if not (1 <= month <= 12) or not (1 <= day <= 31) or hour > 23 or minute > 59 or second > 60:
|
|
133
|
+
return None
|
|
134
|
+
years = year - 1970
|
|
135
|
+
leaps = ((year - 1) // 4 - 492) - ((year - 1) // 100 - 19) + ((year - 1) // 400 - 4)
|
|
136
|
+
days = years * 365 + leaps + _DAYS_BEFORE_MONTH[month - 1] + (day - 1)
|
|
137
|
+
if month > 2 and (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)):
|
|
138
|
+
days += 1
|
|
139
|
+
return ((days * 24 + hour) * 60 + minute) * 60000 + second * 1000 + milli
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_timestamp(epoch_ms: int) -> str:
|
|
143
|
+
"""The inverse, reusing the event log's formatter so one spelling exists in the repo."""
|
|
144
|
+
from bantamkit.eventlog import format_timestamp as _fmt
|
|
145
|
+
|
|
146
|
+
return _fmt(epoch_ms)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --------------------------------------------------------------------------- source A
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class HostRecord:
|
|
154
|
+
"""One classified host line. THERE IS NO FIELD HERE THAT CAN HOLD HOST TEXT.
|
|
155
|
+
|
|
156
|
+
`kind` is a token from this module's vocabulary, `tool` passed `[a-z0-9_]{1,64}`,
|
|
157
|
+
`session` passed `[A-Za-z0-9-]{1,64}`, and the rest are integers. That is the whole
|
|
158
|
+
leak guard: not a filter applied on the way out, but an absence of anywhere to put it.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
ts_ms: int
|
|
162
|
+
session: str
|
|
163
|
+
kind: str # call-start | call-ok | call-fail | error | other
|
|
164
|
+
tool: str | None = None
|
|
165
|
+
duration_ms: int | None = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _clean(token: object, pattern: re.Pattern[str]) -> str | None:
|
|
169
|
+
if not isinstance(token, str):
|
|
170
|
+
return None
|
|
171
|
+
return token if pattern.match(token) else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_host_line(line: str) -> HostRecord | None:
|
|
175
|
+
"""One JSONL line -> a `HostRecord`, or `None` for anything unparseable.
|
|
176
|
+
|
|
177
|
+
`None` covers a blank line, a truncated tail (the host is appending while this
|
|
178
|
+
reads), a record with no usable timestamp, and a record whose session id is not a
|
|
179
|
+
session id. All four are counted as `unclassified` by the caller and nothing else.
|
|
180
|
+
"""
|
|
181
|
+
text = line.strip()
|
|
182
|
+
if not text:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
record = json.loads(text)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return None
|
|
188
|
+
if not isinstance(record, dict):
|
|
189
|
+
return None
|
|
190
|
+
stamp = record.get("timestamp")
|
|
191
|
+
ts_ms = parse_timestamp(stamp) if isinstance(stamp, str) else None
|
|
192
|
+
if ts_ms is None:
|
|
193
|
+
return None
|
|
194
|
+
session = _clean(record.get("sessionId"), _SESSION_TOKEN) or "-"
|
|
195
|
+
|
|
196
|
+
message = record.get("error")
|
|
197
|
+
if isinstance(message, str):
|
|
198
|
+
hit = _ERROR_TOOL.match(message)
|
|
199
|
+
return HostRecord(ts_ms, session, "error", hit.group(1) if hit else None)
|
|
200
|
+
|
|
201
|
+
message = record.get("debug")
|
|
202
|
+
if not isinstance(message, str):
|
|
203
|
+
return None
|
|
204
|
+
hit = _CALL_START.match(message)
|
|
205
|
+
if hit is not None:
|
|
206
|
+
tool = _clean(hit.group(1), _TOOL_TOKEN)
|
|
207
|
+
return HostRecord(ts_ms, session, "call-start" if tool else "other", tool)
|
|
208
|
+
hit = _CALL_OK.match(message)
|
|
209
|
+
if hit is not None:
|
|
210
|
+
tool = _clean(hit.group(1), _TOOL_TOKEN)
|
|
211
|
+
if tool is None:
|
|
212
|
+
return HostRecord(ts_ms, session, "other")
|
|
213
|
+
return HostRecord(ts_ms, session, "call-ok", tool, int(hit.group(2)))
|
|
214
|
+
hit = _CALL_FAIL.match(message)
|
|
215
|
+
if hit is not None:
|
|
216
|
+
tool = _clean(hit.group(1), _TOOL_TOKEN)
|
|
217
|
+
if tool is None:
|
|
218
|
+
return HostRecord(ts_ms, session, "other")
|
|
219
|
+
# SECONDS here, milliseconds on the success arm. That is the host's spelling,
|
|
220
|
+
# not a bug in this parser: `failed after 0s` is what it writes.
|
|
221
|
+
return HostRecord(ts_ms, session, "call-fail", tool, int(hit.group(2)) * 1000)
|
|
222
|
+
return HostRecord(ts_ms, session, "other")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class HostCall:
|
|
227
|
+
"""A dispatch paired with its completion. Either end may be missing; say which."""
|
|
228
|
+
|
|
229
|
+
tool: str
|
|
230
|
+
session: str
|
|
231
|
+
start_ms: int | None
|
|
232
|
+
end_ms: int | None
|
|
233
|
+
ok: bool | None
|
|
234
|
+
duration_ms: int | None
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def anchor_ms(self) -> int:
|
|
238
|
+
"""The one timestamp the join hangs on when only one end was logged."""
|
|
239
|
+
return self.start_ms if self.start_ms is not None else (self.end_ms or 0)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pair_calls(records: Sequence[HostRecord]) -> tuple[list[HostCall], int, int]:
|
|
243
|
+
"""Dispatches to completions, FIFO within one `(session, tool)`.
|
|
244
|
+
|
|
245
|
+
FIFO and not nearest-time, because within one session and one tool the host's own
|
|
246
|
+
file order IS the dispatch order -- it is one writer appending. Across sessions or
|
|
247
|
+
across tools no order is assumed, which is why the queue key carries both.
|
|
248
|
+
|
|
249
|
+
Returns the calls plus two honesty counters: dispatches that never completed (the
|
|
250
|
+
process was killed, or the file was rotated mid-call) and completions with no
|
|
251
|
+
dispatch in view (the dispatch is in an earlier file that was rotated away).
|
|
252
|
+
"""
|
|
253
|
+
pending: dict[tuple[str, str], list[HostRecord]] = {}
|
|
254
|
+
calls: list[HostCall] = []
|
|
255
|
+
orphan_completions = 0
|
|
256
|
+
for record in sorted(records, key=lambda r: (r.ts_ms, r.session, r.kind)):
|
|
257
|
+
if record.tool is None:
|
|
258
|
+
continue
|
|
259
|
+
key = (record.session, record.tool)
|
|
260
|
+
if record.kind == "call-start":
|
|
261
|
+
pending.setdefault(key, []).append(record)
|
|
262
|
+
elif record.kind in ("call-ok", "call-fail"):
|
|
263
|
+
queue = pending.get(key) or []
|
|
264
|
+
start = queue.pop(0) if queue else None
|
|
265
|
+
if start is None:
|
|
266
|
+
orphan_completions += 1
|
|
267
|
+
calls.append(
|
|
268
|
+
HostCall(
|
|
269
|
+
tool=record.tool,
|
|
270
|
+
session=record.session,
|
|
271
|
+
start_ms=start.ts_ms if start else None,
|
|
272
|
+
end_ms=record.ts_ms,
|
|
273
|
+
ok=record.kind == "call-ok",
|
|
274
|
+
duration_ms=record.duration_ms,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
incomplete = 0
|
|
278
|
+
for queue in pending.values():
|
|
279
|
+
for start in queue:
|
|
280
|
+
incomplete += 1
|
|
281
|
+
calls.append(HostCall(start.tool or "", start.session, start.ts_ms, None, None, None))
|
|
282
|
+
calls.sort(key=lambda c: (c.anchor_ms, c.session, c.tool))
|
|
283
|
+
return calls, incomplete, orphan_completions
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class HostScan:
|
|
288
|
+
"""What source A yielded, including the reason it yielded nothing."""
|
|
289
|
+
|
|
290
|
+
root: Path | None
|
|
291
|
+
missing: str | None # None | "not-found" | "unknown-platform"
|
|
292
|
+
platform: str = ""
|
|
293
|
+
dirs: int = 0
|
|
294
|
+
files: int = 0
|
|
295
|
+
records: int = 0
|
|
296
|
+
lifecycle: int = 0
|
|
297
|
+
unclassified: int = 0
|
|
298
|
+
errors: int = 0
|
|
299
|
+
errors_with_tool: int = 0
|
|
300
|
+
calls: list[HostCall] = field(default_factory=list)
|
|
301
|
+
incomplete: int = 0
|
|
302
|
+
orphan_completions: int = 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def default_host_log_root(platform: str, home: Path) -> Path | None:
|
|
306
|
+
"""`~/Library/Caches/claude-cli-nodejs` -- ON macOS, AND NOWHERE ELSE.
|
|
307
|
+
|
|
308
|
+
Where Claude Code writes this on Linux and on Windows has NOT been measured, and a
|
|
309
|
+
guess dressed as a default would produce an empty report that reads like "nothing
|
|
310
|
+
happened". So off darwin this returns `None`, the report says which platform it is
|
|
311
|
+
on and names `BANTAMKIT_HOST_LOG_ROOT`, and source B is reported alone. That is the
|
|
312
|
+
named limitation in `docs/mcpreport.md`; `CLAUDE.md`'s parity rule is satisfied by
|
|
313
|
+
the analyst RUNNING on Windows and saying what it cannot see, not by pretending.
|
|
314
|
+
"""
|
|
315
|
+
if platform != "darwin":
|
|
316
|
+
return None
|
|
317
|
+
return home / "Library" / "Caches" / "claude-cli-nodejs"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def resolve_host_log_root(
|
|
321
|
+
env: dict[str, str], platform: str | None = None, home: Path | None = None
|
|
322
|
+
) -> Path | None:
|
|
323
|
+
override = (env.get(HOST_LOG_ROOT_ENV) or "").strip()
|
|
324
|
+
if override:
|
|
325
|
+
return Path(override)
|
|
326
|
+
return default_host_log_root(platform or sys.platform, home or Path.home())
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def host_log_files(root: Path) -> list[Path]:
|
|
330
|
+
"""Every `<root>/*/mcp-logs-bantamkit/*.jsonl`, sorted, and NOTHING else.
|
|
331
|
+
|
|
332
|
+
The slug directories are listed and each is asked for `mcp-logs-bantamkit` BY NAME.
|
|
333
|
+
No `mcp-logs-*` pattern is ever built, so a sibling server's directory cannot be
|
|
334
|
+
reached by this function even by accident -- `mcp-logs-clickup` is right there.
|
|
335
|
+
"""
|
|
336
|
+
found: list[Path] = []
|
|
337
|
+
try:
|
|
338
|
+
slugs = sorted(root.iterdir())
|
|
339
|
+
except OSError:
|
|
340
|
+
return found
|
|
341
|
+
for slug in slugs:
|
|
342
|
+
ours = slug / HOST_LOG_DIRNAME
|
|
343
|
+
if not ours.is_dir():
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
found.extend(sorted(p for p in ours.iterdir() if p.suffix == ".jsonl" and p.is_file()))
|
|
347
|
+
except OSError:
|
|
348
|
+
continue
|
|
349
|
+
return found
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def scan_host_log(root: Path | None, platform: str | None = None) -> HostScan:
|
|
353
|
+
"""`platform` is carried into the scan, not read at render time, so an injected
|
|
354
|
+
platform reaches the "unknown on this platform" line and a test can see it."""
|
|
355
|
+
plat = platform or sys.platform
|
|
356
|
+
if root is None:
|
|
357
|
+
return HostScan(root=None, missing="unknown-platform", platform=plat)
|
|
358
|
+
if not root.is_dir():
|
|
359
|
+
return HostScan(root=root, missing="not-found", platform=plat)
|
|
360
|
+
files = host_log_files(root)
|
|
361
|
+
dirs = len({path.parent for path in files})
|
|
362
|
+
records: list[HostRecord] = []
|
|
363
|
+
total = lifecycle = unclassified = errors = errors_with_tool = 0
|
|
364
|
+
for path in files:
|
|
365
|
+
try:
|
|
366
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
367
|
+
except OSError:
|
|
368
|
+
continue
|
|
369
|
+
for line in text.split("\n"):
|
|
370
|
+
if not line.strip():
|
|
371
|
+
continue
|
|
372
|
+
total += 1
|
|
373
|
+
parsed = parse_host_line(line)
|
|
374
|
+
if parsed is None:
|
|
375
|
+
unclassified += 1
|
|
376
|
+
continue
|
|
377
|
+
if parsed.kind == "error":
|
|
378
|
+
errors += 1
|
|
379
|
+
if parsed.tool is not None:
|
|
380
|
+
errors_with_tool += 1
|
|
381
|
+
continue
|
|
382
|
+
if parsed.kind == "other":
|
|
383
|
+
# A connection open, a SIGINT, a capability dump. Counted so the record
|
|
384
|
+
# total adds up, and separated from `unclassified` so a parser that
|
|
385
|
+
# stopped recognising tool calls cannot hide inside "lifecycle noise".
|
|
386
|
+
lifecycle += 1
|
|
387
|
+
continue
|
|
388
|
+
records.append(parsed)
|
|
389
|
+
calls, incomplete, orphans = pair_calls(records)
|
|
390
|
+
return HostScan(
|
|
391
|
+
root=root,
|
|
392
|
+
missing=None,
|
|
393
|
+
platform=plat,
|
|
394
|
+
dirs=dirs,
|
|
395
|
+
files=len(files),
|
|
396
|
+
records=total,
|
|
397
|
+
lifecycle=lifecycle,
|
|
398
|
+
unclassified=unclassified,
|
|
399
|
+
errors=errors,
|
|
400
|
+
errors_with_tool=errors_with_tool,
|
|
401
|
+
calls=calls,
|
|
402
|
+
incomplete=incomplete,
|
|
403
|
+
orphan_completions=orphans,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# --------------------------------------------------------------------------- source B
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class Event:
|
|
412
|
+
ts_ms: int
|
|
413
|
+
tool: str
|
|
414
|
+
outcome: str
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@dataclass
|
|
418
|
+
class EventScan:
|
|
419
|
+
path: Path | None
|
|
420
|
+
missing: str | None # None | "not-found" | "off"
|
|
421
|
+
records: int = 0
|
|
422
|
+
unreadable: int = 0
|
|
423
|
+
events: list[Event] = field(default_factory=list)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def parse_event_line(line: str) -> Event | None:
|
|
427
|
+
"""One `docs/eventlog.md` record. Wrong `v`, wrong shape or wrong `ts` -> `None`."""
|
|
428
|
+
text = line.strip()
|
|
429
|
+
if not text:
|
|
430
|
+
return None
|
|
431
|
+
try:
|
|
432
|
+
record = json.loads(text)
|
|
433
|
+
except ValueError:
|
|
434
|
+
return None
|
|
435
|
+
if not isinstance(record, dict):
|
|
436
|
+
return None
|
|
437
|
+
tool = _clean(record.get("tool"), _TOOL_TOKEN)
|
|
438
|
+
outcome = record.get("outcome")
|
|
439
|
+
stamp = record.get("ts")
|
|
440
|
+
if tool is None or not isinstance(outcome, str) or not isinstance(stamp, str):
|
|
441
|
+
return None
|
|
442
|
+
if not re.match(r"^[a-z-]{1,32}$", outcome):
|
|
443
|
+
return None
|
|
444
|
+
ts_ms = parse_timestamp(stamp)
|
|
445
|
+
return None if ts_ms is None else Event(ts_ms, tool, outcome)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def scan_event_log(path: Path | None) -> EventScan:
|
|
449
|
+
"""Reads the live file AND the one rotated generation, because the rotation rule
|
|
450
|
+
keeps `<path>.1` and a report that ignored it would lose the older half of a session.
|
|
451
|
+
"""
|
|
452
|
+
if path is None:
|
|
453
|
+
return EventScan(path=None, missing="off")
|
|
454
|
+
parts = [path.with_name(path.name + ".1"), path]
|
|
455
|
+
if not any(p.is_file() for p in parts):
|
|
456
|
+
return EventScan(path=path, missing="not-found")
|
|
457
|
+
events: list[Event] = []
|
|
458
|
+
total = unreadable = 0
|
|
459
|
+
for part in parts:
|
|
460
|
+
try:
|
|
461
|
+
text = part.read_text(encoding="utf-8", errors="replace")
|
|
462
|
+
except OSError:
|
|
463
|
+
continue
|
|
464
|
+
for line in text.split("\n"):
|
|
465
|
+
if not line.strip():
|
|
466
|
+
continue
|
|
467
|
+
total += 1
|
|
468
|
+
parsed = parse_event_line(line)
|
|
469
|
+
if parsed is None:
|
|
470
|
+
unreadable += 1
|
|
471
|
+
else:
|
|
472
|
+
events.append(parsed)
|
|
473
|
+
events.sort(key=lambda e: (e.ts_ms, e.tool, e.outcome))
|
|
474
|
+
return EventScan(path=path, missing=None, records=total, unreadable=unreadable, events=events)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------------------ join
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class Join:
|
|
482
|
+
"""The whole result of the join, uncertainty first.
|
|
483
|
+
|
|
484
|
+
`matched` holds only MUTUALLY UNIQUE pairs. `ambiguous_groups` holds the size of
|
|
485
|
+
every component that was not one-to-one, as `(events, calls)`. Nothing is dropped:
|
|
486
|
+
every event lands in exactly one of matched / ambiguous / unmatched, and so does
|
|
487
|
+
every call.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
window_ms: int
|
|
491
|
+
matched: list[tuple[Event, HostCall]] = field(default_factory=list)
|
|
492
|
+
ambiguous_groups: list[tuple[int, int]] = field(default_factory=list)
|
|
493
|
+
ambiguous_events: int = 0
|
|
494
|
+
ambiguous_calls: int = 0
|
|
495
|
+
unmatched_events: list[Event] = field(default_factory=list)
|
|
496
|
+
unmatched_calls: list[HostCall] = field(default_factory=list)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _candidates(event: Event, calls: Sequence[HostCall], window_ms: int) -> list[int]:
|
|
500
|
+
out = []
|
|
501
|
+
for index, call in enumerate(calls):
|
|
502
|
+
if call.tool != event.tool:
|
|
503
|
+
continue
|
|
504
|
+
ends = [t for t in (call.start_ms, call.end_ms) if t is not None]
|
|
505
|
+
if not ends:
|
|
506
|
+
continue
|
|
507
|
+
if min(ends) - window_ms <= event.ts_ms <= max(ends) + window_ms:
|
|
508
|
+
out.append(index)
|
|
509
|
+
return out
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def join(events: Sequence[Event], calls: Sequence[HostCall], window_ms: int) -> Join:
|
|
513
|
+
"""Mutual uniqueness or nothing. See the module docstring for why.
|
|
514
|
+
|
|
515
|
+
A greedy nearest-time pass would produce a bigger `matched` number and a report that
|
|
516
|
+
lies about which session an outcome belongs to. The component rule cannot: an event
|
|
517
|
+
whose candidate call is also some other event's candidate is not paired with it, and
|
|
518
|
+
both land in one ambiguous group whose sizes are printed.
|
|
519
|
+
"""
|
|
520
|
+
result = Join(window_ms=window_ms)
|
|
521
|
+
edges = [_candidates(event, calls, window_ms) for event in events]
|
|
522
|
+
call_edges: dict[int, list[int]] = {}
|
|
523
|
+
for ei, indexes in enumerate(edges):
|
|
524
|
+
for ci in indexes:
|
|
525
|
+
call_edges.setdefault(ci, []).append(ei)
|
|
526
|
+
|
|
527
|
+
seen_events: set[int] = set()
|
|
528
|
+
seen_calls: set[int] = set()
|
|
529
|
+
groups: list[tuple[list[int], list[int]]] = []
|
|
530
|
+
for start in range(len(events)):
|
|
531
|
+
if start in seen_events:
|
|
532
|
+
continue
|
|
533
|
+
stack = [("e", start)]
|
|
534
|
+
seen_events.add(start)
|
|
535
|
+
group_events: list[int] = []
|
|
536
|
+
group_calls: list[int] = []
|
|
537
|
+
while stack:
|
|
538
|
+
side, index = stack.pop()
|
|
539
|
+
if side == "e":
|
|
540
|
+
group_events.append(index)
|
|
541
|
+
for ci in edges[index]:
|
|
542
|
+
if ci not in seen_calls:
|
|
543
|
+
seen_calls.add(ci)
|
|
544
|
+
stack.append(("c", ci))
|
|
545
|
+
else:
|
|
546
|
+
group_calls.append(index)
|
|
547
|
+
for ei in call_edges.get(index, ()):
|
|
548
|
+
if ei not in seen_events:
|
|
549
|
+
seen_events.add(ei)
|
|
550
|
+
stack.append(("e", ei))
|
|
551
|
+
groups.append((sorted(group_events), sorted(group_calls)))
|
|
552
|
+
|
|
553
|
+
for group_events, group_calls in groups:
|
|
554
|
+
if len(group_events) == 1 and len(group_calls) == 1:
|
|
555
|
+
result.matched.append((events[group_events[0]], calls[group_calls[0]]))
|
|
556
|
+
elif len(group_events) == 1 and not group_calls:
|
|
557
|
+
result.unmatched_events.append(events[group_events[0]])
|
|
558
|
+
else:
|
|
559
|
+
result.ambiguous_groups.append((len(group_events), len(group_calls)))
|
|
560
|
+
result.ambiguous_events += len(group_events)
|
|
561
|
+
result.ambiguous_calls += len(group_calls)
|
|
562
|
+
for ci, call in enumerate(calls):
|
|
563
|
+
if ci not in seen_calls:
|
|
564
|
+
result.unmatched_calls.append(call)
|
|
565
|
+
result.matched.sort(key=lambda pair: (pair[0].ts_ms, pair[0].tool, pair[0].outcome))
|
|
566
|
+
result.ambiguous_groups.sort()
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
# ---------------------------------------------------------------------------- report
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _p50(values: Sequence[int]) -> int:
|
|
574
|
+
"""The LOWER median: an element of the input, never a mean.
|
|
575
|
+
|
|
576
|
+
A mean of two integers is a float, a float has to be formatted, and two runtimes
|
|
577
|
+
format floats differently at the edges. This is the same reason `docs/eventlog.md`
|
|
578
|
+
builds `ts` from an integer millisecond count.
|
|
579
|
+
"""
|
|
580
|
+
ordered = sorted(values)
|
|
581
|
+
return ordered[(len(ordered) - 1) // 2]
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _lines_or_none(lines: list[str]) -> list[str]:
|
|
585
|
+
return lines if lines else ["(none)"]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def render(host: HostScan, events: EventScan, joined: Join) -> str:
|
|
589
|
+
"""The report, as the bytes `docs/mcpreport.md` specifies. No clock is read here."""
|
|
590
|
+
out: list[str] = [f"bantamkit mcp report v{REPORT_VERSION}", "", "[sources]"]
|
|
591
|
+
|
|
592
|
+
if host.missing == "unknown-platform":
|
|
593
|
+
out.append(
|
|
594
|
+
f"host-log-root: unknown on this platform ({host.platform}); "
|
|
595
|
+
f"set {HOST_LOG_ROOT_ENV}"
|
|
596
|
+
)
|
|
597
|
+
elif host.missing == "not-found":
|
|
598
|
+
out.append(f"host-log-root: not found at {host.root}")
|
|
599
|
+
else:
|
|
600
|
+
out.append(f"host-log-root: {host.root}")
|
|
601
|
+
out += [
|
|
602
|
+
f"host-log-dirs: {host.dirs}",
|
|
603
|
+
f"host-log-files: {host.files}",
|
|
604
|
+
f"host-records: {host.records}",
|
|
605
|
+
f"host-records-lifecycle: {host.lifecycle}",
|
|
606
|
+
f"host-records-unclassified: {host.unclassified}",
|
|
607
|
+
f"host-error-records: {host.errors}",
|
|
608
|
+
f"host-calls: {len(host.calls)}",
|
|
609
|
+
f"host-calls-incomplete: {host.incomplete}",
|
|
610
|
+
f"host-calls-orphan-completion: {host.orphan_completions}",
|
|
611
|
+
]
|
|
612
|
+
if events.missing == "off":
|
|
613
|
+
out.append("event-log: off (BANTAMKIT_EVENT_LOG unset or off)")
|
|
614
|
+
elif events.missing == "not-found":
|
|
615
|
+
out.append(f"event-log: not found at {events.path}")
|
|
616
|
+
else:
|
|
617
|
+
out.append(f"event-log: {events.path}")
|
|
618
|
+
out += [
|
|
619
|
+
f"event-records: {events.records}",
|
|
620
|
+
f"event-records-unreadable: {events.unreadable}",
|
|
621
|
+
"",
|
|
622
|
+
"[join]",
|
|
623
|
+
"method: (ts, tool) interval containment, mutually unique only",
|
|
624
|
+
"APPROXIMATE: the host stamps the dispatch, bantamkit stamps the decision,"
|
|
625
|
+
" and under concurrency the two files can disagree about order. (ts, tool)"
|
|
626
|
+
" is EVIDENCE, NOT A KEY.",
|
|
627
|
+
f"window-ms: {joined.window_ms}",
|
|
628
|
+
f"matched-pairs: {len(joined.matched)}",
|
|
629
|
+
f"ambiguous-groups: {len(joined.ambiguous_groups)}",
|
|
630
|
+
f"ambiguous-events: {joined.ambiguous_events}",
|
|
631
|
+
f"ambiguous-calls: {joined.ambiguous_calls}",
|
|
632
|
+
f"unmatched-events: {len(joined.unmatched_events)}",
|
|
633
|
+
f"unmatched-calls: {len(joined.unmatched_calls)}",
|
|
634
|
+
f"attributed-events: {len(joined.matched)} of {len(events.events)}",
|
|
635
|
+
]
|
|
636
|
+
out += _lines_or_none(
|
|
637
|
+
[f"ambiguous-group: events={e} calls={c}" for e, c in joined.ambiguous_groups]
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# --- durations: the number NEITHER file holds. `ms` is source A, `outcome` is B.
|
|
641
|
+
buckets: dict[tuple[str, str], list[int]] = {}
|
|
642
|
+
for event, call in joined.matched:
|
|
643
|
+
if call.duration_ms is not None:
|
|
644
|
+
buckets.setdefault((event.tool, event.outcome), []).append(call.duration_ms)
|
|
645
|
+
out += ["", "[durations]", "matched pairs only; ms from the host, outcome from bantamkit"]
|
|
646
|
+
out += _lines_or_none(
|
|
647
|
+
[
|
|
648
|
+
f"{tool} {outcome} n={len(v)} min={min(v)} p50={_p50(v)} max={max(v)}"
|
|
649
|
+
for (tool, outcome), v in sorted(buckets.items())
|
|
650
|
+
]
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# --- sessions: the only place a sessionId exists, plus what B could attach to it.
|
|
654
|
+
per_session: dict[str, list[HostCall]] = {}
|
|
655
|
+
for call in host.calls:
|
|
656
|
+
per_session.setdefault(call.session, []).append(call)
|
|
657
|
+
attributed: dict[str, int] = {}
|
|
658
|
+
for _event, call in joined.matched:
|
|
659
|
+
attributed[call.session] = attributed.get(call.session, 0) + 1
|
|
660
|
+
ordered_sessions = sorted(
|
|
661
|
+
per_session.items(), key=lambda kv: (min(c.anchor_ms for c in kv[1]), kv[0])
|
|
662
|
+
)
|
|
663
|
+
out += ["", "[sessions]"]
|
|
664
|
+
out += _lines_or_none(
|
|
665
|
+
[
|
|
666
|
+
f"{name} calls={len(calls)}"
|
|
667
|
+
f" ok={sum(1 for c in calls if c.ok is True)}"
|
|
668
|
+
f" fail={sum(1 for c in calls if c.ok is False)}"
|
|
669
|
+
f" incomplete={sum(1 for c in calls if c.ok is None)}"
|
|
670
|
+
f" first={format_timestamp(min(c.anchor_ms for c in calls))}"
|
|
671
|
+
f" last={format_timestamp(max(c.anchor_ms for c in calls))}"
|
|
672
|
+
f" attributed={attributed.get(name, 0)}"
|
|
673
|
+
f" tools={','.join(sorted({c.tool for c in calls}))}"
|
|
674
|
+
for name, calls in ordered_sessions
|
|
675
|
+
]
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
# --- and then what? An `escalate` that ended a session is not an `escalate` that
|
|
679
|
+
# was retried, and only the pair can tell them apart.
|
|
680
|
+
out += ["", "[after-outcome]", "matched pairs only; " + ",".join(FOLLOW_UP_OUTCOMES)]
|
|
681
|
+
follow: list[str] = []
|
|
682
|
+
for event, call in joined.matched:
|
|
683
|
+
if event.outcome not in FOLLOW_UP_OUTCOMES:
|
|
684
|
+
continue
|
|
685
|
+
later = [
|
|
686
|
+
c
|
|
687
|
+
for c in per_session.get(call.session, ())
|
|
688
|
+
if c.anchor_ms > call.anchor_ms or (c.anchor_ms == call.anchor_ms and c is not call)
|
|
689
|
+
]
|
|
690
|
+
follow.append(
|
|
691
|
+
f"{event.tool} {event.outcome} at={format_timestamp(event.ts_ms)}"
|
|
692
|
+
f" session={call.session} later-calls={len(later)}"
|
|
693
|
+
f" later-same-tool={sum(1 for c in later if c.tool == event.tool)}"
|
|
694
|
+
)
|
|
695
|
+
out += _lines_or_none(follow)
|
|
696
|
+
|
|
697
|
+
# --- the host's errors, counted and never quoted.
|
|
698
|
+
out += [
|
|
699
|
+
"",
|
|
700
|
+
"[host-errors]",
|
|
701
|
+
f"records={host.errors} with-tool={host.errors_with_tool}"
|
|
702
|
+
f" without-tool={host.errors - host.errors_with_tool}",
|
|
703
|
+
"text withheld by design: the host log has been measured to persist argument"
|
|
704
|
+
" values inside exception text",
|
|
705
|
+
]
|
|
706
|
+
|
|
707
|
+
out += [
|
|
708
|
+
"",
|
|
709
|
+
"[limits]",
|
|
710
|
+
"- the join is a heuristic; only mutually unique candidates are paired, and"
|
|
711
|
+
" everything else is counted above rather than guessed",
|
|
712
|
+
"- durations and sessions are reported for matched pairs only; unmatched and"
|
|
713
|
+
" ambiguous events have no session",
|
|
714
|
+
"- the host log is read-only here: nothing under its root is written, created,"
|
|
715
|
+
f" rotated or pruned, and only {HOST_LOG_DIRNAME} directories are read",
|
|
716
|
+
f"- the host-log location is measured on macOS only; elsewhere set"
|
|
717
|
+
f" {HOST_LOG_ROOT_ENV}",
|
|
718
|
+
"",
|
|
719
|
+
]
|
|
720
|
+
return "\n".join(out)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def build_report(
|
|
724
|
+
env: dict[str, str],
|
|
725
|
+
event_log_path: Path | None,
|
|
726
|
+
window_ms: int = DEFAULT_WINDOW_MS,
|
|
727
|
+
platform: str | None = None,
|
|
728
|
+
home: Path | None = None,
|
|
729
|
+
) -> str:
|
|
730
|
+
plat = platform or sys.platform
|
|
731
|
+
root = resolve_host_log_root(env, platform=plat, home=home)
|
|
732
|
+
host = scan_host_log(root, plat)
|
|
733
|
+
events = scan_event_log(event_log_path)
|
|
734
|
+
return render(host, events, join(events.events, host.calls, window_ms))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def resolve_event_log_path(
|
|
738
|
+
env: dict[str, str], store: str | None = None, start: str | None = None
|
|
739
|
+
) -> Path | None:
|
|
740
|
+
"""Where source B would be for THIS invocation, resolved exactly as the server does.
|
|
741
|
+
|
|
742
|
+
One vocabulary for `BANTAMKIT_EVENT_LOG`, not two: `eventlog.resolve_path` decides
|
|
743
|
+
off / default / literal, and this only supplies the store root it needs. The root
|
|
744
|
+
comes from `--store` when given and otherwise from `discover_project_store`, which
|
|
745
|
+
walks up and DESIGNATES without creating anything -- so asking for a report never
|
|
746
|
+
brings a store into existence.
|
|
747
|
+
|
|
748
|
+
A pinned `BANTAMKIT_MEMORY_DIR` that does not exist makes the walk raise. That is a
|
|
749
|
+
real answer, not a crash to hide: the report then names the path the walk would have
|
|
750
|
+
designated, and the `not found at` line says the rest.
|
|
751
|
+
"""
|
|
752
|
+
from bantamkit.eventlog import resolve_path
|
|
753
|
+
from bantamkit.memory.layers import discover_project_store
|
|
754
|
+
|
|
755
|
+
if store:
|
|
756
|
+
root = Path(store)
|
|
757
|
+
else:
|
|
758
|
+
try:
|
|
759
|
+
root = discover_project_store(start)
|
|
760
|
+
except Exception: # noqa: BLE001 - a pin that names nothing must not end the report
|
|
761
|
+
root = Path(start) if start else Path.cwd()
|
|
762
|
+
root = root / ".bantamkit" / "memory"
|
|
763
|
+
return resolve_path(root, env.get("BANTAMKIT_EVENT_LOG"))
|