bantamkit-mcp 0.35.7 → 0.35.9
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 +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
package/dist/memory/store.js
CHANGED
|
@@ -38,11 +38,13 @@
|
|
|
38
38
|
* handed — the two branches Python takes when `_snapshot` is pinned have no reachable
|
|
39
39
|
* caller here. Everything else about both functions is the reference's.
|
|
40
40
|
*/
|
|
41
|
+
import { randomBytes } from 'node:crypto';
|
|
42
|
+
import { writeFileSync } from 'node:fs';
|
|
41
43
|
import { basename } from 'node:path';
|
|
42
44
|
import { BantamError } from '../errors.js';
|
|
43
45
|
import { formatFact, parseFrontmatter, pySplit, pyStrip, todayLocal } from './factfile.js';
|
|
44
46
|
import { PyScalar } from './pyyaml.js';
|
|
45
|
-
import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink,
|
|
47
|
+
import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyIsDir, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyNewlineOut, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWriteText, PyOSError, PyUnicodeDecodeError, sortedPathNames, } from './pyfs.js';
|
|
46
48
|
/**
|
|
47
49
|
* The exact bytes written into a `.bantamkit/.gitignore` the first time such a directory is
|
|
48
50
|
* brought into existence (user ruling 2026-09-15, `.shiftwork/notes-job51/P0-probes.md`).
|
|
@@ -162,6 +164,42 @@ export const DURABLE_TYPES = ['feedback', 'user'];
|
|
|
162
164
|
*/
|
|
163
165
|
const NAME_RE = /^[a-z0-9][a-z0-9-]*\n?$/;
|
|
164
166
|
const NAME_PATTERN = '^[a-z0-9][a-z0-9-]*$';
|
|
167
|
+
/**
|
|
168
|
+
* THE LONGEST NAME A SAVE ACCEPTS (job65/#156): `NAME_MAX_CHARS` in the reference, the same
|
|
169
|
+
* number. The fact lands as `<name>.md` through `<name>.md.<pid>.<8 hex>.tmp` (#161), so the
|
|
170
|
+
* name leaves room for that suffix under the 255-byte file-name limit. WAS: no cap, and a
|
|
171
|
+
* 301-character name came back as a raw `[Errno 63] File name too long: '<absolute path>'`.
|
|
172
|
+
* `NAME_RE` admits ASCII only, so characters and bytes are one count.
|
|
173
|
+
*/
|
|
174
|
+
export const NAME_MAX_CHARS = 128;
|
|
175
|
+
/**
|
|
176
|
+
* ONE LINE MEANS ONE LINE (job65/#155): the reference's `_NOT_ONE_LINE`, the same ranges —
|
|
177
|
+
* every C0/C1 control plus U+2028/U+2029. Refused in a saved `description`; in a fact file on
|
|
178
|
+
* disk each run of them is READ as one space (see `facts()`), so `index.md` and the hook's
|
|
179
|
+
* injected header can never gain a line nobody saved.
|
|
180
|
+
*/
|
|
181
|
+
const NOT_ONE_LINE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
|
|
182
|
+
/**
|
|
183
|
+
* A LONE SURROGATE IS NOT TEXT (job65/#160). JSON can carry `\ud800` on its own; no UTF-8
|
|
184
|
+
* file can hold it. This port used to write U+FFFD and say "saved"; the reference raised a
|
|
185
|
+
* `UnicodeEncodeError`. A JS string holds a pair as two code units, so a surrogate is lone
|
|
186
|
+
* when it is a high one not followed by a low one, or a low one not preceded by a high one.
|
|
187
|
+
*/
|
|
188
|
+
const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
189
|
+
function codepoint(unit) {
|
|
190
|
+
return `U+${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
|
|
191
|
+
}
|
|
192
|
+
/** `U+XXXX` of the first lone surrogate in `text`, or `null` when it is all text. */
|
|
193
|
+
export function loneSurrogate(text) {
|
|
194
|
+
const found = LONE_SURROGATE.exec(text);
|
|
195
|
+
return found === null ? null : codepoint(found[0]);
|
|
196
|
+
}
|
|
197
|
+
function notOneLine(text) {
|
|
198
|
+
NOT_ONE_LINE.lastIndex = 0;
|
|
199
|
+
const found = NOT_ONE_LINE.exec(text);
|
|
200
|
+
NOT_ONE_LINE.lastIndex = 0;
|
|
201
|
+
return found === null ? null : codepoint(found[0]);
|
|
202
|
+
}
|
|
165
203
|
export const DUPLICATE_JACCARD = 0.5;
|
|
166
204
|
// ---- the precision gate (roadmap #6) ---------------------------------------------
|
|
167
205
|
//
|
|
@@ -276,6 +314,13 @@ const FACT_UNREACHABLE = "a fact that could not be stat'd is not a fact that is
|
|
|
276
314
|
'this path';
|
|
277
315
|
const ARCHIVE_DESTINATION_UNREACHABLE = "a destination that could not be stat'd is not a name that is already archived, " +
|
|
278
316
|
'and nothing has moved: the fact is still in facts/';
|
|
317
|
+
/**
|
|
318
|
+
* job65/#141, #123: the codes that say a directory EXISTS and this process may not write into
|
|
319
|
+
* it — `NOT_WRITABLE_ERRNOS` in the reference. EACCES is the chmod case everywhere, EPERM an
|
|
320
|
+
* immutable flag or a Windows ACL, EROFS a read-only mount. On the mkdir path it is only half
|
|
321
|
+
* the test (`ensureDirs` also needs the root to be there); see the reference's docstring.
|
|
322
|
+
*/
|
|
323
|
+
const NOT_WRITABLE_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
|
|
279
324
|
export class MemoryValidationError extends BantamError {
|
|
280
325
|
}
|
|
281
326
|
export class MemoryBudgetExceeded extends BantamError {
|
|
@@ -632,6 +677,13 @@ export class MemoryStore {
|
|
|
632
677
|
* the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
|
|
633
678
|
* internals. `this.root` is the path the caller named and both sides agree on it, which is
|
|
634
679
|
* what lets one conformance case pin this line byte for byte.
|
|
680
|
+
*
|
|
681
|
+
* AMENDED job65/#141: THE SENTENCE ABOVE IS NOW THE "NOT THERE" ARM ONLY. A `chmod 000`
|
|
682
|
+
* store answered it too, about a directory that exists. The second arm is still an outcome:
|
|
683
|
+
* the mkdir failed with one of `NOT_WRITABLE_CODES` AND `this.root` is a directory that is
|
|
684
|
+
* there, and then the refusal is `memory store is not writable: <root>: <strerror>; the
|
|
685
|
+
* directory exists but cannot be written to, so nothing was written`. The cwd-`/` divergence
|
|
686
|
+
* measured above is about a root that is NOT there, so it never reaches this arm.
|
|
635
687
|
*/
|
|
636
688
|
ensureDirs() {
|
|
637
689
|
// Checked BEFORE the mkdir below, per user ruling #2: this is the one moment that can
|
|
@@ -651,12 +703,30 @@ export class MemoryStore {
|
|
|
651
703
|
catch (e) {
|
|
652
704
|
if (!(e instanceof PyOSError))
|
|
653
705
|
throw e;
|
|
706
|
+
if (NOT_WRITABLE_CODES.has(e.code) && this.present())
|
|
707
|
+
throw this.unwritable(e);
|
|
654
708
|
throw new MemoryValidationError(`memory store could not be created: ${this.root}; the directory is not` +
|
|
655
709
|
' there and this filesystem would not make it, so nothing was written');
|
|
656
710
|
}
|
|
657
711
|
if (bantamkitDir !== null)
|
|
658
712
|
ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
|
|
659
713
|
}
|
|
714
|
+
/** `_present`: is `this.root` a directory that is there? "Not allowed to look" is "there". */
|
|
715
|
+
present() {
|
|
716
|
+
try {
|
|
717
|
+
return pyIsDir(this.root);
|
|
718
|
+
}
|
|
719
|
+
catch (e) {
|
|
720
|
+
if (!(e instanceof PyOSError))
|
|
721
|
+
throw e;
|
|
722
|
+
return true;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/** `_unwritable`: one sentence for a store that exists and refused a write (#141). */
|
|
726
|
+
unwritable(error) {
|
|
727
|
+
return new MemoryValidationError(`memory store is not writable: ${this.root}: ${error.strerror}; the directory` +
|
|
728
|
+
' exists but cannot be written to, so nothing was written');
|
|
729
|
+
}
|
|
660
730
|
// ---- ops ----
|
|
661
731
|
/**
|
|
662
732
|
* Write a fact, or refuse it as a near-duplicate of one already here.
|
|
@@ -679,6 +749,24 @@ export class MemoryStore {
|
|
|
679
749
|
if (pyStrip(description || '') === '') {
|
|
680
750
|
throw new MemoryValidationError('description must be a non-empty line');
|
|
681
751
|
}
|
|
752
|
+
if (name.length > NAME_MAX_CHARS) {
|
|
753
|
+
throw new MemoryValidationError(`invalid name: ${name.length} characters; must be at most ${NAME_MAX_CHARS}`);
|
|
754
|
+
}
|
|
755
|
+
const texts = [
|
|
756
|
+
['description', description],
|
|
757
|
+
['body', body],
|
|
758
|
+
...links.map((link, i) => [`links[${i}]`, link]),
|
|
759
|
+
];
|
|
760
|
+
for (const [label, value] of texts) {
|
|
761
|
+
const bad = typeof value === 'string' ? loneSurrogate(value) : null;
|
|
762
|
+
if (bad !== null) {
|
|
763
|
+
throw new MemoryValidationError(`${label} holds a lone surrogate (${bad}), which is not text and cannot be stored as UTF-8; nothing was saved`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const broken = notOneLine(pyStrip(description));
|
|
767
|
+
if (broken !== null) {
|
|
768
|
+
throw new MemoryValidationError(`description must be one line; it holds the control character ${broken}`);
|
|
769
|
+
}
|
|
682
770
|
const newTokens = tokens(`${name} ${description}`);
|
|
683
771
|
let existing = null;
|
|
684
772
|
for (const fact of this.facts()) {
|
|
@@ -703,7 +791,16 @@ export class MemoryStore {
|
|
|
703
791
|
};
|
|
704
792
|
const path = this.factPath(name);
|
|
705
793
|
const existed = pyExists(path) ? pyReadText(path) : null;
|
|
706
|
-
|
|
794
|
+
// #141, the 555 shape: every directory was already there, so the fact write is the first
|
|
795
|
+
// syscall the mode refuses. Only the "exists, not writable" codes become the sentence.
|
|
796
|
+
try {
|
|
797
|
+
this.writeFact(fact);
|
|
798
|
+
}
|
|
799
|
+
catch (e) {
|
|
800
|
+
if (e instanceof PyOSError && NOT_WRITABLE_CODES.has(e.code))
|
|
801
|
+
throw this.unwritable(e);
|
|
802
|
+
throw e;
|
|
803
|
+
}
|
|
707
804
|
try {
|
|
708
805
|
this.checkIndexBudget();
|
|
709
806
|
}
|
|
@@ -745,13 +842,13 @@ export class MemoryStore {
|
|
|
745
842
|
* reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
|
|
746
843
|
* it is killed on both sides.
|
|
747
844
|
*/
|
|
748
|
-
recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO) {
|
|
845
|
+
recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO, skipped) {
|
|
749
846
|
if (!(minRatio >= 0 && minRatio <= 1))
|
|
750
847
|
throw new MemoryValidationError(MIN_RATIO_RANGE);
|
|
751
848
|
const limit = k ?? this.k;
|
|
752
849
|
const q = tokens(query);
|
|
753
850
|
let scored = [];
|
|
754
|
-
for (const fact of this.facts()) {
|
|
851
|
+
for (const fact of this.facts(undefined, skipped)) {
|
|
755
852
|
let score = 0;
|
|
756
853
|
for (const t of tokens(`${pyText(fact.name)} ${pyText(fact.description)}`))
|
|
757
854
|
if (q.has(t))
|
|
@@ -798,8 +895,8 @@ export class MemoryStore {
|
|
|
798
895
|
* order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
|
|
799
896
|
* `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
|
|
800
897
|
*/
|
|
801
|
-
lookup(name, stamp = true) {
|
|
802
|
-
for (const fact of this.facts()) {
|
|
898
|
+
lookup(name, stamp = true, skipped) {
|
|
899
|
+
for (const fact of this.facts(undefined, skipped)) {
|
|
803
900
|
if (typeof fact.name === 'string' && fact.name === name) {
|
|
804
901
|
if (stamp)
|
|
805
902
|
this.stamp(fact);
|
|
@@ -828,11 +925,19 @@ export class MemoryStore {
|
|
|
828
925
|
* and the budget error to `lint: FAIL — …` on stderr with exit 1.
|
|
829
926
|
*/
|
|
830
927
|
lint() {
|
|
831
|
-
|
|
928
|
+
const findings = [];
|
|
929
|
+
for (const fact of this.facts(findings)) {
|
|
832
930
|
if (!VALID_TYPES.includes(pyText(fact.type))) {
|
|
833
931
|
throw new MemoryValidationError(`fact '${pyText(fact.name)}' has invalid type '${pyText(fact.type)}'`);
|
|
834
932
|
}
|
|
835
933
|
}
|
|
934
|
+
// The two hand-edit shapes every other op now TOLERATES (#121, #122) are named here,
|
|
935
|
+
// first one in file order, because lint is the one surface whose job is to say what an
|
|
936
|
+
// operator should fix: an unquoted timestamp that `facts` read as a string, and a file
|
|
937
|
+
// whose name is not the fact's. Neither is a reason for `recall` or `compact` to refuse
|
|
938
|
+
// the whole store, and both are a reason for `lint` to exit 1.
|
|
939
|
+
if (findings.length > 0)
|
|
940
|
+
throw new MemoryValidationError(findings[0]);
|
|
836
941
|
this.checkIndexBudget();
|
|
837
942
|
}
|
|
838
943
|
/**
|
|
@@ -924,9 +1029,13 @@ export class MemoryStore {
|
|
|
924
1029
|
// name, instead of surfacing as a `FileNotFoundError` out of the final write. `exist_ok`
|
|
925
1030
|
// semantics make it free for every store that is already there.
|
|
926
1031
|
this.ensureDirs();
|
|
1032
|
+
// Keyed by the FACT, not by `fact.name` (#122): two files can carry one name, and a map
|
|
1033
|
+
// keyed by name collapsed them — `indexBefore` reported 88 bytes for a 134-byte index, and
|
|
1034
|
+
// the second file's line was never subtracted. `byEviction` sorts a copy of this same
|
|
1035
|
+
// array, so the objects are the keys on both sides of the loop.
|
|
927
1036
|
const sizes = new Map();
|
|
928
1037
|
for (const fact of facts) {
|
|
929
|
-
sizes.set(
|
|
1038
|
+
sizes.set(fact, Buffer.byteLength(this.indexLine(fact), 'utf8'));
|
|
930
1039
|
}
|
|
931
1040
|
const all = [...sizes.values()];
|
|
932
1041
|
if (reserve === null) {
|
|
@@ -952,9 +1061,14 @@ export class MemoryStore {
|
|
|
952
1061
|
for (const fact of this.byEviction(facts)) {
|
|
953
1062
|
if (size <= target)
|
|
954
1063
|
break;
|
|
955
|
-
|
|
1064
|
+
// THE FILE THE FACT WAS READ FROM, not `facts/<name>.md` (#122). The two are the same
|
|
1065
|
+
// path for every fact this store wrote; they part on a hand-edited file whose
|
|
1066
|
+
// frontmatter names another fact, and there `factPath(fact.name)` moved the WRONG file
|
|
1067
|
+
// — the newest fact, under the name of the stalest — or raised for a name no file
|
|
1068
|
+
// carries. `lint` names the mismatch.
|
|
1069
|
+
const path = fact.source ?? this.factPath(fact.name);
|
|
956
1070
|
pyReplace(path, pyJoin(this.root, 'archive', pyName(path)));
|
|
957
|
-
const bytes = sizes.get(
|
|
1071
|
+
const bytes = sizes.get(fact);
|
|
958
1072
|
size -= bytes;
|
|
959
1073
|
archived.push({
|
|
960
1074
|
name: fact.name,
|
|
@@ -1186,6 +1300,18 @@ export class MemoryStore {
|
|
|
1186
1300
|
* bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
|
|
1187
1301
|
* the code-shape difference the Python half had to manufacture, not a behaviour one.
|
|
1188
1302
|
*/
|
|
1303
|
+
/**
|
|
1304
|
+
* Every fact this store can read, and `{file, reason}` for each file it could not.
|
|
1305
|
+
*
|
|
1306
|
+
* THE DECLARED TOLERANT LISTING (job65/#119), `readable_facts` in the reference. One empty,
|
|
1307
|
+
* truncated, non-UTF-8 or unreadable fact file costs that file, not the store: the hook's
|
|
1308
|
+
* SessionStart index and native export read through this, as `Memory.recallOutcome` reads
|
|
1309
|
+
* through `recall(..., skipped)`. A `facts/` that cannot be listed still throws.
|
|
1310
|
+
*/
|
|
1311
|
+
readableFacts() {
|
|
1312
|
+
const skipped = [];
|
|
1313
|
+
return { facts: this.facts(undefined, skipped), skipped };
|
|
1314
|
+
}
|
|
1189
1315
|
internals() {
|
|
1190
1316
|
return {
|
|
1191
1317
|
facts: () => this.facts(),
|
|
@@ -1343,10 +1469,11 @@ export class MemoryStore {
|
|
|
1343
1469
|
/**
|
|
1344
1470
|
* Every fact on disk, parsed.
|
|
1345
1471
|
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1472
|
+
* AMENDED job65/#159: the `read_text` is now INSIDE the reference's try, so a fact file that
|
|
1473
|
+
* is not UTF-8 is `malformed fact file <name>: 'utf-8' codec can't decode …` — naming the
|
|
1474
|
+
* file — instead of the bare decoder message. `pyReadText` raises `PyUnicodeDecodeError`
|
|
1475
|
+
* with CPython's own text and it is wrapped here; a `PyOSError` from the read still escapes
|
|
1476
|
+
* as itself, as in the reference (`OSError` is not in its `except`).
|
|
1350
1477
|
*
|
|
1351
1478
|
* RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
|
|
1352
1479
|
* `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
|
|
@@ -1361,12 +1488,66 @@ export class MemoryStore {
|
|
|
1361
1488
|
* carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
|
|
1362
1489
|
* every run instead of asserted once and forgotten.
|
|
1363
1490
|
*/
|
|
1364
|
-
|
|
1491
|
+
/**
|
|
1492
|
+
* `_facts`: every fact on disk, in `factPaths` order, with two hand-edit shapes tolerated.
|
|
1493
|
+
*
|
|
1494
|
+
* AN UNQUOTED TIMESTAMP IS READ AS ITS `str()` (#121). `yaml.safe_load` hands the reference
|
|
1495
|
+
* a `datetime.date` for `created: 2026-01-01` and a `datetime.datetime` for the longer
|
|
1496
|
+
* forms, while every file the store writes carries the quoted string. `recall` and `save`
|
|
1497
|
+
* never minded, but `compact` sorts `(last_recalled or created, name)` and the sort raised
|
|
1498
|
+
* `'<' not supported between instances of 'datetime.date' and 'str'` the moment one
|
|
1499
|
+
* hand-edited file sat beside a written one — the only remedy the budget refusal names,
|
|
1500
|
+
* dead on the store that named it. The scalar's `text` IS `str(value)` on the reference
|
|
1501
|
+
* (`isoformat()` for a date, `isoformat(' ')` for a datetime), so both runtimes read the
|
|
1502
|
+
* same string and the next write quotes it. Only timestamps are touched: an `int` or `bool`
|
|
1503
|
+
* in these fields is left exactly as the scalar cases in `suites/store.mjs` pin it.
|
|
1504
|
+
*
|
|
1505
|
+
* `findings`, when given, collects one sentence per shape `lint` should name — the coerced
|
|
1506
|
+
* timestamp, and a file whose name is not the fact's (#122) — without throwing, because
|
|
1507
|
+
* every other caller tolerates both. `lint` is the only caller that passes an array.
|
|
1508
|
+
*/
|
|
1509
|
+
/**
|
|
1510
|
+
* `skipped`, when given, makes ONE BAD FILE COST THAT FILE (job65/#119): a file that is
|
|
1511
|
+
* malformed or cannot be read is pushed as `{file, reason}` (the sentence this would have
|
|
1512
|
+
* thrown) and every other file is still read. Only `recall`, `lookup` and `readableFacts`
|
|
1513
|
+
* pass it; a save, a compaction or a lint keeps the strict read.
|
|
1514
|
+
*/
|
|
1515
|
+
facts(findings, skipped) {
|
|
1365
1516
|
const out = [];
|
|
1366
1517
|
for (const path of this.factPaths()) {
|
|
1367
|
-
|
|
1518
|
+
if (skipped === undefined) {
|
|
1519
|
+
out.push(this.readFact(path, findings));
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
out.push(this.readFact(path, findings));
|
|
1524
|
+
}
|
|
1525
|
+
catch (e) {
|
|
1526
|
+
if (!(e instanceof BantamError || e instanceof PyOSError))
|
|
1527
|
+
throw e;
|
|
1528
|
+
skipped.push({ file: basename(path), reason: e.message });
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
return out;
|
|
1532
|
+
}
|
|
1533
|
+
/** One fact file, parsed, or the `malformed fact file` sentence for it (see `facts`). */
|
|
1534
|
+
readFact(path, findings) {
|
|
1535
|
+
const timestampText = (value) => value instanceof PyScalar && (value.ord.kind === 'date' || value.ord.kind === 'datetime')
|
|
1536
|
+
? value.text
|
|
1537
|
+
: value;
|
|
1538
|
+
{
|
|
1368
1539
|
const file = basename(path);
|
|
1369
1540
|
const malformed = (reason) => new MemoryValidationError(`malformed fact file ${file}: ${reason}`);
|
|
1541
|
+
let text;
|
|
1542
|
+
try {
|
|
1543
|
+
text = pyReadText(path);
|
|
1544
|
+
}
|
|
1545
|
+
catch (e) {
|
|
1546
|
+
// #159: `UnicodeDecodeError` is a `ValueError`, which the reference's `except` names.
|
|
1547
|
+
if (e instanceof PyUnicodeDecodeError)
|
|
1548
|
+
throw malformed(e.message);
|
|
1549
|
+
throw e;
|
|
1550
|
+
}
|
|
1370
1551
|
const parts = pySplit(text, '---\n', 2);
|
|
1371
1552
|
if (parts.length !== 3) {
|
|
1372
1553
|
throw malformed(`not enough values to unpack (expected 3, got ${parts.length})`);
|
|
@@ -1385,21 +1566,51 @@ export class MemoryStore {
|
|
|
1385
1566
|
if (!(key in meta))
|
|
1386
1567
|
throw malformed(`'${key}'`);
|
|
1387
1568
|
}
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1569
|
+
const name = meta['name'];
|
|
1570
|
+
const stamps = {};
|
|
1571
|
+
for (const key of ['created', 'last_recalled']) {
|
|
1572
|
+
const raw = meta[key];
|
|
1573
|
+
const value = timestampText(raw);
|
|
1574
|
+
if (findings !== undefined && value !== raw) {
|
|
1575
|
+
findings.push(`fact '${pyText(name)}' in ${file}: ${key} is the unquoted timestamp ${value}, ` +
|
|
1576
|
+
`read as '${value}'; quote it`);
|
|
1577
|
+
}
|
|
1578
|
+
stamps[key] = value;
|
|
1579
|
+
}
|
|
1580
|
+
// `path.stem`, spelled the way `archived()` spells it.
|
|
1581
|
+
const suffix = pySuffix(file);
|
|
1582
|
+
const stem = suffix === '' ? file : file.slice(0, file.length - suffix.length);
|
|
1583
|
+
if (findings !== undefined && stem !== pyText(name)) {
|
|
1584
|
+
findings.push(`fact '${pyText(name)}' in ${file}: the file name does not match the fact name; ` +
|
|
1585
|
+
`rename the file to ${pyText(name)}.md or the fact to ${stem}`);
|
|
1586
|
+
}
|
|
1587
|
+
const rawCreated = stamps['created'];
|
|
1588
|
+
// #155: a description that is not one line is READ as one — each run of `NOT_ONE_LINE`
|
|
1589
|
+
// is one space — and `lint` names it, like the other hand-edit shapes.
|
|
1590
|
+
let description = meta['description'];
|
|
1591
|
+
const broken = typeof description === 'string' ? notOneLine(description) : null;
|
|
1592
|
+
if (broken !== null) {
|
|
1593
|
+
description = description.replace(NOT_ONE_LINE, ' ');
|
|
1594
|
+
if (findings !== undefined) {
|
|
1595
|
+
findings.push(`fact '${pyText(name)}' in ${file}: the description is not one line ` +
|
|
1596
|
+
`(it holds the control character ${broken}), read as '${description}'; make it one line`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
return {
|
|
1600
|
+
name,
|
|
1601
|
+
description,
|
|
1392
1602
|
type: meta['type'],
|
|
1393
1603
|
body: pyStrip(parts[2]),
|
|
1394
1604
|
links: pyList(meta['links']),
|
|
1395
|
-
last_recalled:
|
|
1605
|
+
last_recalled: stamps['last_recalled'] ?? null,
|
|
1396
1606
|
// `or`, not `??`: every FALSY value falls back to the mtime, and that is now more
|
|
1397
1607
|
// than the empty string — `created: 0`, `created: false` and `created: 0.0` are all
|
|
1398
|
-
// falsy in Python too, while `created: 2026-08-23` is a truthy `date`
|
|
1608
|
+
// falsy in Python too, while `created: 2026-08-23` is a truthy `date` (now its text)
|
|
1609
|
+
// and is kept.
|
|
1399
1610
|
created: pyTruthy(rawCreated) ? rawCreated : pyMtimeDate(path),
|
|
1400
|
-
|
|
1611
|
+
source: path,
|
|
1612
|
+
};
|
|
1401
1613
|
}
|
|
1402
|
-
return out;
|
|
1403
1614
|
}
|
|
1404
1615
|
/**
|
|
1405
1616
|
* Date the recall.
|
|
@@ -1411,14 +1622,51 @@ export class MemoryStore {
|
|
|
1411
1622
|
*/
|
|
1412
1623
|
stamp(fact) {
|
|
1413
1624
|
fact.last_recalled = this.today();
|
|
1414
|
-
|
|
1625
|
+
// job65/#123: BEST-EFFORT. A read-only store (chmod 555, a read-only mount, a CI cache)
|
|
1626
|
+
// used to fail the whole recall here, after the hits were computed. A `PyOSError` from the
|
|
1627
|
+
// write now leaves the old date on disk and the recall answers; see the reference's
|
|
1628
|
+
// `_stamp` for why it is silent.
|
|
1629
|
+
try {
|
|
1630
|
+
this.writeFact(fact);
|
|
1631
|
+
}
|
|
1632
|
+
catch (e) {
|
|
1633
|
+
if (!(e instanceof PyOSError))
|
|
1634
|
+
throw e;
|
|
1635
|
+
}
|
|
1415
1636
|
}
|
|
1416
|
-
/**
|
|
1637
|
+
/**
|
|
1638
|
+
* Write via a PER-WRITER temp and `os.replace`, so a reader never sees a half-written fact
|
|
1639
|
+
* and two writers never share a temp (job65/#161).
|
|
1640
|
+
*
|
|
1641
|
+
* WAS `<name>.md.tmp`, one temp per FACT: two servers saving one name raced on it (the
|
|
1642
|
+
* second `replace` found nothing — `[Errno 2] … 'shared-name.md.tmp'`), and a write that
|
|
1643
|
+
* died half-way left the temp beside the facts. NOW the shape `clockOut` and the hook ledger
|
|
1644
|
+
* use (J65-1, J65-4): `<name>.md.<pid>.<8 hex>.tmp`, created with `wx` (O_EXCL), renamed
|
|
1645
|
+
* onto the fact, and unlinked on any failure before the error goes on up. `*.md` listings
|
|
1646
|
+
* never see it.
|
|
1647
|
+
*/
|
|
1417
1648
|
writeFact(fact) {
|
|
1418
1649
|
const path = this.factPath(fact.name);
|
|
1419
|
-
const tmp =
|
|
1420
|
-
|
|
1421
|
-
|
|
1650
|
+
const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
1651
|
+
try {
|
|
1652
|
+
try {
|
|
1653
|
+
writeFileSync(tmp, pyNewlineOut(formatFact(fact)), { encoding: 'utf8', flag: 'wx' });
|
|
1654
|
+
}
|
|
1655
|
+
catch (e) {
|
|
1656
|
+
throw asPyOSError(e, tmp, undefined, 'crt');
|
|
1657
|
+
}
|
|
1658
|
+
pyReplace(tmp, path);
|
|
1659
|
+
}
|
|
1660
|
+
catch (e) {
|
|
1661
|
+
try {
|
|
1662
|
+
pyUnlink(tmp);
|
|
1663
|
+
}
|
|
1664
|
+
catch (cleanup) {
|
|
1665
|
+
if (!(cleanup instanceof PyOSError))
|
|
1666
|
+
throw cleanup;
|
|
1667
|
+
}
|
|
1668
|
+
throw e;
|
|
1669
|
+
}
|
|
1422
1670
|
}
|
|
1423
1671
|
/**
|
|
1424
1672
|
* Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
|
package/dist/npminstall.d.ts
CHANGED
|
@@ -45,6 +45,24 @@ export declare function shlexJoin(command: readonly string[]): string;
|
|
|
45
45
|
* batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
|
|
46
46
|
*/
|
|
47
47
|
export declare function runInstaller(command: readonly string[]): [number, string];
|
|
48
|
+
/** What one spawned command came back with: both streams apart, the status, and any spawn error. */
|
|
49
|
+
export interface Spawned {
|
|
50
|
+
readonly status: number | null;
|
|
51
|
+
readonly stdout: string;
|
|
52
|
+
readonly stderr: string;
|
|
53
|
+
readonly error: (Error & {
|
|
54
|
+
code?: string;
|
|
55
|
+
}) | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One argv, spawned and captured, with the Windows shim handling `runInstaller` established
|
|
59
|
+
* (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
|
|
60
|
+
*
|
|
61
|
+
* `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
|
|
62
|
+
* carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
|
|
63
|
+
* into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
|
|
64
|
+
*/
|
|
65
|
+
export declare function spawnCommand(command: readonly string[], timeoutMs?: number): Spawned;
|
|
48
66
|
/**
|
|
49
67
|
* -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
|
|
50
68
|
*
|
package/dist/npminstall.js
CHANGED
|
@@ -76,17 +76,38 @@ export function shlexJoin(command) {
|
|
|
76
76
|
* batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
|
|
77
77
|
*/
|
|
78
78
|
export function runInstaller(command) {
|
|
79
|
-
const
|
|
80
|
-
const win = process.platform === 'win32';
|
|
81
|
-
const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
|
|
82
|
-
const done = spawnSync(program ?? '', quoted, { encoding: 'utf8', shell: win });
|
|
79
|
+
const done = spawnCommand(command);
|
|
83
80
|
if (done.error) {
|
|
84
81
|
// A missing `npm` is not a stack trace: it is a command that failed, and `COMMAND_FAILED`
|
|
85
82
|
// is the sentence that says so and hands back what went wrong. 127 is the shell's own
|
|
86
83
|
// code for "command not found", so the number means something to the person reading it.
|
|
87
84
|
return [127, done.error.message];
|
|
88
85
|
}
|
|
89
|
-
return [done.status ?? 1, `${done.stdout
|
|
86
|
+
return [done.status ?? 1, `${done.stdout}${done.stderr}`];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One argv, spawned and captured, with the Windows shim handling `runInstaller` established
|
|
90
|
+
* (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
|
|
91
|
+
*
|
|
92
|
+
* `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
|
|
93
|
+
* carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
|
|
94
|
+
* into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
|
|
95
|
+
*/
|
|
96
|
+
export function spawnCommand(command, timeoutMs) {
|
|
97
|
+
const [program, ...args] = command;
|
|
98
|
+
const win = process.platform === 'win32';
|
|
99
|
+
const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
|
|
100
|
+
const done = spawnSync(program ?? '', quoted, {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
shell: win,
|
|
103
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
status: done.status,
|
|
107
|
+
stdout: done.stdout ?? '',
|
|
108
|
+
stderr: done.stderr ?? '',
|
|
109
|
+
error: done.error ?? null,
|
|
110
|
+
};
|
|
90
111
|
}
|
|
91
112
|
/**
|
|
92
113
|
* A dotted version as something orderable, deterministically, without claiming PEP 440.
|
package/dist/pyargparse.js
CHANGED
|
@@ -79,9 +79,55 @@ export class ArgumentTypeError extends Error {
|
|
|
79
79
|
* a second hand-written copy of a conversion rule is the defect this module was built from.
|
|
80
80
|
*/
|
|
81
81
|
export function pyIntStrict(text) {
|
|
82
|
-
|
|
82
|
+
// job66/#174: CPython's `int(str)`, measured on 3.12 rather than recalled. It first maps
|
|
83
|
+
// every NON-ASCII character through `_PyUnicode_TransformDecimalAndSpaceToASCII`: a Unicode
|
|
84
|
+
// decimal digit (category Nd, any script, astral included) becomes its ASCII digit and a
|
|
85
|
+
// Unicode space becomes ' '. ASCII passes through untouched, so the only ASCII whitespace
|
|
86
|
+
// the parser then skips is ` \t\n\v\f\r` — `int('3\x1c')` is a ValueError although
|
|
87
|
+
// `'\x1c'.isspace()` is true — and U+FEFF is not a space at all. Single underscores may sit
|
|
88
|
+
// BETWEEN digits only, and more than 4300 digits is a ValueError (`sys.int_info
|
|
89
|
+
// .default_max_str_digits`). WAS: `/^\s*[+-]?\d+(?:_\d+)*\s*$/` without the `u` flag —
|
|
90
|
+
// ASCII digits only, so `--k ٣` was exit 2 here and served on the reference, while JS
|
|
91
|
+
// `\s` admitted U+FEFF and refused U+0085, both the opposite of CPython.
|
|
92
|
+
let ascii = '';
|
|
93
|
+
for (const ch of text) {
|
|
94
|
+
const cp = ch.codePointAt(0);
|
|
95
|
+
if (cp < 0x80)
|
|
96
|
+
ascii += ch;
|
|
97
|
+
else if (PY_UNICODE_SPACE.has(cp))
|
|
98
|
+
ascii += ' ';
|
|
99
|
+
else if (/\p{Nd}/u.test(ch))
|
|
100
|
+
ascii += String(ndValue(cp));
|
|
101
|
+
else
|
|
102
|
+
throw new TypeError('not an int');
|
|
103
|
+
}
|
|
104
|
+
const match = /^[ \t\n\v\f\r]*([+-]?)([0-9]+(?:_[0-9]+)*)[ \t\n\v\f\r]*$/.exec(ascii);
|
|
105
|
+
if (match === null)
|
|
83
106
|
throw new TypeError('not an int');
|
|
84
|
-
|
|
107
|
+
const digits = match[2].replace(/_/g, '');
|
|
108
|
+
if (digits.length > PY_INT_MAX_STR_DIGITS)
|
|
109
|
+
throw new TypeError('not an int');
|
|
110
|
+
const value = Number(`${match[1]}${digits}`);
|
|
111
|
+
return value === 0 ? 0 : value; // `int('-0')` is 0, never JavaScript's -0
|
|
112
|
+
}
|
|
113
|
+
/** `sys.int_info.default_max_str_digits`: CPython refuses a longer decimal string. */
|
|
114
|
+
const PY_INT_MAX_STR_DIGITS = 4300;
|
|
115
|
+
/** The non-ASCII code points `str.isspace()` is true for (CPython 3.12, Unicode 15.0). */
|
|
116
|
+
const PY_UNICODE_SPACE = new Set([
|
|
117
|
+
0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008,
|
|
118
|
+
0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000,
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* `unicodedata.decimal(ch)` for a category-Nd code point. Unicode allocates every decimal
|
|
122
|
+
* digit set as ten contiguous code points from 0 to 9, and sets that touch are back to back
|
|
123
|
+
* (the five mathematical sets at U+1D7CE), so the value is the offset from the start of the
|
|
124
|
+
* contiguous Nd run, modulo ten.
|
|
125
|
+
*/
|
|
126
|
+
function ndValue(cp) {
|
|
127
|
+
let start = cp;
|
|
128
|
+
while (start > 0 && /\p{Nd}/u.test(String.fromCodePoint(start - 1)))
|
|
129
|
+
start -= 1;
|
|
130
|
+
return (cp - start) % 10;
|
|
85
131
|
}
|
|
86
132
|
/** `_get_action_name`: the name argparse puts after `argument `. */
|
|
87
133
|
const actionName = (action) => action.optionStrings.length > 0
|