bantamkit-mcp 0.26.0
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.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- package/package.json +45 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-facing memory component: skill in the prompt, correctness in the store.
|
|
3
|
+
*
|
|
4
|
+
* A port of `runtime-py/src/bantamkit/memory/component.py`. Roughly half of it is SENTENCES,
|
|
5
|
+
* and the value of a sentence here is that it is exactly the one the Python server produces:
|
|
6
|
+
* these are what an agent acts on when its memory appears empty, and job37 exists because
|
|
7
|
+
* "empty" and "unreadable" were once the same answer. A one-word difference is a defect.
|
|
8
|
+
*
|
|
9
|
+
* WHAT IS DELIBERATELY MISSING. `setup()` and `batch()`, the only two consumers of `Agent`.
|
|
10
|
+
* The prep probe traced a real stdio server through the tools and both resource templates:
|
|
11
|
+
* neither is reachable from the MCP surface. They are absent rather than stubbed so that
|
|
12
|
+
* nobody reads a stub and believes the batch scope exists here. If a tool ever calls one,
|
|
13
|
+
* that is a refutation of the trace and wants reporting, not a quiet addition.
|
|
14
|
+
*
|
|
15
|
+
* WHAT USED TO BE MISSING AND NO LONGER IS. `compact()` was on that list, with the note that
|
|
16
|
+
* its store half was not ported either. The store half landed first (`MemoryStore.compact`,
|
|
17
|
+
* the operator CLI's `compact` verb), and job42 registered `memory_compact` as the ninth MCP
|
|
18
|
+
* tool on the reference — the model's half of compaction, the remedy the refused-budget
|
|
19
|
+
* reply now names. So `compact()` / `compactOutcome()` are ported below, sentence for
|
|
20
|
+
* sentence, and reach ONLY `this.store` (the writable project layer): `layered` puts grants
|
|
21
|
+
* and the profile store in `_layers`, never in `this.store`, so a compaction cannot see them.
|
|
22
|
+
*
|
|
23
|
+
* BOTH REGISTRATIONS ARE REAL AND BOTH ARE PORTED. `.mcp.json` and the user-scope config
|
|
24
|
+
* invoke the server with no `--store`, so PRODUCTION RUNS `Memory.layered` and its recall
|
|
25
|
+
* lines carry a `[project] ` tag that the `--store` form never emits. A port validated
|
|
26
|
+
* against only one of them ships a string the deployment never produces.
|
|
27
|
+
*/
|
|
28
|
+
import { BantamError } from '../errors.js';
|
|
29
|
+
import { pyStrip } from './factfile.js';
|
|
30
|
+
import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
|
|
31
|
+
import { pyHome, pyJoin, pyName, pyParent, pyResolve, PyOSError, PyUnicodeDecodeError } from './pyfs.js';
|
|
32
|
+
import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyHashKey, pyText, } from './store.js';
|
|
33
|
+
/**
|
|
34
|
+
* The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
|
|
35
|
+
*
|
|
36
|
+
* The store's pattern does not move; the component bends the argument to it. Measured cause:
|
|
37
|
+
* 5 of the 7b probe runs burned their whole turn budget retrying `memory_save` with the
|
|
38
|
+
* snake_case names the model invents. Non-strings are handed on untouched so that store
|
|
39
|
+
* validation still speaks.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeName(name) {
|
|
42
|
+
if (typeof name !== 'string')
|
|
43
|
+
return name;
|
|
44
|
+
// `str.strip()` is NOT `String.trim()` — they disagree on six codepoints, and `pyStrip`
|
|
45
|
+
// is N2's answer to that. `str.lower()` IS `String.toLowerCase()`, including the one place
|
|
46
|
+
// it was expected not to be: CPython applies the Final_Sigma condition too, so `ΑΣ`
|
|
47
|
+
// lowercases to `ας` in both runtimes and a per-codepoint fold would have been the thing
|
|
48
|
+
// that differed. MEASURED — the case `normalize_name over the strip and lowercase
|
|
49
|
+
// boundary` compares thirteen spellings, `ΣΟΦΟΣ` among them.
|
|
50
|
+
return pyStrip(name).toLowerCase().replace(/_/g, '-').replace(/ /g, '-');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The last layer `layered` appends, named once so other code can recognise it.
|
|
54
|
+
*
|
|
55
|
+
* It was spelled inline and nowhere else, which is how it came to be prescribed as a place
|
|
56
|
+
* to start a project store: nothing that wrote the advice could see that the directory it
|
|
57
|
+
* was recommending is also the machine-wide layer.
|
|
58
|
+
*/
|
|
59
|
+
export function profileStore() {
|
|
60
|
+
return pyJoin(pyHome(), ...PROJECT_STORE);
|
|
61
|
+
}
|
|
62
|
+
/** The name a layer answers under: the PROJECT directory, not the store directory. */
|
|
63
|
+
export function layerLabel(root) {
|
|
64
|
+
if (pyName(pyParent(root)) === '.bantamkit')
|
|
65
|
+
return pyName(pyParent(pyParent(root)));
|
|
66
|
+
return pyName(root);
|
|
67
|
+
}
|
|
68
|
+
export class Memory {
|
|
69
|
+
store;
|
|
70
|
+
k;
|
|
71
|
+
layers;
|
|
72
|
+
showLayers = false;
|
|
73
|
+
/**
|
|
74
|
+
* How this store came to be the store, captured once. It cannot be re-derived later: the
|
|
75
|
+
* constructor above has already made the directory, so a store that was only DESIGNATED a
|
|
76
|
+
* moment ago is indistinguishable on disk from one that was found empty. `null` means the
|
|
77
|
+
* caller named the path outright and no resolution happened to report.
|
|
78
|
+
*/
|
|
79
|
+
binding;
|
|
80
|
+
constructor(store, options = {}, binding = null) {
|
|
81
|
+
const k = options.k ?? 3;
|
|
82
|
+
this.store = new MemoryStore(store, {
|
|
83
|
+
indexBudget: options.indexBudget ?? DEFAULT_INDEX_BUDGET,
|
|
84
|
+
k,
|
|
85
|
+
...(options.today ? { today: options.today } : {}),
|
|
86
|
+
});
|
|
87
|
+
this.k = k;
|
|
88
|
+
this.layers = [['project', this.store, true]];
|
|
89
|
+
this.binding = binding;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Project store (resolved) + configured read-only grants + profile store.
|
|
93
|
+
*
|
|
94
|
+
* `resolveProjectStore` rather than `discoverProjectStore`: the path both return is the
|
|
95
|
+
* same path, but only the binding carries WHY it is that path and whether it holds
|
|
96
|
+
* anything — and `recall` cannot recover either fact afterwards, because constructing the
|
|
97
|
+
* store creates the directory.
|
|
98
|
+
*/
|
|
99
|
+
static layered(start, options = {}) {
|
|
100
|
+
const binding = resolveProjectStore(start);
|
|
101
|
+
const mem = new Memory(binding.path, options, binding);
|
|
102
|
+
mem.showLayers = true;
|
|
103
|
+
const k = options.k ?? 3;
|
|
104
|
+
const today = options.today;
|
|
105
|
+
for (const grant of loadGrants(binding.path)) {
|
|
106
|
+
mem.layers.push([
|
|
107
|
+
`extra:${layerLabel(grant)}`,
|
|
108
|
+
new MemoryStore(grant, { k, create: false, ...(today ? { today } : {}) }),
|
|
109
|
+
false,
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
mem.layers.push([
|
|
113
|
+
'profile',
|
|
114
|
+
new MemoryStore(profileStore(), { k, create: false, ...(today ? { today } : {}) }),
|
|
115
|
+
false,
|
|
116
|
+
]);
|
|
117
|
+
return mem;
|
|
118
|
+
}
|
|
119
|
+
/** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
|
|
120
|
+
layerLabels() {
|
|
121
|
+
return this.layers.map(([label]) => label);
|
|
122
|
+
}
|
|
123
|
+
/** The reply the model reads. Unchanged, and still the tool's advertised return. */
|
|
124
|
+
save(type, name, description, body, links = null) {
|
|
125
|
+
return this.saveOutcome(type, name, description, body, links).reply;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `save`, with the branch it took carried out alongside the sentence it wrote.
|
|
129
|
+
*
|
|
130
|
+
* Same body, same order, same four exits: the only change is that each exit now NAMES
|
|
131
|
+
* itself. That is the whole of the narrow seam the MCP server needs to log an outcome
|
|
132
|
+
* without matching text, and it moves no wording and no return type — `save()` above
|
|
133
|
+
* still answers `string`, and `assets/tools/memory_save.json` still advertises `str`.
|
|
134
|
+
*/
|
|
135
|
+
saveOutcome(type, name, description, body, links = null) {
|
|
136
|
+
const normalized = normalizeName(name);
|
|
137
|
+
const normalizedLinks = (links ?? []).map((link) => normalizeName(link));
|
|
138
|
+
let result;
|
|
139
|
+
try {
|
|
140
|
+
result = this.store.save(type, normalized, description, body, normalizedLinks);
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (e instanceof MemoryValidationError) {
|
|
144
|
+
return { reply: `error: ${e.message}`, status: 'refused-validation' };
|
|
145
|
+
}
|
|
146
|
+
if (e instanceof MemoryBudgetExceeded) {
|
|
147
|
+
// Not an argument problem: retrying the same call cannot fit the index. Two
|
|
148
|
+
// audiences, two remedies. The store's own text names `compact()`; this reply goes
|
|
149
|
+
// to the MODEL, and since job42 the model HAS a compaction tool — `memory_compact`
|
|
150
|
+
// (`docs/memory.md`: the user ruled compaction automatic on 2026-08-24; the hook
|
|
151
|
+
// covers the 90 % band, the tool covers this refusal) — so the last sentence names
|
|
152
|
+
// it. Everything before that sentence is byte-identical to what it was when
|
|
153
|
+
// compaction was operator-only.
|
|
154
|
+
return {
|
|
155
|
+
reply: `error: ${e.message}. Nothing was saved and retrying will not help — shorten ` +
|
|
156
|
+
'the description, or save under the name of an existing memory to replace it. ' +
|
|
157
|
+
'Or call `memory_compact` to archive the stalest facts and free room — nothing is deleted.',
|
|
158
|
+
status: 'refused-budget',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
if (result.status === 'duplicate') {
|
|
164
|
+
return {
|
|
165
|
+
reply: `similar memory '${result.similar}' already exists — save under that SAME ` +
|
|
166
|
+
'name to update it, or skip. Do not rename to force a copy.',
|
|
167
|
+
status: 'duplicate',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return { reply: `saved '${result.name}'`, status: 'saved' };
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `k` is the model asking for MORE, never for less than the store's default.
|
|
174
|
+
*
|
|
175
|
+
* Measured cause (RB-P1, qwen2.5:14b-instruct): 57 of 60 `memory_recall` calls across 12
|
|
176
|
+
* seeded runs sent `k: 1`, and honouring it truncated recall to the single best-scoring
|
|
177
|
+
* fact. The floor is the operator's configured default, not a constant, so a consumer who
|
|
178
|
+
* really wants top-1 says so once at construction.
|
|
179
|
+
*/
|
|
180
|
+
recall(query, k = null) {
|
|
181
|
+
return this.recallOutcome(query, k).reply;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* `recall`, carrying the walk it performed as numbers rather than as prose.
|
|
185
|
+
*
|
|
186
|
+
* The reply is byte-for-byte what `recall` has always returned; every field beside it is
|
|
187
|
+
* counted here because it CANNOT be recovered afterwards. How far down the layer list the
|
|
188
|
+
* budget got, how many of the layers reached were readable, how many facts those layers
|
|
189
|
+
* held against how many came back, and which of the three empty verdicts fired — all of
|
|
190
|
+
* it is gone by the time the string exists, and the host's log records only that a
|
|
191
|
+
* `memory_recall` "completed successfully".
|
|
192
|
+
*
|
|
193
|
+
* `candidates` counts `facts/*.md` in the layers actually READ, via `countFacts` — the
|
|
194
|
+
* same filter `MemoryStore.recall` scores over, so the ratio to `returned` is between two
|
|
195
|
+
* counts of one population and not between two different ones. A layer whose directory
|
|
196
|
+
* refuses to list contributes nothing and raises the `unreadable` count instead of being
|
|
197
|
+
* scored as empty; that distinction is the whole subject of `nothingToReport` below and
|
|
198
|
+
* must not be undone here.
|
|
199
|
+
*/
|
|
200
|
+
recallOutcome(query, k = null) {
|
|
201
|
+
const budget = k === null ? this.k : Math.max(k, this.k);
|
|
202
|
+
const picked = [];
|
|
203
|
+
const seen = new Set();
|
|
204
|
+
let reached = 0;
|
|
205
|
+
let unreadable = 0;
|
|
206
|
+
let candidates = 0;
|
|
207
|
+
for (const [label, store, writable] of this.layers) {
|
|
208
|
+
if (picked.length >= budget)
|
|
209
|
+
break; // budget spent: later layers are never even read
|
|
210
|
+
reached += 1;
|
|
211
|
+
let facts;
|
|
212
|
+
try {
|
|
213
|
+
facts = store.recall(query, budget, writable);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
if (writable)
|
|
220
|
+
throw e; // the project layer failing is a real error, as in v1
|
|
221
|
+
unreadable += 1;
|
|
222
|
+
continue; // a corrupt grant/profile layer must not take down recall
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
candidates += countFacts(store.root);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
// It scored a moment ago, so it is readable; a race that unlists it now must not
|
|
229
|
+
// turn a successful recall into a failed one for a log field.
|
|
230
|
+
if (!(e instanceof PyOSError))
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
for (const fact of facts) {
|
|
234
|
+
// `if fact.name in seen` over a Python `set`, which is `hash`/`==` and not `str` —
|
|
235
|
+
// see `store.pyHashKey` for why a hand-edited numeric name makes the two differ.
|
|
236
|
+
const key = pyHashKey(fact.name);
|
|
237
|
+
if (seen.has(key) || picked.length >= budget)
|
|
238
|
+
continue;
|
|
239
|
+
seen.add(key);
|
|
240
|
+
picked.push([label, fact]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const counts = {
|
|
244
|
+
budget,
|
|
245
|
+
layers: this.layers.length,
|
|
246
|
+
reached,
|
|
247
|
+
returned: picked.length,
|
|
248
|
+
candidates,
|
|
249
|
+
unreadable,
|
|
250
|
+
};
|
|
251
|
+
if (picked.length === 0) {
|
|
252
|
+
const [status, reply] = this.nothingToReport();
|
|
253
|
+
return { reply, status, source: null, ...counts };
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
reply: picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
|
|
257
|
+
status: 'answered',
|
|
258
|
+
source: picked[0][0].split(':', 1)[0],
|
|
259
|
+
...counts,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Archive the stalest facts until a fact as large as the biggest one kept will fit.
|
|
264
|
+
*
|
|
265
|
+
* The reply is the ONLY place the model learns what left the index: `archive/` is a
|
|
266
|
+
* directory the model calling this will never look in — if the return value does not carry
|
|
267
|
+
* what was lost, nothing does. Ported from `Memory.compact` in `component.py`, which is
|
|
268
|
+
* written for a model; the tool's advertised return is this string, unchanged.
|
|
269
|
+
*/
|
|
270
|
+
compact(reserve = null) {
|
|
271
|
+
return this.compactOutcome(reserve).reply;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* `compact`, with the decision it took carried beside the sentence it wrote.
|
|
275
|
+
*
|
|
276
|
+
* `this.store` is the writable project layer and nothing else: `layered` appends grants
|
|
277
|
+
* and the profile store to `_layers` only, never to `this.store`, so this cannot reach
|
|
278
|
+
* either (`test/layers.test.mjs` pins a profile fact through a compaction). The reply is
|
|
279
|
+
* byte-for-byte what `compact` has always returned on the reference: Python `f"{int}"`
|
|
280
|
+
* is a plain decimal, and `result.archive_dir` is `str(Path)`, which `archiveDir` already
|
|
281
|
+
* is — `MemoryStore.compact` builds it with `pyJoin`.
|
|
282
|
+
*/
|
|
283
|
+
compactOutcome(reserve = null) {
|
|
284
|
+
const result = this.store.compact(reserve);
|
|
285
|
+
let reply;
|
|
286
|
+
let status;
|
|
287
|
+
if (result.archived.length === 0) {
|
|
288
|
+
reply =
|
|
289
|
+
`nothing archived: the index is ${result.indexAfter} bytes against a ` +
|
|
290
|
+
`${result.budget}-byte budget, already at or under the ` +
|
|
291
|
+
`${result.target}-byte compaction target.`;
|
|
292
|
+
status = 'nothing-archived';
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const lost = result.archived
|
|
296
|
+
.map((fact) => `- ${fact.name} (${fact.type}) — ${fact.description}`)
|
|
297
|
+
.join('\n');
|
|
298
|
+
reply =
|
|
299
|
+
`archived ${result.archived.length} memories; the index went from ` +
|
|
300
|
+
`${result.indexBefore} to ${result.indexAfter} bytes against a ` +
|
|
301
|
+
`${result.budget}-byte budget, leaving ${result.headroom} bytes of headroom. ` +
|
|
302
|
+
`These moved to ${result.archiveDir} and are NOT deleted — they can be ` +
|
|
303
|
+
`restored by name:\n${lost}`;
|
|
304
|
+
status = 'archived';
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
reply,
|
|
308
|
+
status,
|
|
309
|
+
archived: result.archived.length,
|
|
310
|
+
indexBefore: result.indexBefore,
|
|
311
|
+
indexAfter: result.indexAfter,
|
|
312
|
+
budget: result.budget,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* `(index bytes, budget)` for the writable project store; bytes may be `null`.
|
|
317
|
+
*
|
|
318
|
+
* Measured exactly the way `MemoryStore.checkIndexBudget` measures — the UTF-8 length of
|
|
319
|
+
* `indexText()` — so the number a log records and the number a refusal was decided
|
|
320
|
+
* against are one number and not two that can drift apart.
|
|
321
|
+
*
|
|
322
|
+
* `null` means the store could not be read, and it is never 0: an unreadable store
|
|
323
|
+
* reporting an empty index is the same slander this module spends `nothingToReport`
|
|
324
|
+
* refusing to commit, and it would read as infinite headroom at exactly the moment there
|
|
325
|
+
* is none. The cost is a full parse of `facts/`, which is why nothing calls this on the
|
|
326
|
+
* tool path unless a log is actually enabled.
|
|
327
|
+
*/
|
|
328
|
+
indexAccounting() {
|
|
329
|
+
try {
|
|
330
|
+
return [Buffer.byteLength(this.store.indexText(), 'utf8'), this.store.indexBudget];
|
|
331
|
+
}
|
|
332
|
+
catch (e) {
|
|
333
|
+
if (e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError) {
|
|
334
|
+
return [null, this.store.indexBudget];
|
|
335
|
+
}
|
|
336
|
+
throw e;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ---- the empty answer, split into the answers it was hiding ----------------
|
|
340
|
+
/**
|
|
341
|
+
* An empty recall is at least three different situations; say which one.
|
|
342
|
+
*
|
|
343
|
+
* Answers `[status, text]`. The status is the SAME BRANCH that picks the sentence, handed
|
|
344
|
+
* out rather than re-derived: a caller that needs to tell the three empties apart — the
|
|
345
|
+
* MCP event log does — would otherwise have to match the prose this comment exists to say
|
|
346
|
+
* is allowed to improve.
|
|
347
|
+
*
|
|
348
|
+
* Until job37 all of them returned "no memories matched. Try different words", which tells
|
|
349
|
+
* a person to rephrase a question against a filing cabinet that may not exist.
|
|
350
|
+
*
|
|
351
|
+
* The verdict is keyed on what could have been read RIGHT NOW, counted the way
|
|
352
|
+
* `MemoryStore._facts` reads, not on the construction-time binding — a store that was empty
|
|
353
|
+
* at construction and has been saved to since really can answer. The DIAGNOSIS is keyed on
|
|
354
|
+
* the binding, because "designated" stops being visible on disk the moment `MemoryStore`
|
|
355
|
+
* creates the directory.
|
|
356
|
+
*/
|
|
357
|
+
nothingToReport() {
|
|
358
|
+
let status;
|
|
359
|
+
let verdict;
|
|
360
|
+
const unreadable = this.unreadableLayers();
|
|
361
|
+
if (this.searchableFacts()) {
|
|
362
|
+
status = 'empty-no-match';
|
|
363
|
+
verdict = 'no memories matched. Try different words, or proceed without.';
|
|
364
|
+
}
|
|
365
|
+
else if (unreadable.length > 0) {
|
|
366
|
+
// A layer nobody could open is not a layer that held nothing. Saying "nothing is
|
|
367
|
+
// saved" here is the same slander as calling an unreadable store empty, one call out.
|
|
368
|
+
status = 'empty-unreadable-layer';
|
|
369
|
+
verdict =
|
|
370
|
+
'no memories matched, and that is not evidence there are none: ' +
|
|
371
|
+
`${unreadable.join(', ')} could not be read.`;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
status = 'empty-nothing-saved';
|
|
375
|
+
verdict = 'no memories to search: nothing is saved in any layer bound here.';
|
|
376
|
+
}
|
|
377
|
+
const diagnosis = this.bindingDiagnosis();
|
|
378
|
+
return [status, diagnosis ? `${verdict} ${diagnosis}` : verdict];
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* How many facts a store holds, or `null` when that cannot be established.
|
|
382
|
+
*
|
|
383
|
+
* A store that cannot be listed answers `null` and never 0: "I could not read it" and "it
|
|
384
|
+
* holds nothing" are the two answers this whole unit exists to keep apart.
|
|
385
|
+
*/
|
|
386
|
+
static factCount(root) {
|
|
387
|
+
try {
|
|
388
|
+
return countFacts(root);
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
if (e instanceof PyOSError)
|
|
392
|
+
return null;
|
|
393
|
+
throw e;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Roots of the layers that could not be listed at all — never counted as 0. */
|
|
397
|
+
unreadableLayers() {
|
|
398
|
+
return this.layers
|
|
399
|
+
.filter(([, store]) => Memory.factCount(store.root) === null)
|
|
400
|
+
.map(([, store]) => store.root);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Facts the layers actually consulted could have matched, counted live.
|
|
404
|
+
*
|
|
405
|
+
* An unreadable layer contributes nothing rather than counting as empty: `recall` already
|
|
406
|
+
* skips it, and it must not become evidence that there was nothing to read.
|
|
407
|
+
*/
|
|
408
|
+
searchableFacts() {
|
|
409
|
+
let total = 0;
|
|
410
|
+
for (const [, store] of this.layers)
|
|
411
|
+
total += Memory.factCount(store.root) ?? 0;
|
|
412
|
+
return total;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Is this store also the machine-wide profile layer `layered` appends?
|
|
416
|
+
*
|
|
417
|
+
* Resolved on both sides because the walk returns its store path unresolved while
|
|
418
|
+
* `Path.home()` may itself be a symlink; a comparison that missed on a symlink would put
|
|
419
|
+
* the harmful advice back exactly where it does damage.
|
|
420
|
+
*/
|
|
421
|
+
static isProfileStore(root) {
|
|
422
|
+
try {
|
|
423
|
+
return pyResolve(root) === pyResolve(profileStore());
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return false; // `except (OSError, RuntimeError)` — a symlink loop lands here too
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Did the walk actually leave the directory it started in?
|
|
431
|
+
*
|
|
432
|
+
* `origin === "walk"` says a walk ran, not that it climbed. A walk that terminates at step
|
|
433
|
+
* zero has bound `searchedFrom`'s OWN store, and the clause that reads well for an ancestor
|
|
434
|
+
* binding — "which has no store of its own" — is then simply false about the commonest case
|
|
435
|
+
* there is: a project whose store exists and is empty.
|
|
436
|
+
*/
|
|
437
|
+
walkAscended() {
|
|
438
|
+
const binding = this.binding;
|
|
439
|
+
if (binding === null || binding.searchedFrom === null)
|
|
440
|
+
return false;
|
|
441
|
+
return binding.path !== pyJoin(binding.searchedFrom, ...PROJECT_STORE);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Why THIS store, when the project layer is the one that cannot answer.
|
|
445
|
+
*
|
|
446
|
+
* Silent while the project store holds facts: that is the case that already works, and it
|
|
447
|
+
* keeps its exact wording. The remedy names `MEMORY_DIR_ENV` from `layers`, never a
|
|
448
|
+
* literal, so the message and the resolver cannot drift onto two different variables.
|
|
449
|
+
*/
|
|
450
|
+
bindingDiagnosis() {
|
|
451
|
+
if (Memory.factCount(this.store.root) !== 0)
|
|
452
|
+
return ''; // holds facts, or unreadable
|
|
453
|
+
const root = this.store.root;
|
|
454
|
+
const binding = this.binding;
|
|
455
|
+
let where;
|
|
456
|
+
if (binding === null) {
|
|
457
|
+
where = `The project store ${root} is empty.`;
|
|
458
|
+
}
|
|
459
|
+
else if (binding.state === 'designated') {
|
|
460
|
+
where =
|
|
461
|
+
`No memory store existed at or above ${binding.searchedFrom}, so the ` +
|
|
462
|
+
`empty ${root} was created for this session.`;
|
|
463
|
+
}
|
|
464
|
+
else if (binding.origin === 'pin') {
|
|
465
|
+
where = `The project store ${root} is empty; ${MEMORY_DIR_ENV} pinned it.`;
|
|
466
|
+
}
|
|
467
|
+
else if (this.walkAscended()) {
|
|
468
|
+
where =
|
|
469
|
+
`The project store ${root} is empty; it was bound by walking up from ` +
|
|
470
|
+
`${binding.searchedFrom}, which has no store of its own.`;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
where =
|
|
474
|
+
`The project store ${root} is empty; it is ${binding.searchedFrom}'s own ` +
|
|
475
|
+
'store, bound without the walk leaving that directory.';
|
|
476
|
+
}
|
|
477
|
+
const remedy = 'That is a binding, not a search result — if your facts are in another ' +
|
|
478
|
+
`store, set ${MEMORY_DIR_ENV} to its absolute path and restart`;
|
|
479
|
+
if (Memory.isProfileStore(root)) {
|
|
480
|
+
// The store bound here IS the profile layer, which every project with no store of its
|
|
481
|
+
// own also binds. "Save a memory to start this one" would therefore start a store that
|
|
482
|
+
// answers for all of them.
|
|
483
|
+
return (`${where} ${remedy}. Do NOT save here to start it: ${root} is also the ` +
|
|
484
|
+
'profile layer, so a memory saved there answers for every project on ' +
|
|
485
|
+
'this machine that has no store of its own — give this project a store ' +
|
|
486
|
+
'of its own instead.');
|
|
487
|
+
}
|
|
488
|
+
return `${where} ${remedy}; otherwise save a memory to start this one.`;
|
|
489
|
+
}
|
|
490
|
+
format(label, fact) {
|
|
491
|
+
// `pyText` for the three interpolated fields, not template-literal coercion: a
|
|
492
|
+
// hand-edited `name:` with nothing after it is `None` in Python's sentence and would be
|
|
493
|
+
// `null` in this one. The store already reproduces it in the index line and on disk.
|
|
494
|
+
const tag = this.showLayers ? `[${label}] ` : '';
|
|
495
|
+
return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}\n${fact.body}`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fact-file codec: the bytes on disk under `<store>/facts/<name>.md`, both directions.
|
|
3
|
+
*
|
|
4
|
+
* A port of `_write_fact` and the frontmatter half of `_facts` in
|
|
5
|
+
* `runtime-py/src/bantamkit/memory/store.py`. The store itself — `save`, `recall`, the
|
|
6
|
+
* index, scoring, the budget — is NOT here.
|
|
7
|
+
*
|
|
8
|
+
* THE PROPERTY THIS FILE OWES
|
|
9
|
+
* ---------------------------
|
|
10
|
+
* For every fact, the bytes Node writes and the bytes Python writes are identical, and
|
|
11
|
+
* each runtime parses what the other wrote. Both directions matter: the live store already
|
|
12
|
+
* contains 65 files Python wrote, and `_stamp` rewrites a file on every recall hit, so a
|
|
13
|
+
* Node server re-emits Python's files constantly. The differential that proves it runs in
|
|
14
|
+
* `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
|
|
15
|
+
*/
|
|
16
|
+
import { PyScalar } from './pyyaml.js';
|
|
17
|
+
export declare class FactParseError extends Error {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What one frontmatter value can be.
|
|
21
|
+
*
|
|
22
|
+
* `PyScalar` is not a widening for its own sake: `_facts` in the reference puts
|
|
23
|
+
* `yaml.safe_load`'s answer into the dataclass with no type check, so a hand-edited
|
|
24
|
+
* `description: 2026` really does give the Python store an `int` in a `str` field and the
|
|
25
|
+
* store really does keep working. The three-way is the whole ruling — see `constructPlain`
|
|
26
|
+
* in `pyyaml.ts` for what lands in which arm, and `store.pyText` for how it is rendered.
|
|
27
|
+
*/
|
|
28
|
+
export type FactValue = string | PyScalar | null;
|
|
29
|
+
/** `store.Fact`. `created` is `null` only before a Fact's first write. */
|
|
30
|
+
export interface Fact {
|
|
31
|
+
name: FactValue;
|
|
32
|
+
description: FactValue;
|
|
33
|
+
type: FactValue;
|
|
34
|
+
body: string;
|
|
35
|
+
links: FactValue[];
|
|
36
|
+
last_recalled: FactValue;
|
|
37
|
+
created?: FactValue;
|
|
38
|
+
}
|
|
39
|
+
/** The frontmatter as it comes off disk — exactly the keys the file carries. */
|
|
40
|
+
export type FactMeta = Record<string, FactValue | FactValue[]>;
|
|
41
|
+
export declare function pyStrip(text: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
|
|
44
|
+
*
|
|
45
|
+
* Text mode applies universal-newline translation: `\r\n` and a lone `\r` both become
|
|
46
|
+
* `\n`. `readFileSync(p, 'utf8')` does not. Measured in the prep probe: a CRLF fact file
|
|
47
|
+
* splits into 3 frontmatter parts in Python and 1 in Node, so without this the Node store
|
|
48
|
+
* calls a file malformed that the Python store reads fine.
|
|
49
|
+
*
|
|
50
|
+
* The WRITE side deliberately does not mirror Python. See `formatFact`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function decodeFactBytes(bytes: Uint8Array): string;
|
|
53
|
+
/**
|
|
54
|
+
* The exact bytes `_write_fact` writes, as a string.
|
|
55
|
+
*
|
|
56
|
+
* RULING — NEWLINES ON THE WAY OUT. Python opens the file in text mode with
|
|
57
|
+
* `newline=None`, which translates `\n` to `os.linesep`, so on Windows every fact file
|
|
58
|
+
* Python writes is CRLF while every byte count the store computes (the index budget, the
|
|
59
|
+
* digests) is LF. This port writes LF on every platform and does not reproduce that.
|
|
60
|
+
* Reasons, in order: the CRLF is incidental to `newline=None` rather than intended; it
|
|
61
|
+
* already disagrees with the store's own arithmetic; and reproducing it would make the
|
|
62
|
+
* same Fact emit different bytes on two machines, which is precisely the property this
|
|
63
|
+
* port exists to hold. Reading stays universal-newline on both sides, so a store written
|
|
64
|
+
* by either runtime is read identically by both. On macOS and Linux `os.linesep` is `\n`
|
|
65
|
+
* and there is no divergence at all — the conformance suite measures byte identity there.
|
|
66
|
+
*/
|
|
67
|
+
export declare function formatFact(fact: Fact): string;
|
|
68
|
+
/**
|
|
69
|
+
* `text.split("---\n", 2)` plus `yaml.safe_load(front)`, and the `body.strip()` after it.
|
|
70
|
+
*
|
|
71
|
+
* Python unpacks the split into three names, so fewer than three parts is a `ValueError`
|
|
72
|
+
* that `_facts` turns into `MemoryValidationError`. Here it is a `FactParseError`; the
|
|
73
|
+
* sentence the model finally sees is the store's to phrase, not this module's.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseFactText(text: string): {
|
|
76
|
+
meta: FactMeta;
|
|
77
|
+
body: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* `str.split(sep, maxsplit)` — JS's `String.split` has no maxsplit and would over-split.
|
|
81
|
+
*
|
|
82
|
+
* Exported because `store._facts` does this split ITSELF in the reference, and needs the
|
|
83
|
+
* part count to build Python's own `ValueError` text for a truncated file. Two spellings of
|
|
84
|
+
* one split is how the two would drift.
|
|
85
|
+
*/
|
|
86
|
+
export declare function pySplit(text: string, sep: string, maxsplit: number): string[];
|
|
87
|
+
/**
|
|
88
|
+
* The frontmatter mapping, for the language `safeDumpMapping` emits and no more.
|
|
89
|
+
*
|
|
90
|
+
* RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
|
|
91
|
+
* root-level block mapping of plain keys whose values are plain, single-quoted or
|
|
92
|
+
* double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
|
|
93
|
+
* column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings.
|
|
94
|
+
* Anything else RAISES instead of guessing. The reason is the failure mode this whole unit
|
|
95
|
+
* exists for: a parser that silently produces something plausible for input it does not
|
|
96
|
+
* understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
|
|
97
|
+
* reports it. A raise is visible; `_facts` already has a sentence for it.
|
|
98
|
+
*
|
|
99
|
+
* KNOWN NON-IDENTICAL BEHAVIOUR, registered rather than hidden: for input outside that
|
|
100
|
+
* language, Python and Node both fail but with different message text (PyYAML's scanner
|
|
101
|
+
* error vs this module's). `_facts` interpolates that text into
|
|
102
|
+
* `malformed fact file {name}: {e}`, so the string a model sees for a corrupt file is not
|
|
103
|
+
* byte-identical across runtimes. Making it identical would mean porting PyYAML's scanner
|
|
104
|
+
* diagnostics, which is a far larger surface than the codec, and no test in
|
|
105
|
+
* `runtime-py/tests` pins those strings. Owner: whichever unit takes `_facts`.
|
|
106
|
+
*/
|
|
107
|
+
export declare function parseFrontmatter(front: string): FactMeta;
|
|
108
|
+
/**
|
|
109
|
+
* `date.today().isoformat()`, which is LOCAL.
|
|
110
|
+
*
|
|
111
|
+
* `new Date().toISOString().slice(0, 10)` is UTC and is a different date for part of every
|
|
112
|
+
* day — measured against CPython under four zones and twelve instants, the local getters
|
|
113
|
+
* agreed in all twelve and the UTC slice disagreed in four. `created` and `last_recalled`
|
|
114
|
+
* are written with this, while shift-work's `ts` is UTC; they are not the same clock and
|
|
115
|
+
* the store must not borrow one for the other.
|
|
116
|
+
*
|
|
117
|
+
* The parameter exists so a caller can freeze it. Every comparison against Python is
|
|
118
|
+
* otherwise a race across midnight, and `_stamp` fires on every recall hit.
|
|
119
|
+
*/
|
|
120
|
+
export declare function todayLocal(now?: Date): string;
|