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.
Files changed (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/mcpserver.py ADDED
@@ -0,0 +1,1334 @@
1
+ """bantamkit as an MCP server: memory + validation over stdio, one instance per person."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import platform
11
+ import sys
12
+ from collections.abc import Callable, Iterator
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from importlib import metadata
16
+ from pathlib import Path
17
+ from typing import Annotated, Any
18
+
19
+ import bantamkit
20
+ from bantamkit import __version__, docread, shiftwork, skillaudit
21
+ from bantamkit.assets import AssetNotFound, assets_root, load_skill, load_tool_asset
22
+ from bantamkit.client import BantamError
23
+ from bantamkit.contract import (
24
+ bantamkit_read_unknown_part,
25
+ document_error,
26
+ document_manifest,
27
+ document_offset_past_end,
28
+ document_page,
29
+ schema_error,
30
+ schema_retry_feedback,
31
+ tool_failed,
32
+ )
33
+ from bantamkit.eventlog import EventLog
34
+ from bantamkit.mcpreport import build_report as build_mcp_report
35
+ from bantamkit.mcpreport import resolve_event_log_path
36
+ from bantamkit.memory import DEFAULT_INDEX_BUDGET, Memory
37
+ from bantamkit.statusline import status_line
38
+
39
+ try:
40
+ from mcp.server import MCPServer
41
+ from mcp.server.mcpserver.exceptions import ResourceError
42
+ from mcp.server.mcpserver.tools import Tool as SDKTool
43
+ from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata
44
+ from pydantic import Field
45
+ except ImportError: # surfaced as a clear SystemExit in main()
46
+ MCPServer = None # type: ignore[assignment]
47
+ Field = None # type: ignore[assignment]
48
+ FuncMetadata = object # type: ignore[assignment,misc]
49
+ ResourceError = None # type: ignore[assignment]
50
+ SDKTool = None # type: ignore[assignment]
51
+
52
+ _INSTALL_HINT = 'bantamkit-mcp needs the MCP extra: pip install "bantamkit[mcp]"'
53
+
54
+ SERVER_NAME = "bantamkit"
55
+
56
+
57
+ def _version() -> str:
58
+ """The version this checkout declares — never the one the last install recorded.
59
+
60
+ RB-P45: `metadata.version("bantamkit")` reads the installed dist-info, and a *stale*
61
+ editable install is not a `PackageNotFoundError`, so the old body could not fall back
62
+ — it returned a confidently wrong number. `__version__` is the same bytes hatchling
63
+ builds the wheel from, is already imported by the time this module exists, and has no
64
+ failure mode to fall back from.
65
+ """
66
+ return __version__
67
+
68
+
69
+ class _Undetermined(Exception):
70
+ """A build fact that could not be derived, carrying WHY. Never becomes a value."""
71
+
72
+
73
+ def _unavailable(reason: str) -> dict[str, str]:
74
+ """The shape an underivable fact takes on the wire.
75
+
76
+ `RB-P51`'s rule is that "could not determine" must never read as a value. A sentinel
77
+ string (`""`, `"unknown"`, `"none"`) is the defect that rule names: it sits in a
78
+ field typed as a path or a digest and a caller comparing fields reads it as one. An
79
+ object cannot — every derivable field here is a string, an int or a bool, so a caller
80
+ that got a dict knows it got no answer, and the key it must read is the reason.
81
+
82
+ What this does NOT buy, said plainly: two servers that both failed to derive the same
83
+ fact carry the same object and compare equal on it. Unavailability is visible, not
84
+ discriminating. That is why `build_id` refuses to exist at all when one of its inputs
85
+ is unavailable, instead of hashing the failure text into something that looks like an
86
+ identity and agrees with every other failure.
87
+ """
88
+ return {"unavailable": reason}
89
+
90
+
91
+ def _tree_digest(root: Path, files: list[Path]) -> str:
92
+ """sha256 over (relative path, size, bytes) of every file, in sorted path order.
93
+
94
+ Paths are folded in so that moving a file is a different build, and sizes so that a
95
+ concatenation cannot be re-partitioned into the same stream. The digest is over the
96
+ CONTENT and its layout under `root` — never over `root` itself — which is the whole
97
+ reason two installs of one build at two different paths fingerprint identically while
98
+ one edited line anywhere does not.
99
+ """
100
+ running = hashlib.sha256()
101
+ for path in files:
102
+ payload = path.read_bytes()
103
+ running.update(path.relative_to(root).as_posix().encode("utf-8"))
104
+ running.update(b"\0%d\0" % len(payload))
105
+ running.update(payload)
106
+ return "sha256:" + running.hexdigest()
107
+
108
+
109
+ def _code_fingerprint() -> tuple[str, int, Path]:
110
+ """Fingerprint the source the interpreter imported this package from.
111
+
112
+ `bantamkit.__file__` is the same resolution the import system already performed, so
113
+ this reads the tree that is actually serving the call — not a checkout that happens
114
+ to be nearby, which is exactly the confusion `RB-P55` names inside a worktree.
115
+
116
+ TWO EXCLUSIONS, AND THE SECOND WAS MEASURED RATHER THAN REASONED. `__pycache__`
117
+ because bytecode is derived, and a build must not fingerprint differently before and
118
+ after its first import. `<package>/assets/` because THE ASSET PACK LIVES INSIDE THE
119
+ PACKAGE IN A WHEEL and beside the repository root in a checkout (`RB-P85`,
120
+ `docs/install.md`, `assets_root()`), while carrying eleven `.py` files of its own
121
+ (`assets/evals/devteam/repo/`): a walk that swept them in counted 22 files from an
122
+ editable checkout and 33 from a wheel-shaped install OF IDENTICAL CONTENT, and
123
+ reported the two as different builds. That is the false positive this whole surface
124
+ exists to avoid — a machine's user-scope wheel and project-scope editable install of
125
+ one commit are ONE build. The pack is fingerprinted in full by `_assets_fingerprint`,
126
+ so nothing goes unmeasured; it is measured once, under the field that names it.
127
+ """
128
+ located = getattr(bantamkit, "__file__", None)
129
+ if not located:
130
+ raise _Undetermined("bantamkit has no __file__; the running code is not on disk")
131
+ root = Path(located).resolve().parent
132
+ packed_assets = root / "assets"
133
+ files = sorted(
134
+ p
135
+ for p in root.rglob("*.py")
136
+ if "__pycache__" not in p.parts and not p.is_relative_to(packed_assets)
137
+ )
138
+ if not files:
139
+ # A digest over nothing is a constant, and two servers that both computed one
140
+ # would agree — the vacuity this whole surface exists to prevent.
141
+ raise _Undetermined(f"no .py source found under {root}")
142
+ try:
143
+ return _tree_digest(root, files), len(files), root
144
+ except OSError as exc:
145
+ raise _Undetermined(f"unreadable source under {root}: {exc}") from None
146
+
147
+
148
+ def _assets_fingerprint() -> tuple[str, int, Path]:
149
+ """Fingerprint the asset pack this build would load.
150
+
151
+ Every file, not just the ones some loader knows about: `assets_root()` is a directory
152
+ the server reads at call time, and a pack carrying an extra or an edited file is a
153
+ different pack whether or not today's code opens it. `contracts/default.yaml` is the
154
+ reason — `RB-P84` measured it as the one asset that differed between two live builds
155
+ while no resource template exposed it, so it was invisible on every probed surface.
156
+ """
157
+ try:
158
+ root = assets_root()
159
+ except AssetNotFound as exc:
160
+ raise _Undetermined(f"assets_root() could not resolve a pack: {exc}") from None
161
+ files = sorted(p for p in root.rglob("*") if p.is_file())
162
+ if not files:
163
+ raise _Undetermined(f"asset pack at {root} contains no files")
164
+ try:
165
+ return _tree_digest(root, files), len(files), root
166
+ except OSError as exc:
167
+ raise _Undetermined(f"unreadable asset under {root}: {exc}") from None
168
+
169
+
170
+ def build_identity() -> dict[str, Any]:
171
+ """What this build can honestly say about itself, over the protocol.
172
+
173
+ `RB-P84`: two `bantamkit` endpoints are registered under one name on a developer
174
+ machine, both are spawned, and their advertised surfaces were byte-identical over
175
+ 6864 bytes. `tools/mcpdrift/mcpdrift.py` closes that from OUTSIDE — it tells a person
176
+ two endpoints differ. This closes the other half: an agent holding a tool result can
177
+ ask which build produced it, mid-call, without reading the server's filesystem.
178
+
179
+ THE FIELD THAT IS THE ANSWER IS `build_id`, and it is deliberately computed from
180
+ content alone — server name, declared version, code digest, asset digest. `version`
181
+ is echoed but is not identity: it moves on release bumps, it has already lied in this
182
+ program (`RB-P45`, a `v0.25.0` checkout advertising `0.3.0`), and refreshing a pinned
183
+ install from HEAD leaves both endpoints reading one number while one keeps drifting
184
+ forward. `package_path`, `assets_root` and `interpreter` are LOCATION, reported
185
+ because they are what a person acts on, and kept out of `build_id` because two
186
+ installs of one build at two paths are one build.
187
+
188
+ WHAT IS REFUSED, and it is a refusal rather than a gap: no commit. See `git_commit`.
189
+
190
+ WHAT THIS DOES NOT COVER, stated so the gap is visible rather than implied: the
191
+ dependency tree. `mcp_sdk_version` is reported for the one SDK that shapes every
192
+ answer on this wire, and nothing else is — two builds whose `pydantic` differs
193
+ fingerprint identically here. `tools/mcpdrift/mcpdrift.py`'s `recall_bad_arg_type`
194
+ probe is the surface that catches that, and it is a person's instrument, not this one.
195
+ """
196
+ identity: dict[str, Any] = {"server_name": SERVER_NAME, "version": _version()}
197
+
198
+ try:
199
+ digest, count, root = _code_fingerprint()
200
+ identity["code_digest"] = digest
201
+ identity["code_files"] = count
202
+ identity["package_path"] = str(root)
203
+ except _Undetermined as exc:
204
+ for field_name in ("code_digest", "code_files", "package_path"):
205
+ identity[field_name] = _unavailable(str(exc))
206
+
207
+ try:
208
+ digest, count, root = _assets_fingerprint()
209
+ identity["assets_digest"] = digest
210
+ identity["assets_files"] = count
211
+ identity["assets_root"] = str(root)
212
+ except _Undetermined as exc:
213
+ for field_name in ("assets_digest", "assets_files", "assets_root"):
214
+ identity[field_name] = _unavailable(str(exc))
215
+
216
+ # Location, never identity: `BANTAMKIT_ASSETS` redirects the pack, so whether the
217
+ # root was chosen by the operator or resolved by the package is a fact a caller
218
+ # comparing two endpoints needs. A bool, so it is never confusable with a missing one.
219
+ identity["assets_root_from_env"] = bool(os.environ.get("BANTAMKIT_ASSETS"))
220
+
221
+ identity["git_commit"] = _unavailable(
222
+ "refused, not missing. An installed wheel carries no repository at all, and "
223
+ "reading a checkout's HEAD would describe the TREE rather than the bytes that "
224
+ "were imported — an edited working copy serves different code under an unchanged "
225
+ "sha, which is precisely the confusion RB-P84 filed. A commit reported that way "
226
+ "would be a value that is sometimes a lie; `code_digest` is derived from the "
227
+ "bytes themselves and cannot be."
228
+ )
229
+
230
+ identity["interpreter"] = sys.executable or _unavailable(
231
+ "sys.executable is empty; this interpreter cannot name its own binary"
232
+ )
233
+ identity["python_version"] = platform.python_version()
234
+ identity["python_implementation"] = platform.python_implementation()
235
+ try:
236
+ # The SDK's OWN version, from the only source there is for a third-party dist.
237
+ # This is NOT a rollback of RB-P45: bantamkit's version still comes from
238
+ # `__version__` (see `_version`), this number is never used for it, and it is
239
+ # kept OUT of `build_id` for the same reason RB-P45 gives — an editable install's
240
+ # dist-info can be stale, so it is reported as environment, not as identity.
241
+ identity["mcp_sdk_version"] = metadata.version("mcp")
242
+ except metadata.PackageNotFoundError:
243
+ identity["mcp_sdk_version"] = _unavailable(
244
+ "no installed distribution metadata for `mcp`; the SDK is importable but "
245
+ "not pip-recorded, so its version is not knowable here"
246
+ )
247
+
248
+ identity_inputs = ("server_name", "version", "code_digest", "assets_digest")
249
+ inputs = {key: identity[key] for key in identity_inputs}
250
+ missing = sorted(key for key, value in inputs.items() if isinstance(value, dict))
251
+ if missing:
252
+ identity["build_id"] = _unavailable(
253
+ "computed from server_name, version, code_digest and assets_digest; could "
254
+ f"not derive {', '.join(missing)}. A build_id short of an input would agree "
255
+ "with every other build that lost the same input, so none is reported."
256
+ )
257
+ else:
258
+ canonical = json.dumps(inputs, sort_keys=True, separators=(",", ":"))
259
+ identity["build_id"] = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
260
+
261
+ identity["unavailable"] = sorted(
262
+ key
263
+ for key, value in identity.items()
264
+ if isinstance(value, dict) and "unavailable" in value
265
+ )
266
+ return identity
267
+
268
+
269
+ # ---------------------------------------------------------------------------------------
270
+ # `bantamkit_status`, the degraded footer, and the conditions both are built from.
271
+ #
272
+ # WHY A TOOL RESULT AND NOT A NOTIFICATION, MEASURED BEFORE ANY OF THIS WAS WRITTEN.
273
+ # The request this answers is "let me SEE that the server is alive", and the obvious
274
+ # implementation — push a line at the host — does not exist. Claude Code's own binary
275
+ # carries the sentence `modern protocol revision with no unsolicited notification path`,
276
+ # and its proprietary `claude/channel` capability is gated six ways, one of which is
277
+ # `provider !== "firstParty"` — which rules out Copilot on its own. bantamkit's host log
278
+ # says the same thing from the running side: `"protocolEra":"modern"` in every
279
+ # `Connection established` record and ten `Channel notifications skipped: server did not
280
+ # declare claude/channel capability`. So the only surface every host is guaranteed to
281
+ # render is a TOOL RESULT, and that is what all three pieces here are.
282
+ #
283
+ # THE THREE PIECES AND WHO EACH ONE IS FOR:
284
+ # * `bantamkit_status` — a tool. The MODEL can call it, so an agent mid-conversation can
285
+ # answer "is this thing working" without the operator leaving the transcript.
286
+ # * a same-named PROMPT — `prompts/list` was empty on both runtimes. A prompt is what a
287
+ # PERSON invokes. The operator asking whether their server is alive is the person.
288
+ # * the FOOTER — one line appended to the OTHER tools' results, and only when something
289
+ # is wrong. Never on a healthy call: a footer on every result is noise, noise trains
290
+ # the reader to stop reading, and the one time it matters it is then invisible.
291
+ #
292
+ # `docs/status.md` is the contract. Both runtimes emit these bytes.
293
+ # ---------------------------------------------------------------------------------------
294
+
295
+ #: The tool AND the prompt answer to this one name. Deliberately the same word: the
296
+ #: operator who read `bantamkit_status` in a footer must find it in their prompt menu
297
+ #: without translating, and a model that read it in a prompt must find the tool.
298
+ STATUS_NAME = "bantamkit_status"
299
+
300
+ #: What the prompt says after the report. The report is the payload; this is the one line
301
+ #: that tells the model what the person wanted with it, and it asks for a RELAY rather
302
+ #: than an interpretation — the operator invoked this to read the server's own words.
303
+ STATUS_PROMPT_TAIL = (
304
+ "Show me that report as it stands. If it says Degraded, tell me which of the problems "
305
+ "above you would deal with first and why; if it says Active, say so in one line and "
306
+ "stop."
307
+ )
308
+
309
+ #: The prompt and resource-template counts `status_report` prints, named here because the
310
+ #: SDK offers no public count of either and reaching into its managers is exactly the
311
+ #: private-attribute habit `_from_manifest` exists to have ended. They are PINNED AGAINST
312
+ #: THE WIRE by `test_status_surface.py`, which drives a real `prompts/list` and
313
+ #: `resources/templates/list` and fails if a registration is added or removed without
314
+ #: moving these — so they are a declaration, not a guess.
315
+ SERVED_PROMPTS = 1
316
+ SERVED_RESOURCE_TEMPLATES = 2
317
+
318
+ #: Percent of the index budget that has to be SPENT before the store is called degraded.
319
+ #:
320
+ #: 90 and not 100 because the useful moment is before the refusal, not after it: at 100%
321
+ #: the next `memory_save` has already failed and the operator has already seen the error.
322
+ #: An INTEGER percent, compared by cross-multiplication below, so the two runtimes cannot
323
+ #: land on opposite sides of the line through a float they rounded differently.
324
+ INDEX_PRESSURE_PERCENT = 90
325
+
326
+
327
+ @dataclass(frozen=True)
328
+ class Condition:
329
+ """One thing that is wrong, carried in the two forms the two surfaces need.
330
+
331
+ `key` is an ASCII token from a closed set, which is the ONLY form that may reach the
332
+ event log (`docs/eventlog.md`'s metadata-only rule); nothing here writes one today,
333
+ and the field exists so that a future record cannot be tempted to log the prose.
334
+ `sentence` is what a person reads. It never carries a tool argument, a memory body, a
335
+ validated output, or a grant NAME — a layer's KIND is reportable, its name is not.
336
+ """
337
+
338
+ key: str
339
+ sentence: str
340
+
341
+
342
+ def _plural(count: int, word: str) -> str:
343
+ """`1 prompt` / `2 prompts`. Spelled once so the two runtimes cannot disagree twice."""
344
+ return f"{count} {word}" if count == 1 else f"{count} {word}s"
345
+
346
+
347
+ def _index_bytes(memory: Memory) -> int | None:
348
+ """The size of the writable store's `index.md` on disk, or `None` if it has none.
349
+
350
+ THE STAT, NOT THE PARSE, and the difference is the reason this can run on every tool
351
+ call. `Memory.index_accounting()` re-derives the index by reading every fact file —
352
+ the docstring there says so, and the event log only calls it when a log is actually
353
+ enabled. A footer that has to decide on EVERY call cannot pay that, so this reads the
354
+ rendered artefact the store itself keeps up to date (`MemoryStore._rebuild_index`
355
+ writes it on every save) with one `stat`.
356
+
357
+ What that trade costs, said plainly: a store whose facts were edited on disk behind
358
+ the server's back has a stale `index.md`, and this reports the stale size. The
359
+ condition is a WARNING that the budget is nearly spent, not the budget check itself —
360
+ `MemoryStore._check_index_budget` is still the thing that refuses a save, and it still
361
+ measures the parse. The two cannot disagree about a store only bantamkit has written.
362
+
363
+ ABSENT IS 0 AND UNREADABLE IS `None`, and the two are split on the errno rather than
364
+ collapsed. A store that has never been saved to has no `index.md` and really does
365
+ spend nothing of its budget; a store whose directory refuses a `stat` has an unknown
366
+ index, and reporting that as 0 would claim infinite headroom at exactly the moment
367
+ there may be none — the same slander `Memory.index_accounting` refuses to commit.
368
+ """
369
+ try:
370
+ return (memory.store.root / "index.md").stat().st_size
371
+ except FileNotFoundError:
372
+ return 0
373
+ except OSError:
374
+ return None
375
+
376
+
377
+ def _asset_pack_condition() -> Condition | None:
378
+ """Is the pack this build loads still there?
379
+
380
+ `build_server` raises `AssetNotFound` at startup for a tool without a manifest, so a
381
+ server that is RUNNING resolved its pack once. It can still lose it afterwards — an
382
+ upgrade that replaces the directory, a `BANTAMKIT_ASSETS` pointed at a scratch tree
383
+ that gets cleaned up — and the failure is quiet: the tool descriptions were read at
384
+ startup and keep being served, while `resources/read` and every later `load_skill`
385
+ have nothing behind them.
386
+
387
+ Deliberately no path in the sentence. `assets_root()` resolves to DIFFERENT paths in
388
+ the two runtimes by construction (`_print_assets_root` says why at length), so a path
389
+ here would make the one line of this surface that cannot be compared across them, to
390
+ buy what `--assets-root` prints on demand.
391
+ """
392
+ try:
393
+ root = assets_root()
394
+ except AssetNotFound:
395
+ return Condition(
396
+ "asset-pack-missing",
397
+ "the asset pack cannot be resolved at all, so skills, rubrics and tool "
398
+ "descriptions have nothing behind them — reinstall the package, or point "
399
+ "BANTAMKIT_ASSETS at a real pack and restart the server.",
400
+ )
401
+ if not root.is_dir():
402
+ return Condition(
403
+ "asset-pack-missing",
404
+ "the asset pack is gone from where this server resolved it, so skills, "
405
+ "rubrics and tool descriptions can no longer be re-read — run "
406
+ "`bantamkit-mcp --assets-root` to see where it is looking, then restart.",
407
+ )
408
+ return None
409
+
410
+
411
+ def _unreadable_layer_condition(memory: Memory) -> Condition | None:
412
+ """A memory layer that could not be LISTED — which is not a layer that held nothing.
413
+
414
+ This is the sharpest of the four, because it is the one whose damage is a wrong ANSWER
415
+ rather than a missing one: `Memory._nothing_to_report` already refuses to say "nothing
416
+ is saved" when a layer is unreadable, but that sentence only reaches a person who
417
+ happened to run an empty recall. The footer says it on every call.
418
+
419
+ KIND, NEVER NAME. A layer's label is `project`, `extra:<grant name>` or `profile`, and
420
+ the grant name is the operator's own words for somebody's directory. The kind is the
421
+ part that is reportable; the split is here rather than at the call site so there is
422
+ exactly one place it can be got wrong.
423
+
424
+ It reads `Memory._layers` and `Memory._fact_count` through their private names on
425
+ purpose. A public accessor would be the right shape and would be a change to the
426
+ MEMORY layer, which this unit is not authorised to make (job39 invariant 4). Nothing
427
+ is re-implemented: `_fact_count` is the same counter `_unreadable_layers` uses, so
428
+ "unreadable" means here exactly what it means to recall.
429
+ """
430
+ unreadable = [
431
+ label
432
+ for label, store, _writable in memory._layers
433
+ if memory._fact_count(store.root) is None
434
+ ]
435
+ if not unreadable:
436
+ return None
437
+ kinds = sorted({label.split(":", 1)[0] for label in unreadable})
438
+ return Condition(
439
+ "memory-layer-unreadable",
440
+ f"{_plural(len(unreadable), 'memory layer')} could not be read "
441
+ f"({_plural(len(kinds), 'kind')}: {', '.join(kinds)}), so an empty recall is not "
442
+ "evidence that nothing is saved — check that those store directories exist and "
443
+ "are readable.",
444
+ )
445
+
446
+
447
+ def _index_pressure_condition(memory: Memory) -> Condition | None:
448
+ """The index is nearly as big as the budget that has to hold it."""
449
+ size = _index_bytes(memory)
450
+ budget = memory.store.index_budget
451
+ if size is None or size * 100 < INDEX_PRESSURE_PERCENT * budget:
452
+ return None
453
+ return Condition(
454
+ "index-budget-low",
455
+ f"the memory index is {size} bytes of a {budget}-byte budget, so the next save "
456
+ "is close to being refused — archive or shorten facts with "
457
+ "`python -m bantamkit.memory compact`.",
458
+ )
459
+
460
+
461
+ def _event_log_condition(log: EventLog) -> Condition | None:
462
+ """The log was asked for and a record has already been lost.
463
+
464
+ Only reachable when the operator turned it on: `write_failed` is set inside `record`'s
465
+ `except OSError` and a disabled log returns before any I/O. So this never fires for
466
+ the default configuration, which is off.
467
+ """
468
+ if not (log.enabled and log.write_failed):
469
+ return None
470
+ return Condition(
471
+ "event-log-unwritable",
472
+ "the event log is switched on but a write to it has already failed, so tool "
473
+ "outcomes are going unrecorded — check the path in BANTAMKIT_EVENT_LOG and "
474
+ "whether its directory is writable.",
475
+ )
476
+
477
+
478
+ def degraded_conditions(memory: Memory, log: EventLog) -> list[Condition]:
479
+ """Everything wrong right now, worst first. Empty list means healthy.
480
+
481
+ ORDER IS SEVERITY AND IT IS LOAD-BEARING, because the footer shows the first one: a
482
+ pack that vanished breaks every asset-backed surface; an unreadable layer makes recall
483
+ ANSWER WRONGLY rather than fail; a full index refuses the next save; a broken event log
484
+ costs diagnostics only.
485
+
486
+ EVERY CONDITION IS OBSERVED, NOT INFERRED — no heartbeat, no timer, no last-seen
487
+ timestamp. Each one is a state a test can construct and then watch this report: delete
488
+ the pack, make a layer's `facts/` a file, build a store whose index already exceeds
489
+ nine tenths of its budget, point the log at an unwritable path. A condition that
490
+ cannot be constructed is not claimed.
491
+
492
+ THE COST, because this runs on every tool call: one `stat` for the index, one `is_dir`
493
+ for the pack, one `scandir` per memory layer (two to four), and a field read for the
494
+ log. No fact file is opened and no index is parsed.
495
+ """
496
+ found = (
497
+ _asset_pack_condition(),
498
+ _unreadable_layer_condition(memory),
499
+ _index_pressure_condition(memory),
500
+ _event_log_condition(log),
501
+ )
502
+ return [condition for condition in found if condition is not None]
503
+
504
+
505
+ def degraded_notice(conditions: list[Condition]) -> str:
506
+ """The one line other tools' results carry when something is wrong. `""` when nothing is.
507
+
508
+ THE EMPTY STRING FOR A HEALTHY SERVER IS THE WHOLE POINT, and it is checked in a pair:
509
+ `test_status_surface.py` asserts the notice is present on a degraded call AND absent on
510
+ a healthy one, because only the pair proves it is conditional. A footer on every result
511
+ is noise, and noise trains a reader to skip it — at which point the one time it matters
512
+ it is invisible. That is what the operator asked for in their own words
513
+ ("ร่วม footer เฉพาะตอนผิดปกติ ด้วย") and it is the property to protect.
514
+
515
+ ONE SHAPE, ALWAYS, including for a single condition — a count of 1 reads fine, and a
516
+ second shape is a second thing for the port to get right. The worst condition is spelt
517
+ out because a bare count is not actionable; the rest are a number and a pointer,
518
+ because this is a FOOTER on somebody else's answer and has no licence to become the
519
+ answer.
520
+ """
521
+ if not conditions:
522
+ return ""
523
+ return (
524
+ f"⚠️ bantamkit degraded ({len(conditions)}): {conditions[0].sentence} "
525
+ "Call `bantamkit_status` for the full report."
526
+ )
527
+
528
+
529
+ def status_report(
530
+ memory: Memory,
531
+ log: EventLog,
532
+ conditions: list[Condition],
533
+ tools: int,
534
+ prompts: int,
535
+ templates: int,
536
+ ) -> str:
537
+ """The whole answer `bantamkit_status` returns — prose, for a person, in a transcript.
538
+
539
+ THE FIRST LINE IS THE ANSWER and it is the line the operator asked for by name:
540
+ `bantamkit Active 🟢`, or `bantamkit Degraded 🟠` when `conditions` is non-empty. A
541
+ reader who stops after eight characters has still learned the thing they came for.
542
+
543
+ THE ONE FIELD THAT IS NOT COMPARABLE ACROSS RUNTIMES is `build`. It is `build_id`,
544
+ which is a fingerprint of the running source, and the two runtimes fingerprint two
545
+ different trees by construction — `docs/porting.md`'s divergence table already rules
546
+ exactly this for `build_identity`. It is here anyway because "which bantamkit" is half
547
+ the question this tool exists to answer: two endpoints registered under one name is
548
+ the situation RB-P84 filed, and a version string cannot tell them apart.
549
+
550
+ NO ARGUMENT VALUE CAN REACH THIS. It takes none, and every input above is server
551
+ state; `test_status_surface.py` asserts the absence positively with a sentinel.
552
+ """
553
+ identity = build_identity()
554
+ build = identity["build_id"]
555
+ facts = memory._fact_count(memory.store.root)
556
+ size = _index_bytes(memory)
557
+ budget = memory.store.index_budget
558
+ lines = [
559
+ f"bantamkit {'Degraded 🟠' if conditions else 'Active 🟢'}",
560
+ f"version {identity['version']}, build "
561
+ + (build if isinstance(build, str) else "unavailable"),
562
+ f"serving {_plural(tools, 'tool')}, {_plural(prompts, 'prompt')}, "
563
+ f"{_plural(templates, 'resource template')}",
564
+ "memory: "
565
+ + (
566
+ "the project store could not be read"
567
+ if facts is None
568
+ else f"{_plural(facts, 'fact')} in the project store"
569
+ )
570
+ + ", index "
571
+ + ("unreadable" if size is None else str(size))
572
+ + f" of {budget} bytes",
573
+ f"event log: {'on' if log.enabled else 'off'}",
574
+ ]
575
+ if conditions:
576
+ lines.append(f"{_plural(len(conditions), 'problem')}:")
577
+ lines.extend(f"- {condition.sentence}" for condition in conditions)
578
+ return "\n".join(lines)
579
+
580
+
581
+ class _ArgMetadata(FuncMetadata): # type: ignore[misc,valid-type]
582
+ """The SDK's argument metadata, with the JSON pre-parse switched off where Node has none.
583
+
584
+ Before validating, `FuncMetadata.pre_parse_json` runs `json.loads` over every string
585
+ argument whose annotation is not exactly `str` (so `str | None` and `int | None`
586
+ qualify) to unwrap a JSON-encoded list or object some hosts send. The Node SDK does no
587
+ such thing, and `bantamkit_read` is the one tool on this surface whose arguments are
588
+ not plain `str`, so it was the one tool whose two halves could read the same call
589
+ differently. Measured on the wire (job43 F2, then review round 2): a 4301-digit `part`
590
+ reached the model as `isError: Exceeds the limit (4300 digits) for integer string
591
+ conversion` — `json.loads` hit CPython's integer-string cap before the handler ran;
592
+ `part="null"` was unwrapped to `None` and served the MANIFEST where Node refuses an
593
+ unknown part named `null`; `part="[1]"` became a list and a pydantic `string_type`
594
+ error where Node says `no part named [1]`; `offset="null"` paged from row 0 where
595
+ Node's `pyargs` refuses it as `int_parsing`.
596
+
597
+ THE PROPERTY: for `bantamkit_read`, no argument is JSON-unwrapped — a string is the
598
+ string that was sent. Pydantic's own lax coercion stays (`offset="5"` → 5), because
599
+ Node's `pyargs` does the same. `unwrap_json` is `False` for that tool alone: the other
600
+ nine take plain `str` (plus `list`/`dict` fields the SDK unwraps by design, e.g.
601
+ `memory_save.links`), and for them the per-key loop below is the one change: a
602
+ `ValueError` the SDK's own loop would have let escape — `json.loads` on a 4301-digit
603
+ integer string hits CPython's digit cap — now leaves that key as the string it was, so
604
+ `memory_save(links="[" + "1"*4301 + "]")` is pydantic's `list_type` validation frame
605
+ instead of an `isError` carrying `Exceeds the limit (4300 digits)` (measured, review
606
+ round 3). That is the frame Node already printed, so it is a parity gain, not a
607
+ behaviour the nine keep.
608
+ """
609
+
610
+ unwrap_json: bool = True
611
+
612
+ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
613
+ if not self.unwrap_json:
614
+ return data
615
+ out = data.copy()
616
+ for key, value in data.items():
617
+ try:
618
+ out[key] = super().pre_parse_json({key: value})[key]
619
+ except ValueError:
620
+ out[key] = value
621
+ return out
622
+
623
+
624
+ # Tools whose arguments reach the handler exactly as sent — see `_ArgMetadata`.
625
+ _NO_JSON_UNWRAP = frozenset({"bantamkit_read"})
626
+
627
+
628
+ # `assets/tools/bantamkit_read.json` `offset.maximum` — Number.MAX_SAFE_INTEGER, the largest
629
+ # integer a JSON parser on the Node side reads back unchanged.
630
+ OFFSET_MAXIMUM = 9007199254740991
631
+
632
+
633
+ def _from_manifest(fn: Callable[..., Any], name: str) -> Any:
634
+ """Bind one handler to its manifest entry — description, BOTH schemas, and the surface.
635
+
636
+ The asset pack under `assets/tools/` is the tool contract for every runtime that
637
+ serves this surface, so the schema has to arrive WITH the registration rather than be
638
+ corrected onto it afterwards. Until this function existed, `build_server` registered
639
+ each handler by decorator (description from a Python constant, schema derived from the
640
+ signature) and then reached through a private attribute of the SDK's tool manager to
641
+ overwrite two of the schemas after the fact. The served bytes were right, but only for
642
+ as long as that attribute stayed reachable under that name: an SDK that renamed it
643
+ would have gone back to advertising whatever the Python signature says, silently, and
644
+ a port to a second runtime would have had to re-read Python to learn the contract.
645
+
646
+ `MCPServer(..., tools=[...])` is the public seam. The object handed to the constructor
647
+ already carries the manifest's description and schema, so registration and truth are
648
+ one step. `from_function` still derives `fn_metadata` from the signature, and that is
649
+ what validates arguments at CALL time; the signatures mirror the manifest. Only what
650
+ is ADVERTISED changes hands here, and it now has exactly one source.
651
+
652
+ `output_schema` goes through the same seam by a different route. It is not a field on
653
+ the SDK's `Tool`; it is a `cached_property` returning `fn_metadata.output_schema`, and
654
+ `MCPServer.list_tools` copies it straight onto the wire's `outputSchema`. Putting the
655
+ manifest's value in the instance dict is what a `cached_property` reads first, so the
656
+ override lands — and it lands on the ADVERTISEMENT ONLY, because the call path
657
+ (`FuncMetadata.convert_result`) consults `fn_metadata`, which is untouched. That is
658
+ the same division `parameters` already has: the manifest says what is promised, the
659
+ signature still says what is enforced.
660
+
661
+ The `surfaces` check is what makes that field load-bearing. `assets/tools/` serves the
662
+ eval agent too, and a manifest entry that does not claim `mcp` must not become a tool
663
+ on this server — otherwise the field is a comment, and a port that trusts it would
664
+ serve a different set of tools than this runtime does.
665
+ """
666
+ asset = load_tool_asset(name)
667
+ if "mcp" not in asset["surfaces"]:
668
+ raise BantamError(
669
+ f"tool asset {name!r} does not claim the mcp surface: {asset['surfaces']}"
670
+ )
671
+ tool = SDKTool.from_function(fn, name=asset["name"], description=asset["description"])
672
+ return tool.model_copy(
673
+ update={
674
+ "parameters": asset["parameters"],
675
+ "output_schema": asset["output_schema"],
676
+ "fn_metadata": _ArgMetadata(
677
+ **dict(tool.fn_metadata), unwrap_json=name not in _NO_JSON_UNWRAP
678
+ ),
679
+ }
680
+ )
681
+
682
+
683
+ @contextmanager
684
+ def _record_raise(log: EventLog, tool: str) -> Iterator[None]:
685
+ """Record the TYPE of anything that escapes, then let it escape unchanged.
686
+
687
+ The host already logs that a tool failed and how long it took; what it cannot say is
688
+ which exception the component threw — and the one place it tried, it leaked an
689
+ argument value doing it (`eventlog.py`'s docstring, measured). So: `type(exc).__name__`
690
+ and nothing else, via `EventLog.raised`, which has no other input available to it.
691
+
692
+ `BaseException` rather than `Exception` deliberately. A `KeyboardInterrupt` or a
693
+ `SystemExit` out of a handler is exactly the shape whose cause is hardest to
694
+ reconstruct afterwards, the record costs one line, and the `raise` is unconditional
695
+ — nothing here decides whether the tool fails, only whether the failure was written
696
+ down. `EventLog.record` swallows its own `OSError`, so this cannot mask the original.
697
+ """
698
+ try:
699
+ yield
700
+ except BaseException as exc:
701
+ log.raised(tool, exc)
702
+ raise
703
+
704
+
705
+ def _record_result(log: EventLog, tool: str, call: Callable[[], dict[str, Any]]) -> dict[str, Any]:
706
+ """Run a shiftwork handler and record its `result` — the register's OWN verdict.
707
+
708
+ Every `shiftwork` entry point answers a dict whose `result` key is the decision it
709
+ made: `brief`, `escalate`, `success`, `ok`, `status`, `error`. `clock_in` returning
710
+ `escalate` is the sharp case — the register's whole stop-and-ask contract, invisible
711
+ in the host's log because the tool call succeeded. Reading the key is reading the
712
+ decision; nothing here inspects a `reason` string, which is prose and can carry a
713
+ checkpoint path.
714
+
715
+ `raised` is the outcome for an ESCAPING EXCEPTION and is spelled differently from
716
+ `error` on purpose: `result: "error"` is a refusal the register composed and returned
717
+ normally, and collapsing the two would lose the only distinction between a checkpoint
718
+ that was rejected and a handler that fell over.
719
+ """
720
+ with _record_raise(log, tool):
721
+ answer = call()
722
+ log.record(tool, str(answer.get("result", "unknown")))
723
+ return answer
724
+
725
+
726
+ def build_server(memory: Memory, log: EventLog | None = None) -> Any:
727
+ """Assemble the MCP server around one Memory instance (the per-person state).
728
+
729
+ `log` is the event-log sink (`docs/eventlog.md`), resolved from `BANTAMKIT_EVENT_LOG`
730
+ when the caller does not supply one and DISABLED unless that variable asks for it.
731
+ It is a parameter and not only an environment read so that a test can inject a fixed
732
+ clock and a scratch path without setting a process-wide variable — the same seam
733
+ `runtime-ts` needs for its half of the conformance case.
734
+
735
+ EVERY HANDLER BELOW RECORDS FROM A DECISION, NEVER FROM ITS REPLY. `memory_save`
736
+ reads `SaveOutcome.status`, `memory_recall` reads `RecallOutcome.status`, the three
737
+ shiftwork tools read the register's own `result` key, `validate_json` reads the
738
+ `valid` bool it is about to return, and `build_identity` reads the length of the
739
+ `unavailable` list it computed. Not one of them looks at the words. That is the
740
+ property `test_eventlog.py::test_the_record_does_not_move_when_the_reply_wording_
741
+ does` holds: change a reply's wording and the record must be byte-identical.
742
+
743
+ THE DEGRADED FOOTER IS APPLIED AFTER THE RECORD, EVERY TIME. A footer is a rendering
744
+ decision about somebody else's answer; the record is the decision the component made.
745
+ Folding one into the other would put a filesystem observation into a line a
746
+ conformance case byte-compares, and would make the log move when nothing the tool did
747
+ moved. `_noted` and `_noted_dict` below therefore run last, on the way out.
748
+ """
749
+ if MCPServer is None:
750
+ raise SystemExit(_INSTALL_HINT)
751
+ if log is None:
752
+ log = EventLog.from_env(memory.store.root)
753
+
754
+ def _notice() -> str:
755
+ """The footer for right now — recomputed per call, never cached.
756
+
757
+ A cache would be the one thing that could make this lie: a condition that cleared
758
+ (or arrived) between two calls has to be visible on the next one, and the whole
759
+ probe is a handful of `stat`s. It is also what keeps two servers over one store
760
+ from disagreeing about the state of it.
761
+ """
762
+ return degraded_notice(degraded_conditions(memory, log))
763
+
764
+ def _noted(reply: str) -> str:
765
+ """A prose reply, plus the footer if there is one. Byte-identical when healthy."""
766
+ notice = _notice()
767
+ return f"{reply}\n\n{notice}" if notice else reply
768
+
769
+ def _noted_dict(answer: dict[str, Any]) -> dict[str, Any]:
770
+ """A structured reply, plus the footer if there is one, under one reserved key.
771
+
772
+ A JSON result has no margin to write in: the rendered text of these tools IS the
773
+ serialised object, so a sentence appended to it would stop being parseable. The
774
+ footer therefore arrives as `bantamkit_degraded`, LAST in the key order and only
775
+ when it exists — every one of these tools advertises
776
+ `additionalProperties: true`, so a key that comes and goes is inside the contract
777
+ it already declares. A healthy call is byte-identical to what it was before this
778
+ surface existed, which is the same guarantee `_noted` gives for prose.
779
+ """
780
+ notice = _notice()
781
+ return {**answer, "bantamkit_degraded": notice} if notice else answer
782
+
783
+ def memory_save(
784
+ type: str, name: str, description: str, body: str, links: list[str] | None = None
785
+ ) -> str:
786
+ with _record_raise(log, "memory_save"):
787
+ outcome = memory.save_outcome(type, name, description, body, links)
788
+ if log.enabled:
789
+ # Only when a log is actually on: `index_accounting` re-parses `facts/`, and
790
+ # a diagnostic must not put that on the path of an operator who did not ask
791
+ # for one. Headroom is left to the reader rather than stored — it is
792
+ # `budget - index_bytes`, and a derived field is a second thing to keep true.
793
+ index_bytes, budget = memory.index_accounting()
794
+ detail: dict[str, Any] = {"budget": budget}
795
+ if index_bytes is not None:
796
+ detail["index_bytes"] = index_bytes
797
+ log.record("memory_save", outcome.status, detail)
798
+ return _noted(outcome.reply)
799
+
800
+ def memory_recall(query: str, k: int | None = None) -> str:
801
+ if k is not None:
802
+ k = max(1, min(k, 5)) # the advertised schema's bounds; clients may ignore it
803
+ with _record_raise(log, "memory_recall"):
804
+ outcome = memory.recall_outcome(query, k)
805
+ detail = {
806
+ "budget": outcome.budget,
807
+ "candidates": outcome.candidates,
808
+ "layers": outcome.layers,
809
+ "reached": outcome.reached,
810
+ "returned": outcome.returned,
811
+ "unreadable": outcome.unreadable,
812
+ }
813
+ if outcome.source is not None:
814
+ detail["source"] = outcome.source
815
+ log.record("memory_recall", outcome.status, detail)
816
+ return _noted(outcome.reply)
817
+
818
+ def memory_compact(reserve: int | None = None) -> str:
819
+ """The model's half of compaction; the hook (`docs/hooks.md`) is the automatic half.
820
+
821
+ `memory_save`'s refused-budget reply names this tool, so it acts on exactly the
822
+ store that refused: `Memory.compact_outcome` reaches `memory.store` — the
823
+ writable project layer — and never a grant or the profile layer. The status is
824
+ the store's own decision (`archived` when the archive list is non-empty,
825
+ `nothing-archived` otherwise), never a match on the reply.
826
+ """
827
+ # No floor here: `MemoryStore.compact` clamps `reserve` to `[0, budget // 2]`
828
+ # itself, so a negative value from a client that ignored the schema is already
829
+ # handled where the arithmetic lives, and a second clamp would be a second thing
830
+ # to keep equal across the two runtimes.
831
+ with _record_raise(log, "memory_compact"):
832
+ outcome = memory.compact_outcome(reserve)
833
+ log.record(
834
+ "memory_compact",
835
+ outcome.status,
836
+ {
837
+ "archived": outcome.archived,
838
+ "budget": outcome.budget,
839
+ "index_after": outcome.index_after,
840
+ "index_before": outcome.index_before,
841
+ },
842
+ )
843
+ return _noted(outcome.reply)
844
+
845
+ def validate_json(output: str, schema: dict[str, Any]) -> dict[str, Any]:
846
+ with _record_raise(log, "validate_json"):
847
+ error = schema_error(output, schema)
848
+ if error is None:
849
+ log.record("validate_json", "valid")
850
+ return _noted_dict({"valid": True, "feedback": None})
851
+ log.record("validate_json", "invalid")
852
+ return _noted_dict(
853
+ {
854
+ "valid": False,
855
+ "feedback": schema_retry_feedback(error),
856
+ }
857
+ )
858
+
859
+ def shiftwork_clock_in(checkpoint: str) -> dict[str, Any]:
860
+ return _noted_dict(
861
+ _record_result(log, "shiftwork_clock_in", lambda: shiftwork.clock_in(checkpoint))
862
+ )
863
+
864
+ def shiftwork_clock_out(
865
+ checkpoint: str,
866
+ unit_id: str,
867
+ status: str,
868
+ handoff_patch: dict[str, Any],
869
+ history_entry: dict[str, Any],
870
+ accounting: dict[str, Any] | None = None,
871
+ ) -> dict[str, Any]:
872
+ return _noted_dict(
873
+ _record_result(
874
+ log,
875
+ "shiftwork_clock_out",
876
+ lambda: shiftwork.clock_out(
877
+ checkpoint, unit_id, status, handoff_patch, history_entry, accounting
878
+ ),
879
+ )
880
+ )
881
+
882
+ def shiftwork_status(checkpoint: str) -> dict[str, Any]:
883
+ return _noted_dict(
884
+ _record_result(log, "shiftwork_status", lambda: shiftwork.status(checkpoint))
885
+ )
886
+
887
+ # A TOOL and not a resource or an `initialize` field, because the gap RB-P84 names is
888
+ # an AGENT MID-CALL: the host reads `serverInfo` once at handshake and the
889
+ # tool-calling model never sees it, and `resources/read` is a host-facing surface
890
+ # most clients never expose to the model at all. A tool is in `tools/list`, so the
891
+ # model that just received a `memory_recall` answer can ask who answered it.
892
+ def build_identity_tool() -> dict[str, Any]:
893
+ with _record_raise(log, "build_identity"):
894
+ identity = build_identity()
895
+ # The COUNT of underivable fields, not the fields and not the digests. A code
896
+ # digest is by construction different in the two runtimes — they fingerprint two
897
+ # different trees (`docs/porting.md`'s divergence table says so about `build_id`)
898
+ # — so putting one in a record that a conformance case byte-compares would make
899
+ # the record unportable to buy nothing the tool's own reply does not already say.
900
+ log.record(
901
+ "build_identity",
902
+ "partial" if identity["unavailable"] else "complete",
903
+ {"unavailable": len(identity["unavailable"])},
904
+ )
905
+ return _noted_dict(identity)
906
+
907
+ def bantamkit_status() -> str:
908
+ """The one surface every host renders, answering "is this thing working".
909
+
910
+ NO FOOTER ON THIS ONE, and the omission is the design rather than an oversight:
911
+ the report already carries every condition in full, and a footer would repeat the
912
+ worst of them three lines below itself.
913
+
914
+ NOTHING IS RECORDED IN THE EVENT LOG EITHER. Every other handler records the
915
+ decision its component made; this one makes no decision — it observes. A record
916
+ would be a second, worse copy of a state the log's own reader can see, and its
917
+ `outcome` would have to be the health verdict, which moves with the filesystem
918
+ rather than with anything the call did. `_record_raise` still wraps the body, so
919
+ a handler that FALLS OVER is still written down.
920
+ """
921
+ with _record_raise(log, "bantamkit_status"):
922
+ return status_report(
923
+ memory,
924
+ log,
925
+ degraded_conditions(memory, log),
926
+ len(tools),
927
+ SERVED_PROMPTS,
928
+ SERVED_RESOURCE_TEMPLATES,
929
+ )
930
+
931
+ def bantamkit_read(
932
+ path: str,
933
+ part: str | None = None,
934
+ offset: Annotated[int, Field(le=OFFSET_MAXIMUM)] | None = None,
935
+ limit: int | None = None,
936
+ ) -> str:
937
+ """The reader on the MCP surface (job43): `docread` digests, `contract` words it.
938
+
939
+ The eval pair (`evalrun._document_tools`) already renders a manifest, a page and
940
+ every refusal from these two modules, and this handler makes the SAME calls with
941
+ the path standing in for the document name, so the two surfaces print the same
942
+ bytes for the same file. Three sentences are this tool's own — the continuation
943
+ line names `bantamkit_read`, an unknown part is a fact about the file, and so is
944
+ a part with no rows (`document_error` over `"{part}" in {path} has no rows`).
945
+
946
+ THE RECORD IS A DECISION, NEVER A REPLY. `manifest` and `page` are the branch
947
+ taken; `refused-unreadable` is `extract` raising (a missing file, a directory, a
948
+ container this reader has no extractor for, an `OSError` the filesystem threw —
949
+ all of them reach the model as a `document_error` sentence in the reader's own
950
+ words, never as an exception on the wire); `refused-unknown-part` and
951
+ `refused-offset` are the two argument refusals — the latter also when the part has
952
+ no rows at all, where NO offset can be in range and the sentence says so instead of
953
+ "numbered 0 to -1" (review round 3). `detail` carries the container
954
+ kind (a token from `docread`'s closed set), the part count, and the rows and
955
+ UTF-8 bytes the reply carries — never the path, never a part name, never a row.
956
+
957
+ `limit` is clamped to the advertised `[1, 200]` and `offset` to `>= 0` the way
958
+ `memory_recall` clamps `k`: the schema says so, and a client may ignore it. The
959
+ one bound NOT clamped is `offset`'s `maximum` (`OFFSET_MAXIMUM`, 2**53 - 1): a
960
+ clamp would silently read a different row than the one asked for, and the Node
961
+ port cannot even carry the number — `JSON.parse` has already rounded it — so both
962
+ sides refuse it with the schema refusal they already share. It is bound in the
963
+ SIGNATURE (`Field(le=...)`), which is what `from_function` validates at call time;
964
+ the advertised schema still comes from the manifest alone (`_from_manifest`).
965
+ """
966
+ start = 0 if offset is None else max(0, offset)
967
+ rows = docread.DEFAULT_ROW_LIMIT
968
+ if limit is not None:
969
+ rows = max(1, min(limit, docread.PAGE_MAX_ROWS))
970
+ with _record_raise(log, "bantamkit_read"):
971
+ try:
972
+ doc = docread.extract(path)
973
+ except (docread.DocumentReadError, OSError) as exc:
974
+ log.record("bantamkit_read", "refused-unreadable")
975
+ return _noted(document_error(exc))
976
+ detail: dict[str, Any] = {"kind": doc.kind, "parts": len(doc.parts)}
977
+ if part is None:
978
+ reply = document_manifest(
979
+ [
980
+ {
981
+ "document": path,
982
+ "kind": doc.kind,
983
+ "index": p.index,
984
+ "part": p.name,
985
+ "row_count": p.row_count,
986
+ "rows": p.rows,
987
+ "omissions": [o.as_dict() for o in p.omissions],
988
+ }
989
+ for p in doc.parts
990
+ ],
991
+ [{"document": path, "omissions": [o.as_dict() for o in doc.omissions]}]
992
+ if doc.omissions
993
+ else [],
994
+ )
995
+ detail.update(rows=sum(p.row_count for p in doc.parts), bytes=doc.text_bytes)
996
+ log.record("bantamkit_read", "manifest", detail)
997
+ return _noted(reply)
998
+ try:
999
+ target = doc.part(part)
1000
+ except docread.DocumentReadError:
1001
+ log.record("bantamkit_read", "refused-unknown-part", detail)
1002
+ return _noted(bantamkit_read_unknown_part(part, path, [p.name for p in doc.parts]))
1003
+ if target.row_count == 0:
1004
+ log.record("bantamkit_read", "refused-offset", detail)
1005
+ return _noted(document_error(f'"{target.name}" in {path} has no rows'))
1006
+ if start >= target.row_count:
1007
+ log.record("bantamkit_read", "refused-offset", detail)
1008
+ return _noted(document_offset_past_end(target.name, start, target.row_count))
1009
+ got = docread.page(doc, part, start, rows, docread.PAGE_MAX_BYTES)
1010
+ detail.update(rows=len(got.rows), bytes=len(got.text.encode()))
1011
+ log.record("bantamkit_read", "page", detail)
1012
+ return _noted(
1013
+ document_page(
1014
+ document=path,
1015
+ part=got.part,
1016
+ offset=got.offset,
1017
+ rows=list(got.rows),
1018
+ row_count=got.total_rows,
1019
+ next_offset=got.next_offset,
1020
+ truncated_bytes=got.truncated_bytes,
1021
+ next_key="bantamkit_read_page_next",
1022
+ )
1023
+ )
1024
+
1025
+ def skill_audit(
1026
+ root: str,
1027
+ enabled: list[str] | None = None,
1028
+ usage: dict[str, int] | None = None,
1029
+ check: str | None = None,
1030
+ budget: int | None = None,
1031
+ versions: dict[str, str] | None = None,
1032
+ ) -> str:
1033
+ """The catalogue auditor on the MCP surface: `skillaudit` measures, this serves it.
1034
+
1035
+ THE REPLY IS A JSON DOCUMENT AND NOT PROSE, which is the one thing that makes this
1036
+ handler shaped differently from `bantamkit_read` next door. The reader answers a
1037
+ person reading a transcript, so `contract` words it; this answers a caller that has
1038
+ to compare `catalogue_bytes` against a budget it set and act on the ids in
1039
+ `findings[].skills`. A sentence would have to be parsed back. So `Audit.as_json` is
1040
+ the reply verbatim, and the only strings this layer authors are the refusals.
1041
+
1042
+ THE REFUSALS ARE ARGUMENT FAILURES, ALL THREE OF THEM, so they go through
1043
+ `tool_failed` — an unknown `check`, a negative `budget`, a `root` that is not a
1044
+ directory. Nothing about the CONTENT of the tree refuses: a file that will not
1045
+ decode, a block that will not parse and a plugin that is switched off are recorded
1046
+ as omissions and counted. An audit that refused because one of twenty-six files is
1047
+ malformed would have said nothing about the other twenty-five.
1048
+
1049
+ `check` defaults HERE rather than in the signature's default so that the one
1050
+ spelling of the default lives in `skillaudit.audit`, and `None` and an absent
1051
+ argument reach it as the same thing.
1052
+
1053
+ `versions` is the third caller-supplied host fact beside `enabled` and `usage`: the
1054
+ version directory the host actually serves, per `<plugin>@<marketplace>`. It is
1055
+ passed straight through, and an absent map is the byte-order fallback.
1056
+
1057
+ THE RECORD IS A DECISION, NEVER A REPLY, and it holds no free text: `audited`
1058
+ carries the four counts the host cannot see (skills, catalogue bytes, findings,
1059
+ omissions) and `refused` carries nothing at all. `root` is a path the operator
1060
+ typed and `findings[].skills` are the names of their skills; neither is a decision
1061
+ this handler made, so neither is written down.
1062
+ """
1063
+ with _record_raise(log, "skill_audit"):
1064
+ try:
1065
+ result = skillaudit.audit(
1066
+ root,
1067
+ enabled=enabled,
1068
+ usage=usage,
1069
+ check="all" if check is None else check,
1070
+ budget=budget,
1071
+ versions=versions,
1072
+ )
1073
+ except (skillaudit.SkillAuditError, OSError) as exc:
1074
+ log.record("skill_audit", "refused")
1075
+ return _noted(tool_failed("skill_audit", exc))
1076
+ log.record(
1077
+ "skill_audit",
1078
+ "audited",
1079
+ {
1080
+ "skills": result.skills,
1081
+ "bytes": result.catalogue_bytes,
1082
+ "findings": len(result.findings),
1083
+ "omissions": len(result.omissions),
1084
+ },
1085
+ )
1086
+ return _noted(result.as_json())
1087
+
1088
+ # The served surface, in one place, read out of the asset pack. Adding a tool here
1089
+ # without an asset raises AssetNotFound at startup — the manifest cannot drift behind
1090
+ # the server, because the server cannot start without it.
1091
+ #
1092
+ # `bantamkit_status` went LAST rather than first, `memory_compact` after it rather
1093
+ # than beside `memory_save` where a reader would look for it, `bantamkit_read`
1094
+ # after that and `skill_audit` after that. Registration order IS the served order
1095
+ # (`test_tool_manifest.py::test_the_golden_records_the_order_the_wire_actually_
1096
+ # serves`), and appending is the only edit that leaves the others where every
1097
+ # existing declaration says they are.
1098
+ tools = [
1099
+ _from_manifest(memory_save, "memory_save"),
1100
+ _from_manifest(memory_recall, "memory_recall"),
1101
+ _from_manifest(validate_json, "validate_json"),
1102
+ _from_manifest(shiftwork_clock_in, "shiftwork_clock_in"),
1103
+ _from_manifest(shiftwork_clock_out, "shiftwork_clock_out"),
1104
+ _from_manifest(shiftwork_status, "shiftwork_status"),
1105
+ _from_manifest(build_identity_tool, "build_identity"),
1106
+ _from_manifest(bantamkit_status, "bantamkit_status"),
1107
+ _from_manifest(memory_compact, "memory_compact"),
1108
+ _from_manifest(bantamkit_read, "bantamkit_read"),
1109
+ _from_manifest(skill_audit, "skill_audit"),
1110
+ ]
1111
+
1112
+ server = MCPServer(
1113
+ SERVER_NAME,
1114
+ instructions=load_skill("memory"),
1115
+ version=_version(),
1116
+ tools=tools,
1117
+ )
1118
+
1119
+ # THE PROMPT, AND WHY IT IS NOT A DUPLICATE OF THE TOOL ABOVE.
1120
+ #
1121
+ # `prompts/list` was EMPTY on both runtimes while both advertised `hasPrompts: true`,
1122
+ # so this is a new surface rather than an addition to one. A tool is what the MODEL
1123
+ # can call; a prompt is what a PERSON can invoke — in Claude Code it is a slash
1124
+ # command in the operator's own menu. The person wanting to know whether their server
1125
+ # is alive is the operator, and until now the only way for them to ask was to talk a
1126
+ # model into asking for them.
1127
+ #
1128
+ # IT CARRIES THE ANSWER, not an instruction to go and get it. `prompts/get` runs
1129
+ # server-side, so the report is already in the message the host inserts: the operator
1130
+ # sees it with no tool round trip, and it is true as of the moment they asked.
1131
+ @server.prompt(
1132
+ name=STATUS_NAME,
1133
+ title="bantamkit status",
1134
+ description=(
1135
+ "Is bantamkit actually working? Inserts the running server's own status "
1136
+ "report — active or degraded, its version and build fingerprint, the memory "
1137
+ "store it is bound to, whether the event log is on, and every degraded "
1138
+ "condition in full. Invoke it when you want the server to answer for itself "
1139
+ "rather than ask a model to go and check."
1140
+ ),
1141
+ )
1142
+ def bantamkit_status_prompt() -> str:
1143
+ conditions = degraded_conditions(memory, log)
1144
+ report = status_report(
1145
+ memory, log, conditions, len(tools), SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES
1146
+ )
1147
+ return f"{report}\n\n{STATUS_PROMPT_TAIL}"
1148
+
1149
+ @server.resource("bantamkit://skills/{name}")
1150
+ def skill_resource(name: str) -> str:
1151
+ try:
1152
+ return load_skill(name)
1153
+ except AssetNotFound:
1154
+ raise ResourceError(f"unknown skill asset: {name}") from None
1155
+
1156
+ @server.resource("bantamkit://rubrics/{name}")
1157
+ def rubric_resource(name: str) -> str:
1158
+ path = assets_root() / "rubrics" / f"{name}.yaml"
1159
+ if not path.is_file():
1160
+ raise ResourceError(f"unknown rubric asset: {name}")
1161
+ return path.read_text(encoding="utf-8")
1162
+
1163
+ return server
1164
+
1165
+
1166
+ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
1167
+ parser = argparse.ArgumentParser(
1168
+ prog="bantamkit-mcp",
1169
+ description="bantamkit MCP server (stdio): per-person memory + JSON validation.",
1170
+ )
1171
+ # POSITION IS WIRE-VISIBLE. argparse prints optionals in the order they were added, so
1172
+ # this line -- not any format string -- decides where `[--assets-root]` sits in the
1173
+ # generated usage, and the Node formatter has to reproduce that. It goes FIRST, beside
1174
+ # the `-h` argparse adds for us, because those two share a property nothing below them
1175
+ # has: they print and return 0 without a memory store, a transport, or a server. Every
1176
+ # flag after them configures a server that is actually going to run.
1177
+ parser.add_argument(
1178
+ "--assets-root",
1179
+ action="store_true",
1180
+ help="print the resolved asset pack root and its file count, then exit",
1181
+ )
1182
+ parser.add_argument("--k", type=int, default=3, help="default recall budget (default: 3)")
1183
+ # The index is loaded into every prompt, so its ceiling is a deployment decision.
1184
+ # It had no flag: 4096 was reachable only by editing `store.py`, which made
1185
+ # `docs/memory.md`'s "lifecycle is an operator decision" true of the design and
1186
+ # false of the deployment. Lifecycle ACTIONS stay off this process — it speaks MCP
1187
+ # over stdout — and live on `python -m bantamkit.memory`.
1188
+ parser.add_argument(
1189
+ "--index-budget",
1190
+ type=int,
1191
+ default=DEFAULT_INDEX_BUDGET,
1192
+ metavar="BYTES",
1193
+ help=f"memory index byte budget (default: {DEFAULT_INDEX_BUDGET})",
1194
+ )
1195
+ # WHY THIS IS A FLAG ON THIS PROCESS AND NOT A NEW ENTRY POINT.
1196
+ # `memory/__main__.py` argues lifecycle needs a stream it owns, because this process
1197
+ # speaks MCP over stdout. True of a RUNNING server; not true of a flag that prints and
1198
+ # returns before any transport exists -- `--assets-root` above is the precedent. And it
1199
+ # is decisive here rather than merely convenient: `runtime-ts/package.json` declares
1200
+ # exactly one bin, `bantamkit-mcp`, so anything hung off `python -m ...` is unreachable
1201
+ # in a pure-npx install, which is the shipped product.
1202
+ #
1203
+ # POSITION IS DELIBERATE AND IT IS NOT BESIDE `-h`. `--assets-root` sits first because
1204
+ # it needs NOTHING; this one honours `--store`/`--start` to find the event log, so it
1205
+ # reads after the flags it consumes. The second reason is measured: the first line of
1206
+ # the wrapped usage is pinned in two places -- this repo's
1207
+ # `test_assets_root_appears_in_the_generated_help_in_the_documented_position` and the
1208
+ # `cli` conformance suite's reference precondition -- and at the 80-column fallback the
1209
+ # line breaks after `[--index-budget BYTES]`. Adding here leaves that line byte-identical;
1210
+ # adding before it would move it and turn a differential suite into a re-baselining one.
1211
+ parser.add_argument(
1212
+ "--mcp-report",
1213
+ action="store_true",
1214
+ help="print an analysis of the host MCP log joined with bantamkit's event log, then exit",
1215
+ )
1216
+ # THE SAME THREE ARGUMENTS AS `--mcp-report`, and the same place for the same reason.
1217
+ # It prints and returns before a transport exists; the single Node bin makes a flag the
1218
+ # only reachable surface in the shipped npx install, which is what a `statusLine`
1219
+ # registration has to name; and it honours `--store`/`--start` to find the event log, so
1220
+ # it belongs after the flags it consumes and before the store group. Registering it here
1221
+ # leaves the FIRST line of the 80-column usage -- the pinned one -- byte-identical and
1222
+ # grows only the second.
1223
+ parser.add_argument(
1224
+ "--statusline",
1225
+ action="store_true",
1226
+ help="print one status line for a host status bar, then exit",
1227
+ )
1228
+ stores = parser.add_mutually_exclusive_group()
1229
+ stores.add_argument("--store", help="single memory store path (disables layering)")
1230
+ stores.add_argument(
1231
+ "--start", help="directory to start project-store discovery from (default: cwd)"
1232
+ )
1233
+ return parser.parse_args(argv)
1234
+
1235
+
1236
+ def _build_memory(args: argparse.Namespace) -> Memory:
1237
+ if args.k < 1:
1238
+ raise SystemExit("--k must be >= 1")
1239
+ if args.index_budget < 1:
1240
+ raise SystemExit("--index-budget must be >= 1")
1241
+ if args.store is not None:
1242
+ if not args.store:
1243
+ raise SystemExit("--store requires a non-empty path")
1244
+ return Memory(store=args.store, k=args.k, index_budget=args.index_budget)
1245
+ return Memory.layered(start=args.start, k=args.k, index_budget=args.index_budget)
1246
+
1247
+
1248
+ def _print_mcp_report(args: argparse.Namespace) -> None:
1249
+ """`--mcp-report`: the joined report on stdout, then return. No transport, no server.
1250
+
1251
+ Same discipline as `_print_assets_root`: written through `sys.stdout.buffer`, because
1252
+ `sys.stdout` is a text stream with newline translation and on Windows `print` would
1253
+ emit CRLF where Node's `process.stdout.write` emits LF.
1254
+
1255
+ NOTHING HERE IS WRITTEN. `mcpreport` opens the host's log read-only, creates no
1256
+ directory under its root, and `resolve_event_log_path` designates a store path without
1257
+ bringing a store into existence. The report is an observation, not a session.
1258
+ """
1259
+ text = build_mcp_report(
1260
+ dict(os.environ),
1261
+ resolve_event_log_path(dict(os.environ), store=args.store, start=args.start),
1262
+ )
1263
+ sys.stdout.buffer.write(text.encode("utf-8"))
1264
+ sys.stdout.buffer.flush()
1265
+
1266
+
1267
+ def _print_status_line(args: argparse.Namespace) -> None:
1268
+ """`--statusline`: exactly one line on stdout, then return. Never stderr, never a raise.
1269
+
1270
+ Written through `sys.stdout.buffer` for the same two reasons `_print_mcp_report` is:
1271
+ `print` would emit CRLF on Windows where Node emits LF and the conformance suite
1272
+ compares these bytes, and the line carries U+1F7E2 / U+1F7E0 / U+26AA, which a Windows
1273
+ console's default code page cannot encode -- `sys.stdout` would raise
1274
+ `UnicodeEncodeError` where this writes UTF-8.
1275
+
1276
+ `status_line` is total, so there is no failure arm here to write. That is the point of
1277
+ the surface: this runs on a host's redraw path with nobody watching, and a traceback
1278
+ where a status bar should be is the one output it may not produce.
1279
+ """
1280
+ text = status_line(dict(os.environ), store=args.store, start=args.start)
1281
+ sys.stdout.buffer.write(f"{text}\n".encode())
1282
+ sys.stdout.buffer.flush()
1283
+
1284
+
1285
+ def _print_assets_root() -> None:
1286
+ """`--assets-root`: two lines on stdout, then return -- no store, no transport, no server.
1287
+
1288
+ THE TWO RUNTIMES PRINT DIFFERENT PATHS ON PURPOSE. Do not "fix" that. Python resolves
1289
+ the repo-root `assets/` (or the copy packaged inside `bantamkit/`); Node resolves
1290
+ `runtime-ts/assets/`, which `scripts/sync-assets.mjs` vendors at prepack time. The two
1291
+ trees are byte-identical file for file, but they live at different paths by
1292
+ construction and always will, so line 1 is NOT comparable across runtimes. What IS
1293
+ comparable, and what the conformance case pins: exactly two lines, the shape
1294
+ `<root>\\n<count> files\\n`, the same COUNT on both sides, exit 0, stdout not stderr,
1295
+ empty stderr, and no transport started.
1296
+
1297
+ Written through `sys.stdout.buffer` rather than `print`, because `sys.stdout` is a text
1298
+ stream with newline translation: on Windows `print` would emit CRLF where Node's
1299
+ `process.stdout.write` emits LF, and a byte-comparing conformance runner would call
1300
+ that a divergence. UTF-8 is what Node's `Buffer.from(string)` uses, so the encoding
1301
+ matches too.
1302
+ """
1303
+ root = assets_root()
1304
+ files = sum(1 for path in root.rglob("*") if path.is_file())
1305
+ sys.stdout.buffer.write(f"{root}\n{files} files\n".encode())
1306
+ sys.stdout.buffer.flush()
1307
+
1308
+
1309
+ def main() -> None:
1310
+ if MCPServer is None:
1311
+ raise SystemExit(_INSTALL_HINT)
1312
+ args = _parse_args()
1313
+ # Before `_build_memory`, which touches the filesystem, and before the server exists at
1314
+ # all -- the Node arm returns from `main` here too, ahead of `new RawStdioTransport()`.
1315
+ if args.assets_root:
1316
+ _print_assets_root()
1317
+ return
1318
+ if args.mcp_report:
1319
+ _print_mcp_report(args)
1320
+ return
1321
+ if args.statusline:
1322
+ _print_status_line(args)
1323
+ return
1324
+ server = build_server(_build_memory(args))
1325
+ asyncio.run(server.run_stdio_async())
1326
+
1327
+
1328
+ # `python -m bantamkit.mcpserver` is the invocation a host config reaches for when the
1329
+ # console script is not on PATH. Without this guard the module imported fine, defined
1330
+ # `main`, and exited 0 with nothing on either stream; the client saw CONNECTION_CLOSED,
1331
+ # which names the symptom and not the cause. Measured 2026-08-21 before this line:
1332
+ # `.venv/bin/python -m bantamkit.mcpserver` -> exit 0, stdout 0 bytes, stderr 0 bytes.
1333
+ if __name__ == "__main__":
1334
+ main()