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.
- package/README.md +1 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/dist/docread.d.ts +82 -8
- package/dist/docread.js +348 -96
- package/dist/eventlog.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/pyargs.d.ts +20 -1
- package/dist/mcp/pyargs.js +80 -1
- package/dist/mcp/server.d.ts +36 -6
- package/dist/mcp/server.js +280 -39
- package/dist/memory/cli.js +44 -2
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/factfile.d.ts +13 -1
- package/dist/memory/factfile.js +7 -17
- package/dist/memory/pyfs.d.ts +20 -12
- package/dist/memory/pyfs.js +21 -51
- package/dist/memory/store.d.ts +88 -1
- package/dist/memory/store.js +168 -5
- package/dist/pysem.d.ts +81 -0
- package/dist/pysem.js +126 -0
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/dist/skillaudit.js +31 -38
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/memory/factfile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
*
|
package/dist/memory/pyfs.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
248
|
-
* chicken first because its lead surrogate is
|
|
249
|
-
* 0x1F414 > 0xFF01. `_fact_paths` sorts, `recall`
|
|
250
|
-
* index is written in that order — so a single astral
|
|
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
|
|
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.
|
|
481
|
-
* same predicate
|
|
482
|
-
*
|
|
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
|
|
497
|
+
export { pyRepr };
|
|
490
498
|
/**
|
|
491
499
|
* `repr(float)`.
|
|
492
500
|
*
|
package/dist/memory/pyfs.js
CHANGED
|
@@ -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.
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
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
|
|
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.
|
|
1466
|
-
* same predicate
|
|
1467
|
-
*
|
|
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
|
|
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
|
*
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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`);
|