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/component.js
CHANGED
|
@@ -45,6 +45,20 @@ import { dream as runDream, formatFixed3, SUPERSEDED_HEADING } from './dream.js'
|
|
|
45
45
|
const NAME_SHAPE = /^[a-z0-9][a-z0-9-]*$/;
|
|
46
46
|
const isNameShaped = (query) => query.includes('-') && NAME_SHAPE.test(query);
|
|
47
47
|
const missLine = (name) => `no fact named '${name}' in any layer bound here; matching by words instead:`;
|
|
48
|
+
/**
|
|
49
|
+
* The closing paragraph of a recall that left fact files out (job65/#119), or `''`: the
|
|
50
|
+
* store's own sentence for each, labelled by layer, so the person knows what to repair and
|
|
51
|
+
* that the rest was searched. `_skipped_note` in the reference.
|
|
52
|
+
*/
|
|
53
|
+
function skippedNote(skipped) {
|
|
54
|
+
if (skipped.length === 0)
|
|
55
|
+
return '';
|
|
56
|
+
const count = skipped.length;
|
|
57
|
+
const noun = count === 1 ? 'fact file' : 'fact files';
|
|
58
|
+
return (`\n\n${count} ${noun} could not be read and ${count === 1 ? 'was' : 'were'} skipped; ` +
|
|
59
|
+
`every other fact was searched:\n` +
|
|
60
|
+
skipped.map((s) => `[${s.layer}] ${s.reason}`).join('\n'));
|
|
61
|
+
}
|
|
48
62
|
/**
|
|
49
63
|
* The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
|
|
50
64
|
*
|
|
@@ -246,6 +260,17 @@ export class Memory {
|
|
|
246
260
|
const normalizedLinks = (links ?? []).map((link) => normalizeName(link));
|
|
247
261
|
let result;
|
|
248
262
|
try {
|
|
263
|
+
// A SPELLING IS NOT A NAME (job65/#156): a name that had to be bent never lands ON
|
|
264
|
+
// ANOTHER FACT. "Deploy Command" used to replace `deploy-command` silently; now, when
|
|
265
|
+
// the bent name is a fact already, the answer is the duplicate nudge naming it and
|
|
266
|
+
// nothing is written. The exact slug is the one spelling that updates. See the reference.
|
|
267
|
+
if (typeof name === 'string' && name !== normalized && this.store.lookup(normalized, false) !== null) {
|
|
268
|
+
return {
|
|
269
|
+
reply: `similar memory '${normalized}' already exists — save under that SAME ` +
|
|
270
|
+
'name to update it, or skip. Do not rename to force a copy.',
|
|
271
|
+
status: 'duplicate',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
249
274
|
result = this.store.save(type, normalized, description, body, normalizedLinks);
|
|
250
275
|
}
|
|
251
276
|
catch (e) {
|
|
@@ -360,13 +385,17 @@ export class Memory {
|
|
|
360
385
|
let reached = 0;
|
|
361
386
|
let unreadable = 0;
|
|
362
387
|
let candidates = 0;
|
|
388
|
+
const skipped = [];
|
|
363
389
|
for (const [label, store, writable] of this.layers) {
|
|
364
390
|
if (picked.length >= budget)
|
|
365
391
|
break; // budget spent: later layers are never even read
|
|
366
392
|
reached += 1;
|
|
393
|
+
// ONE BAD FILE COSTS THAT FILE (job65/#119): the layer is scored over every fact it CAN
|
|
394
|
+
// read and the rest are named. Only a layer that cannot be listed is still the error.
|
|
395
|
+
const leftOut = [];
|
|
367
396
|
let facts;
|
|
368
397
|
try {
|
|
369
|
-
facts = store.recall(query, budget, writable && stamp, minRatio);
|
|
398
|
+
facts = store.recall(query, budget, writable && stamp, minRatio, leftOut);
|
|
370
399
|
}
|
|
371
400
|
catch (e) {
|
|
372
401
|
if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
|
|
@@ -377,6 +406,8 @@ export class Memory {
|
|
|
377
406
|
unreadable += 1;
|
|
378
407
|
continue; // a corrupt grant/profile layer must not take down recall
|
|
379
408
|
}
|
|
409
|
+
for (const s of leftOut)
|
|
410
|
+
skipped.push({ layer: label, ...s });
|
|
380
411
|
try {
|
|
381
412
|
candidates += countFacts(store.root);
|
|
382
413
|
}
|
|
@@ -404,16 +435,19 @@ export class Memory {
|
|
|
404
435
|
candidates,
|
|
405
436
|
unreadable,
|
|
406
437
|
lookup,
|
|
438
|
+
skipped,
|
|
407
439
|
};
|
|
408
440
|
const lead = lookup === 'miss' ? `${missLine(name)}\n\n` : '';
|
|
441
|
+
const note = skippedNote(skipped);
|
|
409
442
|
if (picked.length === 0) {
|
|
410
443
|
const [status, reply] = this.nothingToReport();
|
|
411
|
-
return { reply: lead + reply, status, source: null, ...counts };
|
|
444
|
+
return { reply: lead + reply + note, status, source: null, headers: [], ...counts };
|
|
412
445
|
}
|
|
413
446
|
return {
|
|
414
|
-
reply: lead + picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
|
|
447
|
+
reply: lead + picked.map(([label, fact]) => this.format(label, fact)).join('\n\n') + note,
|
|
415
448
|
status: 'answered',
|
|
416
449
|
source: picked[0][0].split(':', 1)[0],
|
|
450
|
+
headers: picked.map(([label, fact]) => this.header(label, fact)),
|
|
417
451
|
...counts,
|
|
418
452
|
};
|
|
419
453
|
}
|
|
@@ -431,11 +465,13 @@ export class Memory {
|
|
|
431
465
|
let reached = 0;
|
|
432
466
|
let unreadable = 0;
|
|
433
467
|
let candidates = 0;
|
|
468
|
+
const skipped = [];
|
|
434
469
|
for (const [label, store, writable] of this.layers) {
|
|
435
470
|
reached += 1;
|
|
471
|
+
const leftOut = [];
|
|
436
472
|
let fact;
|
|
437
473
|
try {
|
|
438
|
-
fact = store.lookup(name, writable && stamp);
|
|
474
|
+
fact = store.lookup(name, writable && stamp, leftOut);
|
|
439
475
|
}
|
|
440
476
|
catch (e) {
|
|
441
477
|
if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
|
|
@@ -446,6 +482,8 @@ export class Memory {
|
|
|
446
482
|
unreadable += 1;
|
|
447
483
|
continue;
|
|
448
484
|
}
|
|
485
|
+
for (const s of leftOut)
|
|
486
|
+
skipped.push({ layer: label, ...s });
|
|
449
487
|
try {
|
|
450
488
|
candidates += countFacts(store.root);
|
|
451
489
|
}
|
|
@@ -456,7 +494,7 @@ export class Memory {
|
|
|
456
494
|
}
|
|
457
495
|
if (fact !== null) {
|
|
458
496
|
return {
|
|
459
|
-
reply: this.format(label, fact),
|
|
497
|
+
reply: this.format(label, fact) + skippedNote(skipped),
|
|
460
498
|
status: 'answered',
|
|
461
499
|
source: label.split(':', 1)[0],
|
|
462
500
|
budget,
|
|
@@ -466,6 +504,8 @@ export class Memory {
|
|
|
466
504
|
candidates,
|
|
467
505
|
unreadable,
|
|
468
506
|
lookup: 'hit',
|
|
507
|
+
skipped,
|
|
508
|
+
headers: [this.header(label, fact)],
|
|
469
509
|
};
|
|
470
510
|
}
|
|
471
511
|
}
|
|
@@ -856,11 +896,15 @@ export class Memory {
|
|
|
856
896
|
}
|
|
857
897
|
return `${where} ${remedy}; otherwise save a memory to start this one.`;
|
|
858
898
|
}
|
|
859
|
-
|
|
899
|
+
/** `[layer] [name] (type) description`, from the parsed fields (job65/#155). */
|
|
900
|
+
header(label, fact) {
|
|
860
901
|
// `pyText` for the three interpolated fields, not template-literal coercion: a
|
|
861
902
|
// hand-edited `name:` with nothing after it is `None` in Python's sentence and would be
|
|
862
903
|
// `null` in this one. The store already reproduces it in the index line and on disk.
|
|
863
904
|
const tag = this.showLayers ? `[${label}] ` : '';
|
|
864
|
-
return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}
|
|
905
|
+
return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}`;
|
|
906
|
+
}
|
|
907
|
+
format(label, fact) {
|
|
908
|
+
return `${this.header(label, fact)}\n${fact.body}`;
|
|
865
909
|
}
|
|
866
910
|
}
|
|
@@ -27,7 +27,16 @@ export declare class FactParseError extends Error {
|
|
|
27
27
|
* in `pyyaml.ts` for what lands in which arm, and `store.pyText` for how it is rendered.
|
|
28
28
|
*/
|
|
29
29
|
export type FactValue = string | PyScalar | null;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* `store.Fact`. `created` is `null` only before a Fact's first write.
|
|
32
|
+
*
|
|
33
|
+
* `source` is the file the Fact was READ FROM — set by `MemoryStore.facts` and by nothing
|
|
34
|
+
* else, absent on a Fact built in memory. `compact` moves THIS path (#122): before it moved
|
|
35
|
+
* `facts/<name>.md` derived from the frontmatter, so a hand-edited `stale.md` whose
|
|
36
|
+
* frontmatter said `name: fresh-one` archived the NEWEST fact and left the stale one live,
|
|
37
|
+
* and a `name: 7` raised for a `facts/7.md` that never existed. The reference's dataclass
|
|
38
|
+
* field is `compare=False`, so nothing here compares it either.
|
|
39
|
+
*/
|
|
31
40
|
export interface Fact {
|
|
32
41
|
name: FactValue;
|
|
33
42
|
description: FactValue;
|
|
@@ -36,6 +45,7 @@ export interface Fact {
|
|
|
36
45
|
links: FactValue[];
|
|
37
46
|
last_recalled: FactValue;
|
|
38
47
|
created?: FactValue;
|
|
48
|
+
source?: string;
|
|
39
49
|
}
|
|
40
50
|
/** The frontmatter as it comes off disk — exactly the keys the file carries. */
|
|
41
51
|
export type FactMeta = Record<string, FactValue | FactValue[]>;
|
|
@@ -102,8 +112,16 @@ export declare function pySplit(text: string, sep: string, maxsplit: number): st
|
|
|
102
112
|
* RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
|
|
103
113
|
* root-level block mapping of plain keys whose values are plain, single-quoted or
|
|
104
114
|
* double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
|
|
105
|
-
* column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings
|
|
106
|
-
*
|
|
115
|
+
* column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings —
|
|
116
|
+
* plus, since #120, the two shapes a human writes by hand that the emitter never does:
|
|
117
|
+
* block scalars (`|`, `>`, every chomping and indentation indicator) and a one-line flow
|
|
118
|
+
* sequence of scalars (`[a, 'b c', 2]`), each read exactly as PyYAML reads it, and blank
|
|
119
|
+
* and comment lines between entries. Anything else RAISES instead of guessing: every
|
|
120
|
+
* indicator in `REFUSED_INDICATORS`, a nested or multi-line flow sequence, a tab in a
|
|
121
|
+
* plain scalar. Before #120 most of those fell through to the plain-scalar scanner, so
|
|
122
|
+
* `description: |` became the text `| hand edited block` and the first recall stamped
|
|
123
|
+
* that guess back to disk; the conformance suite now generates 1500 frontmatters a run and
|
|
124
|
+
* requires the port never to answer one differently from PyYAML. The reason is the failure mode this whole unit
|
|
107
125
|
* exists for: a parser that silently produces something plausible for input it does not
|
|
108
126
|
* understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
|
|
109
127
|
* reports it. A raise is visible; `_facts` already has a sentence for it.
|
package/dist/memory/factfile.js
CHANGED
|
@@ -110,8 +110,16 @@ export function pySplit(text, sep, maxsplit) {
|
|
|
110
110
|
* RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
|
|
111
111
|
* root-level block mapping of plain keys whose values are plain, single-quoted or
|
|
112
112
|
* double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
|
|
113
|
-
* column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings
|
|
114
|
-
*
|
|
113
|
+
* column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings —
|
|
114
|
+
* plus, since #120, the two shapes a human writes by hand that the emitter never does:
|
|
115
|
+
* block scalars (`|`, `>`, every chomping and indentation indicator) and a one-line flow
|
|
116
|
+
* sequence of scalars (`[a, 'b c', 2]`), each read exactly as PyYAML reads it, and blank
|
|
117
|
+
* and comment lines between entries. Anything else RAISES instead of guessing: every
|
|
118
|
+
* indicator in `REFUSED_INDICATORS`, a nested or multi-line flow sequence, a tab in a
|
|
119
|
+
* plain scalar. Before #120 most of those fell through to the plain-scalar scanner, so
|
|
120
|
+
* `description: |` became the text `| hand edited block` and the first recall stamped
|
|
121
|
+
* that guess back to disk; the conformance suite now generates 1500 frontmatters a run and
|
|
122
|
+
* requires the port never to answer one differently from PyYAML. The reason is the failure mode this whole unit
|
|
115
123
|
* exists for: a parser that silently produces something plausible for input it does not
|
|
116
124
|
* understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
|
|
117
125
|
* reports it. A raise is visible; `_facts` already has a sentence for it.
|
|
@@ -128,6 +136,14 @@ export function parseFrontmatter(front) {
|
|
|
128
136
|
const meta = {};
|
|
129
137
|
let pos = 0;
|
|
130
138
|
while (pos < front.length) {
|
|
139
|
+
// A blank line, or a line holding only a comment, is not an entry to PyYAML's scanner
|
|
140
|
+
// (`scan_to_next_token` steps over both), so it is not one here either. A block scalar's
|
|
141
|
+
// trailing `#` line at column 0 is the shape that needs this (#120).
|
|
142
|
+
const blank = /^ *(?:#[^\n]*)?(?:\n|$)/.exec(front.slice(pos));
|
|
143
|
+
if (blank && blank[0].length > 0) {
|
|
144
|
+
pos += blank[0].length;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
131
147
|
const key = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(front.slice(pos, front.indexOf('\n', pos) + 1 || undefined));
|
|
132
148
|
if (!key) {
|
|
133
149
|
throw new FactParseError(`frontmatter line is not a plain "key:" mapping entry: ${line(front, pos)}`);
|
|
@@ -146,6 +162,7 @@ export function parseFrontmatter(front) {
|
|
|
146
162
|
else if (front[pos] === ' ') {
|
|
147
163
|
pos += 1;
|
|
148
164
|
[value, pos] = scanNode(front, pos);
|
|
165
|
+
pos = skipTrailingComment(front, pos);
|
|
149
166
|
if (pos < front.length && front[pos] !== '\n') {
|
|
150
167
|
throw new FactParseError(`trailing content after the value of "${key[1]}": ${line(front, pos)}`);
|
|
151
168
|
}
|
|
@@ -162,22 +179,334 @@ function line(text, pos) {
|
|
|
162
179
|
const end = text.indexOf('\n', pos);
|
|
163
180
|
return JSON.stringify(text.slice(pos, end === -1 ? undefined : end));
|
|
164
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* What a YAML indicator this codec does NOT implement means to PyYAML, keyed by the
|
|
184
|
+
* character that starts the value. RULING (#120): each of these RAISES a named
|
|
185
|
+
* `FactParseError` instead of falling through to `scanPlain`. Before #120 they fell through,
|
|
186
|
+
* so `description: &a x` was read as the string `&a x` where Python reads `x`, and the first
|
|
187
|
+
* recall stamped that guess back to disk quoted — the hand-edited YAML was gone for good.
|
|
188
|
+
* A refusal names the file and changes nothing; a guess rewrites it.
|
|
189
|
+
*
|
|
190
|
+
* `|` and `>` are not here: block scalars ARE implemented (`scanBlockScalar`). Nor is `[`:
|
|
191
|
+
* a one-line flow sequence of scalars is implemented (`scanFlowSequence`). Nor is `#`: a
|
|
192
|
+
* comment where the value should be leaves the value null, which is what PyYAML reads.
|
|
193
|
+
*/
|
|
194
|
+
const REFUSED_INDICATORS = {
|
|
195
|
+
'&': 'an anchor',
|
|
196
|
+
'*': 'an alias',
|
|
197
|
+
'!': 'a tag',
|
|
198
|
+
'%': 'a directive',
|
|
199
|
+
'@': 'a reserved indicator',
|
|
200
|
+
'`': 'a reserved indicator',
|
|
201
|
+
'{': 'a flow mapping',
|
|
202
|
+
'}': 'a flow-mapping end outside a flow collection',
|
|
203
|
+
']': 'a flow-sequence end outside a flow collection',
|
|
204
|
+
',': 'a flow-entry separator outside a flow collection',
|
|
205
|
+
'\t': 'a tab, which cannot start a YAML token',
|
|
206
|
+
};
|
|
207
|
+
/** `'\0 \t\r\n\x85\u2028\u2029'` in PyYAML's scanner: end of input, whitespace or a break. */
|
|
208
|
+
function blankOrEnd(ch) {
|
|
209
|
+
return ch === undefined || ' \t\r\n\x85\u2028\u2029'.includes(ch);
|
|
210
|
+
}
|
|
211
|
+
function refuseIndicator(front, pos, what) {
|
|
212
|
+
throw new FactParseError(`${JSON.stringify(front[pos])} starts ${what} — YAML this codec does not implement, ` +
|
|
213
|
+
`so it is refused rather than read as plain text: ${line(front, pos)}`);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* After a value: spaces, then an optional comment to end of line. PyYAML's
|
|
217
|
+
* `scan_to_next_token` skips exactly that (spaces, never tabs), so `'x' # note` and
|
|
218
|
+
* `[a]#note` are the value and nothing more. A plain scalar has already eaten its own
|
|
219
|
+
* ` #` comment, and a block scalar ends at a line break, so this is a no-op for both.
|
|
220
|
+
*/
|
|
221
|
+
function skipTrailingComment(front, pos) {
|
|
222
|
+
let i = pos;
|
|
223
|
+
while (front[i] === ' ')
|
|
224
|
+
i += 1;
|
|
225
|
+
if (front[i] === '#') {
|
|
226
|
+
while (i < front.length && front[i] !== '\n')
|
|
227
|
+
i += 1;
|
|
228
|
+
return i;
|
|
229
|
+
}
|
|
230
|
+
return i === front.length || front[i] === '\n' ? i : pos;
|
|
231
|
+
}
|
|
165
232
|
/** A single-line-or-wrapped scalar starting at `pos`. Returns the value and the index after it. */
|
|
166
233
|
function scanNode(front, pos) {
|
|
234
|
+
// PyYAML steps over SPACES before a token (never tabs), so `description: 'x'` is `x`
|
|
235
|
+
// and not the plain text `'x'` it used to be here.
|
|
236
|
+
while (front[pos] === ' ')
|
|
237
|
+
pos += 1;
|
|
167
238
|
const ch = front[pos];
|
|
239
|
+
if (ch === undefined || ch === '\n')
|
|
240
|
+
return [null, pos]; // `key: ` is null, as `key:` is
|
|
241
|
+
if (ch === '#') {
|
|
242
|
+
// A comment where the value should be: the value is empty, so null.
|
|
243
|
+
const eol = front.indexOf('\n', pos);
|
|
244
|
+
return [null, eol === -1 ? front.length : eol];
|
|
245
|
+
}
|
|
168
246
|
if (ch === "'")
|
|
169
247
|
return scanQuoted(front, pos, "'");
|
|
170
248
|
if (ch === '"')
|
|
171
249
|
return scanQuoted(front, pos, '"');
|
|
172
|
-
if (ch === '[')
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
250
|
+
if (ch === '[')
|
|
251
|
+
return scanFlowSequence(front, pos);
|
|
252
|
+
if (ch === '|' || ch === '>')
|
|
253
|
+
return scanBlockScalar(front, pos);
|
|
254
|
+
const refused = REFUSED_INDICATORS[ch];
|
|
255
|
+
if (refused !== undefined)
|
|
256
|
+
refuseIndicator(front, pos, refused);
|
|
257
|
+
if ((ch === '-' || ch === '?' || ch === ':') && blankOrEnd(front[pos + 1])) {
|
|
258
|
+
refuseIndicator(front, pos, ch === '-' ? 'a sequence entry' : ch === '?' ? 'a mapping key' : 'a mapping value');
|
|
178
259
|
}
|
|
179
260
|
return scanPlain(front, pos);
|
|
180
261
|
}
|
|
262
|
+
/** The column of `pos`: characters since the last `\n`. PyYAML's `self.column`. */
|
|
263
|
+
function column(front, pos) {
|
|
264
|
+
return pos - (front.lastIndexOf('\n', pos - 1) + 1);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* A block scalar, `|` (literal) or `>` (folded), with its chomping and indentation
|
|
268
|
+
* indicators. A port of PyYAML's `scan_block_scalar` and its three helpers, line for line,
|
|
269
|
+
* including the folding rule PyYAML picked where the spec is ambiguous (a more-indented
|
|
270
|
+
* line keeps its break). Implemented rather than refused (#120) because it is the shape a
|
|
271
|
+
* human reaches for when a description runs to two lines, and refusing it bricks the whole
|
|
272
|
+
* store for Node while Python serves it.
|
|
273
|
+
*
|
|
274
|
+
* The value is a mapping value or a column-0 sequence item, so PyYAML's `self.indent` is 0
|
|
275
|
+
* and `min_indent` is 1 in both places. Returns the value and the index of the `\n` that ends
|
|
276
|
+
* its last consumed line (or the end of input), the same contract `scanPlain` keeps.
|
|
277
|
+
*
|
|
278
|
+
* Line breaks other than `\n` (`\x85`, `\u2028`, `\u2029`, a stray `\r`) are breaks to
|
|
279
|
+
* PyYAML and not to this port, so a block scalar holding one is refused, not misread.
|
|
280
|
+
*/
|
|
281
|
+
function scanBlockScalar(front, pos) {
|
|
282
|
+
const folded = front[pos] === '>';
|
|
283
|
+
const bad = (why) => new FactParseError(`while scanning a block scalar, ${why}: ${line(front, pos)}`);
|
|
284
|
+
const digit = (c) => c !== undefined && c >= '0' && c <= '9';
|
|
285
|
+
let i = pos + 1;
|
|
286
|
+
// scan_block_scalar_indicators
|
|
287
|
+
let chomping = null;
|
|
288
|
+
let increment = null;
|
|
289
|
+
if (front[i] === '+' || front[i] === '-') {
|
|
290
|
+
chomping = front[i] === '+';
|
|
291
|
+
i += 1;
|
|
292
|
+
if (digit(front[i])) {
|
|
293
|
+
increment = Number(front[i]);
|
|
294
|
+
if (increment === 0)
|
|
295
|
+
throw bad('expected indentation indicator in the range 1-9, but found 0');
|
|
296
|
+
i += 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (digit(front[i])) {
|
|
300
|
+
increment = Number(front[i]);
|
|
301
|
+
if (increment === 0)
|
|
302
|
+
throw bad('expected indentation indicator in the range 1-9, but found 0');
|
|
303
|
+
i += 1;
|
|
304
|
+
if (front[i] === '+' || front[i] === '-') {
|
|
305
|
+
chomping = front[i] === '+';
|
|
306
|
+
i += 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!(i === front.length || front[i] === ' ' || front[i] === '\n')) {
|
|
310
|
+
throw bad(`expected chomping or indentation indicators, but found ${JSON.stringify(front[i])}`);
|
|
311
|
+
}
|
|
312
|
+
// scan_block_scalar_ignored_line
|
|
313
|
+
while (front[i] === ' ')
|
|
314
|
+
i += 1;
|
|
315
|
+
if (front[i] === '#') {
|
|
316
|
+
while (i < front.length && front[i] !== '\n') {
|
|
317
|
+
if ('\r\x85\u2028\u2029'.includes(front[i]))
|
|
318
|
+
throw bad('a line break other than \\n');
|
|
319
|
+
i += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (i < front.length && front[i] !== '\n') {
|
|
323
|
+
throw bad(`expected a comment or a line break, but found ${JSON.stringify(front[i])}`);
|
|
324
|
+
}
|
|
325
|
+
if (i < front.length)
|
|
326
|
+
i += 1;
|
|
327
|
+
// scan_block_scalar_breaks
|
|
328
|
+
const scanBreaks = (indent) => {
|
|
329
|
+
const chunks = [];
|
|
330
|
+
while (column(front, i) < indent && front[i] === ' ')
|
|
331
|
+
i += 1;
|
|
332
|
+
while (front[i] === '\n') {
|
|
333
|
+
chunks.push('\n');
|
|
334
|
+
i += 1;
|
|
335
|
+
while (column(front, i) < indent && front[i] === ' ')
|
|
336
|
+
i += 1;
|
|
337
|
+
}
|
|
338
|
+
return chunks;
|
|
339
|
+
};
|
|
340
|
+
const minIndent = 1;
|
|
341
|
+
let indent;
|
|
342
|
+
let breaks;
|
|
343
|
+
if (increment === null) {
|
|
344
|
+
// scan_block_scalar_indentation
|
|
345
|
+
breaks = [];
|
|
346
|
+
let maxIndent = 0;
|
|
347
|
+
while (front[i] === ' ' || front[i] === '\n') {
|
|
348
|
+
if (front[i] === '\n') {
|
|
349
|
+
breaks.push('\n');
|
|
350
|
+
i += 1;
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
i += 1;
|
|
354
|
+
maxIndent = Math.max(maxIndent, column(front, i));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
indent = Math.max(minIndent, maxIndent);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
indent = minIndent + increment - 1;
|
|
361
|
+
breaks = scanBreaks(indent);
|
|
362
|
+
}
|
|
363
|
+
const chunks = [];
|
|
364
|
+
let lineBreak = '';
|
|
365
|
+
while (column(front, i) === indent && i < front.length) {
|
|
366
|
+
chunks.push(...breaks);
|
|
367
|
+
const leadingNonSpace = front[i] !== ' ' && front[i] !== '\t';
|
|
368
|
+
const eol = front.indexOf('\n', i);
|
|
369
|
+
const end = eol === -1 ? front.length : eol;
|
|
370
|
+
const text = front.slice(i, end);
|
|
371
|
+
if (/[\r\x85\u2028\u2029]/.test(text))
|
|
372
|
+
throw bad('a line break other than \\n');
|
|
373
|
+
chunks.push(text);
|
|
374
|
+
i = end;
|
|
375
|
+
lineBreak = i < front.length ? '\n' : '';
|
|
376
|
+
if (lineBreak)
|
|
377
|
+
i += 1;
|
|
378
|
+
breaks = scanBreaks(indent);
|
|
379
|
+
if (column(front, i) === indent && i < front.length) {
|
|
380
|
+
if (folded && lineBreak === '\n' && leadingNonSpace && front[i] !== ' ' && front[i] !== '\t') {
|
|
381
|
+
if (breaks.length === 0)
|
|
382
|
+
chunks.push(' ');
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
chunks.push(lineBreak);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (chomping !== false)
|
|
393
|
+
chunks.push(lineBreak);
|
|
394
|
+
if (chomping === true)
|
|
395
|
+
chunks.push(...breaks);
|
|
396
|
+
// What follows must be the next entry at column 0 (or nothing). A less-indented line that
|
|
397
|
+
// is not at column 0 is a parser error to PyYAML (`|\n a\n x` is measured), so it is one
|
|
398
|
+
// here; nothing after the scalar is read as part of it. The one exception is a comment,
|
|
399
|
+
// which PyYAML's `scan_to_next_token` steps over at any column.
|
|
400
|
+
if (i < front.length && column(front, i) !== 0 && front[i] === '#') {
|
|
401
|
+
const eol = front.indexOf('\n', i);
|
|
402
|
+
return [chunks.join(''), eol === -1 ? front.length : eol];
|
|
403
|
+
}
|
|
404
|
+
if (i < front.length && column(front, i) !== 0) {
|
|
405
|
+
throw new FactParseError(`content after a block scalar is not at column 0: ${line(front, i)}`);
|
|
406
|
+
}
|
|
407
|
+
return [chunks.join(''), i === front.length ? i : i - 1];
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* A flow sequence on ONE line, of plain, single- or double-quoted scalars: `[a, 'b c', 2]`.
|
|
411
|
+
* A port of how PyYAML's scanner reads a plain scalar at `flow_level > 0` (it ends at `,`,
|
|
412
|
+
* `?`, `[`, `]`, `{`, `}`, at `: ` and at ` #`), with each item resolved exactly as a
|
|
413
|
+
* mapping value is. A trailing comma is allowed, as PyYAML allows it.
|
|
414
|
+
*
|
|
415
|
+
* RULING (#120): the empty `[]` was all this accepted before, and `links: [a, b]` made Node
|
|
416
|
+
* refuse the whole store while Python served it. What is still REFUSED, each a named
|
|
417
|
+
* `FactParseError`: a nested collection (`[a, [b]]`, `[a, {b: c}]`), a single-pair mapping
|
|
418
|
+
* (`[a: b]`, `[? x]`), and a sequence that continues onto the next line. Python reads all
|
|
419
|
+
* three; registered in `docs/porting.md` with the other scanner shapes.
|
|
420
|
+
*/
|
|
421
|
+
function scanFlowSequence(front, pos) {
|
|
422
|
+
const eol = front.indexOf('\n', pos);
|
|
423
|
+
const end = eol === -1 ? front.length : eol;
|
|
424
|
+
const refuse = (why) => {
|
|
425
|
+
throw new FactParseError(`a flow sequence ${why} is outside this codec's language: ${line(front, pos)}`);
|
|
426
|
+
};
|
|
427
|
+
const items = [];
|
|
428
|
+
let i = pos + 1;
|
|
429
|
+
const skipSpaces = () => {
|
|
430
|
+
while (front[i] === ' ')
|
|
431
|
+
i += 1;
|
|
432
|
+
};
|
|
433
|
+
skipSpaces();
|
|
434
|
+
if (front[i] === ']')
|
|
435
|
+
return [items, i + 1];
|
|
436
|
+
for (;;) {
|
|
437
|
+
if (i >= end)
|
|
438
|
+
refuse('that continues past its line');
|
|
439
|
+
const ch = front[i];
|
|
440
|
+
if (ch === "'" || ch === '"') {
|
|
441
|
+
const [value, next] = scanQuoted(front, i, ch);
|
|
442
|
+
if (next > end)
|
|
443
|
+
refuse('whose quoted item spans lines');
|
|
444
|
+
items.push(value);
|
|
445
|
+
i = next;
|
|
446
|
+
}
|
|
447
|
+
else if (ch === '[' || ch === '{') {
|
|
448
|
+
refuse('holding a nested collection');
|
|
449
|
+
}
|
|
450
|
+
else if (ch === '?' || ch === ':') {
|
|
451
|
+
refuse('holding a mapping entry');
|
|
452
|
+
}
|
|
453
|
+
else if (ch === '-' && blankOrEnd(front[i + 1])) {
|
|
454
|
+
refuse('holding a block sequence entry');
|
|
455
|
+
}
|
|
456
|
+
else if (REFUSED_INDICATORS[ch] !== undefined || ch === '#' || ch === '|' || ch === '>') {
|
|
457
|
+
refuseIndicator(front, i, ch === '#' ? 'a comment' : ch === '|' || ch === '>' ? 'a block scalar' : REFUSED_INDICATORS[ch]);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
// scan_plain at flow_level > 0, confined to this line.
|
|
461
|
+
let text = '';
|
|
462
|
+
let spaces = '';
|
|
463
|
+
for (;;) {
|
|
464
|
+
if (front[i] === '#')
|
|
465
|
+
break;
|
|
466
|
+
let length = 0;
|
|
467
|
+
for (;;) {
|
|
468
|
+
const c = front[i + length];
|
|
469
|
+
if (blankOrEnd(c))
|
|
470
|
+
break;
|
|
471
|
+
if (c === ':' && (blankOrEnd(front[i + length + 1]) || ',[]{}'.includes(front[i + length + 1])))
|
|
472
|
+
break;
|
|
473
|
+
if (',?[]{}'.includes(c))
|
|
474
|
+
break;
|
|
475
|
+
length += 1;
|
|
476
|
+
}
|
|
477
|
+
if (length === 0)
|
|
478
|
+
break;
|
|
479
|
+
text += spaces + front.slice(i, i + length);
|
|
480
|
+
i += length;
|
|
481
|
+
const from = i;
|
|
482
|
+
while (front[i] === ' ')
|
|
483
|
+
i += 1;
|
|
484
|
+
spaces = front.slice(from, i);
|
|
485
|
+
if (i >= end)
|
|
486
|
+
refuse('that continues past its line');
|
|
487
|
+
if (spaces === '' || front[i] === '#')
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
items.push(constructPlain(text));
|
|
491
|
+
}
|
|
492
|
+
skipSpaces();
|
|
493
|
+
if (front[i] === ']')
|
|
494
|
+
return [items, i + 1];
|
|
495
|
+
if (front[i] !== ',') {
|
|
496
|
+
if (front[i] === ':' || front[i] === '?')
|
|
497
|
+
refuse('holding a mapping entry');
|
|
498
|
+
if (i >= end)
|
|
499
|
+
refuse('that continues past its line');
|
|
500
|
+
refuse(`with ${JSON.stringify(front[i])} where "," or "]" belongs`);
|
|
501
|
+
}
|
|
502
|
+
i += 1;
|
|
503
|
+
skipSpaces();
|
|
504
|
+
if (front[i] === ']')
|
|
505
|
+
return [items, i + 1];
|
|
506
|
+
if (front[i] === ',')
|
|
507
|
+
refuse('with an empty entry');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
181
510
|
/**
|
|
182
511
|
* A plain scalar and its wrapped continuation lines.
|
|
183
512
|
*
|
|
@@ -196,6 +525,12 @@ function scanPlain(front, pos) {
|
|
|
196
525
|
// Python reads is the point.
|
|
197
526
|
const hash = raw.indexOf(' #');
|
|
198
527
|
const cut = hash === -1 ? raw : raw.slice(0, hash);
|
|
528
|
+
// A tab is not whitespace to PyYAML's plain-scalar scanner (`scan_plain_spaces` takes
|
|
529
|
+
// spaces only), and a tab cannot start a token, so `x\t` and `a\tb` are a ScannerError
|
|
530
|
+
// there. Trimming it here answered a value Python refuses (#120, measured).
|
|
531
|
+
if (cut.includes('\t')) {
|
|
532
|
+
throw new FactParseError(`a tab inside a plain scalar is not readable YAML — PyYAML rejects it too: ${line(front, i)}`);
|
|
533
|
+
}
|
|
199
534
|
const piece = cut.replace(/[ \t]+$/, '').replace(/^[ \t]+/, '');
|
|
200
535
|
if (pieces.length > 0 && piece === '') {
|
|
201
536
|
throw new FactParseError('a blank line inside a plain scalar is outside this codec\'s language');
|
|
@@ -214,7 +549,10 @@ function scanPlain(front, pos) {
|
|
|
214
549
|
i = next;
|
|
215
550
|
}
|
|
216
551
|
const value = pieces.join(' ');
|
|
217
|
-
|
|
552
|
+
// `:` followed by a blank ends a plain scalar in PyYAML's block context, whatever precedes
|
|
553
|
+
// it — `a: b` and `a:` are both `mapping values are not allowed here`. The old pattern
|
|
554
|
+
// demanded a blank BEFORE the colon too, so `a: b` was answered (#120, measured).
|
|
555
|
+
if (/:([ \t]|$)/.test(value)) {
|
|
218
556
|
throw new FactParseError(`a plain scalar containing ": " is not readable YAML — PyYAML rejects it too: ${JSON.stringify(value)}`);
|
|
219
557
|
}
|
|
220
558
|
return [resolvePlain(value), i];
|
|
@@ -370,10 +708,11 @@ function scanBlockSequence(front, pos) {
|
|
|
370
708
|
throw new FactParseError('a nested sequence item is not in this codec\'s language');
|
|
371
709
|
}
|
|
372
710
|
items.push(value);
|
|
373
|
-
|
|
374
|
-
|
|
711
|
+
const after = skipTrailingComment(front, next);
|
|
712
|
+
if (after < front.length && front[after] !== '\n') {
|
|
713
|
+
throw new FactParseError(`trailing content after a sequence item: ${line(front, after)}`);
|
|
375
714
|
}
|
|
376
|
-
i =
|
|
715
|
+
i = after + 1;
|
|
377
716
|
}
|
|
378
717
|
return [items, i];
|
|
379
718
|
}
|
package/dist/memory/layers.d.ts
CHANGED
|
@@ -90,3 +90,22 @@ export declare function countFacts(root: string): number;
|
|
|
90
90
|
* surface at construction, not silently drop. Returned grant paths are fully resolved.
|
|
91
91
|
*/
|
|
92
92
|
export declare function loadGrants(projectStore: string): string[];
|
|
93
|
+
/**
|
|
94
|
+
* job65/#158 — the sentence both runtimes give for a tab PyYAML will not scan. Python
|
|
95
|
+
* rewrites PyYAML's `found character '\t' that cannot start any token` into exactly this,
|
|
96
|
+
* from the error's own mark; this port finds the same byte with `firstTokenTab`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function tabSentence(line: number, column: number): string;
|
|
99
|
+
/**
|
|
100
|
+
* The first tab PyYAML's scanner would refuse, as 1-based `[line, column]`, or `null`.
|
|
101
|
+
*
|
|
102
|
+
* MEASURED, not read off the spec: PyYAML 6.0.3's `scan_to_next_token` skips SPACES only, and
|
|
103
|
+
* a plain scalar stops at a tab, so every tab outside a quoted scalar and outside a comment
|
|
104
|
+
* is `found character '\t' that cannot start any token` — leading indentation, after `:`,
|
|
105
|
+
* after `-`, inside `[ ]`, between a value and its comment, trailing, alone on a line, and
|
|
106
|
+
* after `---`. A tab inside `'...'`/`"..."` or after a ` #` is accepted. The probe battery
|
|
107
|
+
* is in `.shiftwork/notes-job65/J65-10.md`. A quote OPENS a scalar only where a token can
|
|
108
|
+
* start (line start, or after a space, `[`, `{` or `,`) — `it's` is a plain scalar — and an
|
|
109
|
+
* open quote carries across lines, as a flow scalar does.
|
|
110
|
+
*/
|
|
111
|
+
export declare function firstTokenTab(text: string): [number, number] | null;
|