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