bantamkit-mcp 0.29.0 → 0.29.2

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.
@@ -742,28 +742,86 @@ export class MemoryStore {
742
742
  throw error;
743
743
  }
744
744
  }
745
+ /**
746
+ * `NAME_RE` ENFORCED BEFORE ANY SYSCALL, matching `archive` (job44, z). `restore` has
747
+ * built `facts/<name>.md` out of whatever it was handed since it was written; measured on
748
+ * macOS before this check, both runtimes: `restore ALPHA` against a live
749
+ * `archive/ALPHA.md` exited 0 and wrote `facts/ALPHA.md` — the case-insensitive
750
+ * filesystem matched the archived source, and nothing asked the store's own naming rule
751
+ * about the destination it was about to create.
752
+ *
753
+ * THE OCCUPIED-DESTINATION GUARD NOW ALSO CATCHES A DANGLING SYMLINK (job44, z). `reachable`
754
+ * is `pyExists`, `Path.exists()`'s equivalent, which FOLLOWS symlinks — so a dangling
755
+ * symlink at `facts/<name>.md` is an occupied directory entry the guard alone cannot see:
756
+ * measured, `pyLexists` true and `pyExists` false, and without the check below the guard
757
+ * passed and execution reached the pre-move `facts()` parse, which then raised a raw
758
+ * `FileNotFoundError` reading the dangling link it was about to parse as a fact. `pyLexists`
759
+ * is only consulted when `reachable` returned `false` WITHOUT raising — an EACCES on the
760
+ * same stat already escaped out of `reachable` first, so this never masks that distinction.
761
+ *
762
+ * TWO SENTENCES, NOT ONE, on job44's reconciliation with the reference. The first two
763
+ * answers tried here were both wrong: treating a dangling link the same as `reachable` said
764
+ * "already live", and a live fact is exactly what a dangling link is NOT; letting it fall
765
+ * through to `facts()`'s pre-read dressed an unrelated `FileNotFoundError` as a move
766
+ * failure when no move had been attempted (both runtimes made one of these two mistakes).
767
+ * The link occupies the path — refusing is still correct — but the REASON is that it
768
+ * cannot be read as a fact, not that a fact is already there, so the second branch below
769
+ * says that instead and never reaches `facts()` at all.
770
+ *
771
+ * THIS IS THE OPPOSITE OUTCOME FROM `archive`'s mirror of the same defect: `archive`'s
772
+ * forward move is `pyReplace`, which overwrites a dangling link identically on every
773
+ * platform, so the guard there is deliberately left as `reachable` alone (see the test
774
+ * pinning that). Closing the guard here removes the ONLY path that could reach an occupied
775
+ * destination, on both runtimes, rather than picking a side of what would happen there.
776
+ *
777
+ * AND THE FORWARD MOVE ITSELF NO LONGER DIVERGES — this sentence used to say it did, and
778
+ * was stale against its own commit. `restore`'s forward move is `pyReplace` on this side
779
+ * and `source.replace(destination)` on the reference (`store.py`, `restore`), changed in
780
+ * the SAME commit that added the guard above; at its parent `f484c70` the reference still
781
+ * called `source.rename(destination)`, which is what this comment described.
782
+ * `d239480`'s `rename`-versus-`pyReplace` difference is therefore REMOVED here, not merely
783
+ * made unreachable, and the same is true of `archive` (fixed by review round 5) and of
784
+ * `compact` (fixed by `d239480` itself). Every forward move in this store is `replace` on
785
+ * both sides; the only `rename` calls left on the reference are the two ROLLBACKS, which
786
+ * move back onto a path the forward move has just emptied — see `docs/porting.md`.
787
+ */
745
788
  restore(name) {
789
+ if (!NAME_RE.test(name || '')) {
790
+ throw new MemoryValidationError(`invalid name '${name}'; must match ${NAME_PATTERN}`);
791
+ }
746
792
  const archive = pyJoin(this.root, 'archive');
747
793
  const source = pyJoin(archive, `${name}.md`);
748
794
  if (!this.reachable(source, archive, ARCHIVE_UNREACHABLE)) {
749
795
  throw new MemoryValidationError(`no archived fact '${name}' under ${archive}`);
750
796
  }
751
797
  const destination = this.factPath(name);
752
- if (this.reachable(destination, pyJoin(this.root, 'facts'), FACTS_UNREACHABLE)) {
798
+ const facts = pyJoin(this.root, 'facts');
799
+ if (this.reachable(destination, facts, FACTS_UNREACHABLE)) {
753
800
  throw new MemoryValidationError(`fact '${name}' is already live; refusing to overwrite it from archive`);
754
801
  }
802
+ if (pyLexists(destination)) {
803
+ throw new MemoryValidationError(`facts/${name}.md already exists but cannot be read as a fact; refusing to restore over it`);
804
+ }
755
805
  this.facts(); // parse BEFORE the move, not after it
756
- pyMkdirParents(pyJoin(this.root, 'facts'));
806
+ pyMkdirParents(facts);
757
807
  pyReplace(source, destination);
808
+ // THE FINAL `rebuildIndex()` IS INSIDE THIS TRY, and job44's reconciliation is why: it
809
+ // used to sit AFTER this block, uncovered, so a failure there — measured by driving the
810
+ // real fault (`index.md` a directory) rather than reading the code shape — left the fact
811
+ // STUCK in `facts/`, GONE from `archive/`, with no rollback attempted at all, because
812
+ // `checkIndexBudget` never touches disk and passes cleanly first. `archive`'s single try
813
+ // already covered its own write-then-check in one block; this brings restore's rollback
814
+ // up to the same coverage `runtime-py` gave `_check_index_budget` and the closing
815
+ // `_rebuild_index` together, in the same reconciliation.
758
816
  try {
759
817
  this.checkIndexBudget();
818
+ this.rebuildIndex();
760
819
  }
761
820
  catch (error) {
762
821
  pyReplace(destination, source);
763
822
  this.rebuildIndex();
764
823
  throw error;
765
824
  }
766
- this.rebuildIndex();
767
825
  }
768
826
  /**
769
827
  * The index as it should be on disk, derived from `facts/` and nothing else.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * CPython string semantics this runtime needs in more than one place, written ONCE.
3
+ *
4
+ * `docs/roadmap-toolbox.md` row 8, entry (n): `pyStrip`, `cmpCodepoint` and `pyRepr` had each
5
+ * been ported twice — once in `docread.ts` and once under `memory/` — as independent
6
+ * re-derivations of the same three CPython behaviours. Two implementations of one semantic are
7
+ * two chances to be right and one certainty of drifting, and neither copy had a test that
8
+ * compared it to the other.
9
+ *
10
+ * **The pairs were diffed before they were merged**, because unifying two functions that are
11
+ * not the same function is a behaviour change wearing a refactor's clothes:
12
+ *
13
+ * * `pyStrip` — the two agreed on the set of characters `str.isspace()` is true for (29 each,
14
+ * with an empty symmetric difference over all 1,114,112 codepoints) and on 200,000 random
15
+ * strings built from that alphabet. Kept: the REGEX spelling, because the set-based one
16
+ * spreads the whole string into an array of characters first, and this is called once per
17
+ * rendered row.
18
+ * * `pyRepr` — byte-identical output on every one of the 1,114,112 codepoints and on a pool of
19
+ * quoting edge cases. `docread`'s explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class and `memory`'s
20
+ * `\p{C}|\p{Z}` are the same set spelled two ways. Kept: the precompiled class.
21
+ * * `cmpCodepoint` — the same ORDER on every pair (0 sign disagreements), and different
22
+ * NUMBERS: `docread`'s returned the codepoint difference and the `memory` copy returns
23
+ * -1/0/1, so `cmp('', 'ab')` was -1 there and -2 here. Kept: the `memory` spelling to the
24
+ * digit, because every importer outside `docread.ts` already sees those numbers, while
25
+ * `docread`'s copy had no importer at all and one use — `Array.prototype.sort`, which reads
26
+ * the sign and nothing else.
27
+ *
28
+ * **A THIRD COPY SURVIVED THAT CLOSURE AND WAS FOLDED IN ON 2026-09-06 (job44, unit F4).**
29
+ * "written ONCE" was not true when it was written: entry (n) named `docread.ts` and `memory/`
30
+ * and nothing else, so `skillaudit.ts` — product surface, the eleventh MCP tool — kept its own
31
+ * `pyStrip` on a 29-codepoint Set and its own `cmpCodepoint` in the `cx - cy` spelling, the two
32
+ * this module had just rejected. Both now import from here, gated first by the same method:
33
+ * an empty symmetric difference between the two whitespace sets over every codepoint, 0
34
+ * disagreements on `pyStrip` over 200,000 random strings, 0 disagreements on a
35
+ * `` `^${PY_WS_CLASS}` `` regex against that module's Set-membership `startsWithSpace` over
36
+ * every codepoint, and — the reason the gate is not optional — `cmpCodepoint` agreeing on SIGN
37
+ * in all of 324 exhaustive plus 200,000 random pairs while disagreeing on the NUMBER in
38
+ * 167,851 of them. `skillaudit.ts`'s six uses read the sign alone, which is what made the
39
+ * substitution safe; its header carries the reading and the caveat.
40
+ *
41
+ * Nothing here is re-exported from `index.ts`: the names stay exported from the modules that
42
+ * always exported them, so this is a delegation and not a surface change.
43
+ */
44
+ /**
45
+ * The characters `str.isspace()` is true for — what `strip()` and `split()` consume.
46
+ *
47
+ * NOT `String.prototype.trim()`'s set. Measured over all 0x110000 codepoints: Python strips 29,
48
+ * JS strips 25, and the two disagree on six — Python also strips U+001C..U+001F and U+0085, JS
49
+ * also strips U+FEFF. `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six
50
+ * is a byte of difference in a file the memory store then compares, hashes and diffs; in
51
+ * `docread` the same six decide whether a row is blank.
52
+ */
53
+ export declare const PY_WS = "\t\n\v\f\r\u001C\u001D\u001E\u001F \u0085\u00A0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000";
54
+ /** `PY_WS` as a character class, with the four characters a class gives meaning escaped. */
55
+ export declare const PY_WS_CLASS: string;
56
+ /** `str.strip()` with no argument. */
57
+ export declare function pyStrip(s: string): string;
58
+ /**
59
+ * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
60
+ *
61
+ * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the chicken
62
+ * first because its lead surrogate is 0xD83D, and Python puts it last because 0x1F414 > 0xFF01.
63
+ * `_fact_paths` sorts, `recall` breaks score ties on the name, and the index is written in that
64
+ * order — so a single astral fact name is enough to make two `index.md` files that differ. In
65
+ * `docread` the same order decides which omission reason is rendered first.
66
+ */
67
+ export declare function cmpCodepoint(a: string, b: string): number;
68
+ /**
69
+ * `repr(str)` — the refusal sentences' quoting, and `_pinned_store`'s `{raw!r}`.
70
+ *
71
+ * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
72
+ * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
73
+ * everything in a `C*` category and every separator except the space itself. The escapes are
74
+ * `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
75
+ *
76
+ * The one place it can drift is a codepoint whose category changed between CPython 3.12's
77
+ * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
78
+ * (not printable, escaped) for Python and assigned (printable, raw) here. A path made of
79
+ * brand-new codepoints is the only input that reaches it.
80
+ */
81
+ export declare function pyRepr(s: string): string;
package/dist/pysem.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * CPython string semantics this runtime needs in more than one place, written ONCE.
3
+ *
4
+ * `docs/roadmap-toolbox.md` row 8, entry (n): `pyStrip`, `cmpCodepoint` and `pyRepr` had each
5
+ * been ported twice — once in `docread.ts` and once under `memory/` — as independent
6
+ * re-derivations of the same three CPython behaviours. Two implementations of one semantic are
7
+ * two chances to be right and one certainty of drifting, and neither copy had a test that
8
+ * compared it to the other.
9
+ *
10
+ * **The pairs were diffed before they were merged**, because unifying two functions that are
11
+ * not the same function is a behaviour change wearing a refactor's clothes:
12
+ *
13
+ * * `pyStrip` — the two agreed on the set of characters `str.isspace()` is true for (29 each,
14
+ * with an empty symmetric difference over all 1,114,112 codepoints) and on 200,000 random
15
+ * strings built from that alphabet. Kept: the REGEX spelling, because the set-based one
16
+ * spreads the whole string into an array of characters first, and this is called once per
17
+ * rendered row.
18
+ * * `pyRepr` — byte-identical output on every one of the 1,114,112 codepoints and on a pool of
19
+ * quoting edge cases. `docread`'s explicit `Cc|Cf|Cs|Co|Cn|Zl|Zp|Zs` class and `memory`'s
20
+ * `\p{C}|\p{Z}` are the same set spelled two ways. Kept: the precompiled class.
21
+ * * `cmpCodepoint` — the same ORDER on every pair (0 sign disagreements), and different
22
+ * NUMBERS: `docread`'s returned the codepoint difference and the `memory` copy returns
23
+ * -1/0/1, so `cmp('', 'ab')` was -1 there and -2 here. Kept: the `memory` spelling to the
24
+ * digit, because every importer outside `docread.ts` already sees those numbers, while
25
+ * `docread`'s copy had no importer at all and one use — `Array.prototype.sort`, which reads
26
+ * the sign and nothing else.
27
+ *
28
+ * **A THIRD COPY SURVIVED THAT CLOSURE AND WAS FOLDED IN ON 2026-09-06 (job44, unit F4).**
29
+ * "written ONCE" was not true when it was written: entry (n) named `docread.ts` and `memory/`
30
+ * and nothing else, so `skillaudit.ts` — product surface, the eleventh MCP tool — kept its own
31
+ * `pyStrip` on a 29-codepoint Set and its own `cmpCodepoint` in the `cx - cy` spelling, the two
32
+ * this module had just rejected. Both now import from here, gated first by the same method:
33
+ * an empty symmetric difference between the two whitespace sets over every codepoint, 0
34
+ * disagreements on `pyStrip` over 200,000 random strings, 0 disagreements on a
35
+ * `` `^${PY_WS_CLASS}` `` regex against that module's Set-membership `startsWithSpace` over
36
+ * every codepoint, and — the reason the gate is not optional — `cmpCodepoint` agreeing on SIGN
37
+ * in all of 324 exhaustive plus 200,000 random pairs while disagreeing on the NUMBER in
38
+ * 167,851 of them. `skillaudit.ts`'s six uses read the sign alone, which is what made the
39
+ * substitution safe; its header carries the reading and the caveat.
40
+ *
41
+ * Nothing here is re-exported from `index.ts`: the names stay exported from the modules that
42
+ * always exported them, so this is a delegation and not a surface change.
43
+ */
44
+ // ------------------------------------------------------------------------- str.strip()
45
+ /**
46
+ * The characters `str.isspace()` is true for — what `strip()` and `split()` consume.
47
+ *
48
+ * NOT `String.prototype.trim()`'s set. Measured over all 0x110000 codepoints: Python strips 29,
49
+ * JS strips 25, and the two disagree on six — Python also strips U+001C..U+001F and U+0085, JS
50
+ * also strips U+FEFF. `_write_fact` ends with `fact.body.strip() + "\n"`, so each of those six
51
+ * is a byte of difference in a file the memory store then compares, hashes and diffs; in
52
+ * `docread` the same six decide whether a row is blank.
53
+ */
54
+ export const PY_WS = '\t\n\x0b\x0c\r\x1c\x1d\x1e\x1f \x85\xa0            

   ';
55
+ /** `PY_WS` as a character class, with the four characters a class gives meaning escaped. */
56
+ export const PY_WS_CLASS = `[${PY_WS.replace(/[\]\\^-]/g, '\\$&')}]`;
57
+ const PY_STRIP = new RegExp(`^${PY_WS_CLASS}+|${PY_WS_CLASS}+$`, 'g');
58
+ /** `str.strip()` with no argument. */
59
+ export function pyStrip(s) {
60
+ return s.replace(PY_STRIP, '');
61
+ }
62
+ // ------------------------------------------------------------------------ sorted() on str
63
+ /**
64
+ * Python's `<` on `str`, which is CODEPOINT order. JS compares UTF-16 code units.
65
+ *
66
+ * They disagree for every astral character: `['\u{1F414}.md', '!.md'].sort()` puts the chicken
67
+ * first because its lead surrogate is 0xD83D, and Python puts it last because 0x1F414 > 0xFF01.
68
+ * `_fact_paths` sorts, `recall` breaks score ties on the name, and the index is written in that
69
+ * order — so a single astral fact name is enough to make two `index.md` files that differ. In
70
+ * `docread` the same order decides which omission reason is rendered first.
71
+ */
72
+ export function cmpCodepoint(a, b) {
73
+ const x = [...a];
74
+ const y = [...b];
75
+ const n = Math.min(x.length, y.length);
76
+ for (let i = 0; i < n; i += 1) {
77
+ const p = x[i].codePointAt(0);
78
+ const q = y[i].codePointAt(0);
79
+ if (p !== q)
80
+ return p < q ? -1 : 1;
81
+ }
82
+ return x.length - y.length;
83
+ }
84
+ // ---------------------------------------------------------------------------- repr(str)
85
+ // `str.isprintable()` is false for these categories (space itself excepted). Precompiled: the
86
+ // refusal sentences build one of these per character of a path.
87
+ const NONPRINTABLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/u;
88
+ /**
89
+ * `repr(str)` — the refusal sentences' quoting, and `_pinned_store`'s `{raw!r}`.
90
+ *
91
+ * Python quotes with `'` unless the value contains a `'` and no `"`; it escapes `\\`, the
92
+ * quote, `\t`, `\n`, `\r`, and every codepoint that is not `str.isprintable()` — that is,
93
+ * everything in a `C*` category and every separator except the space itself. The escapes are
94
+ * `\xNN` below U+0100, `\uNNNN` below U+10000, `\UNNNNNNNN` above.
95
+ *
96
+ * The one place it can drift is a codepoint whose category changed between CPython 3.12's
97
+ * Unicode 15.0 and the ICU this Node was built against: a newly assigned character is `Cn`
98
+ * (not printable, escaped) for Python and assigned (printable, raw) here. A path made of
99
+ * brand-new codepoints is the only input that reaches it.
100
+ */
101
+ export function pyRepr(s) {
102
+ const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
103
+ let out = quote;
104
+ for (const ch of s) {
105
+ const code = ch.codePointAt(0);
106
+ if (ch === quote || ch === '\\')
107
+ out += '\\' + ch;
108
+ else if (ch === '\t')
109
+ out += '\\t';
110
+ else if (ch === '\n')
111
+ out += '\\n';
112
+ else if (ch === '\r')
113
+ out += '\\r';
114
+ else if (ch !== ' ' && NONPRINTABLE.test(ch)) {
115
+ if (code < 0x100)
116
+ out += '\\x' + code.toString(16).padStart(2, '0');
117
+ else if (code < 0x10000)
118
+ out += '\\u' + code.toString(16).padStart(4, '0');
119
+ else
120
+ out += '\\U' + code.toString(16).padStart(8, '0');
121
+ }
122
+ else
123
+ out += ch;
124
+ }
125
+ return out + quote;
126
+ }
@@ -72,6 +72,7 @@
72
72
  import { readdirSync, readFileSync, statSync } from 'node:fs';
73
73
  import { basename, dirname, join } from 'node:path';
74
74
  import { BantamError } from './errors.js';
75
+ import { cmpCodepoint, PY_WS_CLASS, pyStrip } from './pysem.js';
75
76
  import { reprValue } from './pyjson.js';
76
77
  // The file a skill IS. Case-sensitive: the host loads this name and so does this reader.
77
78
  export const SKILL_FILE = 'SKILL.md';
@@ -168,47 +169,39 @@ export class SkillAuditError extends BantamError {
168
169
  }
169
170
  // -------------------------------------------------------------- Python string semantics
170
171
  /**
171
- * `str.strip()`, which is NOT `String.prototype.trim()` — measured, they disagree on six
172
- * code points (`memory/factfile.ts`). A frontmatter line's indent is stripped with this.
172
+ * `str.strip()` and `sorted()`'s order are `pysem.ts`'s, and no longer a third copy.
173
+ *
174
+ * This module carried its own `pyStrip` (a `PY_SPACE` Set of 29 codepoints, spread over
175
+ * `[...text]`) and its own `cmpCodepoint` (paired iterators returning `cx - cy`) —
176
+ * independently re-derived, and the two spellings `pysem.ts` explicitly REJECTED when it
177
+ * unified `docread.ts`'s and `memory/`'s copies for `docs/roadmap-toolbox.md` row 8, entry
178
+ * (n). That entry named only those two homes, so this third copy survived its closure while
179
+ * `pysem.ts`'s header claimed the semantics were written ONCE.
180
+ *
181
+ * **The pairs were gated before they were merged** (2026-09-06, job44 unit F4, the method U2
182
+ * used, against `dist/pysem.js` with this module's two functions transcribed beside it):
183
+ *
184
+ * * the SET — 29 codepoints on each side, and an EMPTY symmetric difference walking every
185
+ * codepoint from 0 to 0x10FFFF, surrogates skipped;
186
+ * * `pyStrip` — 0 disagreements over 200,000 random strings built from the union alphabet
187
+ * plus `a Z 0 U+1F414 U+FEFF U+200B U+FF1A` as non-space padding (the last three are the
188
+ * characters `String.prototype.trim()` and `str.strip()` argue about);
189
+ * * `startsWithSpace` — 0 disagreements against `` new RegExp(`^${PY_WS_CLASS}`) `` over
190
+ * eight hand-picked shapes AND over `String.fromCodePoint(cp) + 'x'` for every codepoint,
191
+ * which is what lets the Set membership test below become a regex test;
192
+ * * `cmpCodepoint` — **0 sign disagreements** over 324 exhaustive pairs and 200,000 random
193
+ * astral-bearing pairs, and **167,851 NUMBER disagreements** in the same run, the first
194
+ * being `cmp('', 'ab')`: `-1` in the spelling deleted here, `-2` in `pysem.ts`'s. Same
195
+ * order, different function. Safe only because every one of this module's six uses reads
196
+ * the sign alone — `cmpParts` (`!== 0`, and itself only ever a `.sort` comparator), the
197
+ * `> 0` version pick, and four bare `.sort()` calls — so the numbers were never observable
198
+ * from here. A future use that subtracts or scales one of these values is NOT covered by
199
+ * that argument and would have to re-establish it.
173
200
  */
174
- const PY_SPACE = new Set([
175
- 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000,
176
- 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028,
177
- 0x2029, 0x202f, 0x205f, 0x3000,
178
- ]);
179
- function pyStrip(text) {
180
- const chars = [...text];
181
- let start = 0;
182
- let end = chars.length;
183
- while (start < end && PY_SPACE.has(chars[start].codePointAt(0)))
184
- start += 1;
185
- while (end > start && PY_SPACE.has(chars[end - 1].codePointAt(0)))
186
- end -= 1;
187
- return chars.slice(start, end).join('');
188
- }
189
201
  /** `line[:1].isspace()`: whether the line is a FOLD rather than a key. */
202
+ const STARTS_WITH_PY_SPACE = new RegExp(`^${PY_WS_CLASS}`);
190
203
  function startsWithSpace(line) {
191
- const first = line.codePointAt(0);
192
- return first !== undefined && PY_SPACE.has(first);
193
- }
194
- /** `sorted()` on strings: by code point, which is not what `Array.sort` does above U+FFFF. */
195
- function cmpCodepoint(a, b) {
196
- const ia = a[Symbol.iterator]();
197
- const ib = b[Symbol.iterator]();
198
- for (;;) {
199
- const x = ia.next();
200
- const y = ib.next();
201
- if (x.done && y.done)
202
- return 0;
203
- if (x.done)
204
- return -1;
205
- if (y.done)
206
- return 1;
207
- const cx = x.value.codePointAt(0);
208
- const cy = y.value.codePointAt(0);
209
- if (cx !== cy)
210
- return cx - cy;
211
- }
204
+ return STARTS_WITH_PY_SPACE.test(line);
212
205
  }
213
206
  /** `sorted(paths, key=lambda p: p.parts)`: a tuple comparison, segment by segment. */
214
207
  function cmpParts(a, b) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.29.0",
3
+ "version": "0.29.2",
4
4
  "description": "bantamkit MCP server — pure Node, runnable with npx",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "assets"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=18"
23
+ "node": ">=20"
24
24
  },
25
25
  "scripts": {
26
26
  "build": "tsc -p tsconfig.json",