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,1034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MemoryStore`: save, recall, the index and its budget.
|
|
3
|
+
*
|
|
4
|
+
* A port of `runtime-py/src/bantamkit/memory/store.py`. The fact-file BYTES are not here —
|
|
5
|
+
* `factfile.ts` owns them and this imports it, because a second emitter is a second answer
|
|
6
|
+
* to a question that already has one.
|
|
7
|
+
*
|
|
8
|
+
* THE PROPERTY THIS FILE OWES
|
|
9
|
+
* ---------------------------
|
|
10
|
+
* Given the same store on disk and the same call, Node and Python leave the directory in
|
|
11
|
+
* BYTE-IDENTICAL states and return the same string. `index.md` is included on purpose: its
|
|
12
|
+
* byte length is an input to the budget, so a difference there is not cosmetic — it changes
|
|
13
|
+
* whether a save is refused. The differential that measures it is
|
|
14
|
+
* `tools/conformance/suites/store.mjs`, which runs each op against two copies of one store
|
|
15
|
+
* and diffs the whole tree.
|
|
16
|
+
*
|
|
17
|
+
* THE LIFECYCLE OPS ARE HERE NOW, AND WHY THEY WERE NOT
|
|
18
|
+
* -----------------------------------------------------
|
|
19
|
+
* `compact`, `restore`, `archived`, `lint` and `_staleness_key` used to be listed here as
|
|
20
|
+
* DELIBERATELY MISSING, on a prep probe that traced a real stdio server through the tools
|
|
21
|
+
* it served at the time (served-tools: dated), both resource templates and every error arm
|
|
22
|
+
* and found none of them reachable. That trace was true when taken. Since job42, `compact`
|
|
23
|
+
* IS reachable from the wire — `memory_compact` is the ninth tool — while `restore`,
|
|
24
|
+
* `archived` and `lint` remain operator-only, as `docs/memory.md` rules.
|
|
25
|
+
*
|
|
26
|
+
* What the trace could not see is the OTHER surface. `runtime-py` gives the operator that
|
|
27
|
+
* decision at `python -m bantamkit.memory`; `runtime-ts` gave them nothing, so an operator
|
|
28
|
+
* who ran `npx bantamkit-mcp` and nothing else could not compact, could not lint, and could
|
|
29
|
+
* not restore an archived fact. A position that hands lifecycle to an operator and ships
|
|
30
|
+
* half its runtimes without a lever is not a position. `memory/cli.ts` is that lever and
|
|
31
|
+
* these four ops are what it calls; nothing on the MCP surface reaches them.
|
|
32
|
+
*
|
|
33
|
+
* `snapshot` IS still missing, and the trace still covers it: it is only entered by
|
|
34
|
+
* `component.batch`, which is not on the surface either. It is absent rather than stubbed so
|
|
35
|
+
* that nobody reads a stub and believes the scope exists here.
|
|
36
|
+
*
|
|
37
|
+
* Because `snapshot` is absent, `recall` reads live and `_stamp` writes the fact it was
|
|
38
|
+
* handed — the two branches Python takes when `_snapshot` is pinned have no reachable
|
|
39
|
+
* caller here. Everything else about both functions is the reference's.
|
|
40
|
+
*/
|
|
41
|
+
import { basename } from 'node:path';
|
|
42
|
+
import { BantamError } from '../errors.js';
|
|
43
|
+
import { formatFact, parseFrontmatter, pySplit, pyStrip, todayLocal } from './factfile.js';
|
|
44
|
+
import { PyScalar } from './pyyaml.js';
|
|
45
|
+
import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, PyOSError, sortedPathNames, } from './pyfs.js';
|
|
46
|
+
/** `sorted(VALID_TYPES)` is what the error message interpolates, so the order is load-bearing. */
|
|
47
|
+
export const VALID_TYPES = ['feedback', 'project', 'reference', 'user'];
|
|
48
|
+
/**
|
|
49
|
+
* `DURABLE_TYPES` — the types whose worth does NOT decay with time-since-last-recall, and
|
|
50
|
+
* which `compact` therefore archives only after every other candidate is exhausted
|
|
51
|
+
* (`byEviction`).
|
|
52
|
+
*
|
|
53
|
+
* An ARRAY walked with one `pyEqualValue` per entry, never a `Set`: this is compared
|
|
54
|
+
* against a value that came out of YAML uncast, and Python's `in` on a tuple is `==` per
|
|
55
|
+
* element where `in` on a set hashes and raises on a `list`. `runtime-py`'s
|
|
56
|
+
* `store.DURABLE_TYPES`.
|
|
57
|
+
*/
|
|
58
|
+
export const DURABLE_TYPES = ['feedback', 'user'];
|
|
59
|
+
/**
|
|
60
|
+
* `re.compile(r"^[a-z0-9][a-z0-9-]*$")` used with `re.match`.
|
|
61
|
+
*
|
|
62
|
+
* The `\n?` is not decoration and not a widening: Python's `$` matches at the end of the
|
|
63
|
+
* string OR immediately before a trailing newline, so `re.match` ACCEPTS `"a-fact\n"` and
|
|
64
|
+
* the JS spelling of the same pattern rejects it. Writing the pattern the obvious way would
|
|
65
|
+
* make this port refuse a save the reference store performs. `NAME_PATTERN` below is what
|
|
66
|
+
* the message prints, and it prints Python's spelling.
|
|
67
|
+
*/
|
|
68
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]*\n?$/;
|
|
69
|
+
const NAME_PATTERN = '^[a-z0-9][a-z0-9-]*$';
|
|
70
|
+
export const DUPLICATE_JACCARD = 0.5;
|
|
71
|
+
/** See the reference's comment: measured against a real store, not chosen. */
|
|
72
|
+
export const DEFAULT_INDEX_BUDGET = 24_000;
|
|
73
|
+
// The second half of the "unreadable" sentence, one per directory this store lists — and
|
|
74
|
+
// then the same distinction one syscall down, for the stats `restore` does instead of a
|
|
75
|
+
// listing. They are separate strings because the failures do different damage, and an error
|
|
76
|
+
// that names the wrong damage sends the reader to the wrong place.
|
|
77
|
+
const FACTS_UNREADABLE = 'a store whose facts could not be listed is not a store with no facts, and ' +
|
|
78
|
+
"answering 'empty' here is what rewrites index.md from nothing";
|
|
79
|
+
const ARCHIVE_UNREADABLE = 'an archive that could not be listed is not an empty archive, and answering ' +
|
|
80
|
+
"'nothing is archived' here is what makes compaction look like deletion — the " +
|
|
81
|
+
'facts compact() moved are still on disk under this path';
|
|
82
|
+
const ARCHIVE_UNREACHABLE = "an archived fact that could not be stat'd is not an archived fact that is not " +
|
|
83
|
+
"there, and answering 'no archived fact' here sends the operator looking for a " +
|
|
84
|
+
'file that is still on disk under this path';
|
|
85
|
+
const FACTS_UNREACHABLE = "a destination that could not be stat'd is not a name that is already taken, and " +
|
|
86
|
+
'nothing has moved: the fact is still in archive/';
|
|
87
|
+
// The same two distinctions again for `archive`, which walks the move in the opposite
|
|
88
|
+
// direction. They cannot reuse the pair above: each sentence names the side the fact is
|
|
89
|
+
// STILL on when the stat is refused, and that side is the other one here.
|
|
90
|
+
const FACT_UNREACHABLE = "a fact that could not be stat'd is not a fact that is not there, and answering " +
|
|
91
|
+
"'no fact' here sends the operator looking for a file that is still on disk under " +
|
|
92
|
+
'this path';
|
|
93
|
+
const ARCHIVE_DESTINATION_UNREACHABLE = "a destination that could not be stat'd is not a name that is already archived, " +
|
|
94
|
+
'and nothing has moved: the fact is still in facts/';
|
|
95
|
+
export class MemoryValidationError extends BantamError {
|
|
96
|
+
}
|
|
97
|
+
export class MemoryBudgetExceeded extends BantamError {
|
|
98
|
+
}
|
|
99
|
+
// --------------------------------------------------------------------------- scoring
|
|
100
|
+
/**
|
|
101
|
+
* `set(re.findall(r"[a-z0-9]+", text.lower()))`.
|
|
102
|
+
*
|
|
103
|
+
* ASCII-only, no casefold and no Unicode normalization — so a wholly non-ASCII name and
|
|
104
|
+
* description produce the EMPTY set in both runtimes. That is not a defect to fix in a
|
|
105
|
+
* port; it is the behaviour the duplicate gate is built on, and `_jaccard` below is where
|
|
106
|
+
* it stops being a division by zero.
|
|
107
|
+
*/
|
|
108
|
+
export function tokens(text) {
|
|
109
|
+
return new Set(text.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `len(a & b) / len(a | b)`, except that an empty side is 0.0 and never a division.
|
|
113
|
+
*
|
|
114
|
+
* Python decided this and Python's answer is the one that ships: two facts whose tokens are
|
|
115
|
+
* both empty score 0.0, so they are NOT duplicates of each other and both save. A port that
|
|
116
|
+
* "fixed" it to 1.0 for two empty sets would refuse the second of two Thai descriptions.
|
|
117
|
+
*/
|
|
118
|
+
export function jaccard(a, b) {
|
|
119
|
+
if (a.size === 0 || b.size === 0)
|
|
120
|
+
return 0.0;
|
|
121
|
+
let shared = 0;
|
|
122
|
+
for (const t of a)
|
|
123
|
+
if (b.has(t))
|
|
124
|
+
shared += 1;
|
|
125
|
+
return shared / (a.size + b.size - shared);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Python's `f"{value}"` for the values a hand-edited frontmatter can put in a `str` field.
|
|
129
|
+
*
|
|
130
|
+
* `_facts` does no type check: `meta["name"]` is whatever the YAML resolved to, and the
|
|
131
|
+
* store then interpolates it into the index line, the token text and the FILE PATH. For
|
|
132
|
+
* `None` Python writes `None` and JS would write `null` — a different index line and a
|
|
133
|
+
* different file on disk from the same store. Only `null` is reproduced; see the ruling in
|
|
134
|
+
* `tools/conformance/suites/store.mjs` for the sequence case, which is left differing.
|
|
135
|
+
*
|
|
136
|
+
* Exported for `component._format`, which interpolates the same three fields into the line a
|
|
137
|
+
* model reads. Two spellings of one coercion is how the index line and the recall line would
|
|
138
|
+
* come to print a different name for the same fact.
|
|
139
|
+
*/
|
|
140
|
+
export function pyText(value) {
|
|
141
|
+
return value === null || value === undefined ? 'None' : String(value);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* `hash`/`==` equivalence for the values a `Fact.name` can hold — the key `Memory.recall`'s
|
|
145
|
+
* `seen` set uses across layers.
|
|
146
|
+
*
|
|
147
|
+
* `pyText` is the WRONG key here and the difference is observable: a fact named `7` in the
|
|
148
|
+
* project layer and one named `'7'` in the profile layer are two different keys to Python
|
|
149
|
+
* and would collapse into one under `str`. The other direction is just as real — Python's
|
|
150
|
+
* numeric tower makes `7`, `7.0` and `True` all equal and all one key — so a key built from
|
|
151
|
+
* the JS type would drop a dedupe Python performs. This spells out both.
|
|
152
|
+
*
|
|
153
|
+
* Dates are keyed by their type as well as their text: `date(2026, 8, 23)` and the
|
|
154
|
+
* `datetime` at midnight of the same day are NOT equal in Python.
|
|
155
|
+
*/
|
|
156
|
+
export function pyHashKey(value) {
|
|
157
|
+
if (value === null)
|
|
158
|
+
return 'None';
|
|
159
|
+
if (typeof value === 'string')
|
|
160
|
+
return `str:${value}`;
|
|
161
|
+
switch (value.pyType) {
|
|
162
|
+
case 'bool':
|
|
163
|
+
return `num:${value.text === 'True' ? '1' : '0'}`;
|
|
164
|
+
case 'int':
|
|
165
|
+
return `num:${value.text}`;
|
|
166
|
+
case 'float': {
|
|
167
|
+
// An integral float hashes with the equal int; `inf`/`nan` key on their own text, and
|
|
168
|
+
// `nan != nan` in Python, which no string key can reproduce — see the ruling.
|
|
169
|
+
const n = Number(value.text);
|
|
170
|
+
return Number.isInteger(n) ? `num:${BigInt(value.text.split('.')[0]).toString()}` : `num:${value.text}`;
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return `${value.pyType}:${value.text}`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** A Python `TypeError`, raised where CPython raises one and with CPython's sentence. */
|
|
177
|
+
function pyTypeError(message) {
|
|
178
|
+
const e = new TypeError(message);
|
|
179
|
+
e.name = 'TypeError';
|
|
180
|
+
return e;
|
|
181
|
+
}
|
|
182
|
+
const typeName = (v) => v === null ? 'NoneType' : typeof v === 'string' ? 'str' : v.pyType;
|
|
183
|
+
/**
|
|
184
|
+
* `a < b` over the values a `Fact.name` can hold — including the refusals, which are the
|
|
185
|
+
* point.
|
|
186
|
+
*
|
|
187
|
+
* `recall` sorts on `(-score, fact.name)`, so two facts that TIE on score make Python
|
|
188
|
+
* compare their names, and Python has no order between an `int` and a `str`. The whole
|
|
189
|
+
* matrix was measured against the reference (90 ordered pairs over ten values) and it is
|
|
190
|
+
* not one rule but four: `bool`/`int`/`float` are ONE family that compares (`True < 7`),
|
|
191
|
+
* `str` is its own, `date` and `datetime` are two families that do NOT compare with each
|
|
192
|
+
* other — and CPython says so with a different sentence, `can't compare datetime.datetime
|
|
193
|
+
* to datetime.date`, always naming `datetime` first however the operands were written —
|
|
194
|
+
* and an aware `datetime` does not compare with a naive one at all.
|
|
195
|
+
*/
|
|
196
|
+
function numEq(a, b) {
|
|
197
|
+
if (typeof a === 'bigint' && typeof b === 'bigint')
|
|
198
|
+
return a === b;
|
|
199
|
+
if (typeof a === 'bigint')
|
|
200
|
+
return Number.isInteger(b) && a === BigInt(b);
|
|
201
|
+
if (typeof b === 'bigint')
|
|
202
|
+
return Number.isInteger(a) && BigInt(a) === b;
|
|
203
|
+
return a === b;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* `a == b` over the same values — the OTHER half of the tuple comparison, and not a
|
|
207
|
+
* rephrasing of `<`.
|
|
208
|
+
*
|
|
209
|
+
* `tuplerichcompare` walks the key tuple with `==` and calls `<` only on the first element
|
|
210
|
+
* where `==` answers False. That is what keeps `sorted` from raising on two facts both named
|
|
211
|
+
* `None`: `None == None` is True, the tuples are equal, and `None < None` — which DOES raise
|
|
212
|
+
* — is never reached. Measured: without this the port raised `'NoneType' and 'NoneType'`
|
|
213
|
+
* where CPython answered an order.
|
|
214
|
+
*
|
|
215
|
+
* `==` never raises. Across types it is simply False — `7 == 'a'`, a `date` against a
|
|
216
|
+
* `datetime`, a naive `datetime` against an aware one — while `<` on the same pairs raises.
|
|
217
|
+
* Inside the numeric family it is Python's: `True == 1`, `7 == 7.0`, and `nan == nan` is
|
|
218
|
+
* False, which is why NaN is not special-cased anywhere here.
|
|
219
|
+
*/
|
|
220
|
+
export function pyEqualValue(a, b) {
|
|
221
|
+
if (a === null || b === null)
|
|
222
|
+
return a === null && b === null;
|
|
223
|
+
if (typeof a === 'string' || typeof b === 'string')
|
|
224
|
+
return a === b;
|
|
225
|
+
const x = a.ord;
|
|
226
|
+
const y = b.ord;
|
|
227
|
+
if (x.kind === 'num' && y.kind === 'num')
|
|
228
|
+
return numEq(x.n, y.n);
|
|
229
|
+
if (x.kind === 'date' && y.kind === 'date')
|
|
230
|
+
return x.iso === y.iso;
|
|
231
|
+
if (x.kind === 'datetime' && y.kind === 'datetime') {
|
|
232
|
+
return x.aware === y.aware && x.sec === y.sec && x.us === y.us;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
export function pyCompareLt(a, b) {
|
|
237
|
+
if (typeof a === 'string' && typeof b === 'string')
|
|
238
|
+
return cmpCodepoint(a, b) < 0;
|
|
239
|
+
if (a !== null && b !== null && typeof a !== 'string' && typeof b !== 'string') {
|
|
240
|
+
const x = a.ord;
|
|
241
|
+
const y = b.ord;
|
|
242
|
+
if (x.kind === 'num' && y.kind === 'num') {
|
|
243
|
+
// A `bigint` and a `number` do not compare with `<` in JS unless one side is coerced;
|
|
244
|
+
// mixed pairs go through `Number`, which is what Python's int/float comparison
|
|
245
|
+
// approximates anyway for every magnitude a frontmatter can hold in one line.
|
|
246
|
+
if (typeof x.n === 'bigint' && typeof y.n === 'bigint')
|
|
247
|
+
return x.n < y.n;
|
|
248
|
+
return Number(x.n) < Number(y.n);
|
|
249
|
+
}
|
|
250
|
+
if (x.kind === 'date' && y.kind === 'date')
|
|
251
|
+
return x.iso < y.iso;
|
|
252
|
+
if (x.kind === 'datetime' && y.kind === 'datetime') {
|
|
253
|
+
if (x.aware !== y.aware) {
|
|
254
|
+
throw pyTypeError("can't compare offset-naive and offset-aware datetimes");
|
|
255
|
+
}
|
|
256
|
+
return x.sec !== y.sec ? x.sec < y.sec : x.us < y.us;
|
|
257
|
+
}
|
|
258
|
+
if ((x.kind === 'date' && y.kind === 'datetime') || (x.kind === 'datetime' && y.kind === 'date')) {
|
|
259
|
+
throw pyTypeError("can't compare datetime.datetime to datetime.date");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
throw pyTypeError(`'<' not supported between instances of '${typeName(a)}' and '${typeName(b)}'`);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* `sorted(scored, key=lambda pair: (-pair[0], pair[1].name))`, comparison for comparison.
|
|
266
|
+
*
|
|
267
|
+
* RULING — WHY THE SORT IS SPELLED OUT AND NOT HANDED TO `Array.prototype.sort`. When every
|
|
268
|
+
* name is a `str` the two agree and this takes the fast path. When they are not, the sort
|
|
269
|
+
* can RAISE, and then WHICH pair CPython happens to compare first decides the sentence a
|
|
270
|
+
* model reads: `'int' and 'str'` or `'str' and 'int'`, and with three numeric types in play,
|
|
271
|
+
* which of `int`/`float`/`bool` gets named. `Array.prototype.sort`'s comparison order is
|
|
272
|
+
* unspecified and V8's is not Timsort's.
|
|
273
|
+
*
|
|
274
|
+
* So the slow path is CPython's `listsort` for a single run: `count_run` (adjacent
|
|
275
|
+
* `a[i] < a[i-1]`, reversing a strictly descending prefix) then `binarysort` (pivot on the
|
|
276
|
+
* LEFT of every probe, midpoint `l + ((r - l) >> 1)`), over key tuples compared by
|
|
277
|
+
* `tuplerichcompare` — `==` down the tuple and `<` only where `==` says they differ.
|
|
278
|
+
*
|
|
279
|
+
* MEASURED, and re-measured on every conformance run: the `recall tie-break` case in
|
|
280
|
+
* `tools/conformance/suites/store.mjs` generates 50,000 `(score, name)` lists from a seeded
|
|
281
|
+
* LCG — ten name spellings across all four families, n from 2 to 300 — and runs each through
|
|
282
|
+
* CPython's `sorted` and through this function. 45,629 of them raise. All 50,000 agree, on
|
|
283
|
+
* the order where there is one and on the TypeError text where there is not.
|
|
284
|
+
*
|
|
285
|
+
* TWO MODELS THIS REFUTED, kept because each looks right until it is run. (1) "raise if any
|
|
286
|
+
* equal-score pair is incomparable" gets the DECISION right — 20,000 of 20,000 in the
|
|
287
|
+
* derivation probe — and the SENTENCE wrong in 1,087 of 30,000, because the types it names
|
|
288
|
+
* are whichever pair the binary search actually probed. (2) Calling `<` wherever the scores
|
|
289
|
+
* tie raises `'NoneType' and 'NoneType'` for two facts both named `None`, where CPython
|
|
290
|
+
* answers an order: `==` short-circuits the tuple before `<` is ever reached.
|
|
291
|
+
*
|
|
292
|
+
* NOT MEASURED, and the honest edge: past 64 elements CPython splits the list into runs and
|
|
293
|
+
* merges them, while this binary-inserts the whole list. It can only matter if the FIRST
|
|
294
|
+
* incomparable pair lies past the first run, and the 8,000 generated lists with n between 65
|
|
295
|
+
* and 300 produced no such case. A store would need 65+ facts tied on one recall score with
|
|
296
|
+
* the type change late in the file order.
|
|
297
|
+
*/
|
|
298
|
+
export function sortScored(scored) {
|
|
299
|
+
if (scored.every((s) => typeof s.name === 'string')) {
|
|
300
|
+
// Every name a `str`: the comparison cannot raise and the order is total, so the stable
|
|
301
|
+
// library sort is the same answer for less work.
|
|
302
|
+
return [...scored].sort((a, b) => b.score - a.score || cmpCodepoint(a.name, b.name));
|
|
303
|
+
}
|
|
304
|
+
// `tuplerichcompare`: `==` down the tuple, then `<` on the first element that differs.
|
|
305
|
+
const lt = (x, y) => x.score !== y.score
|
|
306
|
+
? x.score > y.score
|
|
307
|
+
: pyEqualValue(x.name, y.name)
|
|
308
|
+
? false
|
|
309
|
+
: pyCompareLt(x.name, y.name);
|
|
310
|
+
const a = [...scored];
|
|
311
|
+
const n = a.length;
|
|
312
|
+
if (n < 2)
|
|
313
|
+
return a;
|
|
314
|
+
// `count_run`
|
|
315
|
+
let k;
|
|
316
|
+
if (lt(a[1], a[0])) {
|
|
317
|
+
let i = 2;
|
|
318
|
+
while (i < n && lt(a[i], a[i - 1]))
|
|
319
|
+
i += 1;
|
|
320
|
+
k = i;
|
|
321
|
+
a.splice(0, k, ...a.slice(0, k).reverse());
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
let i = 2;
|
|
325
|
+
while (i < n && !lt(a[i], a[i - 1]))
|
|
326
|
+
i += 1;
|
|
327
|
+
k = i;
|
|
328
|
+
}
|
|
329
|
+
// `binarysort`
|
|
330
|
+
for (let start = Math.max(k, 1); start < n; start += 1) {
|
|
331
|
+
const pivot = a[start];
|
|
332
|
+
let l = 0;
|
|
333
|
+
let r = start;
|
|
334
|
+
while (l < r) {
|
|
335
|
+
const p = l + ((r - l) >> 1);
|
|
336
|
+
if (lt(pivot, a[p]))
|
|
337
|
+
r = p;
|
|
338
|
+
else
|
|
339
|
+
l = p + 1;
|
|
340
|
+
}
|
|
341
|
+
a.copyWithin(l + 1, l, start);
|
|
342
|
+
a[l] = pivot;
|
|
343
|
+
}
|
|
344
|
+
return a;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* `bool(value)` for a frontmatter value.
|
|
348
|
+
*
|
|
349
|
+
* `_facts` asks it twice — `meta.get("links") or []` and `meta.get("created") or
|
|
350
|
+
* _mtime_date(path)` — and the answer is not "is it a non-empty string". A `PyScalar`
|
|
351
|
+
* carries Python's own answer (`0`, `0.0` and `false` are falsy; a `date` never is), an
|
|
352
|
+
* empty list is falsy, and `undefined` stands for the absent key.
|
|
353
|
+
*/
|
|
354
|
+
function pyTruthy(value) {
|
|
355
|
+
if (value === undefined || value === null)
|
|
356
|
+
return false;
|
|
357
|
+
if (typeof value === 'string')
|
|
358
|
+
return value !== '';
|
|
359
|
+
if (Array.isArray(value))
|
|
360
|
+
return value.length > 0;
|
|
361
|
+
return value.truthy;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* `list(meta.get("links") or [])`.
|
|
365
|
+
*
|
|
366
|
+
* A string is iterated into its CHARACTERS, which the spread does over codepoints — the
|
|
367
|
+
* same faithful-not-tidy answer as before. What is new is the last arm: `list(7)` is a
|
|
368
|
+
* `TypeError` in Python and the `except (ValueError, KeyError, yaml.YAMLError)` in `_facts`
|
|
369
|
+
* does NOT catch it, so it escapes the store with CPython's own sentence rather than
|
|
370
|
+
* becoming `malformed fact file …`. That is reproduced, message and escape route both,
|
|
371
|
+
* because a caller that catches `MemoryValidationError` would swallow one and not the
|
|
372
|
+
* other. Not fixed in `runtime-py` (invariant 8) — registered.
|
|
373
|
+
*/
|
|
374
|
+
function pyList(value) {
|
|
375
|
+
if (!pyTruthy(value))
|
|
376
|
+
return [];
|
|
377
|
+
if (Array.isArray(value))
|
|
378
|
+
return [...value];
|
|
379
|
+
if (typeof value === 'string')
|
|
380
|
+
return [...value];
|
|
381
|
+
const e = new TypeError(`'${value.pyType}' object is not iterable`);
|
|
382
|
+
e.name = 'TypeError';
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* `isinstance(yaml.safe_load(front), dict)` — the QUESTION, not a second YAML parser.
|
|
387
|
+
*
|
|
388
|
+
* The reference calls `safe_load` on the frontmatter and rejects anything that is not a
|
|
389
|
+
* mapping with its own sentence, BEFORE any key lookup. `parseFrontmatter` cannot answer
|
|
390
|
+
* that: its grammar is the emitter's output, so it raises for a document PyYAML would
|
|
391
|
+
* happily load as a string or a list, and the store would then report a parse failure where
|
|
392
|
+
* the reference reports "not a mapping". This decides only the cases where YAML's answer is
|
|
393
|
+
* unambiguous from the first content line, and hands everything else to the parser — where
|
|
394
|
+
* the message-text ruling in `facts()` already applies.
|
|
395
|
+
*/
|
|
396
|
+
function looksLikeMapping(front) {
|
|
397
|
+
if (pyStrip(front) === '')
|
|
398
|
+
return false; // an empty document loads as None
|
|
399
|
+
const first = front.split('\n').find((line) => pyStrip(line) !== '');
|
|
400
|
+
const head = first.replace(/^[ \t]+/, '');
|
|
401
|
+
if (head.startsWith('#'))
|
|
402
|
+
return true; // a comment says nothing; let the parser rule
|
|
403
|
+
if (head === '-' || head.startsWith('- ') || head.startsWith('['))
|
|
404
|
+
return false; // a sequence
|
|
405
|
+
if (head.startsWith('{') || head.startsWith('? '))
|
|
406
|
+
return true; // flow mapping, explicit key
|
|
407
|
+
return /:(\s|$)/.test(head); // a key, or a plain scalar that is not one
|
|
408
|
+
}
|
|
409
|
+
// ----------------------------------------------------------------------------- the store
|
|
410
|
+
export class MemoryStore {
|
|
411
|
+
root;
|
|
412
|
+
indexBudget;
|
|
413
|
+
k;
|
|
414
|
+
today;
|
|
415
|
+
constructor(root, options = {}) {
|
|
416
|
+
this.root = pyJoin(root);
|
|
417
|
+
this.indexBudget = options.indexBudget ?? DEFAULT_INDEX_BUDGET;
|
|
418
|
+
this.k = options.k ?? 3;
|
|
419
|
+
this.today = options.today ?? (() => todayLocal());
|
|
420
|
+
if (options.create ?? true)
|
|
421
|
+
this.ensureDirs();
|
|
422
|
+
}
|
|
423
|
+
ensureDirs() {
|
|
424
|
+
pyMkdirParents(pyJoin(this.root, 'facts'));
|
|
425
|
+
pyMkdirParents(pyJoin(this.root, 'archive'));
|
|
426
|
+
}
|
|
427
|
+
// ---- ops ----
|
|
428
|
+
/**
|
|
429
|
+
* Write a fact, or refuse it as a near-duplicate of one already here.
|
|
430
|
+
*
|
|
431
|
+
* THE FIRST OF TWO READS IS WHAT MAKES THIS SAFE, and the ordering is the reference's, not
|
|
432
|
+
* an implementation detail: `_facts()` runs for the duplicate check BEFORE `_writeFact`,
|
|
433
|
+
* so an unreadable store fails the whole op with nothing written and nothing to roll back.
|
|
434
|
+
* The measured alternative, one directory over in job37: a blind listing let the duplicate
|
|
435
|
+
* check pass vacuously, the fact landed, and `_rebuildIndex` then wrote a 13,472-byte
|
|
436
|
+
* index down to zero from the same empty listing.
|
|
437
|
+
*/
|
|
438
|
+
save(type, name, description, body, links = []) {
|
|
439
|
+
this.ensureDirs();
|
|
440
|
+
if (!VALID_TYPES.includes(type)) {
|
|
441
|
+
throw new MemoryValidationError(`invalid type '${type}'; must be one of [${VALID_TYPES.map((t) => `'${t}'`).join(', ')}]`);
|
|
442
|
+
}
|
|
443
|
+
if (!NAME_RE.test(name || '')) {
|
|
444
|
+
throw new MemoryValidationError(`invalid name '${name}'; must match ${NAME_PATTERN}`);
|
|
445
|
+
}
|
|
446
|
+
if (pyStrip(description || '') === '') {
|
|
447
|
+
throw new MemoryValidationError('description must be a non-empty line');
|
|
448
|
+
}
|
|
449
|
+
const newTokens = tokens(`${name} ${description}`);
|
|
450
|
+
let existing = null;
|
|
451
|
+
for (const fact of this.facts()) {
|
|
452
|
+
if (fact.name === name) {
|
|
453
|
+
existing = fact; // same name = update, not duplicate
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (jaccard(newTokens, tokens(`${pyText(fact.name)} ${pyText(fact.description)}`)) >= DUPLICATE_JACCARD) {
|
|
457
|
+
return { status: 'duplicate', name, similar: fact.name };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const fact = {
|
|
461
|
+
name,
|
|
462
|
+
description: pyStrip(description),
|
|
463
|
+
type,
|
|
464
|
+
body,
|
|
465
|
+
links: [...links],
|
|
466
|
+
last_recalled: null,
|
|
467
|
+
// An update keeps the date the fact first landed: rewriting a fact is not the same
|
|
468
|
+
// event as creating it, and resetting this would launder a stale fact into a fresh one.
|
|
469
|
+
created: existing !== null ? existing.created ?? null : this.today(),
|
|
470
|
+
};
|
|
471
|
+
const path = this.factPath(name);
|
|
472
|
+
const existed = pyExists(path) ? pyReadText(path) : null;
|
|
473
|
+
this.writeFact(fact);
|
|
474
|
+
try {
|
|
475
|
+
this.checkIndexBudget();
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
if (!(e instanceof MemoryBudgetExceeded))
|
|
479
|
+
throw e;
|
|
480
|
+
if (existed === null)
|
|
481
|
+
pyUnlink(path);
|
|
482
|
+
else
|
|
483
|
+
pyWriteText(path, existed);
|
|
484
|
+
this.rebuildIndex();
|
|
485
|
+
throw e;
|
|
486
|
+
}
|
|
487
|
+
this.rebuildIndex();
|
|
488
|
+
return { status: 'saved', name, similar: null };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Top-`k` facts whose name+description share tokens with `query`.
|
|
492
|
+
*
|
|
493
|
+
* An unreadable store raises out of `facts()` rather than scoring zero facts and returning
|
|
494
|
+
* `[]` — the same answer as a real miss, which `component.Memory` used to turn into "no
|
|
495
|
+
* memories matched. Try different words", telling a person to rephrase a question at a
|
|
496
|
+
* filing cabinet nobody could open.
|
|
497
|
+
*/
|
|
498
|
+
recall(query, k = null, stamp = true) {
|
|
499
|
+
const limit = k ?? this.k;
|
|
500
|
+
const q = tokens(query);
|
|
501
|
+
const scored = [];
|
|
502
|
+
for (const fact of this.facts()) {
|
|
503
|
+
let score = 0;
|
|
504
|
+
for (const t of tokens(`${pyText(fact.name)} ${pyText(fact.description)}`))
|
|
505
|
+
if (q.has(t))
|
|
506
|
+
score += 1;
|
|
507
|
+
if (score > 0)
|
|
508
|
+
scored.push({ score, name: fact.name, fact });
|
|
509
|
+
}
|
|
510
|
+
// `key=lambda pair: (-pair[0], pair[1].name)`. The name comparison is CODEPOINT order
|
|
511
|
+
// for two strings — which is not what `Array.prototype.sort` does — and Python's `<`
|
|
512
|
+
// for everything else, refusals included. See `sortScored`.
|
|
513
|
+
const hits = sortScored(scored).slice(0, limit).map((s) => s.fact);
|
|
514
|
+
if (stamp)
|
|
515
|
+
for (const fact of hits)
|
|
516
|
+
this.stamp(fact);
|
|
517
|
+
return hits;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* `len(store._facts())` at the operator CLI, which is the only caller.
|
|
521
|
+
*
|
|
522
|
+
* `facts()` stays private: this hands out a COUNT, not the parsed list, so no caller
|
|
523
|
+
* outside this file can hold a fact array and drift from what is on disk. It is a full
|
|
524
|
+
* parse — an unreadable or malformed store raises here rather than counting 0, which is
|
|
525
|
+
* the same rule `indexText` holds and for the same reason.
|
|
526
|
+
*/
|
|
527
|
+
factCount() {
|
|
528
|
+
return this.facts().length;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Every fact parses and carries a valid type, and the index fits its budget.
|
|
532
|
+
*
|
|
533
|
+
* The raise on an unreadable store is the reference's and it is load-bearing: a checker
|
|
534
|
+
* that passes hardest on the store it could not open is worse than no checker. `_facts()`
|
|
535
|
+
* raises before the type sweep starts, and `_cmd_lint` in `memory/cli.ts` routes both that
|
|
536
|
+
* and the budget error to `lint: FAIL — …` on stderr with exit 1.
|
|
537
|
+
*/
|
|
538
|
+
lint() {
|
|
539
|
+
for (const fact of this.facts()) {
|
|
540
|
+
if (!VALID_TYPES.includes(pyText(fact.type))) {
|
|
541
|
+
throw new MemoryValidationError(`fact '${pyText(fact.name)}' has invalid type '${pyText(fact.type)}'`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
this.checkIndexBudget();
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Archive the stalest facts until the index sits at `budget - reserve` or below.
|
|
548
|
+
*
|
|
549
|
+
* THE TARGET IS BELOW THE BUDGET ON PURPOSE, and the reference's docstring records why:
|
|
550
|
+
* `save` rolls its fact back before raising, so by the time anyone is told to compact, the
|
|
551
|
+
* index already fits — a loop that stopped at "fits" would archive nothing at the only
|
|
552
|
+
* moment the remedy is ever named. The default `reserve` is the largest index line the
|
|
553
|
+
* store currently holds (capped at half the budget), so the headroom bought is "a fact as
|
|
554
|
+
* big as your biggest one will fit", measured from this store's own data.
|
|
555
|
+
*
|
|
556
|
+
* `facts()` runs FIRST, before any rename, so an unreadable store moves nothing.
|
|
557
|
+
*
|
|
558
|
+
* THE MOVE IS `os.replace`, AND THE REFERENCE'S IS NOW TOO — the difference this comment
|
|
559
|
+
* used to report is CLOSED. `pyReplace` is `os.replace`, which replaces an existing
|
|
560
|
+
* destination on every platform. `Path.rename` is `os.rename`, which replaces silently on
|
|
561
|
+
* POSIX and raises `FileExistsError` on Windows; the reference called it here until
|
|
562
|
+
* `d239480` moved it onto `Path.replace`, so Node-on-Windows used to match Python-on-POSIX
|
|
563
|
+
* and Python-on-Windows matched neither. Nothing about the port changed: this line has
|
|
564
|
+
* always been `pyReplace`.
|
|
565
|
+
*
|
|
566
|
+
* WHAT IS STILL TRUE is the reachability argument, which is why the fix needed a fixture
|
|
567
|
+
* built for it. The only state that tells `rename` and `replace` apart is an
|
|
568
|
+
* `archive/<name>.md` that ALREADY exists when this loop moves the live fact over it — an
|
|
569
|
+
* earlier compaction's copy of a fact that was restored and then went stale again.
|
|
570
|
+
* `restore` cannot produce it, because it moves the archived copy OUT. On POSIX the two
|
|
571
|
+
* calls are indistinguishable even in that state, so `memorycli`'s
|
|
572
|
+
* `compact re-archives over an existing archive entry` is a WINDOWS-ONLY regression guard:
|
|
573
|
+
* a revert to `rename` on either side stays green on this machine.
|
|
574
|
+
*
|
|
575
|
+
* THIS PARAGRAPH USED TO CLAIM THE OTHER TWO MOVES ARE UNOBSERVABLE BY CONSTRUCTION —
|
|
576
|
+
* "guarded by a `reachable` check that refuses when `facts/<name>.md` is live" and "moves
|
|
577
|
+
* back onto a path it has just emptied", so "there is no state in which the two calls could
|
|
578
|
+
* answer differently". THE FIRST HALF IS FALSE, and `archive` (2026-09-05) is what made it
|
|
579
|
+
* reachable enough to notice. `reachable` is `Path.exists()`, which FOLLOWS symlinks, so a
|
|
580
|
+
* DANGLING symlink at the destination is an occupied directory ENTRY that the guard reports
|
|
581
|
+
* as absent — measured on macOS at `archive/alpha.md`: `lexists` True, `exists` False, the
|
|
582
|
+
* guard passed, and the move landed on top of the link. `os.rename` would raise
|
|
583
|
+
* `FileExistsError` there on Windows and `pyReplace` would not, which is exactly the
|
|
584
|
+
* divergence `d239480` closed one method up. The reference's `archive` is therefore on
|
|
585
|
+
* `Path.replace` too, as of the same day, and its FORWARD move and this one are the same
|
|
586
|
+
* call on every platform. `restore`'s forward move has the identical hole one directory
|
|
587
|
+
* over (a dangling symlink at `facts/<name>.md`) and is NOT changed here: narrowing a
|
|
588
|
+
* shipped command is not this fix's to make, and it is registered instead.
|
|
589
|
+
*
|
|
590
|
+
* WHAT IS STILL UNOBSERVABLE BY CONSTRUCTION is the ROLLBACK on both methods, and only that:
|
|
591
|
+
* it moves back onto a path the forward move has just emptied, so it cannot meet an occupied
|
|
592
|
+
* destination at all — there is no red to demonstrate for it, which is the reason `d239480`
|
|
593
|
+
* gave for leaving restore's alone and the reason the reference's two rollbacks are still
|
|
594
|
+
* `rename` against this file's `pyReplace`.
|
|
595
|
+
*
|
|
596
|
+
* THE ORDER IS `byEviction`, NOT `byStaleness` — `sorted(facts, key=self._eviction_key)`.
|
|
597
|
+
* A `DURABLE_TYPES` fact — `feedback`, the user's standing instruction, or `user`, a
|
|
598
|
+
* durable fact about them — holds until revoked and its worth does not decay with
|
|
599
|
+
* time-since-last-recall, so a purely temporal key ranks those classes exactly
|
|
600
|
+
* backwards. Measured on the real project store (index 21698 of a 24000-byte budget): one
|
|
601
|
+
* auto-compaction archived 15 facts and 6 of them were `feedback`. See `byEviction`.
|
|
602
|
+
*/
|
|
603
|
+
compact(reserve = null) {
|
|
604
|
+
const facts = this.facts();
|
|
605
|
+
const sizes = new Map();
|
|
606
|
+
for (const fact of facts) {
|
|
607
|
+
sizes.set(pyHashKey(fact.name), Buffer.byteLength(this.indexLine(fact), 'utf8'));
|
|
608
|
+
}
|
|
609
|
+
const all = [...sizes.values()];
|
|
610
|
+
if (reserve === null)
|
|
611
|
+
reserve = all.length === 0 ? 0 : Math.max(...all);
|
|
612
|
+
// `min(reserve, self.index_budget // 2)`: floor division, and the budget is >= 1 here
|
|
613
|
+
// because both CLIs refuse a smaller one at the edge.
|
|
614
|
+
reserve = Math.max(0, Math.min(reserve, Math.floor(this.indexBudget / 2)));
|
|
615
|
+
const target = this.indexBudget - reserve;
|
|
616
|
+
let size = all.reduce((total, bytes) => total + bytes, 0);
|
|
617
|
+
const before = size;
|
|
618
|
+
const archived = [];
|
|
619
|
+
for (const fact of this.byEviction(facts)) {
|
|
620
|
+
if (size <= target)
|
|
621
|
+
break;
|
|
622
|
+
const path = this.factPath(fact.name);
|
|
623
|
+
pyReplace(path, pyJoin(this.root, 'archive', pyName(path)));
|
|
624
|
+
const bytes = sizes.get(pyHashKey(fact.name));
|
|
625
|
+
size -= bytes;
|
|
626
|
+
archived.push({
|
|
627
|
+
name: fact.name,
|
|
628
|
+
type: fact.type,
|
|
629
|
+
description: fact.description,
|
|
630
|
+
indexBytes: bytes,
|
|
631
|
+
lastRecalled: fact.last_recalled ?? null,
|
|
632
|
+
created: fact.created ?? null,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
this.rebuildIndex();
|
|
636
|
+
return {
|
|
637
|
+
archived,
|
|
638
|
+
indexBefore: before,
|
|
639
|
+
indexAfter: size,
|
|
640
|
+
budget: this.indexBudget,
|
|
641
|
+
target,
|
|
642
|
+
reserve,
|
|
643
|
+
headroom: this.indexBudget - size,
|
|
644
|
+
archiveDir: pyJoin(this.root, 'archive'),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Names of the facts sitting in `archive/` — everything `compact` moved out.
|
|
649
|
+
*
|
|
650
|
+
* This is the worst place in the module to answer "empty" wrongly, because `compact()` has
|
|
651
|
+
* already MOVED the operator's facts here; an archive that cannot be listed is not an empty
|
|
652
|
+
* archive, and saying so makes compaction look like deletion. Hence `listing`, not a glob.
|
|
653
|
+
*
|
|
654
|
+
* The sort is over the STEMS and it is a plain string sort — `sorted(Path(name).stem …)` —
|
|
655
|
+
* not `sortedPathNames`, whose Windows case fold belongs to comparing `Path`s.
|
|
656
|
+
*/
|
|
657
|
+
archived() {
|
|
658
|
+
const archive = pyJoin(this.root, 'archive');
|
|
659
|
+
return this.listing(archive, ARCHIVE_UNREADABLE)
|
|
660
|
+
.map((name) => {
|
|
661
|
+
const suffix = pySuffix(name);
|
|
662
|
+
return suffix === '' ? name : name.slice(0, name.length - suffix.length);
|
|
663
|
+
})
|
|
664
|
+
.sort(cmpCodepoint);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Move an archived fact back into `facts/`; refuse if it would blow the budget.
|
|
668
|
+
*
|
|
669
|
+
* THE PROMISE IS THAT A FAILED RESTORE LEAVES THE STORE EXACTLY AS IT FOUND IT, and it
|
|
670
|
+
* takes both halves the reference has: a `facts()` PARSE before the move (a listing is not
|
|
671
|
+
* enough — a malformed fact passes a listing and fails the budget check afterwards), and a
|
|
672
|
+
* rollback keyed on "the op after the move failed" rather than on a list of error types,
|
|
673
|
+
* because when the ARCHIVED file is the bad one it is not a fact until after the rename.
|
|
674
|
+
*
|
|
675
|
+
* `restore` is deliberately NOT routed through `archived()`: it stats one named path
|
|
676
|
+
* instead of listing, so an unlistable-but-traversable `archive/` still restores. Refusing
|
|
677
|
+
* a recovery the filesystem was still willing to perform is the wrong direction for the
|
|
678
|
+
* door back.
|
|
679
|
+
*/
|
|
680
|
+
/**
|
|
681
|
+
* Move one named fact out of `facts/` and into `archive/`.
|
|
682
|
+
*
|
|
683
|
+
* The door out, taken deliberately. `compact` already moves facts out, but it chooses
|
|
684
|
+
* them by eviction rank and stops as soon as the index fits the budget, so it can
|
|
685
|
+
* neither be asked for a PARTICULAR fact nor be used at all when the store is already
|
|
686
|
+
* under budget. `restore` has taken a name since it was written; until this method the
|
|
687
|
+
* store could bring a named fact back but not send one away.
|
|
688
|
+
*
|
|
689
|
+
* THE NAME IS CHECKED AGAINST `NAME_RE` BEFORE ANY SYSCALL, and the reference does the
|
|
690
|
+
* same as of 2026-09-05. `save` was the only op enforcing it, and `save` is not the only
|
|
691
|
+
* op that CREATES a filename: this one builds `archive/<name>.md` out of what it is
|
|
692
|
+
* handed. Measured on macOS before the check: `archive ALPHA` against a live
|
|
693
|
+
* `facts/alpha.md` exited 0 on BOTH runtimes and left `archive/ALPHA.md` whose
|
|
694
|
+
* frontmatter says `name: alpha`, because the filesystem is case-insensitive and nothing
|
|
695
|
+
* asked the store's naming rule about the destination; on a case-sensitive filesystem the
|
|
696
|
+
* same command refuses. `restore` is deliberately left unvalidated on both sides.
|
|
697
|
+
*
|
|
698
|
+
* Same promise as `restore` — a failed archive leaves the store exactly as it found it —
|
|
699
|
+
* and one of its three guards carries over while two drop out:
|
|
700
|
+
*
|
|
701
|
+
* - Both stats go through `reachable`, not an existence check, for the reason spelled at
|
|
702
|
+
* `ARCHIVE_UNREACHABLE`. The two sentences are their own constants because each names
|
|
703
|
+
* the side the fact is still on, and that side is the mirror of restore's.
|
|
704
|
+
* - NO budget check. Archiving removes an index line, so the index can only shrink;
|
|
705
|
+
* `checkIndexBudget` is restore's guard in restore's direction and here it could not
|
|
706
|
+
* fail.
|
|
707
|
+
* - NO `facts()` PARSE BEFORE THE MOVE, and the reference dropped it the same day. The
|
|
708
|
+
* parse reads EVERY fact, so ONE malformed file in `facts/` refused every archive in
|
|
709
|
+
* the store INCLUDING ITS OWN, and no other command removes a fact by name — the one
|
|
710
|
+
* file the store calls broken was the one file no CLI route could remove, which is the
|
|
711
|
+
* opposite of what this method exists for. Without it the same command succeeds for a
|
|
712
|
+
* reason: the move takes the bad file out of `facts/` first, so `rebuildIndex` parses a
|
|
713
|
+
* directory that no longer holds it. A DIFFERENT fact being malformed still fails at
|
|
714
|
+
* that rebuild, and the rollback below puts the moved fact back.
|
|
715
|
+
*
|
|
716
|
+
* The rollback is keyed on "the rebuild after the move failed", and the route that
|
|
717
|
+
* reaches it is `index.md` BEING A DIRECTORY — not, as the reference's docstring used to
|
|
718
|
+
* say, a directory at `archive/<name>.md`, which the guard above stats and refuses first.
|
|
719
|
+
*/
|
|
720
|
+
archive(name) {
|
|
721
|
+
if (!NAME_RE.test(name || '')) {
|
|
722
|
+
throw new MemoryValidationError(`invalid name '${name}'; must match ${NAME_PATTERN}`);
|
|
723
|
+
}
|
|
724
|
+
const facts = pyJoin(this.root, 'facts');
|
|
725
|
+
const source = this.factPath(name);
|
|
726
|
+
if (!this.reachable(source, facts, FACT_UNREACHABLE)) {
|
|
727
|
+
throw new MemoryValidationError(`no fact '${name}' under ${facts}`);
|
|
728
|
+
}
|
|
729
|
+
const archive = pyJoin(this.root, 'archive');
|
|
730
|
+
const destination = pyJoin(archive, `${name}.md`);
|
|
731
|
+
if (this.reachable(destination, archive, ARCHIVE_DESTINATION_UNREACHABLE)) {
|
|
732
|
+
throw new MemoryValidationError(`fact '${name}' is already archived; refusing to overwrite it`);
|
|
733
|
+
}
|
|
734
|
+
pyMkdirParents(archive);
|
|
735
|
+
pyReplace(source, destination);
|
|
736
|
+
try {
|
|
737
|
+
this.rebuildIndex();
|
|
738
|
+
}
|
|
739
|
+
catch (error) {
|
|
740
|
+
pyReplace(destination, source);
|
|
741
|
+
this.rebuildIndex();
|
|
742
|
+
throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
restore(name) {
|
|
746
|
+
const archive = pyJoin(this.root, 'archive');
|
|
747
|
+
const source = pyJoin(archive, `${name}.md`);
|
|
748
|
+
if (!this.reachable(source, archive, ARCHIVE_UNREACHABLE)) {
|
|
749
|
+
throw new MemoryValidationError(`no archived fact '${name}' under ${archive}`);
|
|
750
|
+
}
|
|
751
|
+
const destination = this.factPath(name);
|
|
752
|
+
if (this.reachable(destination, pyJoin(this.root, 'facts'), FACTS_UNREACHABLE)) {
|
|
753
|
+
throw new MemoryValidationError(`fact '${name}' is already live; refusing to overwrite it from archive`);
|
|
754
|
+
}
|
|
755
|
+
this.facts(); // parse BEFORE the move, not after it
|
|
756
|
+
pyMkdirParents(pyJoin(this.root, 'facts'));
|
|
757
|
+
pyReplace(source, destination);
|
|
758
|
+
try {
|
|
759
|
+
this.checkIndexBudget();
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
pyReplace(destination, source);
|
|
763
|
+
this.rebuildIndex();
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
this.rebuildIndex();
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The index as it should be on disk, derived from `facts/` and nothing else.
|
|
770
|
+
*
|
|
771
|
+
* An unreadable store raises here rather than answering `""` — that empty string was both
|
|
772
|
+
* the input `_rebuildIndex` wrote over `index.md` and the number `_checkIndexBudget`
|
|
773
|
+
* measured, which is the whole shape of the defect and is why the raise lives in the READ.
|
|
774
|
+
*
|
|
775
|
+
* THE BUDGET AND THE BYTES ON DISK AGREE IN THIS PORT, on every platform, because
|
|
776
|
+
* `pyWriteText` writes exactly this string. They do NOT agree in `runtime-py` on Windows:
|
|
777
|
+
* `write_text` opens with `newline=None`, so the file gets `\r\n` per line while
|
|
778
|
+
* `_checkIndexBudget` counts this LF text — 65 bytes apart on the live 65-fact store.
|
|
779
|
+
* Registered as a runtime-py defect, not fixed here (invariant 8).
|
|
780
|
+
*/
|
|
781
|
+
indexText() {
|
|
782
|
+
return this.facts().map((fact) => this.indexLine(fact)).join('');
|
|
783
|
+
}
|
|
784
|
+
// ---- internals ----
|
|
785
|
+
factPath(name) {
|
|
786
|
+
return pyJoin(this.root, 'facts', `${pyText(name)}.md`);
|
|
787
|
+
}
|
|
788
|
+
indexLine(fact) {
|
|
789
|
+
// The em dash is a raw U+2014 and there is no header line. Both are budget inputs.
|
|
790
|
+
return `- [[${pyText(fact.name)}]] (${pyText(fact.type)}) — ${pyText(fact.description)}\n`;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* `sorted(facts, key=self._eviction_key)` — the eviction order `compact` archives in.
|
|
794
|
+
*
|
|
795
|
+
* CLASS FIRST, THEN STALENESS. A `feedback` fact is a standing instruction from the user:
|
|
796
|
+
* it holds until revoked, and its worth does not decay with time-since-last-recall, so the
|
|
797
|
+
* temporal key below is INVERTED for that kind of fact — the better an instruction has
|
|
798
|
+
* been internalised the less anything recalls it, the staler it looks, and the sooner it
|
|
799
|
+
* leaves the index that is loaded at session start. That class therefore ranks LAST and
|
|
800
|
+
* every other class is exhausted before any of it is archived. A priority and never a
|
|
801
|
+
* veto: the budget still wins, and with nothing else left it goes by staleness. Both sorts
|
|
802
|
+
* are STABLE, so a tied rank leaves the staleness answer below exactly as it was.
|
|
803
|
+
*
|
|
804
|
+
* `DURABLE_TYPES` AND NOT `"feedback"` ALONE, because that reason is a property of the
|
|
805
|
+
* class and not of the word — review round 4 (M12), mirroring `runtime-py` `1d9e5eb`.
|
|
806
|
+
* `assets/skills/memory.md` tells the model that `user` is "a durable fact about the
|
|
807
|
+
* user", and a durable fact does not become less true because nothing looked it up.
|
|
808
|
+
* Measured on the reference before the change, on four facts one per type where nothing
|
|
809
|
+
* has ever been recalled: one slot to free and `compact` archived `ausr`, a never-recalled
|
|
810
|
+
* `user` fact, ahead of a `project` note created seven months later. The change is
|
|
811
|
+
* MONOTONE — the protected set only grows — so no existing store loses a fact this rank
|
|
812
|
+
* kept for it before. The two protected types share ONE rank rather than being ordered
|
|
813
|
+
* against each other: the reason for protecting them is identical, so any order between
|
|
814
|
+
* them would be an invention, and a tied rank leaves the staleness key to answer.
|
|
815
|
+
*
|
|
816
|
+
* The protected class is deliberately UNCAPPED. Nothing bounds how much of the index it
|
|
817
|
+
* may hold, and that cannot make `compact` fail: once every decaying fact is archived the
|
|
818
|
+
* loop keeps going through the protected ones by staleness, so the budget still wins.
|
|
819
|
+
*
|
|
820
|
+
* `last_recalled` ALONE conflated two opposite facts: one written seconds ago and one
|
|
821
|
+
* nobody has asked for in a year both read as absent, and the empty string sorts before
|
|
822
|
+
* every real ISO date, so the NEWEST fact was the first evicted. Falling back to `created`
|
|
823
|
+
* makes absence of evidence mean "as stale as it is old". Ties break on name only after
|
|
824
|
+
* the dates are equal, so the alphabet can no longer decide a live question.
|
|
825
|
+
*
|
|
826
|
+
* `or`, not `??`: every FALSY value falls through, which is what Python's `or` does — a
|
|
827
|
+
* `last_recalled: ''` or a `created: 0` is not evidence of a recall. The comparison is
|
|
828
|
+
* `pyCompareLt`, Python's `<`, because these fields come out of YAML and a `date` beside a
|
|
829
|
+
* `str` raises there instead of sorting; both runtimes must fail the same way. Both sorts
|
|
830
|
+
* are STABLE, so equal keys keep listing order on either side.
|
|
831
|
+
*/
|
|
832
|
+
byEviction(facts) {
|
|
833
|
+
// `1 if fact.type in DURABLE_TYPES else 0`. `pyEqualValue` and not `===`, because `type`
|
|
834
|
+
// comes out of YAML uncast: a hand-edited `type: 2026` puts a `date` in that field, and
|
|
835
|
+
// Python's `==` answers False across types rather than raising the way `<` would.
|
|
836
|
+
// `some` over the array is one `pyEqualValue` per entry, which is what Python's `in` on
|
|
837
|
+
// a TUPLE is; nothing here hashes `fact.type`, so a `type: [a, b]` ranks 0 rather than
|
|
838
|
+
// taking `compact` — the operator's only way back under budget — down with it.
|
|
839
|
+
const rank = (fact) => DURABLE_TYPES.some((durable) => pyEqualValue(fact.type, durable)) ? 1 : 0;
|
|
840
|
+
const key = (fact) => [
|
|
841
|
+
pyTruthy(fact.last_recalled) ? fact.last_recalled : pyTruthy(fact.created) ? fact.created : '',
|
|
842
|
+
fact.name,
|
|
843
|
+
];
|
|
844
|
+
return [...facts].sort((a, b) => {
|
|
845
|
+
// The rank is an `int` on both sides, so it is compared as one and never handed to
|
|
846
|
+
// `pyCompareLt` — a `number` is not a `FactValue`, and the tuple below stays the
|
|
847
|
+
// two-slot staleness key it has always been. It is only reached on a TIED rank,
|
|
848
|
+
// exactly as `tuplerichcompare` reaches slot 1 in the reference.
|
|
849
|
+
const leftRank = rank(a);
|
|
850
|
+
const rightRank = rank(b);
|
|
851
|
+
if (leftRank !== rightRank)
|
|
852
|
+
return leftRank < rightRank ? -1 : 1;
|
|
853
|
+
const left = key(a);
|
|
854
|
+
const right = key(b);
|
|
855
|
+
for (let slot = 0; slot < 2; slot += 1) {
|
|
856
|
+
if (pyCompareLt(left[slot], right[slot]))
|
|
857
|
+
return -1;
|
|
858
|
+
if (pyCompareLt(right[slot], left[slot]))
|
|
859
|
+
return 1;
|
|
860
|
+
}
|
|
861
|
+
return 0;
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* `path.exists()`, except that "I was not allowed to look" is never "it is not there".
|
|
866
|
+
*
|
|
867
|
+
* The same invariant as `listing`, one syscall down: `pyExists` swallows exactly
|
|
868
|
+
* `pathlib._IGNORED_ERRNOS` and re-raises the rest, so EACCES arrives as an OS error and is
|
|
869
|
+
* converted HERE — in the store, which owns the distinction — rather than in whichever
|
|
870
|
+
* command happened to call `restore`.
|
|
871
|
+
*/
|
|
872
|
+
reachable(path, directory, consequence) {
|
|
873
|
+
try {
|
|
874
|
+
return pyExists(path);
|
|
875
|
+
}
|
|
876
|
+
catch (e) {
|
|
877
|
+
const error = e instanceof PyOSError ? e : asPyOSError(e, path);
|
|
878
|
+
throw this.unreadable(directory, error, consequence, `stat of ${pyName(path)}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* The `*.md` names in one of this store's directories, or a raise. Never a lie.
|
|
883
|
+
*
|
|
884
|
+
* THE THREE-WAY, which is the whole of job37's fix and is keyed on `lexists` PRECISELY so
|
|
885
|
+
* that it does not turn on an errno:
|
|
886
|
+
*
|
|
887
|
+
* 1. the scan failed with ENOENT and SOMETHING is at the path -> unreadable
|
|
888
|
+
* 2. the scan failed with ENOENT and nothing is at the path -> [] (a first run)
|
|
889
|
+
* 3. the scan failed any other way -> unreadable
|
|
890
|
+
*
|
|
891
|
+
* Arm 1 exists because the same shape reports different errnos on different platforms:
|
|
892
|
+
* POSIX raises ENOTDIR for a scan of a regular file and lands in arm 3, while a Windows
|
|
893
|
+
* directory scan of a non-directory reports the path as NOT FOUND and would land in arm 2
|
|
894
|
+
* — answering "empty" for a store that is a file. A dangling symlink reports ENOENT
|
|
895
|
+
* everywhere. Keying on "is anything there" gives one answer on all four CI jobs.
|
|
896
|
+
*
|
|
897
|
+
* Node's `fs` has none of this shape and libuv's Windows error mapping is not CPython's,
|
|
898
|
+
* so what is reproduced here is the DECISION. The syscalls and the `strerror` live in
|
|
899
|
+
* `pyfs.ts`; `layers.count_facts` deliberately decides differently on the same ones.
|
|
900
|
+
*
|
|
901
|
+
* `consequence` is the caller's half of the sentence, because the two directories fail
|
|
902
|
+
* differently and one wording cannot be true of both.
|
|
903
|
+
*/
|
|
904
|
+
listing(directory, consequence) {
|
|
905
|
+
try {
|
|
906
|
+
return pyScandirNames(directory).filter(matchesMd);
|
|
907
|
+
}
|
|
908
|
+
catch (e) {
|
|
909
|
+
const error = e instanceof PyOSError ? e : asPyOSError(e, directory);
|
|
910
|
+
if (error.code === 'ENOENT') {
|
|
911
|
+
if (pyLexists(directory)) {
|
|
912
|
+
throw this.unreadable(directory, error, consequence, 'a path exists there');
|
|
913
|
+
}
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
throw this.unreadable(directory, error, consequence);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/** Every `facts/*.md`, listed so that "I could not read it" is never "it is empty". */
|
|
920
|
+
factPaths() {
|
|
921
|
+
const facts = pyJoin(this.root, 'facts');
|
|
922
|
+
return sortedPathNames(this.listing(facts, FACTS_UNREADABLE)).map((n) => pyJoin(facts, n));
|
|
923
|
+
}
|
|
924
|
+
/** One sentence for a directory that could not be listed, and it never says "empty". */
|
|
925
|
+
unreadable(directory, error, consequence, detail = '') {
|
|
926
|
+
const because = `${error.strerror}${detail ? ` (${detail})` : ''}`;
|
|
927
|
+
return new MemoryValidationError(`memory store is unreadable: ${directory}: ${because}; ${consequence}`);
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Every fact on disk, parsed.
|
|
931
|
+
*
|
|
932
|
+
* The `read_text` is OUTSIDE the try in the reference, so a fact file that is not UTF-8
|
|
933
|
+
* raises a bare `UnicodeDecodeError` rather than a `malformed fact file …` sentence. That
|
|
934
|
+
* ordering is reproduced: `pyReadText` raises `PyUnicodeDecodeError` with CPython's own
|
|
935
|
+
* message and it is not caught here.
|
|
936
|
+
*
|
|
937
|
+
* RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
|
|
938
|
+
* `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
|
|
939
|
+
* are matched EXACTLY, because their text is Python's own and short: too few `---\n` parts
|
|
940
|
+
* (`ValueError` from the tuple unpack), frontmatter that is not a mapping (the store's own
|
|
941
|
+
* sentence), and a missing key (`KeyError.__str__`, which is the key in single quotes).
|
|
942
|
+
* The fourth — input outside the grammar `parseFrontmatter` accepts — is NOT matched:
|
|
943
|
+
* PyYAML answers with a `ScannerError`/`ParserError` carrying its marks and a rendered
|
|
944
|
+
* snippet, and reproducing that means porting PyYAML's scanner diagnostics, a surface
|
|
945
|
+
* larger than this whole module, for strings no test in `runtime-py/tests` pins. Node
|
|
946
|
+
* emits `parseFrontmatter`'s own reason after the identical prefix. The divergence is
|
|
947
|
+
* carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
|
|
948
|
+
* every run instead of asserted once and forgotten.
|
|
949
|
+
*/
|
|
950
|
+
facts() {
|
|
951
|
+
const out = [];
|
|
952
|
+
for (const path of this.factPaths()) {
|
|
953
|
+
const text = pyReadText(path); // outside the try, exactly as in the reference
|
|
954
|
+
const file = basename(path);
|
|
955
|
+
const malformed = (reason) => new MemoryValidationError(`malformed fact file ${file}: ${reason}`);
|
|
956
|
+
const parts = pySplit(text, '---\n', 2);
|
|
957
|
+
if (parts.length !== 3) {
|
|
958
|
+
throw malformed(`not enough values to unpack (expected 3, got ${parts.length})`);
|
|
959
|
+
}
|
|
960
|
+
const front = parts[1];
|
|
961
|
+
let meta;
|
|
962
|
+
if (!looksLikeMapping(front))
|
|
963
|
+
throw malformed('frontmatter is not a mapping');
|
|
964
|
+
try {
|
|
965
|
+
meta = parseFrontmatter(front);
|
|
966
|
+
}
|
|
967
|
+
catch (e) {
|
|
968
|
+
throw malformed(e instanceof Error ? e.message : String(e));
|
|
969
|
+
}
|
|
970
|
+
for (const key of ['name', 'description', 'type']) {
|
|
971
|
+
if (!(key in meta))
|
|
972
|
+
throw malformed(`'${key}'`);
|
|
973
|
+
}
|
|
974
|
+
const rawCreated = meta['created'];
|
|
975
|
+
out.push({
|
|
976
|
+
name: meta['name'],
|
|
977
|
+
description: meta['description'],
|
|
978
|
+
type: meta['type'],
|
|
979
|
+
body: pyStrip(parts[2]),
|
|
980
|
+
links: pyList(meta['links']),
|
|
981
|
+
last_recalled: meta['last_recalled'] ?? null,
|
|
982
|
+
// `or`, not `??`: every FALSY value falls back to the mtime, and that is now more
|
|
983
|
+
// than the empty string — `created: 0`, `created: false` and `created: 0.0` are all
|
|
984
|
+
// falsy in Python too, while `created: 2026-08-23` is a truthy `date` and is kept.
|
|
985
|
+
created: pyTruthy(rawCreated) ? rawCreated : pyMtimeDate(path),
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Date the recall.
|
|
992
|
+
*
|
|
993
|
+
* The reference's `snapshot` branch is absent here for the reason given at the top of the
|
|
994
|
+
* file: nothing on the MCP surface enters a snapshot scope, so `_snapshot` is always
|
|
995
|
+
* `None` and only this arm ever runs. No fact is left half-dated either way, because the
|
|
996
|
+
* listing inside `_facts` has already run before any write.
|
|
997
|
+
*/
|
|
998
|
+
stamp(fact) {
|
|
999
|
+
fact.last_recalled = this.today();
|
|
1000
|
+
this.writeFact(fact);
|
|
1001
|
+
}
|
|
1002
|
+
/** Write via `<name>.md.tmp` and `os.replace`, so a reader never sees a half-written fact. */
|
|
1003
|
+
writeFact(fact) {
|
|
1004
|
+
const path = this.factPath(fact.name);
|
|
1005
|
+
const tmp = pyWithSuffix(path, '.md.tmp');
|
|
1006
|
+
pyWriteText(tmp, formatFact(fact));
|
|
1007
|
+
pyReplace(tmp, path);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
|
|
1011
|
+
*
|
|
1012
|
+
* Note what is deliberately NOT here: a guard refusing to shrink the index. It could not
|
|
1013
|
+
* tell a wipe from a legitimate `compact()`, and it would leave every reader still being
|
|
1014
|
+
* lied to. The read was wrong and the read is where it is fixed.
|
|
1015
|
+
*/
|
|
1016
|
+
rebuildIndex() {
|
|
1017
|
+
pyWriteText(pyJoin(this.root, 'index.md'), this.indexText());
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Raise `MemoryBudgetExceeded` if the index would not fit.
|
|
1021
|
+
*
|
|
1022
|
+
* This is a PARSE, not a listing: `indexText` reads every fact file, so a caller that
|
|
1023
|
+
* pre-read with `factPaths` has not pre-read what this raises on. An unreadable store
|
|
1024
|
+
* raises `MemoryValidationError` from in here — a DIFFERENT error from the budget one, and
|
|
1025
|
+
* `save`'s rollback below is written to re-throw it untouched.
|
|
1026
|
+
*/
|
|
1027
|
+
checkIndexBudget() {
|
|
1028
|
+
const size = Buffer.byteLength(this.indexText(), 'utf8');
|
|
1029
|
+
if (size > this.indexBudget) {
|
|
1030
|
+
throw new MemoryBudgetExceeded(`memory index is ${size} bytes, budget is ${this.indexBudget}: ` +
|
|
1031
|
+
'run compact() or tersen descriptions');
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|