bantamkit 0.27.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
)
|