bantamkit-mcp 0.29.1 → 0.30.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.
@@ -14,6 +14,7 @@
14
14
  * `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
15
15
  */
16
16
  import { PyScalar } from './pyyaml.js';
17
+ import { pyStrip } from '../pysem.js';
17
18
  export declare class FactParseError extends Error {
18
19
  }
19
20
  /**
@@ -38,7 +39,18 @@ export interface Fact {
38
39
  }
39
40
  /** The frontmatter as it comes off disk — exactly the keys the file carries. */
40
41
  export type FactMeta = Record<string, FactValue | FactValue[]>;
41
- export declare function pyStrip(text: string): string;
42
+ /**
43
+ * `str.strip()`, which is NOT `String.prototype.trim()` — `pysem.ts`'s, and no longer a second
44
+ * copy of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
45
+ *
46
+ * Measured over all 0x110000 codepoints: Python strips 29, JS strips 25, and the two sets
47
+ * disagree on six. Python also strips U+001C..U+001F and U+0085; JS also strips U+FEFF.
48
+ * `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six is a byte of
49
+ * difference in a file the store then compares, hashes and diffs. `docread.ts` had derived
50
+ * the same set independently; the two were diffed over every codepoint and 200,000 random
51
+ * strings before they were merged, and the name stays exported from here.
52
+ */
53
+ export { pyStrip };
42
54
  /**
43
55
  * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
44
56
  *
@@ -14,32 +14,22 @@
14
14
  * `tools/conformance/suites/codec.mjs`; the shapes are pinned in `test/codec.test.mjs`.
15
15
  */
16
16
  import { constructPlain, PyScalar, safeDumpMapping } from './pyyaml.js';
17
+ import { pyStrip } from '../pysem.js';
17
18
  export class FactParseError extends Error {
18
19
  }
19
20
  // ------------------------------------------------------------------------ whitespace
20
21
  /**
21
- * `str.strip()`, which is NOT `String.prototype.trim()`.
22
+ * `str.strip()`, which is NOT `String.prototype.trim()` — `pysem.ts`'s, and no longer a second
23
+ * copy of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
22
24
  *
23
25
  * Measured over all 0x110000 codepoints: Python strips 29, JS strips 25, and the two sets
24
26
  * disagree on six. Python also strips U+001C..U+001F and U+0085; JS also strips U+FEFF.
25
27
  * `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six is a byte of
26
- * difference in a file the store then compares, hashes and diffs.
28
+ * difference in a file the store then compares, hashes and diffs. `docread.ts` had derived
29
+ * the same set independently; the two were diffed over every codepoint and 200,000 random
30
+ * strings before they were merged, and the name stays exported from here.
27
31
  */
28
- const PY_SPACE = new Set([
29
- 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000,
30
- 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028,
31
- 0x2029, 0x202f, 0x205f, 0x3000,
32
- ]);
33
- export function pyStrip(text) {
34
- const chars = [...text];
35
- let start = 0;
36
- let end = chars.length;
37
- while (start < end && PY_SPACE.has(chars[start].codePointAt(0)))
38
- start += 1;
39
- while (end > start && PY_SPACE.has(chars[end - 1].codePointAt(0)))
40
- end -= 1;
41
- return chars.slice(start, end).join('');
42
- }
32
+ export { pyStrip };
43
33
  /**
44
34
  * The bytes of a fact file as Python's `Path.read_text(encoding="utf-8")` sees them.
45
35
  *
@@ -1,3 +1,4 @@
1
+ import { cmpCodepoint, pyRepr } from '../pysem.js';
1
2
  /** The errno names this port claims to render in CPython's words. The suite reads it. */
2
3
  export declare const STRERROR_NAMES: readonly string[];
3
4
  /**
@@ -242,15 +243,20 @@ export declare function matchesMd(name: string): boolean;
242
243
  /** `os.path.normcase` — identity off Windows, `s.replace('/', '\\').lower()` on it. */
243
244
  export declare function normcase(name: string): string;
244
245
  /**
245
- * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
246
+ * Python's `<` on `str`, which is CODEPOINT order — `pysem.ts`'s, and no longer a second copy
247
+ * of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
246
248
  *
247
- * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the
248
- * chicken first because its lead surrogate is 0xD83D, and Python puts it last because
249
- * 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall` breaks score ties on the name, and the
250
- * index is written in that order — so a single astral fact name is enough to make two
251
- * `index.md` files that differ.
249
+ * JS compares UTF-16 code units, and the two disagree for every astral character:
250
+ * `['\u{1F414}.md', '!.md'].sort()` puts the chicken first because its lead surrogate is
251
+ * 0xD83D, and Python puts it last because 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
252
+ * breaks score ties on the name, and the index is written in that order — so a single astral
253
+ * fact name is enough to make two `index.md` files that differ.
254
+ *
255
+ * `docread.ts` carried its own, which agreed on the ORDER for every pair and disagreed on the
256
+ * numbers (it returned the codepoint difference, this returns -1/0/1). This spelling is the
257
+ * one that survived, to the digit, because every importer already sees these numbers.
252
258
  */
253
- export declare function cmpCodepoint(a: string, b: string): number;
259
+ export { cmpCodepoint };
254
260
  /**
255
261
  * `sorted(directory / name for name in names)` — sorting `Path`s, not strings.
256
262
  *
@@ -473,20 +479,22 @@ export declare function pyIsFile(path: string): boolean;
473
479
  */
474
480
  export declare function pyStatIsDir(path: string): boolean;
475
481
  /**
476
- * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin.
482
+ * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin — and
483
+ * which `pysem.ts` now spells once (`docs/roadmap-toolbox.md` row 8, entry (n)).
477
484
  *
478
485
  * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
479
486
  * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
480
- * everything in a `C*` category and every separator except the space itself. JS spells the
481
- * same predicate `\p{C}` / `\p{Z}` under the `u` flag, so this is the rule rather than a
482
- * table. The escapes are `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
487
+ * everything in a `C*` category and every separator except the space itself. `docread.ts` had
488
+ * spelled the same predicate as an explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class where this one
489
+ * spelled it `\p{C}|\p{Z}`; the two were compared on all 1,114,112 codepoints and answered
490
+ * byte-identically before they were merged.
483
491
  *
484
492
  * The one place it can drift is a codepoint whose category changed between CPython 3.12's
485
493
  * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
486
494
  * (not printable, escaped) for Python and assigned (printable, raw) here. A pin path made of
487
495
  * brand-new codepoints is the only input that reaches it.
488
496
  */
489
- export declare function pyRepr(value: string): string;
497
+ export { pyRepr };
490
498
  /**
491
499
  * `repr(float)`.
492
500
  *
@@ -55,6 +55,7 @@
55
55
  */
56
56
  import { appendFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
57
57
  import { constants as osConstants, homedir, userInfo } from 'node:os';
58
+ import { cmpCodepoint, pyRepr } from '../pysem.js';
58
59
  // ------------------------------------------------------------------------------ errno
59
60
  /**
60
61
  * `os.strerror(errno)` for the errnos a directory scan, a `mkdir` or a file write can
@@ -750,26 +751,20 @@ export function normcase(name) {
750
751
  }
751
752
  // -------------------------------------------------------------------------- comparison
752
753
  /**
753
- * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
754
- *
755
- * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the
756
- * chicken first because its lead surrogate is 0xD83D, and Python puts it last because
757
- * 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall` breaks score ties on the name, and the
758
- * index is written in that order — so a single astral fact name is enough to make two
759
- * `index.md` files that differ.
754
+ * Python's `<` on `str`, which is CODEPOINT order — `pysem.ts`'s, and no longer a second copy
755
+ * of it (`docs/roadmap-toolbox.md` row 8, entry (n)).
756
+ *
757
+ * JS compares UTF-16 code units, and the two disagree for every astral character:
758
+ * `['\u{1F414}.md', '!.md'].sort()` puts the chicken first because its lead surrogate is
759
+ * 0xD83D, and Python puts it last because 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
760
+ * breaks score ties on the name, and the index is written in that order — so a single astral
761
+ * fact name is enough to make two `index.md` files that differ.
762
+ *
763
+ * `docread.ts` carried its own, which agreed on the ORDER for every pair and disagreed on the
764
+ * numbers (it returned the codepoint difference, this returns -1/0/1). This spelling is the
765
+ * one that survived, to the digit, because every importer already sees these numbers.
760
766
  */
761
- export function cmpCodepoint(a, b) {
762
- const x = [...a];
763
- const y = [...b];
764
- const n = Math.min(x.length, y.length);
765
- for (let i = 0; i < n; i += 1) {
766
- const p = x[i].codePointAt(0);
767
- const q = y[i].codePointAt(0);
768
- if (p !== q)
769
- return p < q ? -1 : 1;
770
- }
771
- return x.length - y.length;
772
- }
767
+ export { cmpCodepoint };
773
768
  /**
774
769
  * `sorted(directory / name for name in names)` — sorting `Path`s, not strings.
775
770
  *
@@ -1458,47 +1453,22 @@ export function pyStatIsDir(path) {
1458
1453
  }
1459
1454
  // --------------------------------------------------------------------------------- repr
1460
1455
  /**
1461
- * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin.
1456
+ * `repr(str)`, which `_pinned_store` interpolates with `{raw!r}` for a relative pin — and
1457
+ * which `pysem.ts` now spells once (`docs/roadmap-toolbox.md` row 8, entry (n)).
1462
1458
  *
1463
1459
  * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
1464
1460
  * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
1465
- * everything in a `C*` category and every separator except the space itself. JS spells the
1466
- * same predicate `\p{C}` / `\p{Z}` under the `u` flag, so this is the rule rather than a
1467
- * table. The escapes are `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
1461
+ * everything in a `C*` category and every separator except the space itself. `docread.ts` had
1462
+ * spelled the same predicate as an explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class where this one
1463
+ * spelled it `\p{C}|\p{Z}`; the two were compared on all 1,114,112 codepoints and answered
1464
+ * byte-identically before they were merged.
1468
1465
  *
1469
1466
  * The one place it can drift is a codepoint whose category changed between CPython 3.12's
1470
1467
  * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
1471
1468
  * (not printable, escaped) for Python and assigned (printable, raw) here. A pin path made of
1472
1469
  * brand-new codepoints is the only input that reaches it.
1473
1470
  */
1474
- export function pyRepr(value) {
1475
- const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
1476
- let out = quote;
1477
- for (const ch of value) {
1478
- if (ch === '\\')
1479
- out += '\\\\';
1480
- else if (ch === quote)
1481
- out += `\\${ch}`;
1482
- else if (ch === '\t')
1483
- out += '\\t';
1484
- else if (ch === '\n')
1485
- out += '\\n';
1486
- else if (ch === '\r')
1487
- out += '\\r';
1488
- else if (ch === ' ' || !/^(?:\p{C}|\p{Z})$/u.test(ch))
1489
- out += ch;
1490
- else {
1491
- const cp = ch.codePointAt(0);
1492
- if (cp < 0x100)
1493
- out += `\\x${cp.toString(16).padStart(2, '0')}`;
1494
- else if (cp < 0x10000)
1495
- out += `\\u${cp.toString(16).padStart(4, '0')}`;
1496
- else
1497
- out += `\\U${cp.toString(16).padStart(8, '0')}`;
1498
- }
1499
- }
1500
- return out + quote;
1501
- }
1471
+ export { pyRepr };
1502
1472
  /**
1503
1473
  * `repr(float)`.
1504
1474
  *
@@ -15,6 +15,7 @@ export declare const VALID_TYPES: readonly ["feedback", "project", "reference",
15
15
  */
16
16
  export declare const DURABLE_TYPES: readonly ["feedback", "user"];
17
17
  export declare const DUPLICATE_JACCARD = 0.5;
18
+ export declare const RECALL_MIN_SCORE_RATIO = 0;
18
19
  /** See the reference's comment: measured against a real store, not chosen. */
19
20
  export declare const DEFAULT_INDEX_BUDGET = 24000;
20
21
  export declare class MemoryValidationError extends BantamError {
@@ -55,6 +56,17 @@ export interface CompactResult {
55
56
  headroom: number;
56
57
  archiveDir: string;
57
58
  }
59
+ /**
60
+ * The store members `dream.ts` needs and no caller outside it should have. See
61
+ * `MemoryStore.internals`.
62
+ */
63
+ export interface StoreInternals {
64
+ facts(): Fact[];
65
+ factPath(name: FactValue): string;
66
+ indexLine(fact: Fact): string;
67
+ writeFact(fact: Fact): void;
68
+ rebuildIndex(): void;
69
+ }
58
70
  export interface MemoryStoreOptions {
59
71
  indexBudget?: number;
60
72
  k?: number;
@@ -186,8 +198,25 @@ export declare class MemoryStore {
186
198
  * `[]` — the same answer as a real miss, which `component.Memory` used to turn into "no
187
199
  * memories matched. Try different words", telling a person to rephrase a question at a
188
200
  * filing cabinet nobody could open.
201
+ *
202
+ * `minRatio` is roadmap #6's precision gate: keep a fact only if its score is at least
203
+ * that fraction of the BEST score in this same recall. The default is
204
+ * `RECALL_MIN_SCORE_RATIO`, which is 0.0 and gates nothing — see the paragraph at that
205
+ * constant for why the number is a no-op today and what has to be measured before it
206
+ * stops being one. The comparison is relative to this store's own best because that is
207
+ * the only quantity in reach here that cancels query length; a layered `Memory` therefore
208
+ * applies the gate once per layer, against each layer's own top hit, and never across
209
+ * layers.
210
+ *
211
+ * THE RANGE CHECK RUNS BEFORE ANY FILE IS READ. A ratio outside `[0.0, 1.0]` is a
212
+ * caller's bug, and reporting it as "your store has no matches" would send someone to
213
+ * look at their memories for a defect that is in the argument. It is spelled
214
+ * `!(minRatio >= 0 && minRatio <= 1)` and NOT `(minRatio < 0 || minRatio > 1)`: every
215
+ * comparison against `NaN` is false, so the second spelling ADMITS `NaN` where the
216
+ * reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
217
+ * it is killed on both sides.
189
218
  */
190
- recall(query: string, k?: number | null, stamp?: boolean): Fact[];
219
+ recall(query: string, k?: number | null, stamp?: boolean, minRatio?: number): Fact[];
191
220
  /**
192
221
  * `len(store._facts())` at the operator CLI, which is the only caller.
193
222
  *
@@ -330,6 +359,49 @@ export declare class MemoryStore {
330
359
  * say, a directory at `archive/<name>.md`, which the guard above stats and refuses first.
331
360
  */
332
361
  archive(name: string): void;
362
+ /**
363
+ * `NAME_RE` ENFORCED BEFORE ANY SYSCALL, matching `archive` (job44, z). `restore` has
364
+ * built `facts/<name>.md` out of whatever it was handed since it was written; measured on
365
+ * macOS before this check, both runtimes: `restore ALPHA` against a live
366
+ * `archive/ALPHA.md` exited 0 and wrote `facts/ALPHA.md` — the case-insensitive
367
+ * filesystem matched the archived source, and nothing asked the store's own naming rule
368
+ * about the destination it was about to create.
369
+ *
370
+ * THE OCCUPIED-DESTINATION GUARD NOW ALSO CATCHES A DANGLING SYMLINK (job44, z). `reachable`
371
+ * is `pyExists`, `Path.exists()`'s equivalent, which FOLLOWS symlinks — so a dangling
372
+ * symlink at `facts/<name>.md` is an occupied directory entry the guard alone cannot see:
373
+ * measured, `pyLexists` true and `pyExists` false, and without the check below the guard
374
+ * passed and execution reached the pre-move `facts()` parse, which then raised a raw
375
+ * `FileNotFoundError` reading the dangling link it was about to parse as a fact. `pyLexists`
376
+ * is only consulted when `reachable` returned `false` WITHOUT raising — an EACCES on the
377
+ * same stat already escaped out of `reachable` first, so this never masks that distinction.
378
+ *
379
+ * TWO SENTENCES, NOT ONE, on job44's reconciliation with the reference. The first two
380
+ * answers tried here were both wrong: treating a dangling link the same as `reachable` said
381
+ * "already live", and a live fact is exactly what a dangling link is NOT; letting it fall
382
+ * through to `facts()`'s pre-read dressed an unrelated `FileNotFoundError` as a move
383
+ * failure when no move had been attempted (both runtimes made one of these two mistakes).
384
+ * The link occupies the path — refusing is still correct — but the REASON is that it
385
+ * cannot be read as a fact, not that a fact is already there, so the second branch below
386
+ * says that instead and never reaches `facts()` at all.
387
+ *
388
+ * THIS IS THE OPPOSITE OUTCOME FROM `archive`'s mirror of the same defect: `archive`'s
389
+ * forward move is `pyReplace`, which overwrites a dangling link identically on every
390
+ * platform, so the guard there is deliberately left as `reachable` alone (see the test
391
+ * pinning that). Closing the guard here removes the ONLY path that could reach an occupied
392
+ * destination, on both runtimes, rather than picking a side of what would happen there.
393
+ *
394
+ * AND THE FORWARD MOVE ITSELF NO LONGER DIVERGES — this sentence used to say it did, and
395
+ * was stale against its own commit. `restore`'s forward move is `pyReplace` on this side
396
+ * and `source.replace(destination)` on the reference (`store.py`, `restore`), changed in
397
+ * the SAME commit that added the guard above; at its parent `f484c70` the reference still
398
+ * called `source.rename(destination)`, which is what this comment described.
399
+ * `d239480`'s `rename`-versus-`pyReplace` difference is therefore REMOVED here, not merely
400
+ * made unreachable, and the same is true of `archive` (fixed by review round 5) and of
401
+ * `compact` (fixed by `d239480` itself). Every forward move in this store is `replace` on
402
+ * both sides; the only `rename` calls left on the reference are the two ROLLBACKS, which
403
+ * move back onto a path the forward move has just emptied — see `docs/porting.md`.
404
+ */
333
405
  restore(name: string): void;
334
406
  /**
335
407
  * The index as it should be on disk, derived from `facts/` and nothing else.
@@ -345,6 +417,21 @@ export declare class MemoryStore {
345
417
  * Registered as a runtime-py defect, not fixed here (invariant 8).
346
418
  */
347
419
  indexText(): string;
420
+ /**
421
+ * The `_`-prefixed members `dream.ts` reaches for, handed out as ONE named seam.
422
+ *
423
+ * The reference's `dream.py` calls `store._facts()`, `_fact_path`, `_index_line`,
424
+ * `_write_fact` and `_rebuild_index` directly — Python's underscore is a convention and
425
+ * costs nothing to cross. TypeScript's `private` is enforced, so the port needs a door,
426
+ * and this is deliberately one door rather than five relaxed modifiers: it is greppable,
427
+ * it names its one caller, and a sixth internal does not become reachable by accident.
428
+ *
429
+ * `_fact_text` has no member here because it never needed splitting out on this side —
430
+ * `formatFact` in `factfile.ts` has always been the serialiser `writeFact` calls, so the
431
+ * bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
432
+ * the code-shape difference the Python half had to manufacture, not a behaviour one.
433
+ */
434
+ internals(): StoreInternals;
348
435
  private factPath;
349
436
  private indexLine;
350
437
  /**
@@ -68,6 +68,55 @@ export const DURABLE_TYPES = ['feedback', 'user'];
68
68
  const NAME_RE = /^[a-z0-9][a-z0-9-]*\n?$/;
69
69
  const NAME_PATTERN = '^[a-z0-9][a-z0-9-]*$';
70
70
  export const DUPLICATE_JACCARD = 0.5;
71
+ // ---- the precision gate (roadmap #6) ---------------------------------------------
72
+ //
73
+ // `recall` keeps a fact whose score is at least `minRatio` of the BEST score in the same
74
+ // recall. WHY THIS NUMBER IS 0.0, and what will replace it:
75
+ //
76
+ // 0.0 IS A DELIBERATE NO-OP, not a tuned value. `score >= 0.0 * best` is true for every
77
+ // fact `recall` was ever going to return — the scoring loop keeps only `score > 0` — so
78
+ // shipping this gate at its default changes not one recall, not one injected header, and
79
+ // not one byte of any reply. That is the whole point. The threshold that would actually
80
+ // cut something has to come from `tools/ledger/injection-precision.mjs`, and on the day
81
+ // that tool shipped (2026-09-06) it REFUSED to report a rate: 491 injection records, 3 of
82
+ // them carrying names+scores+session, 2 distinct sessions, and no control arm, against a
83
+ // floor of 100 joinable injections across 5 sessions. There is no retroactive baseline —
84
+ // the 488 older records carry `hits` and `bytes` and nothing joinable — so a number chosen
85
+ // today would be a number chosen off three rows, shipped as a silent suppressor of memory
86
+ // injection. The mechanism lands now; the number lands when that tool answers instead of
87
+ // refusing.
88
+ //
89
+ // WHY A RATIO AND NOT A COUNT. `score` is an unnormalised intersection size,
90
+ // `len(tokens(name + " " + description) & tokens(query))`, so it scales with how long the
91
+ // QUERY is. Measured on the three instrumented records: the same two-fact shape scored 2
92
+ // and 2 on a 452-character prompt, 4 and 4 on a 453-character prompt, and 22 and 21 on a
93
+ // 7855-character one. An absolute cut of, say, 5 would gate out both short prompts
94
+ // entirely and admit everything on the long one, which is a rule about prompt length
95
+ // wearing a relevance costume. Within ONE recall the query is fixed, so dividing by the
96
+ // best score in that same recall cancels the length term exactly. Jaccard was the other
97
+ // candidate and was rejected for the mirror-image bias: `|a & b| / |a | b|` puts the
98
+ // query's own token count in the denominator, so it would gate out LONG prompts instead.
99
+ //
100
+ // THE GATE IS RELATIVE, SO IT CAN NEVER EMPTY A RECALL. The top hit IS the max, so
101
+ // `best >= minRatio * best` holds for every ratio in range, 1.0 included. It narrows an
102
+ // injection; it never suppresses one, and the COUNT of prompts that get an injection is
103
+ // invariant at every setting.
104
+ //
105
+ // WHAT THE RATIO DOES NOT FIX: `tokens` is `re.findall(r"[a-z0-9]+", text.lower())`,
106
+ // ASCII-only. A wholly non-Latin prompt tokenises to the empty set and scores 0 against
107
+ // every fact, so it never reaches this gate at all — it is already an empty recall. This
108
+ // store's operator writes Thai; a threshold tuned on English prompts would be tuned on a
109
+ // population that structurally excludes theirs.
110
+ //
111
+ // The number lives in TWO places, one per runtime, and they must hold the SAME float:
112
+ // `runtime-py/src/bantamkit/memory/store.py`'s `RECALL_MIN_SCORE_RATIO` is the reference.
113
+ // `Memory.recallOutcome` and the `UserPromptSubmit` hook that calls it inherit it rather
114
+ // than restating it.
115
+ export const RECALL_MIN_SCORE_RATIO = 0.0;
116
+ // Spelled once because both runtimes raise it verbatim. The offending value is NOT
117
+ // interpolated: Python renders `2.0` as `2.0` and JavaScript renders it as `2`, so a
118
+ // sentence carrying the number would be a divergence manufactured by float formatting.
119
+ const MIN_RATIO_RANGE = 'recall min-score ratio must be between 0.0 and 1.0';
71
120
  /** See the reference's comment: measured against a real store, not chosen. */
72
121
  export const DEFAULT_INDEX_BUDGET = 24_000;
73
122
  // The second half of the "unreadable" sentence, one per directory this store lists — and
@@ -494,11 +543,30 @@ export class MemoryStore {
494
543
  * `[]` — the same answer as a real miss, which `component.Memory` used to turn into "no
495
544
  * memories matched. Try different words", telling a person to rephrase a question at a
496
545
  * filing cabinet nobody could open.
546
+ *
547
+ * `minRatio` is roadmap #6's precision gate: keep a fact only if its score is at least
548
+ * that fraction of the BEST score in this same recall. The default is
549
+ * `RECALL_MIN_SCORE_RATIO`, which is 0.0 and gates nothing — see the paragraph at that
550
+ * constant for why the number is a no-op today and what has to be measured before it
551
+ * stops being one. The comparison is relative to this store's own best because that is
552
+ * the only quantity in reach here that cancels query length; a layered `Memory` therefore
553
+ * applies the gate once per layer, against each layer's own top hit, and never across
554
+ * layers.
555
+ *
556
+ * THE RANGE CHECK RUNS BEFORE ANY FILE IS READ. A ratio outside `[0.0, 1.0]` is a
557
+ * caller's bug, and reporting it as "your store has no matches" would send someone to
558
+ * look at their memories for a defect that is in the argument. It is spelled
559
+ * `!(minRatio >= 0 && minRatio <= 1)` and NOT `(minRatio < 0 || minRatio > 1)`: every
560
+ * comparison against `NaN` is false, so the second spelling ADMITS `NaN` where the
561
+ * reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
562
+ * it is killed on both sides.
497
563
  */
498
- recall(query, k = null, stamp = true) {
564
+ recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO) {
565
+ if (!(minRatio >= 0 && minRatio <= 1))
566
+ throw new MemoryValidationError(MIN_RATIO_RANGE);
499
567
  const limit = k ?? this.k;
500
568
  const q = tokens(query);
501
- const scored = [];
569
+ let scored = [];
502
570
  for (const fact of this.facts()) {
503
571
  let score = 0;
504
572
  for (const t of tokens(`${pyText(fact.name)} ${pyText(fact.description)}`))
@@ -507,6 +575,20 @@ export class MemoryStore {
507
575
  if (score > 0)
508
576
  scored.push({ score, name: fact.name, fact });
509
577
  }
578
+ if (scored.length > 0) {
579
+ // No `if (minRatio > 0)` shortcut on purpose: at 0.0 this line still runs and still
580
+ // keeps everything, so "the default gates nothing" is a fact about the arithmetic
581
+ // rather than about a branch that could be edited away. `Math.max(...scored)` is NOT
582
+ // used: a spread is one argument per fact and a large store would overflow the
583
+ // argument limit, which is a crash the reference's `max(...)` over a generator does
584
+ // not have.
585
+ let best = scored[0].score;
586
+ for (const s of scored)
587
+ if (s.score > best)
588
+ best = s.score;
589
+ const floor = minRatio * best;
590
+ scored = scored.filter((s) => s.score >= floor);
591
+ }
510
592
  // `key=lambda pair: (-pair[0], pair[1].name)`. The name comparison is CODEPOINT order
511
593
  // for two strings — which is not what `Array.prototype.sort` does — and Python's `<`
512
594
  // for everything else, refusals included. See `sortScored`.
@@ -742,28 +824,86 @@ export class MemoryStore {
742
824
  throw error;
743
825
  }
744
826
  }
827
+ /**
828
+ * `NAME_RE` ENFORCED BEFORE ANY SYSCALL, matching `archive` (job44, z). `restore` has
829
+ * built `facts/<name>.md` out of whatever it was handed since it was written; measured on
830
+ * macOS before this check, both runtimes: `restore ALPHA` against a live
831
+ * `archive/ALPHA.md` exited 0 and wrote `facts/ALPHA.md` — the case-insensitive
832
+ * filesystem matched the archived source, and nothing asked the store's own naming rule
833
+ * about the destination it was about to create.
834
+ *
835
+ * THE OCCUPIED-DESTINATION GUARD NOW ALSO CATCHES A DANGLING SYMLINK (job44, z). `reachable`
836
+ * is `pyExists`, `Path.exists()`'s equivalent, which FOLLOWS symlinks — so a dangling
837
+ * symlink at `facts/<name>.md` is an occupied directory entry the guard alone cannot see:
838
+ * measured, `pyLexists` true and `pyExists` false, and without the check below the guard
839
+ * passed and execution reached the pre-move `facts()` parse, which then raised a raw
840
+ * `FileNotFoundError` reading the dangling link it was about to parse as a fact. `pyLexists`
841
+ * is only consulted when `reachable` returned `false` WITHOUT raising — an EACCES on the
842
+ * same stat already escaped out of `reachable` first, so this never masks that distinction.
843
+ *
844
+ * TWO SENTENCES, NOT ONE, on job44's reconciliation with the reference. The first two
845
+ * answers tried here were both wrong: treating a dangling link the same as `reachable` said
846
+ * "already live", and a live fact is exactly what a dangling link is NOT; letting it fall
847
+ * through to `facts()`'s pre-read dressed an unrelated `FileNotFoundError` as a move
848
+ * failure when no move had been attempted (both runtimes made one of these two mistakes).
849
+ * The link occupies the path — refusing is still correct — but the REASON is that it
850
+ * cannot be read as a fact, not that a fact is already there, so the second branch below
851
+ * says that instead and never reaches `facts()` at all.
852
+ *
853
+ * THIS IS THE OPPOSITE OUTCOME FROM `archive`'s mirror of the same defect: `archive`'s
854
+ * forward move is `pyReplace`, which overwrites a dangling link identically on every
855
+ * platform, so the guard there is deliberately left as `reachable` alone (see the test
856
+ * pinning that). Closing the guard here removes the ONLY path that could reach an occupied
857
+ * destination, on both runtimes, rather than picking a side of what would happen there.
858
+ *
859
+ * AND THE FORWARD MOVE ITSELF NO LONGER DIVERGES — this sentence used to say it did, and
860
+ * was stale against its own commit. `restore`'s forward move is `pyReplace` on this side
861
+ * and `source.replace(destination)` on the reference (`store.py`, `restore`), changed in
862
+ * the SAME commit that added the guard above; at its parent `f484c70` the reference still
863
+ * called `source.rename(destination)`, which is what this comment described.
864
+ * `d239480`'s `rename`-versus-`pyReplace` difference is therefore REMOVED here, not merely
865
+ * made unreachable, and the same is true of `archive` (fixed by review round 5) and of
866
+ * `compact` (fixed by `d239480` itself). Every forward move in this store is `replace` on
867
+ * both sides; the only `rename` calls left on the reference are the two ROLLBACKS, which
868
+ * move back onto a path the forward move has just emptied — see `docs/porting.md`.
869
+ */
745
870
  restore(name) {
871
+ if (!NAME_RE.test(name || '')) {
872
+ throw new MemoryValidationError(`invalid name '${name}'; must match ${NAME_PATTERN}`);
873
+ }
746
874
  const archive = pyJoin(this.root, 'archive');
747
875
  const source = pyJoin(archive, `${name}.md`);
748
876
  if (!this.reachable(source, archive, ARCHIVE_UNREACHABLE)) {
749
877
  throw new MemoryValidationError(`no archived fact '${name}' under ${archive}`);
750
878
  }
751
879
  const destination = this.factPath(name);
752
- if (this.reachable(destination, pyJoin(this.root, 'facts'), FACTS_UNREACHABLE)) {
880
+ const facts = pyJoin(this.root, 'facts');
881
+ if (this.reachable(destination, facts, FACTS_UNREACHABLE)) {
753
882
  throw new MemoryValidationError(`fact '${name}' is already live; refusing to overwrite it from archive`);
754
883
  }
884
+ if (pyLexists(destination)) {
885
+ throw new MemoryValidationError(`facts/${name}.md already exists but cannot be read as a fact; refusing to restore over it`);
886
+ }
755
887
  this.facts(); // parse BEFORE the move, not after it
756
- pyMkdirParents(pyJoin(this.root, 'facts'));
888
+ pyMkdirParents(facts);
757
889
  pyReplace(source, destination);
890
+ // THE FINAL `rebuildIndex()` IS INSIDE THIS TRY, and job44's reconciliation is why: it
891
+ // used to sit AFTER this block, uncovered, so a failure there — measured by driving the
892
+ // real fault (`index.md` a directory) rather than reading the code shape — left the fact
893
+ // STUCK in `facts/`, GONE from `archive/`, with no rollback attempted at all, because
894
+ // `checkIndexBudget` never touches disk and passes cleanly first. `archive`'s single try
895
+ // already covered its own write-then-check in one block; this brings restore's rollback
896
+ // up to the same coverage `runtime-py` gave `_check_index_budget` and the closing
897
+ // `_rebuild_index` together, in the same reconciliation.
758
898
  try {
759
899
  this.checkIndexBudget();
900
+ this.rebuildIndex();
760
901
  }
761
902
  catch (error) {
762
903
  pyReplace(destination, source);
763
904
  this.rebuildIndex();
764
905
  throw error;
765
906
  }
766
- this.rebuildIndex();
767
907
  }
768
908
  /**
769
909
  * The index as it should be on disk, derived from `facts/` and nothing else.
@@ -781,6 +921,29 @@ export class MemoryStore {
781
921
  indexText() {
782
922
  return this.facts().map((fact) => this.indexLine(fact)).join('');
783
923
  }
924
+ /**
925
+ * The `_`-prefixed members `dream.ts` reaches for, handed out as ONE named seam.
926
+ *
927
+ * The reference's `dream.py` calls `store._facts()`, `_fact_path`, `_index_line`,
928
+ * `_write_fact` and `_rebuild_index` directly — Python's underscore is a convention and
929
+ * costs nothing to cross. TypeScript's `private` is enforced, so the port needs a door,
930
+ * and this is deliberately one door rather than five relaxed modifiers: it is greppable,
931
+ * it names its one caller, and a sixth internal does not become reachable by accident.
932
+ *
933
+ * `_fact_text` has no member here because it never needed splitting out on this side —
934
+ * `formatFact` in `factfile.ts` has always been the serialiser `writeFact` calls, so the
935
+ * bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
936
+ * the code-shape difference the Python half had to manufacture, not a behaviour one.
937
+ */
938
+ internals() {
939
+ return {
940
+ facts: () => this.facts(),
941
+ factPath: (name) => this.factPath(name),
942
+ indexLine: (fact) => this.indexLine(fact),
943
+ writeFact: (fact) => this.writeFact(fact),
944
+ rebuildIndex: () => this.rebuildIndex(),
945
+ };
946
+ }
784
947
  // ---- internals ----
785
948
  factPath(name) {
786
949
  return pyJoin(this.root, 'facts', `${pyText(name)}.md`);