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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/assets/contracts/default.yaml +90 -0
  4. package/assets/evals/devteam/manifest.yaml +351 -0
  5. package/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. package/assets/evals/devteam/repo/README.md +12 -0
  7. package/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. package/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. package/assets/evals/fixtures/.gitkeep +0 -0
  40. package/assets/evals/fixtures/catalog.json +6 -0
  41. package/assets/evals/perturbations/task-completion.yaml +576 -0
  42. package/assets/evals/tasks/.gitkeep +0 -0
  43. package/assets/evals/tasks/extract-contact.yaml +14 -0
  44. package/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. package/assets/evals/tasks/extract-order.yaml +15 -0
  46. package/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. package/assets/evals/tasks/extract-versions.yaml +17 -0
  48. package/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. package/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. package/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. package/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. package/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. package/assets/evals/tasks/recall-owner.yaml +13 -0
  59. package/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. package/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. package/assets/evals/tasks/shop-compare.yaml +9 -0
  62. package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. package/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. package/assets/evals/tasks/shop-total.yaml +9 -0
  65. package/assets/profiles/default.yaml +31 -0
  66. package/assets/profiles/patient.yaml +31 -0
  67. package/assets/rubrics/.gitkeep +0 -0
  68. package/assets/rubrics/code-quality.yaml +20 -0
  69. package/assets/rubrics/grounded-completion.yaml +37 -0
  70. package/assets/rubrics/task-completion.yaml +28 -0
  71. package/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. package/assets/skills/.gitkeep +0 -0
  73. package/assets/skills/file-graph.md +7 -0
  74. package/assets/skills/memory.md +35 -0
  75. package/assets/tools/.gitkeep +0 -0
  76. package/assets/tools/bantamkit_read.json +48 -0
  77. package/assets/tools/bantamkit_status.json +25 -0
  78. package/assets/tools/build_identity.json +17 -0
  79. package/assets/tools/document_list.json +12 -0
  80. package/assets/tools/document_read.json +31 -0
  81. package/assets/tools/file_graph.json +12 -0
  82. package/assets/tools/memory_compact.json +31 -0
  83. package/assets/tools/memory_recall.json +38 -0
  84. package/assets/tools/memory_save.json +61 -0
  85. package/assets/tools/shiftwork_clock_in.json +25 -0
  86. package/assets/tools/shiftwork_clock_out.json +60 -0
  87. package/assets/tools/shiftwork_status.json +25 -0
  88. package/assets/tools/skill_audit.json +70 -0
  89. package/assets/tools/validate_json.json +31 -0
  90. package/dist/assets.d.ts +48 -0
  91. package/dist/assets.js +109 -0
  92. package/dist/charsets.d.ts +17 -0
  93. package/dist/charsets.js +580 -0
  94. package/dist/cli.d.ts +12 -0
  95. package/dist/cli.js +280 -0
  96. package/dist/contract.d.ts +145 -0
  97. package/dist/contract.js +608 -0
  98. package/dist/docread.d.ts +357 -0
  99. package/dist/docread.js +3315 -0
  100. package/dist/errors.d.ts +11 -0
  101. package/dist/errors.js +14 -0
  102. package/dist/eventlog.d.ts +137 -0
  103. package/dist/eventlog.js +294 -0
  104. package/dist/htmlentities.d.ts +2 -0
  105. package/dist/htmlentities.js +16 -0
  106. package/dist/index.d.ts +23 -0
  107. package/dist/index.js +13 -0
  108. package/dist/mcp/identity.d.ts +25 -0
  109. package/dist/mcp/identity.js +275 -0
  110. package/dist/mcp/pyargs.d.ts +80 -0
  111. package/dist/mcp/pyargs.js +362 -0
  112. package/dist/mcp/sdkjson.d.ts +56 -0
  113. package/dist/mcp/sdkjson.js +91 -0
  114. package/dist/mcp/server.d.ts +45 -0
  115. package/dist/mcp/server.js +740 -0
  116. package/dist/mcp/status.d.ts +210 -0
  117. package/dist/mcp/status.js +357 -0
  118. package/dist/mcp/transport.d.ts +68 -0
  119. package/dist/mcp/transport.js +111 -0
  120. package/dist/mcpreport.d.ts +214 -0
  121. package/dist/mcpreport.js +758 -0
  122. package/dist/memory/cli.d.ts +2 -0
  123. package/dist/memory/cli.js +396 -0
  124. package/dist/memory/component.d.ts +256 -0
  125. package/dist/memory/component.js +497 -0
  126. package/dist/memory/factfile.d.ts +120 -0
  127. package/dist/memory/factfile.js +406 -0
  128. package/dist/memory/layers.d.ts +92 -0
  129. package/dist/memory/layers.js +410 -0
  130. package/dist/memory/pyfs.d.ts +521 -0
  131. package/dist/memory/pyfs.js +1608 -0
  132. package/dist/memory/pyyaml.d.ts +155 -0
  133. package/dist/memory/pyyaml.js +868 -0
  134. package/dist/memory/store.d.ts +478 -0
  135. package/dist/memory/store.js +1034 -0
  136. package/dist/pyargparse.d.ts +141 -0
  137. package/dist/pyargparse.js +813 -0
  138. package/dist/pyjson.d.ts +110 -0
  139. package/dist/pyjson.js +524 -0
  140. package/dist/pyjsonschema.d.ts +50 -0
  141. package/dist/pyjsonschema.js +951 -0
  142. package/dist/shiftwork.d.ts +60 -0
  143. package/dist/shiftwork.js +324 -0
  144. package/dist/skillaudit.d.ts +172 -0
  145. package/dist/skillaudit.js +914 -0
  146. package/dist/statusline.d.ts +106 -0
  147. package/dist/statusline.js +281 -0
  148. 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
+ }