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
@@ -0,0 +1,569 @@
1
+ """Agent-facing memory component: skill in the prompt, correctness in the store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import ExitStack, contextmanager
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from bantamkit.agent import Agent, ToolDef
11
+ from bantamkit.assets import load_skill, load_tool
12
+ from bantamkit.client import BantamError
13
+ from bantamkit.memory.layers import (
14
+ MEMORY_DIR_ENV,
15
+ PROJECT_STORE,
16
+ StoreBinding,
17
+ count_facts,
18
+ load_grants,
19
+ resolve_project_store,
20
+ )
21
+ from bantamkit.memory.store import (
22
+ DEFAULT_INDEX_BUDGET,
23
+ Fact,
24
+ MemoryBudgetExceeded,
25
+ MemoryStore,
26
+ MemoryValidationError,
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SaveOutcome:
32
+ """What `save` DECIDED, beside the sentence it says about it.
33
+
34
+ THE POINT OF THIS TYPE IS THAT `status` IS NOT DERIVED FROM `reply`. `Memory.save`
35
+ has four outcomes — stored, deduped, refused by validation, refused by the budget —
36
+ and all four leave the process as one string that the MCP host records as
37
+ "completed successfully in Nms". Anything downstream that wanted to tell them apart
38
+ had exactly one route: match the reply text. That route is a re-derivation which
39
+ breaks silently the day the wording improves, and it is why this field exists.
40
+
41
+ `status` is one of `saved`, `duplicate`, `refused-validation`, `refused-budget`, and
42
+ every one of them is read off a decision the code had already made — `SaveResult`'s
43
+ own `status`, or which `except` clause caught. `reply` is the unchanged string
44
+ `save()` has always returned; nothing reads it.
45
+ """
46
+
47
+ reply: str
48
+ status: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RecallOutcome:
53
+ """What `recall` DID, beside the facts it formatted.
54
+
55
+ `recall` walks the layers in order, stops the moment the budget is spent, dedupes by
56
+ name, and — when nothing matched — picks one of three different verdicts about WHY
57
+ (see `_nothing_to_report`). None of that survived into the reply the host times: an
58
+ empty recall and a three-fact recall are the same log line to it, and the three
59
+ empties are indistinguishable from each other even to a reader of the reply, because
60
+ telling them apart means matching prose.
61
+
62
+ `status` is `answered` or one of `empty-no-match` / `empty-unreadable-layer` /
63
+ `empty-nothing-saved`, keyed on the same branch that chooses the verdict sentence
64
+ rather than on the sentence. `source` is the KIND of the layer that answered first —
65
+ `project`, `extra` or `profile` — deliberately not the layer's full label, because
66
+ `extra:<name>` carries a directory name off the operator's disk and the log this
67
+ feeds is metadata-only.
68
+ """
69
+
70
+ reply: str
71
+ status: str
72
+ budget: int
73
+ layers: int
74
+ reached: int
75
+ returned: int
76
+ candidates: int
77
+ source: str | None = None
78
+ unreadable: int = 0
79
+
80
+
81
+ def normalize_name(name: str) -> str:
82
+ """The model's spelling adapted to the store's contract: lowercase, `_`/space → `-`.
83
+
84
+ The store's pattern `^[a-z0-9][a-z0-9-]*$` does not move; the component
85
+ bends the argument to it, the same direction as tool-argument coercion.
86
+ Measured cause: 5 of the 7b probe runs burned their whole turn budget
87
+ retrying `memory_save` with the snake_case names the model invents.
88
+ Non-strings are handed on untouched so store validation still speaks.
89
+ """
90
+ if not isinstance(name, str):
91
+ return name
92
+ return name.strip().lower().replace("_", "-").replace(" ", "-")
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class CompactOutcome:
97
+ """What `compact` DID, beside the sentence it says about it.
98
+
99
+ Same seam as `SaveOutcome` and `RecallOutcome`: `status` is read off a decision the
100
+ store already made — whether `CompactResult.archived` is empty — and never off the
101
+ reply. `archived` (**not** deleted, see `MemoryStore.compact`) is the ONLY place the
102
+ count survives as a number, because the reply spells it out in prose and the host
103
+ records only that a `memory_compact` "completed successfully".
104
+
105
+ `status` is `archived` or `nothing-archived`. `reply` is the unchanged string
106
+ `compact()` has always returned; nothing reads it.
107
+ """
108
+
109
+ reply: str
110
+ status: str
111
+ archived: int
112
+ index_before: int
113
+ index_after: int
114
+ budget: int
115
+
116
+
117
+ def _profile_store() -> Path:
118
+ """The last layer `layered` appends, named once so other code can recognise it.
119
+
120
+ It was spelled inline in `layered` and nowhere else, which is how it came to be
121
+ prescribed as a place to start a project store: nothing that wrote the advice
122
+ could see that the directory it was recommending is also the machine-wide layer.
123
+ """
124
+ return Path.home() / ".bantamkit" / "memory"
125
+
126
+
127
+ def _layer_label(root: Path) -> str:
128
+ if root.parent.name == ".bantamkit":
129
+ return root.parent.parent.name
130
+ return root.name
131
+
132
+
133
+ class Memory:
134
+ def __init__(
135
+ self,
136
+ store: str | Path,
137
+ k: int = 3,
138
+ index_budget: int = DEFAULT_INDEX_BUDGET,
139
+ binding: StoreBinding | None = None,
140
+ ):
141
+ self.store = MemoryStore(store, index_budget=index_budget, k=k)
142
+ self.k = k
143
+ self._layers: list[tuple[str, MemoryStore, bool]] = [("project", self.store, True)]
144
+ self._show_layers = False
145
+ # How this store came to be the store, captured once. It cannot be re-derived
146
+ # later: `MemoryStore(create=True)` above has already made the directory, so a
147
+ # store that was only DESIGNATED a moment ago is indistinguishable on disk from
148
+ # one that was found empty. `None` means the caller named the path outright --
149
+ # `Memory(store=...)` — and no resolution happened to report.
150
+ self._binding = binding
151
+
152
+ @classmethod
153
+ def layered(
154
+ cls,
155
+ start: str | Path | None = None,
156
+ k: int = 3,
157
+ index_budget: int = DEFAULT_INDEX_BUDGET,
158
+ ) -> Memory:
159
+ """Project store (resolved) + configured read-only grants + profile store.
160
+
161
+ `resolve_project_store` rather than `discover_project_store`: the path both
162
+ return is the same path (`test_resolve_path_never_disagrees_with_discover`),
163
+ but only the binding carries WHY it is that path and whether it holds
164
+ anything — and `recall` cannot recover either fact afterwards, because
165
+ constructing the store creates the directory.
166
+ """
167
+ binding = resolve_project_store(start)
168
+ project_root = binding.path
169
+ mem = cls(project_root, k=k, index_budget=index_budget, binding=binding)
170
+ mem._show_layers = True
171
+ for grant in load_grants(project_root):
172
+ mem._layers.append(
173
+ (f"extra:{_layer_label(grant)}", MemoryStore(grant, k=k, create=False), False)
174
+ )
175
+ mem._layers.append(("profile", MemoryStore(_profile_store(), k=k, create=False), False))
176
+ return mem
177
+
178
+ def setup(self, agent: Agent) -> None:
179
+ agent.register_tool(ToolDef(tool=load_tool("memory_save"), handler=self.save))
180
+ agent.register_tool(ToolDef(tool=load_tool("memory_recall"), handler=self.recall))
181
+ # Bound here so the refused-budget reply below is TRUE on this surface too: it
182
+ # tells the model to call `memory_compact`, and until 2026-08-28 the eval agent
183
+ # had no such tool (`memory_compact.json` claimed `mcp` alone).
184
+ agent.register_tool(ToolDef(tool=load_tool("memory_compact"), handler=self.compact))
185
+ agent.add_batch_scope(self.batch)
186
+ agent.add_system(load_skill("memory"))
187
+
188
+ @contextmanager
189
+ def batch(self) -> Iterator[None]:
190
+ """One assistant turn's recalls read the store as it was before the turn.
191
+
192
+ Measured cause (RB-P1, seed 2418578173): the model dispatched recall / save /
193
+ recall in a single turn, the speculative save updated `payments-api-owner`
194
+ between the two reads, and it then answered `finance-team` off its own
195
+ fabrication instead of the seeded `Atlas`. Nothing was wrong with the write —
196
+ `save` is doing its documented job — the defect is that a batch the model
197
+ composed from one view of memory got answered from another.
198
+
199
+ Only writable layers are pinned. A read-only grant cannot be written by
200
+ `save`, so it cannot be poisoned, and pinning it would move a corrupt-layer
201
+ error from `recall` (where it is caught and the layer skipped) to the batch
202
+ boundary (where it would take down the run).
203
+ """
204
+ with ExitStack() as stack:
205
+ for _, store, writable in self._layers:
206
+ if writable:
207
+ stack.enter_context(store.snapshot())
208
+ yield
209
+
210
+ def save(
211
+ self, type: str, name: str, description: str, body: str, links: list[str] | None = None
212
+ ) -> str:
213
+ """The reply the model reads. Unchanged, and still the tool's advertised return."""
214
+ return self.save_outcome(type, name, description, body, links).reply
215
+
216
+ def save_outcome(
217
+ self, type: str, name: str, description: str, body: str, links: list[str] | None = None
218
+ ) -> SaveOutcome:
219
+ """`save`, with the branch it took carried out alongside the sentence it wrote.
220
+
221
+ Same body, same order, same four exits: the only change is that each exit now
222
+ NAMES itself. That is the whole of the narrow seam the MCP server needs to log
223
+ an outcome without matching text, and it moves no wording and no return type —
224
+ `save()` above still answers `str`, and `assets/tools/memory_save.json` still
225
+ advertises `str`.
226
+ """
227
+ name = normalize_name(name)
228
+ links = [normalize_name(link) for link in links or []]
229
+ try:
230
+ result = self.store.save(type, name, description, body, tuple(links))
231
+ except MemoryValidationError as e:
232
+ return SaveOutcome(reply=f"error: {e}", status="refused-validation")
233
+ except MemoryBudgetExceeded as e:
234
+ # Not an argument problem: retrying the same call cannot fit the index.
235
+ # Two audiences, two remedies. The store's own text names `compact()`,
236
+ # which is now true for host code (it was a guaranteed no-op in exactly
237
+ # this state until it grew a headroom target). This reply goes to the
238
+ # MODEL, and since job42 the model HAS a compaction tool — `memory_compact`
239
+ # (`docs/memory.md`: the user ruled compaction automatic on 2026-08-24; the
240
+ # hook covers the 90 % band, the tool covers this refusal) — so the last
241
+ # sentence names it. Everything before that sentence is byte-identical to
242
+ # what it was when compaction was operator-only.
243
+ return SaveOutcome(
244
+ reply=(
245
+ f"error: {e}. Nothing was saved and retrying will not help — shorten "
246
+ f"the description, or save under the name of an existing memory to "
247
+ f"replace it. Or call `memory_compact` to archive the stalest facts "
248
+ f"and free room — nothing is deleted."
249
+ ),
250
+ status="refused-budget",
251
+ )
252
+ if result.status == "duplicate":
253
+ return SaveOutcome(
254
+ reply=(
255
+ f"similar memory '{result.similar}' already exists — save under that "
256
+ f"SAME name to update it, or skip. Do not rename to force a copy."
257
+ ),
258
+ status="duplicate",
259
+ )
260
+ return SaveOutcome(reply=f"saved '{result.name}'", status="saved")
261
+
262
+ def recall(self, query: str, k: int | None = None) -> str:
263
+ """`k` is the model asking for *more*, never for less than the store's default.
264
+
265
+ Measured cause (RB-P1, qwen2.5:14b-instruct): 57 of 60 `memory_recall` calls
266
+ across 12 seeded runs sent `k: 1`, and honouring it truncated recall to the
267
+ single best-scoring fact. Every two-fact task then answered from half its
268
+ evidence — `recall-org-quota` never once saw `org-seat-count`, which was on
269
+ disk the whole time. Same direction as `normalize_name` above: the component
270
+ bends the model's argument to the store's contract, and `MemoryStore.recall`
271
+ stays honest about returning exactly the `k` it was told.
272
+
273
+ The floor is the operator's configured default, not a constant, so a consumer
274
+ who really wants top-1 says so once at construction (`Memory(store, k=1)`).
275
+ """
276
+ return self.recall_outcome(query, k).reply
277
+
278
+ def recall_outcome(self, query: str, k: int | None = None) -> RecallOutcome:
279
+ """`recall`, carrying the walk it performed as numbers rather than as prose.
280
+
281
+ The reply is byte-for-byte what `recall` has always returned; every field beside
282
+ it is counted here because it CANNOT be recovered afterwards. How far down the
283
+ layer list the budget got, how many of the layers reached were readable, how
284
+ many facts those layers held against how many came back, and which of the three
285
+ empty verdicts fired — all of it is gone by the time the string exists, and the
286
+ host's log records only that a `memory_recall` "completed successfully".
287
+
288
+ `candidates` counts `facts/*.md` in the layers actually READ, via `count_facts`
289
+ — the same filter `MemoryStore.recall` scores over, so the ratio to `returned`
290
+ is between two counts of one population and not between two different ones. A
291
+ layer whose directory refuses to list contributes nothing and raises the
292
+ `unreadable` count instead of being scored as empty; that distinction is the
293
+ whole subject of `_nothing_to_report` below and must not be undone here.
294
+ """
295
+ budget = self.k if k is None else max(k, self.k)
296
+ picked: list[tuple[str, Fact]] = []
297
+ seen: set[str] = set()
298
+ reached = 0
299
+ unreadable = 0
300
+ candidates = 0
301
+ for label, store, writable in self._layers:
302
+ if len(picked) >= budget:
303
+ break # budget spent: later (read-only) layers are never even read
304
+ reached += 1
305
+ try:
306
+ facts = store.recall(query, budget, stamp=writable)
307
+ except (BantamError, OSError, UnicodeDecodeError):
308
+ if writable:
309
+ raise # the project layer failing is a real error, as in v1
310
+ unreadable += 1
311
+ continue # a corrupt grant/profile layer must not take down recall
312
+ try:
313
+ candidates += count_facts(store.root)
314
+ except OSError:
315
+ # It scored a moment ago, so it is readable; a race that unlists it now
316
+ # must not turn a successful recall into a failed one for a log field.
317
+ pass
318
+ for fact in facts:
319
+ if fact.name in seen or len(picked) >= budget:
320
+ continue
321
+ seen.add(fact.name)
322
+ picked.append((label, fact))
323
+ counts = {
324
+ "budget": budget,
325
+ "layers": len(self._layers),
326
+ "reached": reached,
327
+ "returned": len(picked),
328
+ "candidates": candidates,
329
+ "unreadable": unreadable,
330
+ }
331
+ if not picked:
332
+ status, reply = self._nothing_to_report()
333
+ return RecallOutcome(reply=reply, status=status, **counts)
334
+ return RecallOutcome(
335
+ reply="\n\n".join(self._format(label, fact) for label, fact in picked),
336
+ status="answered",
337
+ source=picked[0][0].split(":", 1)[0],
338
+ **counts,
339
+ )
340
+
341
+ def index_accounting(self) -> tuple[int | None, int]:
342
+ """`(index bytes, budget)` for the writable project store; bytes may be `None`.
343
+
344
+ Measured exactly the way `MemoryStore._check_index_budget` measures — the UTF-8
345
+ length of `index_text()` — so the number a log records and the number a refusal
346
+ was decided against are one number and not two that can drift apart.
347
+
348
+ `None` means the store could not be read, and it is never 0: an unreadable store
349
+ reporting an empty index is the same slander this module spends
350
+ `_nothing_to_report` refusing to commit, and it would read as infinite headroom
351
+ at exactly the moment there is none. The cost is a full parse of `facts/`, which
352
+ is why nothing calls this on the tool path unless a log is actually enabled.
353
+ """
354
+ try:
355
+ return len(self.store.index_text().encode("utf-8")), self.store.index_budget
356
+ except (BantamError, OSError, UnicodeDecodeError):
357
+ return None, self.store.index_budget
358
+
359
+ # ---- the empty answer, split into the answers it was hiding ----------------
360
+
361
+ def _nothing_to_report(self) -> tuple[str, str]:
362
+ """An empty recall is at least three different situations; say which one.
363
+
364
+ Answers `(status, text)`. The status is the SAME BRANCH that picks the sentence,
365
+ handed out rather than re-derived: a caller that needs to tell the three empties
366
+ apart — the MCP event log does — would otherwise have to match the prose this
367
+ docstring exists to say is allowed to improve.
368
+
369
+ Until now all of them returned "no memories matched. Try different words",
370
+ which tells a person to rephrase a question against a filing cabinet that may
371
+ not exist. Measured 2026-08-22 in a sandbox rebuilt from the live topology of
372
+ MCP server pid 34377 (cwd .../trader-platform, no store of its own, nearest
373
+ ancestor store ~/.bantamkit/memory holding 0 facts, the operator's real facts
374
+ in a SIBLING project): both pre-N1 and N1+N2 answered with that exact string.
375
+ N1 and N2 made the distinction nameable; nothing yet said it out loud.
376
+
377
+ The verdict is keyed on what could have been read RIGHT NOW, counted the way
378
+ `MemoryStore._facts` reads (`facts/*.md`), not on the construction-time
379
+ binding — a store that was empty at construction and has been saved to since
380
+ really can answer, and must not be slandered as empty. The diagnosis is keyed
381
+ on the binding, because `designated` (there was no store) stops being visible
382
+ on disk the moment `MemoryStore` creates the directory.
383
+ """
384
+ if self._searchable_facts():
385
+ status = "empty-no-match"
386
+ verdict = "no memories matched. Try different words, or proceed without."
387
+ elif unreadable := self._unreadable_layers():
388
+ # A layer nobody could open is not a layer that held nothing. Saying
389
+ # "nothing is saved" here is the same slander as calling an unreadable
390
+ # store empty, one call further out.
391
+ status = "empty-unreadable-layer"
392
+ verdict = (
393
+ "no memories matched, and that is not evidence there are none: "
394
+ + ", ".join(str(root) for root in unreadable)
395
+ + " could not be read."
396
+ )
397
+ else:
398
+ status = "empty-nothing-saved"
399
+ verdict = "no memories to search: nothing is saved in any layer bound here."
400
+ diagnosis = self._binding_diagnosis()
401
+ return status, (f"{verdict} {diagnosis}" if diagnosis else verdict)
402
+
403
+ @staticmethod
404
+ def _fact_count(root: Path) -> int | None:
405
+ """How many facts a store holds, or `None` when that cannot be established.
406
+
407
+ Counted the way `MemoryStore._facts` reads (`facts/*.md`) and the way
408
+ `resolve_project_store` counts, so a 0 here means recall had nothing to
409
+ return rather than that two counters disagree. A store that cannot be listed
410
+ answers `None` and never 0: "I could not read it" and "it holds nothing" are
411
+ the two answers this whole unit exists to keep apart.
412
+
413
+ Via `count_facts`, because the `glob` this used to call swallowed the
414
+ `OSError` and returned 0 — which made the `except` below unreachable dead
415
+ code and this paragraph a description of a branch that could not fire.
416
+ """
417
+ try:
418
+ return count_facts(root)
419
+ except OSError:
420
+ return None
421
+
422
+ def _unreadable_layers(self) -> list[Path]:
423
+ """Roots of the layers that could not be listed at all — never counted as 0."""
424
+ return [
425
+ store.root for _, store, _ in self._layers if self._fact_count(store.root) is None
426
+ ]
427
+
428
+ def _searchable_facts(self) -> int:
429
+ """Facts the layers actually consulted could have matched, counted live.
430
+
431
+ An unreadable layer contributes nothing rather than counting as empty:
432
+ `recall` above already skips it, and it must not become evidence that there
433
+ was nothing to read.
434
+ """
435
+ return sum(n for _, store, _ in self._layers if (n := self._fact_count(store.root)))
436
+
437
+ @staticmethod
438
+ def _is_profile_store(root: Path) -> bool:
439
+ """Is this store also the machine-wide profile layer `layered` appends?
440
+
441
+ Resolved on both sides because the walk returns its store path unresolved
442
+ while `Path.home()` may itself be a symlink; a comparison that missed on a
443
+ symlink would put the harmful advice back exactly where it does damage.
444
+ """
445
+ try:
446
+ return Path(root).resolve() == _profile_store().resolve()
447
+ except (OSError, RuntimeError):
448
+ return False
449
+
450
+ def _walk_ascended(self) -> bool:
451
+ """Did the walk actually leave the directory it started in?
452
+
453
+ `origin == "walk"` says a walk ran, not that it climbed. A walk that
454
+ terminates at step zero has bound `searched_from`'s OWN store, and the
455
+ clause that reads well for an ancestor binding — "which has no store of its
456
+ own" — is then simply false about the commonest case there is: a project
457
+ whose store exists and is empty. That sentence is what sends an operator
458
+ hunting a binding bug that is not there.
459
+ """
460
+ binding = self._binding
461
+ if binding is None or binding.searched_from is None:
462
+ return False
463
+ return Path(binding.path) != Path(binding.searched_from) / PROJECT_STORE
464
+
465
+ def _binding_diagnosis(self) -> str:
466
+ """Why THIS store, when the project layer is the one that cannot answer.
467
+
468
+ Silent while the project store holds facts: that is the case that already
469
+ works, and it keeps its exact wording. The remedy names `MEMORY_DIR_ENV`
470
+ from `layers`, never a literal, so the message and the resolver cannot drift
471
+ onto two different variable names.
472
+ """
473
+ if self._fact_count(self.store.root) != 0:
474
+ return "" # it holds facts, or could not be read: either way, no claim
475
+ root = self.store.root
476
+ binding = self._binding
477
+ if binding is None:
478
+ where = f"The project store {root} is empty."
479
+ elif binding.state == "designated":
480
+ where = (
481
+ f"No memory store existed at or above {binding.searched_from}, so the "
482
+ f"empty {root} was created for this session."
483
+ )
484
+ elif binding.origin == "pin":
485
+ where = f"The project store {root} is empty; {MEMORY_DIR_ENV} pinned it."
486
+ elif self._walk_ascended():
487
+ where = (
488
+ f"The project store {root} is empty; it was bound by walking up from "
489
+ f"{binding.searched_from}, which has no store of its own."
490
+ )
491
+ else:
492
+ where = (
493
+ f"The project store {root} is empty; it is {binding.searched_from}'s own "
494
+ f"store, bound without the walk leaving that directory."
495
+ )
496
+ remedy = (
497
+ f"That is a binding, not a search result — if your facts are in another "
498
+ f"store, set {MEMORY_DIR_ENV} to its absolute path and restart"
499
+ )
500
+ if self._is_profile_store(root):
501
+ # The store bound here IS the profile layer (`_profile_store`), which
502
+ # every project with no store of its own also binds. "Save a memory to
503
+ # start this one" would therefore start a store that answers for all of
504
+ # them -- measured end to end by
505
+ # `test_a_save_into_the_bound_store_answers_for_an_unrelated_project`.
506
+ return (
507
+ f"{where} {remedy}. Do NOT save here to start it: {root} is also the "
508
+ f"profile layer, so a memory saved there answers for every project on "
509
+ f"this machine that has no store of its own — give this project a store "
510
+ f"of its own instead."
511
+ )
512
+ return f"{where} {remedy}; otherwise save a memory to start this one."
513
+
514
+ def compact(self, reserve: int | None = None) -> str:
515
+ """Free index headroom by archiving the stalest facts, and say what it cost.
516
+
517
+ Only the writable project layer is compacted: a read-only grant is not this
518
+ person's to evict, and the profile layer belongs to another store's budget.
519
+
520
+ The reply names every archived fact with its description, because `archive/`
521
+ is a directory the model calling this will never look in — if the return value
522
+ does not carry what was lost, nothing does.
523
+ """
524
+ return self.compact_outcome(reserve).reply
525
+
526
+ def compact_outcome(self, reserve: int | None = None) -> CompactOutcome:
527
+ """`compact`, with the decision it took carried beside the sentence it wrote.
528
+
529
+ `self.store` is the writable project layer and nothing else: `layered` appends
530
+ grants and the profile store to `_layers` only, never to `self.store`, so this
531
+ cannot reach either (`test_memory_compact_tool.py` pins a profile fact through
532
+ a compaction). The reply is byte-for-byte what `compact` has always returned.
533
+ """
534
+ result = self.store.compact(reserve)
535
+ if not result.archived:
536
+ reply = (
537
+ f"nothing archived: the index is {result.index_after} bytes against a "
538
+ f"{result.budget}-byte budget, already at or under the "
539
+ f"{result.target}-byte compaction target."
540
+ )
541
+ status = "nothing-archived"
542
+ else:
543
+ lost = "\n".join(
544
+ f"- {fact.name} ({fact.type}) — {fact.description}" for fact in result.archived
545
+ )
546
+ reply = (
547
+ f"archived {len(result.archived)} memories; the index went from "
548
+ f"{result.index_before} to {result.index_after} bytes against a "
549
+ f"{result.budget}-byte budget, leaving {result.headroom} bytes of headroom. "
550
+ f"These moved to {result.archive_dir} and are NOT deleted — they can be "
551
+ f"restored by name:\n{lost}"
552
+ )
553
+ status = "archived"
554
+ return CompactOutcome(
555
+ reply=reply,
556
+ status=status,
557
+ archived=len(result.archived),
558
+ index_before=result.index_before,
559
+ index_after=result.index_after,
560
+ budget=result.budget,
561
+ )
562
+
563
+ # Back-compat aliases: the component's API predates the public names.
564
+ _save = save
565
+ _recall = recall
566
+
567
+ def _format(self, label: str, fact: Fact) -> str:
568
+ tag = f"[{label}] " if self._show_layers else ""
569
+ return f"{tag}[{fact.name}] ({fact.type}) {fact.description}\n{fact.body}"