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,940 @@
1
+ """Memory correctness layer: the agent never writes files directly — only these ops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import re
8
+ from collections.abc import Callable, Iterator
9
+ from contextlib import contextmanager
10
+ from dataclasses import dataclass
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from bantamkit.client import BantamError
17
+
18
+ VALID_TYPES = {"user", "feedback", "project", "reference"}
19
+ # The types whose worth does NOT decay with time-since-last-recall, and which `compact`
20
+ # therefore archives only after every other candidate is exhausted (`_eviction_key`).
21
+ # A TUPLE and not a set: this is compared against a value that came out of YAML uncast, and
22
+ # `in` on a tuple is `==` per element where `in` on a set hashes and can raise.
23
+ DURABLE_TYPES = ("feedback", "user")
24
+ NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
25
+ DUPLICATE_JACCARD = 0.5
26
+
27
+ # The index is loaded into the prompt every session, so this is a context bill, not a
28
+ # disk limit. It was 4096 and that number was never measured against a real store.
29
+ # Measured 2026-08-21 against the live 20-fact project store: index 3943 bytes, median
30
+ # index line 199 bytes, so 4096 left 153 bytes of headroom and 19 of the 20 lines were
31
+ # individually larger than that. Replaying 25 fresh saves onto a copy of that store at
32
+ # 4096 evicted 18 facts and the FIRST save already triggered one — the store was not
33
+ # near its budget, it was on a treadmill, archiving a fact for nearly every fact it
34
+ # learned. The same 25 saves at 24000 evicted none. 24000 is also what the sibling
35
+ # `memory-keeper` store on this machine has defaulted to in production for the same
36
+ # always-loaded index (scripts/memctl.py: DEFAULT_BUDGET = 24_000), so this aligns with
37
+ # a number that has run rather than inventing a fresh guess. Callers that want the old
38
+ # ceiling pass `index_budget=4096`; nothing about the budget mechanism changed.
39
+ DEFAULT_INDEX_BUDGET = 24_000
40
+
41
+ # The second half of the "unreadable" sentence, one per directory this store lists.
42
+ # They are separate strings because the two failures do different damage, and an error
43
+ # that names the wrong damage sends the reader to the wrong place. Both are spelled
44
+ # once, here, so a caller's docstring and the message a caller actually emits cannot
45
+ # drift apart.
46
+ _FACTS_UNREADABLE = (
47
+ "a store whose facts could not be listed is not a store with no facts, and "
48
+ "answering 'empty' here is what rewrites index.md from nothing"
49
+ )
50
+ _ARCHIVE_UNREADABLE = (
51
+ "an archive that could not be listed is not an empty archive, and answering "
52
+ "'nothing is archived' here is what makes compaction look like deletion — the "
53
+ "facts compact() moved are still on disk under this path"
54
+ )
55
+ # The same distinction one syscall down, for `restore`, which stats one named path
56
+ # instead of listing (see `archived()` for why). A refused stat is not an absent file,
57
+ # and each side of the move needs its own half of the sentence for the same reason the
58
+ # two listings above do.
59
+ _ARCHIVE_UNREACHABLE = (
60
+ "an archived fact that could not be stat'd is not an archived fact that is not "
61
+ "there, and answering 'no archived fact' here sends the operator looking for a "
62
+ "file that is still on disk under this path"
63
+ )
64
+ _FACTS_UNREACHABLE = (
65
+ "a destination that could not be stat'd is not a name that is already taken, and "
66
+ "nothing has moved: the fact is still in archive/"
67
+ )
68
+ # The same two distinctions again for `archive`, which walks the move in the opposite
69
+ # direction. They cannot reuse the pair above: each sentence names the side the fact is
70
+ # STILL on when the stat is refused, and that side is the other one here.
71
+ _FACT_UNREACHABLE = (
72
+ "a fact that could not be stat'd is not a fact that is not there, and answering "
73
+ "'no fact' here sends the operator looking for a file that is still on disk under "
74
+ "this path"
75
+ )
76
+ _ARCHIVE_DESTINATION_UNREACHABLE = (
77
+ "a destination that could not be stat'd is not a name that is already archived, "
78
+ "and nothing has moved: the fact is still in facts/"
79
+ )
80
+
81
+
82
+ class MemoryValidationError(BantamError):
83
+ pass
84
+
85
+
86
+ class MemoryBudgetExceeded(BantamError):
87
+ pass
88
+
89
+
90
+ @dataclass
91
+ class Fact:
92
+ name: str
93
+ description: str
94
+ type: str
95
+ body: str
96
+ links: list[str]
97
+ last_recalled: str | None
98
+ # ISO date the fact first landed. `None` only on a Fact built in memory before its
99
+ # first write; every Fact read off disk carries one (see `_facts`).
100
+ created: str | None = None
101
+
102
+
103
+ @dataclass
104
+ class SaveResult:
105
+ status: str # "saved" | "duplicate"
106
+ name: str
107
+ similar: str | None = None
108
+
109
+
110
+ @dataclass
111
+ class ArchivedFact:
112
+ """What one archived fact was, kept after its file has left `facts/`."""
113
+
114
+ name: str
115
+ type: str
116
+ description: str
117
+ index_bytes: int
118
+ last_recalled: str | None
119
+ created: str | None
120
+
121
+
122
+ @dataclass
123
+ class CompactResult:
124
+ """Everything the caller needs to understand what compaction cost.
125
+
126
+ Archiving is a one-way *move*, not a delete: the file is still readable under
127
+ `archive/` and `restore()` brings it back. This carries the description of each
128
+ fact that left so a caller that never looks in `archive/` can still say what it
129
+ lost, and the byte arithmetic so it can see the headroom it bought.
130
+ """
131
+
132
+ archived: list[ArchivedFact]
133
+ index_before: int
134
+ index_after: int
135
+ budget: int
136
+ target: int
137
+ reserve: int
138
+ archive_dir: str
139
+
140
+ @property
141
+ def names(self) -> list[str]:
142
+ return [fact.name for fact in self.archived]
143
+
144
+ @property
145
+ def headroom(self) -> int:
146
+ return self.budget - self.index_after
147
+
148
+
149
+ def _tokens(text: str) -> set[str]:
150
+ return set(re.findall(r"[a-z0-9]+", text.lower()))
151
+
152
+
153
+ def _jaccard(a: set[str], b: set[str]) -> float:
154
+ if not a or not b:
155
+ return 0.0
156
+ return len(a & b) / len(a | b)
157
+
158
+
159
+ def _mtime_date(path: Path) -> str:
160
+ """Migration for a fact written before `created` existed: use the file's own mtime.
161
+
162
+ Every store already on disk — the 20 live facts included — has no `created` in its
163
+ frontmatter, and defaulting those to `""` would make the whole pre-existing store
164
+ maximally stale and evict it first. The filesystem already records when the fact was
165
+ last written, which is exactly the fallback the sort key wants, and it needs no
166
+ migration pass over anyone's store. It is only a fallback: the next write of that
167
+ fact persists the date into the frontmatter and the mtime is never consulted again.
168
+ """
169
+ return date.fromtimestamp(path.stat().st_mtime).isoformat()
170
+
171
+
172
+ class MemoryStore:
173
+ def __init__(
174
+ self,
175
+ root: str | Path,
176
+ index_budget: int = DEFAULT_INDEX_BUDGET,
177
+ k: int = 3,
178
+ today: Callable[[], str] | None = None,
179
+ create: bool = True,
180
+ ):
181
+ self.root = Path(root)
182
+ self.index_budget = index_budget
183
+ self.k = k
184
+ self._today = today or (lambda: date.today().isoformat())
185
+ self._snapshot: list[Fact] | None = None
186
+ if create:
187
+ self._ensure_dirs()
188
+
189
+ @contextmanager
190
+ def snapshot(self) -> Iterator[None]:
191
+ """Reads inside this scope see the facts as of scope entry; writes stay live.
192
+
193
+ Measured cause (RB-P1, seed 2418578173): one assistant turn dispatched
194
+ recall / save / recall, the speculative save updated the ground-truth fact
195
+ between the two reads, and the model answered from its own fabrication.
196
+ Pinning the read set makes a write speculative *for the scope only* — `save`
197
+ still reads and writes live state, so same-name-is-update is untouched, and
198
+ the next scope reads the write.
199
+
200
+ Nesting keeps the outermost pin: a scope entered twice is still one turn.
201
+
202
+ AN UNREADABLE STORE, at each of the three moments it can become one — the
203
+ `except` below swallows on purpose, and these are what it buys. WAS: all three
204
+ answered from an empty listing without a word. NOW, measured 2026-08-23:
205
+
206
+ - ALREADY UNREADABLE AT ENTRY. Nothing is pinned (`_snapshot` stays `None`) and
207
+ `recall` raises out of `_facts` on its own, which is why the raise is
208
+ swallowed here rather than turned into a scope-entry failure: `batch()` opens
209
+ this scope around a whole assistant turn, and failing at the boundary would
210
+ take down a turn whose very first op is going to report the same fault with a
211
+ better sentence attached to the op that wanted it.
212
+ - BROKE INSIDE THE SCOPE. The pin holds and it is the point: `recall(...,
213
+ stamp=False)` still answers from the facts as of entry. `recall()` with the
214
+ default `stamp=True` raises out of `_stamp`, which lists the live store —
215
+ AFTER the hits were computed, so the answer is discarded. That is the one
216
+ non-obvious outcome in this whole scope and it is pinned by
217
+ `test_a_recall_pinned_before_the_store_broke_answers_but_never_dates_it`; the
218
+ raise is kept because a store that stops being readable mid-turn is news, and
219
+ no fact is left half-dated (`_stamp` lists before it writes).
220
+ - REPAIRED INSIDE THE SCOPE. Entry pinned nothing, so reads go live and see the
221
+ repair. A scope that pinned nothing has nothing to protect.
222
+ """
223
+ previous = self._snapshot
224
+ if previous is None:
225
+ try:
226
+ self._snapshot = self._facts()
227
+ except (BantamError, OSError, UnicodeDecodeError):
228
+ self._snapshot = None
229
+ try:
230
+ yield
231
+ finally:
232
+ self._snapshot = previous
233
+
234
+ def _ensure_dirs(self) -> None:
235
+ (self.root / "facts").mkdir(parents=True, exist_ok=True)
236
+ (self.root / "archive").mkdir(parents=True, exist_ok=True)
237
+
238
+ # ---- ops ----
239
+
240
+ def save(
241
+ self, type: str, name: str, description: str, body: str, links: tuple[str, ...] = ()
242
+ ) -> SaveResult:
243
+ self._ensure_dirs()
244
+ if type not in VALID_TYPES:
245
+ raise MemoryValidationError(
246
+ f"invalid type '{type}'; must be one of {sorted(VALID_TYPES)}"
247
+ )
248
+ if not NAME_RE.match(name or ""):
249
+ raise MemoryValidationError(f"invalid name '{name}'; must match {NAME_RE.pattern}")
250
+ if not (description or "").strip():
251
+ raise MemoryValidationError("description must be a non-empty line")
252
+
253
+ new_tokens = _tokens(f"{name} {description}")
254
+ existing = None
255
+ # THE FIRST OF TWO READS, AND THE ONE THAT MAKES THIS OP SAFE. WAS: a blind
256
+ # listing made this check pass vacuously and the save went on to have
257
+ # `_rebuild_index` rewrite `index.md` from the same nothing. NOW: an unreadable
258
+ # store raises `MemoryValidationError` from here, before `_write_fact` — so the
259
+ # save fails whole instead of half-way, and there is no state to roll back.
260
+ # Nothing below this line runs. Measured, not assumed:
261
+ # `test_a_listing_that_fails_stops_save_before_it_writes_anything`.
262
+ for fact in self._facts():
263
+ if fact.name == name:
264
+ existing = fact
265
+ continue # same name = update, not duplicate
266
+ if (
267
+ _jaccard(new_tokens, _tokens(f"{fact.name} {fact.description}"))
268
+ >= DUPLICATE_JACCARD
269
+ ):
270
+ return SaveResult(status="duplicate", name=name, similar=fact.name)
271
+
272
+ fact = Fact(
273
+ name=name,
274
+ description=description.strip(),
275
+ type=type,
276
+ body=body,
277
+ links=list(links),
278
+ last_recalled=None,
279
+ # An update keeps the date the fact first landed — rewriting a fact is not
280
+ # the same event as creating it, and resetting this would let a re-save
281
+ # launder a stale fact into a fresh one.
282
+ created=existing.created if existing is not None else self._today(),
283
+ )
284
+ path = self._fact_path(name)
285
+ existed = path.read_text(encoding="utf-8") if path.exists() else None
286
+ self._write_fact(fact)
287
+ try:
288
+ self._check_index_budget()
289
+ except MemoryBudgetExceeded:
290
+ if existed is None:
291
+ path.unlink()
292
+ else:
293
+ path.write_text(existed, encoding="utf-8")
294
+ self._rebuild_index()
295
+ raise
296
+ self._rebuild_index()
297
+ return SaveResult(status="saved", name=name)
298
+
299
+ def recall(self, query: str, k: int | None = None, stamp: bool = True) -> list[Fact]:
300
+ """Top-`k` facts whose name+description share tokens with `query`.
301
+
302
+ WAS: an unreadable store scored zero facts and returned `[]` — the same answer
303
+ as a real miss, and `component.Memory` turned it into "no memories matched. Try
304
+ different words", telling a person to rephrase a question at a filing cabinet
305
+ nobody could open. NOW: `_facts` raises `MemoryValidationError` and this returns
306
+ nothing at all.
307
+
308
+ This is the caller that matters most to a person, so the two halves have to
309
+ compose into one sentence rather than two. They do, and by two different
310
+ routes, both measured 2026-08-23:
311
+
312
+ - The PROJECT layer is writable, so `Memory.recall` re-raises this deliberately
313
+ ("the project layer failing is a real error") and the person sees this
314
+ message, which names the path and the OS reason. `Memory.save` catches the
315
+ same error and returns it as `error: ...` text. Neither one now reaches
316
+ `_nothing_to_report`, and neither should: that function's job is to explain an
317
+ EMPTY answer, and there is no answer here to explain.
318
+ - A read-only GRANT or PROFILE layer is caught and skipped by `Memory.recall`,
319
+ and `_nothing_to_report` then names it out loud — "no memories matched, and
320
+ that is not evidence there are none: <root> could not be read." Verified end
321
+ to end against a grant at 0o311.
322
+
323
+ One shape still composes wrongly and it is not this layer's to fix: a `facts/`
324
+ that is a DANGLING SYMLINK raises here but counts 0 in `layers.count_facts`, so
325
+ a grant in that state is skipped by `recall` and then described by
326
+ `_nothing_to_report` as "nothing is saved in any layer bound here". Deferred to
327
+ the binding layer with the failing node that proves it —
328
+ `test_memory_layers.py::test_a_dangling_facts_symlink_is_unreadable_to_both_layers`.
329
+ """
330
+ k = k if k is not None else self.k
331
+ q = _tokens(query)
332
+ scored = []
333
+ for fact in self._snapshot if self._snapshot is not None else self._facts():
334
+ score = len(q & _tokens(f"{fact.name} {fact.description}"))
335
+ if score > 0:
336
+ scored.append((score, fact))
337
+ scored.sort(key=lambda pair: (-pair[0], pair[1].name))
338
+ hits = [fact for _, fact in scored[:k]]
339
+ if stamp:
340
+ for fact in hits:
341
+ self._stamp(fact)
342
+ return hits
343
+
344
+ def lint(self) -> None:
345
+ """Every fact parses and carries a valid type, and the index fits its budget.
346
+
347
+ WAS: an unreadable store linted CLEAN — zero facts, zero bytes, nothing to
348
+ object to, and `python -m bantamkit.memory lint` printed `lint: ok — 0 facts`.
349
+ A checker that passes hardest on the store it could not open is the one caller
350
+ here whose old answer was actively dangerous. NOW: `MemoryValidationError`, and
351
+ `_cmd_lint` already routes that to `lint: FAIL — ...` on stderr with exit 1
352
+ (verified by running it), so the operator surface needed no change.
353
+ """
354
+ for fact in self._facts(): # raises MemoryValidationError: unreadable, or malformed
355
+ if fact.type not in VALID_TYPES:
356
+ raise MemoryValidationError(f"fact '{fact.name}' has invalid type '{fact.type}'")
357
+ self._check_index_budget()
358
+
359
+ def compact(self, reserve: int | None = None) -> CompactResult:
360
+ """Archive the stalest facts until the index sits at `budget - reserve` or below.
361
+
362
+ Two measured defects live here, and both are about *when* this is reachable.
363
+
364
+ `save` rolls the offending fact back before it raises, so by the time a caller
365
+ can act on "run compact()" the index is under budget again. The old loop tested
366
+ `_check_index_budget()` first and broke on the first iteration: at the only
367
+ moment the remedy is ever named, it archived nothing and returned `[]`. On a real
368
+ 20-fact store (index 3943, budget 4096) three consecutive over-budget saves each
369
+ got `compact() -> []` and left `archive/` empty. Compacting to a *target below the
370
+ budget* is what makes the remedy true — and compacting to merely-fits would not,
371
+ because the very next save is over again and the caller loops forever.
372
+
373
+ The default `reserve` is the largest index line the store currently holds, so the
374
+ headroom bought is exactly "a fact as big as the biggest one you keep will fit" —
375
+ a number that scales with this store's own data instead of a guessed constant. It
376
+ is capped at half the budget: no store surrenders more than half its index to
377
+ headroom however long one description grows. `reserve` is recomputed from the
378
+ survivors, so a second call archives nothing and `compact()` is idempotent.
379
+
380
+ WAS: an unreadable store compacted to `CompactResult(archived=[])`, and the CLI
381
+ printed "nothing to archive — the index is already at or below the target"
382
+ about a store whose size it had failed to measure. NOW: the first statement
383
+ below raises `MemoryValidationError`, before any `rename`, so no fact is moved
384
+ on the strength of a listing that failed. The ordering costs nothing here, the
385
+ same way it costs nothing in `save`: the listing is already the first thing
386
+ this op does, so there is no half-compacted archive to reason about.
387
+
388
+ THE MOVE IS `os.replace` AND NOT `os.rename`, and the difference is a platform.
389
+ `Path.rename` silently replaces an existing destination on POSIX and raises
390
+ `FileExistsError` on Windows; `Path.replace` replaces on both. The only state
391
+ that tells them apart is an `archive/<name>.md` that already exists when this
392
+ loop moves the live fact over it -- an earlier compaction's copy of a fact that
393
+ was restored and then went stale again. `restore` cannot produce it, because it
394
+ moves the archived copy OUT, which is why nothing in this repository had reached
395
+ the state until a test went looking for it. `runtime-ts` has always used
396
+ `os.replace` here (`pyReplace` in `src/memory/store.ts`), so before this the two
397
+ runtimes agreed on POSIX and disagreed on Windows.
398
+
399
+ THE ORDER IS `_eviction_key`, NOT `_staleness_key`, and the difference is a whole
400
+ class of fact. `feedback` is a standing instruction from the user: it holds until
401
+ revoked, and its worth does not decay with time-since-last-recall, so a purely
402
+ temporal key ranks that class exactly backwards -- the better an instruction is
403
+ internalised the less anything recalls it, the staler it looks, and the sooner it is
404
+ archived out of the index that is loaded at session start. Measured on the real
405
+ project store (index 21698 of a 24000-byte budget): ONE auto-compaction archived 15
406
+ facts and 6 of them were `feedback`, three of those loaded into that same session's
407
+ profile. Every non-feedback candidate is now exhausted first. It is a PRIORITY and
408
+ not a veto -- the budget still wins, so once nothing else is left, feedback is
409
+ archived by staleness and this loop still lands at or below `target`.
410
+ """
411
+ facts = self._facts()
412
+ sizes = {fact.name: len(self._index_line(fact).encode()) for fact in facts}
413
+ if reserve is None:
414
+ reserve = max(sizes.values(), default=0)
415
+ reserve = max(0, min(reserve, self.index_budget // 2))
416
+ target = self.index_budget - reserve
417
+
418
+ size = sum(sizes.values())
419
+ before = size
420
+ archived: list[ArchivedFact] = []
421
+ for fact in sorted(facts, key=self._eviction_key):
422
+ if size <= target:
423
+ break
424
+ path = self._fact_path(fact.name)
425
+ # `os.replace`, not `os.rename`: the two agree on POSIX and differ on Windows,
426
+ # where `rename` raises `FileExistsError` over an `archive/<name>.md` that is
427
+ # already there. `runtime-ts` uses `pyReplace` here; this is the same call.
428
+ path.replace(self.root / "archive" / path.name)
429
+ size -= sizes[fact.name]
430
+ archived.append(
431
+ ArchivedFact(
432
+ name=fact.name,
433
+ type=fact.type,
434
+ description=fact.description,
435
+ index_bytes=sizes[fact.name],
436
+ last_recalled=fact.last_recalled,
437
+ created=fact.created,
438
+ )
439
+ )
440
+ self._rebuild_index()
441
+ return CompactResult(
442
+ archived=archived,
443
+ index_before=before,
444
+ index_after=size,
445
+ budget=self.index_budget,
446
+ target=target,
447
+ reserve=reserve,
448
+ archive_dir=str(self.root / "archive"),
449
+ )
450
+
451
+ def archived(self) -> list[str]:
452
+ """Names of the facts sitting in `archive/` — everything `compact` moved out.
453
+
454
+ WAS: `glob("*.md")`, which is the same defect W1 removed from the fact read,
455
+ one directory over. NOW: raises `MemoryValidationError` naming `archive/` when
456
+ the directory is there but cannot be listed; still `[]` for a store that has
457
+ never compacted.
458
+
459
+ This is the worst place in the module to answer "empty" wrongly, because
460
+ `compact()` has already MOVED the operator's facts here. Measured 2026-08-23
461
+ on a store built for the probe: compact archived `fact-0`, `archive/fact-0.md`
462
+ was on disk, `chmod(archive, 0o311)`, and then `python -m bantamkit.memory
463
+ status` printed `archived: 0` and `... archived` printed `archived facts: 0`,
464
+ both exiting 0. The fact had left `facts/`, and the only tool that says where
465
+ it went said nowhere. An operator reading that has been told their memory was
466
+ deleted; the file was intact the whole time.
467
+
468
+ `restore()` is the other half and is deliberately NOT routed through here: it
469
+ stats one named path rather than listing, so an unlistable-but-traversable
470
+ `archive/` still restores (measured 2026-08-23 on a throwaway store: 0o311
471
+ restores fine). Making it list first would refuse a recovery the filesystem was
472
+ still willing to perform, which is the wrong direction for the door back.
473
+
474
+ At 0o000 nothing moves, but the raise does NOT come from `rename` as this
475
+ paragraph used to claim — it comes from `Path.exists()` three lines earlier,
476
+ which does not swallow EACCES: measured, `PermissionError: [Errno 13]
477
+ Permission denied: '.../archive/put-away.md'` straight out of `os.stat`. That
478
+ was a raw traceback until W4 gave the stat the same sentence as the listing
479
+ (`_reachable`, `_ARCHIVE_UNREACHABLE`).
480
+ """
481
+ archive = self.root / "archive"
482
+ return sorted(Path(name).stem for name in self._listing(archive, _ARCHIVE_UNREADABLE))
483
+
484
+ def archive(self, name: str) -> None:
485
+ """Move one named fact out of `facts/` and into `archive/`.
486
+
487
+ The door out, taken deliberately. `compact` already moves facts out, but it
488
+ chooses them by eviction rank and stops as soon as the index fits the budget, so
489
+ it can neither be asked for a PARTICULAR fact nor be used at all when the store
490
+ is already under budget. `restore` has taken a name since it was written; until
491
+ this method the store could bring a named fact back but not send one away, and an
492
+ operator who knew exactly which fact had gone stale had no way to say so.
493
+
494
+ THE NAME IS CHECKED AGAINST `NAME_RE` BEFORE ANY SYSCALL. `save` was the only op
495
+ that enforced it, and `save` is not the only op that CREATES a filename: this one
496
+ builds `archive/<name>.md` out of whatever it is handed. Measured 2026-09-05 on
497
+ macOS, before the check existed: `archive ALPHA` against a live `facts/alpha.md`
498
+ exited 0 and left `archive/ALPHA.md` holding a fact whose frontmatter says
499
+ `name: alpha` — the case-insensitive filesystem matched the source, and nothing
500
+ asked the store's own naming rule about the destination it was about to write. On
501
+ a case-sensitive filesystem the same command refuses with "no fact". An archive
502
+ entry the store can never name again is worse than a refusal, and a command that
503
+ means two things on two filesystems is worse than either. `restore` is
504
+ deliberately NOT changed: its name has been unvalidated since it was written, and
505
+ narrowing a shipped command's input is a product decision rather than this fix's.
506
+ Traversal was never the hole — `..`, an absolute path and `sub/alpha` all refused
507
+ identically on both runtimes before this, because `facts/<name>.md` simply is not
508
+ there; the check makes them refuse EARLIER and with the reason named.
509
+
510
+ THE PROMISE IS THE SAME ONE `restore` MAKES: a failed archive leaves the store
511
+ exactly as it found it. One of its three guards carries over unchanged in shape
512
+ and two drop out:
513
+
514
+ - Both stats are `_reachable`, not `exists()`, for the reason spelled at
515
+ `_ARCHIVE_UNREACHABLE`: a refused stat is not an absent file, and reporting
516
+ "no fact" for an EACCES sends the operator looking for a file that is there.
517
+ The two sentences are their own constants because each names the side the fact
518
+ is still on, and that side is the mirror of restore's.
519
+ - NO `_facts()` PARSE BEFORE THE MOVE, and the asymmetry with `restore` is the
520
+ point rather than an oversight. In restore's direction the pre-read is
521
+ load-bearing: it stops a shape the rollback of the day got wrong. Here it did
522
+ the opposite of its job. The parse reads EVERY fact, so ONE malformed file in
523
+ `facts/` refused every archive in the store INCLUDING ITS OWN — measured
524
+ 2026-09-05, `archive bad` against a `facts/bad.md` with no frontmatter answered
525
+ `malformed fact file bad.md: not enough values to unpack (expected 3, got 1)` —
526
+ and no other command removes a fact by name, so the one file the store calls
527
+ broken was the one file no CLI route could get rid of. That is the exact
528
+ opposite of what the paragraph above says this method is for. Without the parse
529
+ the same command SUCCEEDS, and it succeeds for a reason rather than by luck:
530
+ the move takes the bad file out of `facts/` first, so the `_rebuild_index`
531
+ below parses a directory that no longer holds it. A DIFFERENT fact being
532
+ malformed still fails, at that rebuild, and the rollback below puts the moved
533
+ fact back — which is the case the old docstring said the parse was protecting
534
+ and the rollback was already covering.
535
+ - NO budget check. Archiving removes an index line, so the index can only shrink;
536
+ `_check_index_budget` is restore's guard, in restore's direction, and running
537
+ it here would be a check that cannot fail.
538
+
539
+ THE MOVE IS `Path.replace` AND NOT `Path.rename`, for the reason `d239480` gives
540
+ at `compact`: `os.rename` replaces an existing destination silently on POSIX and
541
+ raises `FileExistsError` on Windows, `os.replace` replaces on both, and
542
+ `runtime-ts` calls `pyReplace` here. The state that reaches it is NOT one guard 2
543
+ refuses. `_reachable` is `Path.exists()`, which FOLLOWS symlinks, so a DANGLING
544
+ symlink at `archive/<name>.md` is an occupied directory entry the guard cannot
545
+ see: measured 2026-09-05, `os.path.lexists` True and `Path.exists` False, the
546
+ guard passed, and the move landed on top of the link. On POSIX both calls replace
547
+ it; on Windows `rename` would have raised where the port's `replace` does not.
548
+ THE ROLLBACK BELOW IS STILL `rename`, on the terms `d239480` used to leave
549
+ restore's alone: it moves back onto a path the forward move has just emptied, so
550
+ it cannot meet an occupied destination and there is no red to demonstrate for it.
551
+
552
+ The rollback stays, keyed on "the rebuild after the move failed" rather than on a
553
+ list of exception types, because the failure it exists for is not a `Memory*`
554
+ error at all. THE ROUTE THAT REACHES IT IS `index.md` BEING A DIRECTORY: the move
555
+ succeeds, `_rebuild_index` writes and raises `IsADirectoryError`, and the fact is
556
+ put back — measured 2026-09-05, `facts/` held `alpha.md` again and `archive/` was
557
+ empty afterwards. WHAT THIS PARAGRAPH USED TO SAY was that the route is "the
558
+ destination in `archive/` being a directory", copied out of restore without
559
+ re-deriving the direction, and that one is unreachable here: guard 2 stats that
560
+ exact path, so a directory at `archive/<name>.md` is refused with "already
561
+ archived" before anything moves (measured the same day, both runtimes).
562
+ """
563
+ if not NAME_RE.match(name or ""):
564
+ raise MemoryValidationError(f"invalid name '{name}'; must match {NAME_RE.pattern}")
565
+ source = self._fact_path(name)
566
+ if not self._reachable(source, self.root / "facts", _FACT_UNREACHABLE):
567
+ raise MemoryValidationError(
568
+ f"no fact '{name}' under {self.root / 'facts'}"
569
+ )
570
+ destination = self.root / "archive" / f"{name}.md"
571
+ if self._reachable(destination, self.root / "archive", _ARCHIVE_DESTINATION_UNREACHABLE):
572
+ raise MemoryValidationError(
573
+ f"fact '{name}' is already archived; refusing to overwrite it"
574
+ )
575
+ destination.parent.mkdir(parents=True, exist_ok=True)
576
+ # `Path.replace`, not `Path.rename`: the two agree on POSIX and differ on Windows,
577
+ # where `rename` raises `FileExistsError` over an occupied `archive/<name>.md`. A
578
+ # dangling symlink there is exactly that and passes the guard above, which follows
579
+ # links. `runtime-ts` calls `pyReplace` here; this is the same call. See `d239480`.
580
+ source.replace(destination)
581
+ try:
582
+ self._rebuild_index()
583
+ except Exception:
584
+ destination.rename(source)
585
+ self._rebuild_index()
586
+ raise
587
+
588
+ def restore(self, name: str) -> None:
589
+ """Move an archived fact back into `facts/`; refuse if it would blow the budget.
590
+
591
+ Compaction is a move, not a delete, and this is the door back. THE PROMISE IS
592
+ THAT A FAILED RESTORE LEAVES THE STORE EXACTLY AS IT FOUND IT, and it takes
593
+ both halves below to keep it: a read that runs before the `rename`, and a
594
+ rollback for the failures no read before the `rename` can see.
595
+
596
+ WAS: nothing read `facts/` until after the rename. W1 put a LISTING there,
597
+ which was not enough, because `_fact_paths` lists and `_check_index_budget`
598
+ parses. Two failures measured on throwaway stores, byte-identical at `533229c`
599
+ and after W1:
600
+
601
+ - `facts/` unlistable (0o311, or any scan that fails): W1's read stops it.
602
+ - `facts/broken.md` malformed: the listing passed it, `_check_index_budget`
603
+ raised `MemoryValidationError` AFTER the rename, the rollback below caught
604
+ `MemoryBudgetExceeded` only, and the fact ended up out of `archive/`, in
605
+ `facts/`, with `index.md` never rebuilt.
606
+
607
+ NOW the pre-read is `_facts()`, which parses. That refuses no restore that
608
+ would otherwise have succeeded: every parse it can fail on is one
609
+ `_check_index_budget` re-runs three lines later, so with a malformed fact on
610
+ disk the restore fails either way — all that changes is whether it fails before
611
+ the move or after it. `save` is immune to the same shape only by luck of
612
+ ordering (its duplicate check lists before `_write_fact`), and a read that
613
+ makes the move never happen is strictly better than a rollback that has to undo
614
+ one.
615
+
616
+ The rollback still has to widen, for the failure no pre-read can reach: when
617
+ the ARCHIVED file is the bad one, it is not a fact until after the `rename`.
618
+ Measured, both shapes: a malformed `archive/put-away.md` raised
619
+ `MemoryValidationError` and an `archive/put-away.md` that is a DIRECTORY raised
620
+ `IsADirectoryError` (POSIX) or `PermissionError` (Windows) — an `OSError`, not
621
+ a `Memory*` error at all. So the clause below is keyed on "the op after the
622
+ move failed", not on a list of exception types: the promise is about the state
623
+ of the store, and it is not a promise about which exception was raised.
624
+ """
625
+ source = self.root / "archive" / f"{name}.md"
626
+ if not self._reachable(source, self.root / "archive", _ARCHIVE_UNREACHABLE):
627
+ raise MemoryValidationError(
628
+ f"no archived fact '{name}' under {self.root / 'archive'}"
629
+ )
630
+ destination = self._fact_path(name)
631
+ if self._reachable(destination, self.root / "facts", _FACTS_UNREACHABLE):
632
+ raise MemoryValidationError(
633
+ f"fact '{name}' is already live; refusing to overwrite it from archive"
634
+ )
635
+ self._facts() # parse BEFORE the move, not after it — see the docstring
636
+ destination.parent.mkdir(parents=True, exist_ok=True)
637
+ source.rename(destination)
638
+ try:
639
+ self._check_index_budget()
640
+ except Exception:
641
+ destination.rename(source)
642
+ self._rebuild_index()
643
+ raise
644
+ self._rebuild_index()
645
+
646
+ def _reachable(self, path: Path, directory: Path, consequence: str) -> bool:
647
+ """`path.exists()`, except that "I was not allowed to look" is never "it is not there".
648
+
649
+ The same invariant as `_listing`, one syscall down. `Path.exists()` swallows
650
+ exactly `pathlib._IGNORED_ERRNOS` — ENOENT, ENOTDIR, EBADF, ELOOP, the answers
651
+ that really do mean "nothing is there" — and re-raises the rest, so EACCES
652
+ arrives as a bare `PermissionError`. Measured before this existed, with
653
+ `archive/` at 0o000: `python -m bantamkit.memory restore` printed a stack trace
654
+ ending in `PermissionError: [Errno 13] Permission denied`, while `_cmd_restore`
655
+ had a sentence ready for `MemoryValidationError` and never saw one. Converted
656
+ here rather than in the CLI because both of `restore`'s probes had it and the
657
+ distinction is the store's to make, not one command's.
658
+ """
659
+ try:
660
+ return path.exists()
661
+ except OSError as e:
662
+ raise self._unreadable(directory, e, consequence, f"stat of {path.name}") from e
663
+
664
+ def index_text(self) -> str:
665
+ """The index as it should be on disk, derived from `facts/` and nothing else.
666
+
667
+ WAS: `""` for an unreadable store — the input `_rebuild_index` wrote over
668
+ `index.md` and the number `_check_index_budget` measured. NOW:
669
+ `MemoryValidationError`. Everything downstream of this inherits it, which is
670
+ the whole shape of the original defect and is why the raise lives in the read
671
+ rather than in a guard on the write (see `_rebuild_index`).
672
+ """
673
+ return "".join(self._index_line(fact) for fact in self._facts())
674
+
675
+ # ---- internals ----
676
+
677
+ def _fact_path(self, name: str) -> Path:
678
+ return self.root / "facts" / f"{name}.md"
679
+
680
+ def _index_line(self, fact: Fact) -> str:
681
+ return f"- [[{fact.name}]] ({fact.type}) — {fact.description}\n"
682
+
683
+ def _staleness_key(self, fact: Fact) -> tuple[str, str]:
684
+ """Order by the last evidence anyone wanted this fact — never by its absence.
685
+
686
+ `last_recalled` alone conflated two opposite facts: one written seconds ago and
687
+ one nobody has asked for in a year both read as `None`, and `None or ""` sorts
688
+ before every real ISO date, so the *newest* fact was the first evicted (measured:
689
+ three facts stamped 2026-01-05 plus one saved today, one slot to free, archived
690
+ `['zulu-newest']`). Falling back to `created` makes absence of evidence mean
691
+ "as stale as it is old" instead of "maximally stale". Ties break on name only
692
+ after the dates are equal, so the alphabet can no longer decide a live question.
693
+ """
694
+ return (fact.last_recalled or fact.created or "", fact.name)
695
+
696
+ def _eviction_key(self, fact: Fact) -> tuple[int, str, str]:
697
+ """`compact`'s order: class first, then staleness. Nothing else reads it.
698
+
699
+ A `feedback` fact is the user's own correction, and its value does NOT decay with
700
+ time-since-last-recall -- it holds until the user revokes it. The temporal key is
701
+ inverted for exactly that kind of fact, which is why this rank exists and why it
702
+ sorts LAST: an instruction internalised well enough that nothing needs to look it up
703
+ again reads as maximally stale, and archiving moves it out of the index loaded at
704
+ session start, so the user's own correction silently stops being surfaced. That is
705
+ the failure this store exists to prevent, and it was measured happening -- on the
706
+ real project store one auto-compaction archived 15 facts, 6 of them `feedback`.
707
+
708
+ `DURABLE_TYPES` AND NOT `"feedback"` ALONE, because that reason is a property of the
709
+ class and not of the word. Review round 4 (M12) read it back against this repo's own
710
+ instructions to the model -- `assets/skills/memory.md`, "A durable fact about the
711
+ user -> `user`" -- and a durable fact does not become less true because nothing
712
+ looked it up. Measured before the change, on four facts one per type where nothing
713
+ has ever been recalled: one slot to free and `compact` archived `ausr`, a
714
+ never-recalled `user` fact, ahead of a `project` note created seven months later.
715
+ The change is MONOTONE -- the protected set only grows -- so no existing store loses
716
+ a fact this rank kept for it before.
717
+
718
+ The two protected types share ONE rank rather than being ordered against each other:
719
+ the reason for protecting them is identical, so any order between them would be an
720
+ invention, and a tied rank leaves `_staleness_key` to answer, which is what it is for.
721
+
722
+ Within a class the order is `_staleness_key` unchanged, and `sorted` is stable, so a
723
+ tied rank leaves the staleness answer exactly as it was.
724
+
725
+ A PRIORITY AND NOT A VETO, and deliberately UNCAPPED. Nothing bounds how much of the
726
+ index the protected class may hold, and that cannot make `compact` fail: once every
727
+ decaying fact is archived the loop keeps going through the protected ones by
728
+ staleness, so the budget still wins. Capping the class instead -- protecting only the
729
+ first N bytes of it -- would archive a fact that today's rank keeps, which is exactly
730
+ what a live user store must not be made to do by a review round. The crowding that a
731
+ cap would address is not live either: on the real store at `952586e` the protected
732
+ classes hold 5634 of 20241 index bytes.
733
+
734
+ `in` against a TUPLE and never a `set` or `is`: `type` comes out of YAML with no
735
+ cast, so a hand-edited `type: 2026` really does put a `date` in that field and
736
+ `type: [a, b]` a `list`. `in` on a tuple is `==` per element -- False across types,
737
+ never raising -- where a `set` would hash the value and take `compact`, the operator's
738
+ only way back under budget, down with a `TypeError`. `runtime-ts` spells the same
739
+ comparison as one `pyEqualValue` per entry.
740
+ """
741
+ return (1 if fact.type in DURABLE_TYPES else 0, *self._staleness_key(fact))
742
+
743
+ def _listing(self, directory: Path, consequence: str) -> list[str]:
744
+ """The `*.md` names in one of this store's two directories, or a raise. Never a lie.
745
+
746
+ `Path.glob` is unusable here and that is the whole reason this function exists:
747
+ it suppresses the `OSError` raised by its own directory scan and yields nothing.
748
+ `_facts` is read TWICE by `save` — once for the duplicate check and once by
749
+ `_rebuild_index` — so a blind listing does not merely under-report, it
750
+ overwrites. Measured 2026-08-23 on a copy of the live 65-fact store with
751
+ `facts/` at 0o311 (writable and traversable, not listable): `save` returned
752
+ `status='saved'`, a 66th fact file landed, and `index.md` went from 13,472
753
+ bytes / 65 lines to 0 / 0 while every fact file sat there unharmed.
754
+
755
+ `os.scandir` raises instead. Three decisions, and each one has a reason:
756
+
757
+ - THE RAISE IS CONVERTED HERE, not left to callers. This is the one deliberate
758
+ difference from `layers.count_facts`, which propagates the `OSError` for
759
+ `_count_into_binding` to phrase: that function has callers wanting different
760
+ sentences, whereas this store has one reader per directory and `save`,
761
+ `recall`, `lint`, `compact`, `index_text`, `restore` and `archived` all reach
762
+ the disk through here. Converting at the read is what makes "no path out of
763
+ this module reports an unreadable directory as an empty one" a property of
764
+ one place instead of seven.
765
+ - THE COUNTED SET DOES NOT CHANGE. `fnmatch.fnmatch` is the match `pathlib`
766
+ performs — dotfiles and directories included, case-sensitive off Windows and
767
+ case-insensitive on it — and both callers sort exactly as `sorted(glob(...))`
768
+ did. A raise bought by quietly redefining which files are facts would be a
769
+ worse defect than the one it fixes
770
+ (`test_a_readable_store_lists_exactly_what_glob_listed`,
771
+ `test_a_readable_archive_lists_exactly_what_glob_listed`).
772
+ - AN ABSENT DIRECTORY IS `[]`, NOT AN ERROR. That is a first run, and
773
+ `_ensure_dirs`, `create=False` and the designate path all depend on it — for
774
+ `archive/` as much as for `facts/`, because a `create=False` store never makes
775
+ either one and `archived()` has always answered `[]` for a store that has
776
+ simply never compacted.
777
+
778
+ The last one is keyed on whether anything is AT the path rather than on the
779
+ errno, and that is the second, smaller difference from `count_facts`. A
780
+ `FileNotFoundError` raised while something is still there is a failed listing
781
+ wearing the absent answer's clothes: POSIX reports ENOTDIR for a scan of a
782
+ regular file, but a Windows directory scan of a non-directory reports the path
783
+ as not found, and a dangling symlink reports ENOENT everywhere. `count_facts`
784
+ answers 0 for a `facts/` that is a regular file — its review measured that as
785
+ its one genuine disagreement with `glob` — while this layer answers
786
+ "unreadable", which is the ruling `test_memory_divergence` already made for a
787
+ file or a dangling symlink where a store belongs, and which makes the answer
788
+ identical on all four CI jobs instead of turning on an errno.
789
+
790
+ `consequence` is the caller's half of the sentence, because the two directories
791
+ fail differently and one wording cannot be true of both: an unlistable `facts/`
792
+ is what rewrites `index.md` from nothing, while an unlistable `archive/` is what
793
+ makes a compaction look like a deletion. An error naming the wrong consequence
794
+ sends the reader to the wrong place, which is this module's defect in a new
795
+ shape rather than a fix for it.
796
+ """
797
+ try:
798
+ with os.scandir(directory) as entries:
799
+ return [e.name for e in entries if fnmatch.fnmatch(e.name, "*.md")]
800
+ except FileNotFoundError as e:
801
+ if os.path.lexists(directory):
802
+ raise self._unreadable(directory, e, consequence, "a path exists there") from e
803
+ return []
804
+ except OSError as e:
805
+ raise self._unreadable(directory, e, consequence) from e
806
+
807
+ def _fact_paths(self) -> list[Path]:
808
+ """Every `facts/*.md`, listed so that "I could not read it" is never "it is empty".
809
+
810
+ The mechanism is `_listing` above, shared with `archived()`; what is local to
811
+ `facts/` is the consequence the error names. This is the read behind `save`,
812
+ `recall`, `lint`, `compact`, `index_text`, `restore`, `_stamp` and `snapshot`,
813
+ and each of those says in its own words what it does when this raises — a
814
+ reader of any one of them should not have to come here to find out.
815
+ """
816
+ facts = self.root / "facts"
817
+ return sorted(facts / name for name in self._listing(facts, _FACTS_UNREADABLE))
818
+
819
+ @staticmethod
820
+ def _unreadable(
821
+ directory: Path, error: OSError, consequence: str, detail: str = ""
822
+ ) -> MemoryValidationError:
823
+ """One sentence for a directory that could not be listed, and it never says "empty"."""
824
+ because = f"{error.strerror}{f' ({detail})' if detail else ''}"
825
+ return MemoryValidationError(
826
+ f"memory store is unreadable: {directory}: {because}; {consequence}"
827
+ )
828
+
829
+ def _facts(self) -> list[Fact]:
830
+ facts = []
831
+ for path in self._fact_paths():
832
+ text = path.read_text(encoding="utf-8")
833
+ try:
834
+ _, front, body = text.split("---\n", 2)
835
+ meta = yaml.safe_load(front)
836
+ if not isinstance(meta, dict):
837
+ raise MemoryValidationError(
838
+ f"malformed fact file {path.name}: frontmatter is not a mapping"
839
+ )
840
+ facts.append(
841
+ Fact(
842
+ name=meta["name"],
843
+ description=meta["description"],
844
+ type=meta["type"],
845
+ body=body.strip(),
846
+ links=list(meta.get("links") or []),
847
+ last_recalled=meta.get("last_recalled"),
848
+ created=meta.get("created") or _mtime_date(path),
849
+ )
850
+ )
851
+ except (ValueError, KeyError, yaml.YAMLError) as e:
852
+ raise MemoryValidationError(f"malformed fact file {path.name}: {e}") from e
853
+ return facts
854
+
855
+ def _stamp(self, fact: Fact) -> None:
856
+ """Date the recall without ever writing pinned content back.
857
+
858
+ Inside `snapshot()` a hit is a pre-scope copy of the file, so writing it
859
+ verbatim would silently revert a `save` made in the same scope — the same
860
+ poisoning, in reverse. The date therefore lands on whatever is on disk now,
861
+ and a fact that is no longer there is left alone rather than resurrected.
862
+
863
+ WAS: an unreadable store made `self._facts()` below return `[]`, so `live` was
864
+ `None` and every stamp was quietly skipped — a recall inside a scope silently
865
+ stopped recording that it happened. NOW: `MemoryValidationError` out of the
866
+ listing. This is the ONLY caller that can raise after `recall` already has its
867
+ answer, which is why `snapshot()` documents it as its non-obvious case.
868
+
869
+ No fact is left half-dated: the listing runs before any `_write_fact`, and it
870
+ raises on the first hit, so a `recall` that raises here has written nothing.
871
+ (`fact.last_recalled` is set on the in-memory `Fact` first and that mutation
872
+ survives on the pinned copy; nothing reads it but `_staleness_key` (through
873
+ `_eviction_key`), and the
874
+ pinned list dies with the scope.)
875
+ """
876
+ fact.last_recalled = self._today()
877
+ if self._snapshot is None:
878
+ self._write_fact(fact)
879
+ return
880
+ live = next((f for f in self._facts() if f.name == fact.name), None)
881
+ if live is None:
882
+ return
883
+ live.last_recalled = fact.last_recalled
884
+ self._write_fact(live)
885
+
886
+ def _write_fact(self, fact: Fact) -> None:
887
+ meta = {
888
+ "name": fact.name,
889
+ "description": fact.description,
890
+ "type": fact.type,
891
+ "created": fact.created,
892
+ "last_recalled": fact.last_recalled,
893
+ "links": fact.links,
894
+ }
895
+ text = (
896
+ "---\n"
897
+ + yaml.safe_dump(meta, sort_keys=False, allow_unicode=True)
898
+ + "---\n\n"
899
+ + fact.body.strip()
900
+ + "\n"
901
+ )
902
+ path = self._fact_path(fact.name)
903
+ tmp = path.with_suffix(".md.tmp")
904
+ tmp.write_text(text, encoding="utf-8")
905
+ tmp.replace(path)
906
+
907
+ def _rebuild_index(self) -> None:
908
+ """Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
909
+
910
+ WAS: `index_text()` answered `""` for a store it could not list and this wrote
911
+ that over a 13,472-byte index. NOW: `index_text()` raises and the `write_text`
912
+ is never reached, so the file on disk is left exactly as it was.
913
+
914
+ Note what is deliberately NOT here: a guard refusing to shrink the index. That
915
+ would be a heuristic over a symptom — it cannot tell a wipe from a legitimate
916
+ `compact()`, and it would leave `recall`, `lint` and `archived` still being lied
917
+ to. The read is what was wrong and the read is where it is fixed.
918
+ """
919
+ (self.root / "index.md").write_text(self.index_text(), encoding="utf-8")
920
+
921
+ def _check_index_budget(self) -> None:
922
+ """Raise `MemoryBudgetExceeded` if the index would not fit.
923
+
924
+ WAS: an unreadable store measured 0 bytes and always fitted. NOW:
925
+ `index_text()` raises `MemoryValidationError` first — a DIFFERENT exception
926
+ from `MemoryBudgetExceeded`, and every caller that rolls back on the budget has
927
+ to decide about it too. `restore` reads ahead of its `rename` AND catches both
928
+ (see its docstring); `save`'s rollback is unaffected, because its listing
929
+ already ran, and failed, before `_write_fact`.
930
+
931
+ Note that this is a PARSE, not a listing: `index_text` -> `_facts` reads every
932
+ fact file. A caller that pre-reads with `_fact_paths` has not pre-read what
933
+ this raises on.
934
+ """
935
+ size = len(self.index_text().encode())
936
+ if size > self.index_budget:
937
+ raise MemoryBudgetExceeded(
938
+ f"memory index is {size} bytes, budget is {self.index_budget}: "
939
+ f"run compact() or tersen descriptions"
940
+ )