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,478 @@
|
|
|
1
|
+
import { BantamError } from '../errors.js';
|
|
2
|
+
import type { Fact, FactValue } from './factfile.js';
|
|
3
|
+
export type { Fact } from './factfile.js';
|
|
4
|
+
/** `sorted(VALID_TYPES)` is what the error message interpolates, so the order is load-bearing. */
|
|
5
|
+
export declare const VALID_TYPES: readonly ["feedback", "project", "reference", "user"];
|
|
6
|
+
/**
|
|
7
|
+
* `DURABLE_TYPES` — the types whose worth does NOT decay with time-since-last-recall, and
|
|
8
|
+
* which `compact` therefore archives only after every other candidate is exhausted
|
|
9
|
+
* (`byEviction`).
|
|
10
|
+
*
|
|
11
|
+
* An ARRAY walked with one `pyEqualValue` per entry, never a `Set`: this is compared
|
|
12
|
+
* against a value that came out of YAML uncast, and Python's `in` on a tuple is `==` per
|
|
13
|
+
* element where `in` on a set hashes and raises on a `list`. `runtime-py`'s
|
|
14
|
+
* `store.DURABLE_TYPES`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const DURABLE_TYPES: readonly ["feedback", "user"];
|
|
17
|
+
export declare const DUPLICATE_JACCARD = 0.5;
|
|
18
|
+
/** See the reference's comment: measured against a real store, not chosen. */
|
|
19
|
+
export declare const DEFAULT_INDEX_BUDGET = 24000;
|
|
20
|
+
export declare class MemoryValidationError extends BantamError {
|
|
21
|
+
}
|
|
22
|
+
export declare class MemoryBudgetExceeded extends BantamError {
|
|
23
|
+
}
|
|
24
|
+
export interface SaveResult {
|
|
25
|
+
status: 'saved' | 'duplicate';
|
|
26
|
+
name: string;
|
|
27
|
+
/** `SaveResult.similar` is the OTHER fact's `name` field, verbatim — see `pyText`. */
|
|
28
|
+
similar: FactValue;
|
|
29
|
+
}
|
|
30
|
+
/** What one archived fact WAS, kept after its file has left `facts/`. */
|
|
31
|
+
export interface ArchivedFact {
|
|
32
|
+
name: FactValue;
|
|
33
|
+
type: FactValue;
|
|
34
|
+
description: FactValue;
|
|
35
|
+
indexBytes: number;
|
|
36
|
+
lastRecalled: FactValue | null;
|
|
37
|
+
created: FactValue | null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Everything the caller needs to understand what compaction cost.
|
|
41
|
+
*
|
|
42
|
+
* Archiving is a one-way MOVE, not a delete: the file is still readable under `archive/` and
|
|
43
|
+
* `restore()` brings it back. This carries the description of each fact that left, so a
|
|
44
|
+
* caller that never looks in `archive/` can still say what it lost, and the byte arithmetic
|
|
45
|
+
* so it can see the headroom it bought. `headroom` is a `@property` in the reference and a
|
|
46
|
+
* plain field here — the value is the same and nothing mutates the result.
|
|
47
|
+
*/
|
|
48
|
+
export interface CompactResult {
|
|
49
|
+
archived: ArchivedFact[];
|
|
50
|
+
indexBefore: number;
|
|
51
|
+
indexAfter: number;
|
|
52
|
+
budget: number;
|
|
53
|
+
target: number;
|
|
54
|
+
reserve: number;
|
|
55
|
+
headroom: number;
|
|
56
|
+
archiveDir: string;
|
|
57
|
+
}
|
|
58
|
+
export interface MemoryStoreOptions {
|
|
59
|
+
indexBudget?: number;
|
|
60
|
+
k?: number;
|
|
61
|
+
today?: () => string;
|
|
62
|
+
create?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `set(re.findall(r"[a-z0-9]+", text.lower()))`.
|
|
66
|
+
*
|
|
67
|
+
* ASCII-only, no casefold and no Unicode normalization — so a wholly non-ASCII name and
|
|
68
|
+
* description produce the EMPTY set in both runtimes. That is not a defect to fix in a
|
|
69
|
+
* port; it is the behaviour the duplicate gate is built on, and `_jaccard` below is where
|
|
70
|
+
* it stops being a division by zero.
|
|
71
|
+
*/
|
|
72
|
+
export declare function tokens(text: string): Set<string>;
|
|
73
|
+
/**
|
|
74
|
+
* `len(a & b) / len(a | b)`, except that an empty side is 0.0 and never a division.
|
|
75
|
+
*
|
|
76
|
+
* Python decided this and Python's answer is the one that ships: two facts whose tokens are
|
|
77
|
+
* both empty score 0.0, so they are NOT duplicates of each other and both save. A port that
|
|
78
|
+
* "fixed" it to 1.0 for two empty sets would refuse the second of two Thai descriptions.
|
|
79
|
+
*/
|
|
80
|
+
export declare function jaccard(a: Set<string>, b: Set<string>): number;
|
|
81
|
+
/**
|
|
82
|
+
* Python's `f"{value}"` for the values a hand-edited frontmatter can put in a `str` field.
|
|
83
|
+
*
|
|
84
|
+
* `_facts` does no type check: `meta["name"]` is whatever the YAML resolved to, and the
|
|
85
|
+
* store then interpolates it into the index line, the token text and the FILE PATH. For
|
|
86
|
+
* `None` Python writes `None` and JS would write `null` — a different index line and a
|
|
87
|
+
* different file on disk from the same store. Only `null` is reproduced; see the ruling in
|
|
88
|
+
* `tools/conformance/suites/store.mjs` for the sequence case, which is left differing.
|
|
89
|
+
*
|
|
90
|
+
* Exported for `component._format`, which interpolates the same three fields into the line a
|
|
91
|
+
* model reads. Two spellings of one coercion is how the index line and the recall line would
|
|
92
|
+
* come to print a different name for the same fact.
|
|
93
|
+
*/
|
|
94
|
+
export declare function pyText(value: unknown): string;
|
|
95
|
+
/**
|
|
96
|
+
* `hash`/`==` equivalence for the values a `Fact.name` can hold — the key `Memory.recall`'s
|
|
97
|
+
* `seen` set uses across layers.
|
|
98
|
+
*
|
|
99
|
+
* `pyText` is the WRONG key here and the difference is observable: a fact named `7` in the
|
|
100
|
+
* project layer and one named `'7'` in the profile layer are two different keys to Python
|
|
101
|
+
* and would collapse into one under `str`. The other direction is just as real — Python's
|
|
102
|
+
* numeric tower makes `7`, `7.0` and `True` all equal and all one key — so a key built from
|
|
103
|
+
* the JS type would drop a dedupe Python performs. This spells out both.
|
|
104
|
+
*
|
|
105
|
+
* Dates are keyed by their type as well as their text: `date(2026, 8, 23)` and the
|
|
106
|
+
* `datetime` at midnight of the same day are NOT equal in Python.
|
|
107
|
+
*/
|
|
108
|
+
export declare function pyHashKey(value: FactValue): string;
|
|
109
|
+
/**
|
|
110
|
+
* `a == b` over the same values — the OTHER half of the tuple comparison, and not a
|
|
111
|
+
* rephrasing of `<`.
|
|
112
|
+
*
|
|
113
|
+
* `tuplerichcompare` walks the key tuple with `==` and calls `<` only on the first element
|
|
114
|
+
* where `==` answers False. That is what keeps `sorted` from raising on two facts both named
|
|
115
|
+
* `None`: `None == None` is True, the tuples are equal, and `None < None` — which DOES raise
|
|
116
|
+
* — is never reached. Measured: without this the port raised `'NoneType' and 'NoneType'`
|
|
117
|
+
* where CPython answered an order.
|
|
118
|
+
*
|
|
119
|
+
* `==` never raises. Across types it is simply False — `7 == 'a'`, a `date` against a
|
|
120
|
+
* `datetime`, a naive `datetime` against an aware one — while `<` on the same pairs raises.
|
|
121
|
+
* Inside the numeric family it is Python's: `True == 1`, `7 == 7.0`, and `nan == nan` is
|
|
122
|
+
* False, which is why NaN is not special-cased anywhere here.
|
|
123
|
+
*/
|
|
124
|
+
export declare function pyEqualValue(a: FactValue, b: FactValue): boolean;
|
|
125
|
+
export declare function pyCompareLt(a: FactValue, b: FactValue): boolean;
|
|
126
|
+
/**
|
|
127
|
+
* `sorted(scored, key=lambda pair: (-pair[0], pair[1].name))`, comparison for comparison.
|
|
128
|
+
*
|
|
129
|
+
* RULING — WHY THE SORT IS SPELLED OUT AND NOT HANDED TO `Array.prototype.sort`. When every
|
|
130
|
+
* name is a `str` the two agree and this takes the fast path. When they are not, the sort
|
|
131
|
+
* can RAISE, and then WHICH pair CPython happens to compare first decides the sentence a
|
|
132
|
+
* model reads: `'int' and 'str'` or `'str' and 'int'`, and with three numeric types in play,
|
|
133
|
+
* which of `int`/`float`/`bool` gets named. `Array.prototype.sort`'s comparison order is
|
|
134
|
+
* unspecified and V8's is not Timsort's.
|
|
135
|
+
*
|
|
136
|
+
* So the slow path is CPython's `listsort` for a single run: `count_run` (adjacent
|
|
137
|
+
* `a[i] < a[i-1]`, reversing a strictly descending prefix) then `binarysort` (pivot on the
|
|
138
|
+
* LEFT of every probe, midpoint `l + ((r - l) >> 1)`), over key tuples compared by
|
|
139
|
+
* `tuplerichcompare` — `==` down the tuple and `<` only where `==` says they differ.
|
|
140
|
+
*
|
|
141
|
+
* MEASURED, and re-measured on every conformance run: the `recall tie-break` case in
|
|
142
|
+
* `tools/conformance/suites/store.mjs` generates 50,000 `(score, name)` lists from a seeded
|
|
143
|
+
* LCG — ten name spellings across all four families, n from 2 to 300 — and runs each through
|
|
144
|
+
* CPython's `sorted` and through this function. 45,629 of them raise. All 50,000 agree, on
|
|
145
|
+
* the order where there is one and on the TypeError text where there is not.
|
|
146
|
+
*
|
|
147
|
+
* TWO MODELS THIS REFUTED, kept because each looks right until it is run. (1) "raise if any
|
|
148
|
+
* equal-score pair is incomparable" gets the DECISION right — 20,000 of 20,000 in the
|
|
149
|
+
* derivation probe — and the SENTENCE wrong in 1,087 of 30,000, because the types it names
|
|
150
|
+
* are whichever pair the binary search actually probed. (2) Calling `<` wherever the scores
|
|
151
|
+
* tie raises `'NoneType' and 'NoneType'` for two facts both named `None`, where CPython
|
|
152
|
+
* answers an order: `==` short-circuits the tuple before `<` is ever reached.
|
|
153
|
+
*
|
|
154
|
+
* NOT MEASURED, and the honest edge: past 64 elements CPython splits the list into runs and
|
|
155
|
+
* merges them, while this binary-inserts the whole list. It can only matter if the FIRST
|
|
156
|
+
* incomparable pair lies past the first run, and the 8,000 generated lists with n between 65
|
|
157
|
+
* and 300 produced no such case. A store would need 65+ facts tied on one recall score with
|
|
158
|
+
* the type change late in the file order.
|
|
159
|
+
*/
|
|
160
|
+
export declare function sortScored<T extends {
|
|
161
|
+
score: number;
|
|
162
|
+
name: FactValue;
|
|
163
|
+
}>(scored: readonly T[]): T[];
|
|
164
|
+
export declare class MemoryStore {
|
|
165
|
+
readonly root: string;
|
|
166
|
+
readonly indexBudget: number;
|
|
167
|
+
readonly k: number;
|
|
168
|
+
private readonly today;
|
|
169
|
+
constructor(root: string, options?: MemoryStoreOptions);
|
|
170
|
+
private ensureDirs;
|
|
171
|
+
/**
|
|
172
|
+
* Write a fact, or refuse it as a near-duplicate of one already here.
|
|
173
|
+
*
|
|
174
|
+
* THE FIRST OF TWO READS IS WHAT MAKES THIS SAFE, and the ordering is the reference's, not
|
|
175
|
+
* an implementation detail: `_facts()` runs for the duplicate check BEFORE `_writeFact`,
|
|
176
|
+
* so an unreadable store fails the whole op with nothing written and nothing to roll back.
|
|
177
|
+
* The measured alternative, one directory over in job37: a blind listing let the duplicate
|
|
178
|
+
* check pass vacuously, the fact landed, and `_rebuildIndex` then wrote a 13,472-byte
|
|
179
|
+
* index down to zero from the same empty listing.
|
|
180
|
+
*/
|
|
181
|
+
save(type: string, name: string, description: string, body: string, links?: readonly string[]): SaveResult;
|
|
182
|
+
/**
|
|
183
|
+
* Top-`k` facts whose name+description share tokens with `query`.
|
|
184
|
+
*
|
|
185
|
+
* An unreadable store raises out of `facts()` rather than scoring zero facts and returning
|
|
186
|
+
* `[]` — the same answer as a real miss, which `component.Memory` used to turn into "no
|
|
187
|
+
* memories matched. Try different words", telling a person to rephrase a question at a
|
|
188
|
+
* filing cabinet nobody could open.
|
|
189
|
+
*/
|
|
190
|
+
recall(query: string, k?: number | null, stamp?: boolean): Fact[];
|
|
191
|
+
/**
|
|
192
|
+
* `len(store._facts())` at the operator CLI, which is the only caller.
|
|
193
|
+
*
|
|
194
|
+
* `facts()` stays private: this hands out a COUNT, not the parsed list, so no caller
|
|
195
|
+
* outside this file can hold a fact array and drift from what is on disk. It is a full
|
|
196
|
+
* parse — an unreadable or malformed store raises here rather than counting 0, which is
|
|
197
|
+
* the same rule `indexText` holds and for the same reason.
|
|
198
|
+
*/
|
|
199
|
+
factCount(): number;
|
|
200
|
+
/**
|
|
201
|
+
* Every fact parses and carries a valid type, and the index fits its budget.
|
|
202
|
+
*
|
|
203
|
+
* The raise on an unreadable store is the reference's and it is load-bearing: a checker
|
|
204
|
+
* that passes hardest on the store it could not open is worse than no checker. `_facts()`
|
|
205
|
+
* raises before the type sweep starts, and `_cmd_lint` in `memory/cli.ts` routes both that
|
|
206
|
+
* and the budget error to `lint: FAIL — …` on stderr with exit 1.
|
|
207
|
+
*/
|
|
208
|
+
lint(): void;
|
|
209
|
+
/**
|
|
210
|
+
* Archive the stalest facts until the index sits at `budget - reserve` or below.
|
|
211
|
+
*
|
|
212
|
+
* THE TARGET IS BELOW THE BUDGET ON PURPOSE, and the reference's docstring records why:
|
|
213
|
+
* `save` rolls its fact back before raising, so by the time anyone is told to compact, the
|
|
214
|
+
* index already fits — a loop that stopped at "fits" would archive nothing at the only
|
|
215
|
+
* moment the remedy is ever named. The default `reserve` is the largest index line the
|
|
216
|
+
* store currently holds (capped at half the budget), so the headroom bought is "a fact as
|
|
217
|
+
* big as your biggest one will fit", measured from this store's own data.
|
|
218
|
+
*
|
|
219
|
+
* `facts()` runs FIRST, before any rename, so an unreadable store moves nothing.
|
|
220
|
+
*
|
|
221
|
+
* THE MOVE IS `os.replace`, AND THE REFERENCE'S IS NOW TOO — the difference this comment
|
|
222
|
+
* used to report is CLOSED. `pyReplace` is `os.replace`, which replaces an existing
|
|
223
|
+
* destination on every platform. `Path.rename` is `os.rename`, which replaces silently on
|
|
224
|
+
* POSIX and raises `FileExistsError` on Windows; the reference called it here until
|
|
225
|
+
* `d239480` moved it onto `Path.replace`, so Node-on-Windows used to match Python-on-POSIX
|
|
226
|
+
* and Python-on-Windows matched neither. Nothing about the port changed: this line has
|
|
227
|
+
* always been `pyReplace`.
|
|
228
|
+
*
|
|
229
|
+
* WHAT IS STILL TRUE is the reachability argument, which is why the fix needed a fixture
|
|
230
|
+
* built for it. The only state that tells `rename` and `replace` apart is an
|
|
231
|
+
* `archive/<name>.md` that ALREADY exists when this loop moves the live fact over it — an
|
|
232
|
+
* earlier compaction's copy of a fact that was restored and then went stale again.
|
|
233
|
+
* `restore` cannot produce it, because it moves the archived copy OUT. On POSIX the two
|
|
234
|
+
* calls are indistinguishable even in that state, so `memorycli`'s
|
|
235
|
+
* `compact re-archives over an existing archive entry` is a WINDOWS-ONLY regression guard:
|
|
236
|
+
* a revert to `rename` on either side stays green on this machine.
|
|
237
|
+
*
|
|
238
|
+
* THIS PARAGRAPH USED TO CLAIM THE OTHER TWO MOVES ARE UNOBSERVABLE BY CONSTRUCTION —
|
|
239
|
+
* "guarded by a `reachable` check that refuses when `facts/<name>.md` is live" and "moves
|
|
240
|
+
* back onto a path it has just emptied", so "there is no state in which the two calls could
|
|
241
|
+
* answer differently". THE FIRST HALF IS FALSE, and `archive` (2026-09-05) is what made it
|
|
242
|
+
* reachable enough to notice. `reachable` is `Path.exists()`, which FOLLOWS symlinks, so a
|
|
243
|
+
* DANGLING symlink at the destination is an occupied directory ENTRY that the guard reports
|
|
244
|
+
* as absent — measured on macOS at `archive/alpha.md`: `lexists` True, `exists` False, the
|
|
245
|
+
* guard passed, and the move landed on top of the link. `os.rename` would raise
|
|
246
|
+
* `FileExistsError` there on Windows and `pyReplace` would not, which is exactly the
|
|
247
|
+
* divergence `d239480` closed one method up. The reference's `archive` is therefore on
|
|
248
|
+
* `Path.replace` too, as of the same day, and its FORWARD move and this one are the same
|
|
249
|
+
* call on every platform. `restore`'s forward move has the identical hole one directory
|
|
250
|
+
* over (a dangling symlink at `facts/<name>.md`) and is NOT changed here: narrowing a
|
|
251
|
+
* shipped command is not this fix's to make, and it is registered instead.
|
|
252
|
+
*
|
|
253
|
+
* WHAT IS STILL UNOBSERVABLE BY CONSTRUCTION is the ROLLBACK on both methods, and only that:
|
|
254
|
+
* it moves back onto a path the forward move has just emptied, so it cannot meet an occupied
|
|
255
|
+
* destination at all — there is no red to demonstrate for it, which is the reason `d239480`
|
|
256
|
+
* gave for leaving restore's alone and the reason the reference's two rollbacks are still
|
|
257
|
+
* `rename` against this file's `pyReplace`.
|
|
258
|
+
*
|
|
259
|
+
* THE ORDER IS `byEviction`, NOT `byStaleness` — `sorted(facts, key=self._eviction_key)`.
|
|
260
|
+
* A `DURABLE_TYPES` fact — `feedback`, the user's standing instruction, or `user`, a
|
|
261
|
+
* durable fact about them — holds until revoked and its worth does not decay with
|
|
262
|
+
* time-since-last-recall, so a purely temporal key ranks those classes exactly
|
|
263
|
+
* backwards. Measured on the real project store (index 21698 of a 24000-byte budget): one
|
|
264
|
+
* auto-compaction archived 15 facts and 6 of them were `feedback`. See `byEviction`.
|
|
265
|
+
*/
|
|
266
|
+
compact(reserve?: number | null): CompactResult;
|
|
267
|
+
/**
|
|
268
|
+
* Names of the facts sitting in `archive/` — everything `compact` moved out.
|
|
269
|
+
*
|
|
270
|
+
* This is the worst place in the module to answer "empty" wrongly, because `compact()` has
|
|
271
|
+
* already MOVED the operator's facts here; an archive that cannot be listed is not an empty
|
|
272
|
+
* archive, and saying so makes compaction look like deletion. Hence `listing`, not a glob.
|
|
273
|
+
*
|
|
274
|
+
* The sort is over the STEMS and it is a plain string sort — `sorted(Path(name).stem …)` —
|
|
275
|
+
* not `sortedPathNames`, whose Windows case fold belongs to comparing `Path`s.
|
|
276
|
+
*/
|
|
277
|
+
archived(): string[];
|
|
278
|
+
/**
|
|
279
|
+
* Move an archived fact back into `facts/`; refuse if it would blow the budget.
|
|
280
|
+
*
|
|
281
|
+
* THE PROMISE IS THAT A FAILED RESTORE LEAVES THE STORE EXACTLY AS IT FOUND IT, and it
|
|
282
|
+
* takes both halves the reference has: a `facts()` PARSE before the move (a listing is not
|
|
283
|
+
* enough — a malformed fact passes a listing and fails the budget check afterwards), and a
|
|
284
|
+
* rollback keyed on "the op after the move failed" rather than on a list of error types,
|
|
285
|
+
* because when the ARCHIVED file is the bad one it is not a fact until after the rename.
|
|
286
|
+
*
|
|
287
|
+
* `restore` is deliberately NOT routed through `archived()`: it stats one named path
|
|
288
|
+
* instead of listing, so an unlistable-but-traversable `archive/` still restores. Refusing
|
|
289
|
+
* a recovery the filesystem was still willing to perform is the wrong direction for the
|
|
290
|
+
* door back.
|
|
291
|
+
*/
|
|
292
|
+
/**
|
|
293
|
+
* Move one named fact out of `facts/` and into `archive/`.
|
|
294
|
+
*
|
|
295
|
+
* The door out, taken deliberately. `compact` already moves facts out, but it chooses
|
|
296
|
+
* them by eviction rank and stops as soon as the index fits the budget, so it can
|
|
297
|
+
* neither be asked for a PARTICULAR fact nor be used at all when the store is already
|
|
298
|
+
* under budget. `restore` has taken a name since it was written; until this method the
|
|
299
|
+
* store could bring a named fact back but not send one away.
|
|
300
|
+
*
|
|
301
|
+
* THE NAME IS CHECKED AGAINST `NAME_RE` BEFORE ANY SYSCALL, and the reference does the
|
|
302
|
+
* same as of 2026-09-05. `save` was the only op enforcing it, and `save` is not the only
|
|
303
|
+
* op that CREATES a filename: this one builds `archive/<name>.md` out of what it is
|
|
304
|
+
* handed. Measured on macOS before the check: `archive ALPHA` against a live
|
|
305
|
+
* `facts/alpha.md` exited 0 on BOTH runtimes and left `archive/ALPHA.md` whose
|
|
306
|
+
* frontmatter says `name: alpha`, because the filesystem is case-insensitive and nothing
|
|
307
|
+
* asked the store's naming rule about the destination; on a case-sensitive filesystem the
|
|
308
|
+
* same command refuses. `restore` is deliberately left unvalidated on both sides.
|
|
309
|
+
*
|
|
310
|
+
* Same promise as `restore` — a failed archive leaves the store exactly as it found it —
|
|
311
|
+
* and one of its three guards carries over while two drop out:
|
|
312
|
+
*
|
|
313
|
+
* - Both stats go through `reachable`, not an existence check, for the reason spelled at
|
|
314
|
+
* `ARCHIVE_UNREACHABLE`. The two sentences are their own constants because each names
|
|
315
|
+
* the side the fact is still on, and that side is the mirror of restore's.
|
|
316
|
+
* - NO budget check. Archiving removes an index line, so the index can only shrink;
|
|
317
|
+
* `checkIndexBudget` is restore's guard in restore's direction and here it could not
|
|
318
|
+
* fail.
|
|
319
|
+
* - NO `facts()` PARSE BEFORE THE MOVE, and the reference dropped it the same day. The
|
|
320
|
+
* parse reads EVERY fact, so ONE malformed file in `facts/` refused every archive in
|
|
321
|
+
* the store INCLUDING ITS OWN, and no other command removes a fact by name — the one
|
|
322
|
+
* file the store calls broken was the one file no CLI route could remove, which is the
|
|
323
|
+
* opposite of what this method exists for. Without it the same command succeeds for a
|
|
324
|
+
* reason: the move takes the bad file out of `facts/` first, so `rebuildIndex` parses a
|
|
325
|
+
* directory that no longer holds it. A DIFFERENT fact being malformed still fails at
|
|
326
|
+
* that rebuild, and the rollback below puts the moved fact back.
|
|
327
|
+
*
|
|
328
|
+
* The rollback is keyed on "the rebuild after the move failed", and the route that
|
|
329
|
+
* reaches it is `index.md` BEING A DIRECTORY — not, as the reference's docstring used to
|
|
330
|
+
* say, a directory at `archive/<name>.md`, which the guard above stats and refuses first.
|
|
331
|
+
*/
|
|
332
|
+
archive(name: string): void;
|
|
333
|
+
restore(name: string): void;
|
|
334
|
+
/**
|
|
335
|
+
* The index as it should be on disk, derived from `facts/` and nothing else.
|
|
336
|
+
*
|
|
337
|
+
* An unreadable store raises here rather than answering `""` — that empty string was both
|
|
338
|
+
* the input `_rebuildIndex` wrote over `index.md` and the number `_checkIndexBudget`
|
|
339
|
+
* measured, which is the whole shape of the defect and is why the raise lives in the READ.
|
|
340
|
+
*
|
|
341
|
+
* THE BUDGET AND THE BYTES ON DISK AGREE IN THIS PORT, on every platform, because
|
|
342
|
+
* `pyWriteText` writes exactly this string. They do NOT agree in `runtime-py` on Windows:
|
|
343
|
+
* `write_text` opens with `newline=None`, so the file gets `\r\n` per line while
|
|
344
|
+
* `_checkIndexBudget` counts this LF text — 65 bytes apart on the live 65-fact store.
|
|
345
|
+
* Registered as a runtime-py defect, not fixed here (invariant 8).
|
|
346
|
+
*/
|
|
347
|
+
indexText(): string;
|
|
348
|
+
private factPath;
|
|
349
|
+
private indexLine;
|
|
350
|
+
/**
|
|
351
|
+
* `sorted(facts, key=self._eviction_key)` — the eviction order `compact` archives in.
|
|
352
|
+
*
|
|
353
|
+
* CLASS FIRST, THEN STALENESS. A `feedback` fact is a standing instruction from the user:
|
|
354
|
+
* it holds until revoked, and its worth does not decay with time-since-last-recall, so the
|
|
355
|
+
* temporal key below is INVERTED for that kind of fact — the better an instruction has
|
|
356
|
+
* been internalised the less anything recalls it, the staler it looks, and the sooner it
|
|
357
|
+
* leaves the index that is loaded at session start. That class therefore ranks LAST and
|
|
358
|
+
* every other class is exhausted before any of it is archived. A priority and never a
|
|
359
|
+
* veto: the budget still wins, and with nothing else left it goes by staleness. Both sorts
|
|
360
|
+
* are STABLE, so a tied rank leaves the staleness answer below exactly as it was.
|
|
361
|
+
*
|
|
362
|
+
* `DURABLE_TYPES` AND NOT `"feedback"` ALONE, because that reason is a property of the
|
|
363
|
+
* class and not of the word — review round 4 (M12), mirroring `runtime-py` `1d9e5eb`.
|
|
364
|
+
* `assets/skills/memory.md` tells the model that `user` is "a durable fact about the
|
|
365
|
+
* user", and a durable fact does not become less true because nothing looked it up.
|
|
366
|
+
* Measured on the reference before the change, on four facts one per type where nothing
|
|
367
|
+
* has ever been recalled: one slot to free and `compact` archived `ausr`, a never-recalled
|
|
368
|
+
* `user` fact, ahead of a `project` note created seven months later. The change is
|
|
369
|
+
* MONOTONE — the protected set only grows — so no existing store loses a fact this rank
|
|
370
|
+
* kept for it before. The two protected types share ONE rank rather than being ordered
|
|
371
|
+
* against each other: the reason for protecting them is identical, so any order between
|
|
372
|
+
* them would be an invention, and a tied rank leaves the staleness key to answer.
|
|
373
|
+
*
|
|
374
|
+
* The protected class is deliberately UNCAPPED. Nothing bounds how much of the index it
|
|
375
|
+
* may hold, and that cannot make `compact` fail: once every decaying fact is archived the
|
|
376
|
+
* loop keeps going through the protected ones by staleness, so the budget still wins.
|
|
377
|
+
*
|
|
378
|
+
* `last_recalled` ALONE conflated two opposite facts: one written seconds ago and one
|
|
379
|
+
* nobody has asked for in a year both read as absent, and the empty string sorts before
|
|
380
|
+
* every real ISO date, so the NEWEST fact was the first evicted. Falling back to `created`
|
|
381
|
+
* makes absence of evidence mean "as stale as it is old". Ties break on name only after
|
|
382
|
+
* the dates are equal, so the alphabet can no longer decide a live question.
|
|
383
|
+
*
|
|
384
|
+
* `or`, not `??`: every FALSY value falls through, which is what Python's `or` does — a
|
|
385
|
+
* `last_recalled: ''` or a `created: 0` is not evidence of a recall. The comparison is
|
|
386
|
+
* `pyCompareLt`, Python's `<`, because these fields come out of YAML and a `date` beside a
|
|
387
|
+
* `str` raises there instead of sorting; both runtimes must fail the same way. Both sorts
|
|
388
|
+
* are STABLE, so equal keys keep listing order on either side.
|
|
389
|
+
*/
|
|
390
|
+
private byEviction;
|
|
391
|
+
/**
|
|
392
|
+
* `path.exists()`, except that "I was not allowed to look" is never "it is not there".
|
|
393
|
+
*
|
|
394
|
+
* The same invariant as `listing`, one syscall down: `pyExists` swallows exactly
|
|
395
|
+
* `pathlib._IGNORED_ERRNOS` and re-raises the rest, so EACCES arrives as an OS error and is
|
|
396
|
+
* converted HERE — in the store, which owns the distinction — rather than in whichever
|
|
397
|
+
* command happened to call `restore`.
|
|
398
|
+
*/
|
|
399
|
+
private reachable;
|
|
400
|
+
/**
|
|
401
|
+
* The `*.md` names in one of this store's directories, or a raise. Never a lie.
|
|
402
|
+
*
|
|
403
|
+
* THE THREE-WAY, which is the whole of job37's fix and is keyed on `lexists` PRECISELY so
|
|
404
|
+
* that it does not turn on an errno:
|
|
405
|
+
*
|
|
406
|
+
* 1. the scan failed with ENOENT and SOMETHING is at the path -> unreadable
|
|
407
|
+
* 2. the scan failed with ENOENT and nothing is at the path -> [] (a first run)
|
|
408
|
+
* 3. the scan failed any other way -> unreadable
|
|
409
|
+
*
|
|
410
|
+
* Arm 1 exists because the same shape reports different errnos on different platforms:
|
|
411
|
+
* POSIX raises ENOTDIR for a scan of a regular file and lands in arm 3, while a Windows
|
|
412
|
+
* directory scan of a non-directory reports the path as NOT FOUND and would land in arm 2
|
|
413
|
+
* — answering "empty" for a store that is a file. A dangling symlink reports ENOENT
|
|
414
|
+
* everywhere. Keying on "is anything there" gives one answer on all four CI jobs.
|
|
415
|
+
*
|
|
416
|
+
* Node's `fs` has none of this shape and libuv's Windows error mapping is not CPython's,
|
|
417
|
+
* so what is reproduced here is the DECISION. The syscalls and the `strerror` live in
|
|
418
|
+
* `pyfs.ts`; `layers.count_facts` deliberately decides differently on the same ones.
|
|
419
|
+
*
|
|
420
|
+
* `consequence` is the caller's half of the sentence, because the two directories fail
|
|
421
|
+
* differently and one wording cannot be true of both.
|
|
422
|
+
*/
|
|
423
|
+
private listing;
|
|
424
|
+
/** Every `facts/*.md`, listed so that "I could not read it" is never "it is empty". */
|
|
425
|
+
private factPaths;
|
|
426
|
+
/** One sentence for a directory that could not be listed, and it never says "empty". */
|
|
427
|
+
private unreadable;
|
|
428
|
+
/**
|
|
429
|
+
* Every fact on disk, parsed.
|
|
430
|
+
*
|
|
431
|
+
* The `read_text` is OUTSIDE the try in the reference, so a fact file that is not UTF-8
|
|
432
|
+
* raises a bare `UnicodeDecodeError` rather than a `malformed fact file …` sentence. That
|
|
433
|
+
* ordering is reproduced: `pyReadText` raises `PyUnicodeDecodeError` with CPython's own
|
|
434
|
+
* message and it is not caught here.
|
|
435
|
+
*
|
|
436
|
+
* RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
|
|
437
|
+
* `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
|
|
438
|
+
* are matched EXACTLY, because their text is Python's own and short: too few `---\n` parts
|
|
439
|
+
* (`ValueError` from the tuple unpack), frontmatter that is not a mapping (the store's own
|
|
440
|
+
* sentence), and a missing key (`KeyError.__str__`, which is the key in single quotes).
|
|
441
|
+
* The fourth — input outside the grammar `parseFrontmatter` accepts — is NOT matched:
|
|
442
|
+
* PyYAML answers with a `ScannerError`/`ParserError` carrying its marks and a rendered
|
|
443
|
+
* snippet, and reproducing that means porting PyYAML's scanner diagnostics, a surface
|
|
444
|
+
* larger than this whole module, for strings no test in `runtime-py/tests` pins. Node
|
|
445
|
+
* emits `parseFrontmatter`'s own reason after the identical prefix. The divergence is
|
|
446
|
+
* carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
|
|
447
|
+
* every run instead of asserted once and forgotten.
|
|
448
|
+
*/
|
|
449
|
+
private facts;
|
|
450
|
+
/**
|
|
451
|
+
* Date the recall.
|
|
452
|
+
*
|
|
453
|
+
* The reference's `snapshot` branch is absent here for the reason given at the top of the
|
|
454
|
+
* file: nothing on the MCP surface enters a snapshot scope, so `_snapshot` is always
|
|
455
|
+
* `None` and only this arm ever runs. No fact is left half-dated either way, because the
|
|
456
|
+
* listing inside `_facts` has already run before any write.
|
|
457
|
+
*/
|
|
458
|
+
private stamp;
|
|
459
|
+
/** Write via `<name>.md.tmp` and `os.replace`, so a reader never sees a half-written fact. */
|
|
460
|
+
private writeFact;
|
|
461
|
+
/**
|
|
462
|
+
* Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
|
|
463
|
+
*
|
|
464
|
+
* Note what is deliberately NOT here: a guard refusing to shrink the index. It could not
|
|
465
|
+
* tell a wipe from a legitimate `compact()`, and it would leave every reader still being
|
|
466
|
+
* lied to. The read was wrong and the read is where it is fixed.
|
|
467
|
+
*/
|
|
468
|
+
private rebuildIndex;
|
|
469
|
+
/**
|
|
470
|
+
* Raise `MemoryBudgetExceeded` if the index would not fit.
|
|
471
|
+
*
|
|
472
|
+
* This is a PARSE, not a listing: `indexText` reads every fact file, so a caller that
|
|
473
|
+
* pre-read with `factPaths` has not pre-read what this raises on. An unreadable store
|
|
474
|
+
* raises `MemoryValidationError` from in here — a DIFFERENT error from the budget one, and
|
|
475
|
+
* `save`'s rollback below is written to re-throw it untouched.
|
|
476
|
+
*/
|
|
477
|
+
private checkIndexBudget;
|
|
478
|
+
}
|