bantamkit-mcp 0.29.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/assets/tools/memory_dream.json +20 -0
- package/assets/tools/repo_map.json +44 -0
- package/dist/docread.d.ts +82 -8
- package/dist/docread.js +348 -96
- package/dist/eventlog.js +5 -4
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/mcp/pyargs.d.ts +20 -1
- package/dist/mcp/pyargs.js +80 -1
- package/dist/mcp/server.d.ts +36 -6
- package/dist/mcp/server.js +280 -39
- package/dist/memory/cli.js +44 -2
- package/dist/memory/component.d.ts +73 -2
- package/dist/memory/component.js +157 -5
- package/dist/memory/dream.d.ts +247 -0
- package/dist/memory/dream.js +903 -0
- package/dist/memory/factfile.d.ts +13 -1
- package/dist/memory/factfile.js +7 -17
- package/dist/memory/pyfs.d.ts +20 -12
- package/dist/memory/pyfs.js +21 -51
- package/dist/memory/store.d.ts +88 -1
- package/dist/memory/store.js +168 -5
- package/dist/pysem.d.ts +81 -0
- package/dist/pysem.js +126 -0
- package/dist/repomap.d.ts +247 -0
- package/dist/repomap.js +1040 -0
- package/dist/skillaudit.js +31 -38
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* indistinguishable from a typo: a manifest renamed or a surface dropped would silently
|
|
23
23
|
* shrink the served set, and the field would be a comment.
|
|
24
24
|
*/
|
|
25
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
26
26
|
import { dirname, join } from 'node:path';
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
@@ -31,6 +31,7 @@ import { AssetNotFound, assetsRoot, loadSkill, loadToolAsset } from '../assets.j
|
|
|
31
31
|
import { BantamError } from '../errors.js';
|
|
32
32
|
import { bantamkitReadUnknownPart, documentError, documentManifest, documentOffsetPastEnd, documentPage, schemaError, toolFailed, } from '../contract.js';
|
|
33
33
|
import * as docread from '../docread.js';
|
|
34
|
+
import * as repomap from '../repomap.js';
|
|
34
35
|
import * as skillaudit from '../skillaudit.js';
|
|
35
36
|
import { EventLog } from '../eventlog.js';
|
|
36
37
|
import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
|
|
@@ -41,13 +42,14 @@ import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js'
|
|
|
41
42
|
import { sdkJson } from './sdkjson.js';
|
|
42
43
|
import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
|
|
43
44
|
/**
|
|
44
|
-
* The
|
|
45
|
+
* The thirteen, in the order `build_server` lists them — which is the order `tools/list` emits.
|
|
45
46
|
*
|
|
46
47
|
* `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
|
|
47
|
-
* beside `memory_save` where a reader would look for it, `bantamkit_read` after that
|
|
48
|
-
* `skill_audit` after that,
|
|
49
|
-
*
|
|
50
|
-
* every existing declaration
|
|
48
|
+
* beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
|
|
49
|
+
* `skill_audit` after that, `memory_dream` after that and `repo_map` after that, for the
|
|
50
|
+
* same reason the reference appends all six: registration order IS the served order, and
|
|
51
|
+
* appending is the only edit that leaves the other twelve where every existing declaration
|
|
52
|
+
* says they are.
|
|
51
53
|
*/
|
|
52
54
|
export const MCP_TOOLS = [
|
|
53
55
|
'memory_save',
|
|
@@ -61,6 +63,8 @@ export const MCP_TOOLS = [
|
|
|
61
63
|
'memory_compact',
|
|
62
64
|
'bantamkit_read',
|
|
63
65
|
'skill_audit',
|
|
66
|
+
'memory_dream',
|
|
67
|
+
'repo_map',
|
|
64
68
|
];
|
|
65
69
|
/**
|
|
66
70
|
* The advertisement for one tool, out of its manifest, with the surface checked.
|
|
@@ -147,6 +151,65 @@ const asText = (value) => (value && value.t === 'str' ? value.v : '');
|
|
|
147
151
|
function asInt(value) {
|
|
148
152
|
return value && value.t === 'int' ? Number(value.v) : null;
|
|
149
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* The errno family `Path.exists()` and `Path.is_dir()` answer `False` for instead of
|
|
156
|
+
* raising — CPython `pathlib`'s `_IGNORED_ERRNOS`, spelled as Node's `code` strings.
|
|
157
|
+
*
|
|
158
|
+
* Not a guess and not a superset: anything OUTSIDE this set makes both predicates raise on
|
|
159
|
+
* the reference, so it must reach `recordRaise` here too. A `statSync` wrapped in a bare
|
|
160
|
+
* `catch { }` would turn a permission failure into "no such directory", which is a
|
|
161
|
+
* different sentence about a different problem.
|
|
162
|
+
*/
|
|
163
|
+
const EXISTS_IGNORED = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP']);
|
|
164
|
+
/**
|
|
165
|
+
* The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
|
|
166
|
+
* byte-identical to the reference's `REPO_MAP_TAIL`.
|
|
167
|
+
*
|
|
168
|
+
* Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
|
|
169
|
+
* and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
|
|
170
|
+
* is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
|
|
171
|
+
* PRECISION feature. A surface that let a caller believe the map is a token saving would
|
|
172
|
+
* say the one thing the measurement forbids, so the refutation travels with every answer
|
|
173
|
+
* rather than living only in a doc nobody reads at call time.
|
|
174
|
+
*
|
|
175
|
+
* The second sentence is the budget's unit, for the same reason: `DEFAULT_BUDGET = 4000` is
|
|
176
|
+
* "1 K tokens" only at the char/4 convention, whose error bar is unmeasured because
|
|
177
|
+
* measuring it needs the tokenizer the pure-node ruling forbids.
|
|
178
|
+
*/
|
|
179
|
+
export const REPO_MAP_TAIL = 'This is a precision pass, not a token saving: this feature\'s build gate was REFUTED ' +
|
|
180
|
+
'by measurement — discovery is 0.114% of real prompt tokens, because 97.8% of the ' +
|
|
181
|
+
'bill is cache_read — so a map does not make a session cheaper. What it buys is the ' +
|
|
182
|
+
'right file found sooner.\n' +
|
|
183
|
+
'The budget above is UTF-8 BYTES of listing, not tokens: neither runtime carries a ' +
|
|
184
|
+
'model tokenizer and this tool will not pretend to one.';
|
|
185
|
+
/** What a listing says when there is nothing to list. The reference's `REPO_MAP_EMPTY`. */
|
|
186
|
+
export const REPO_MAP_EMPTY = '(nothing listed: no file under this root scanned into a definition)';
|
|
187
|
+
/**
|
|
188
|
+
* The `repo_map` tool's prose, byte for byte, from the structured result.
|
|
189
|
+
*
|
|
190
|
+
* Split out of the handler for the same reason the reference splits `repo_map_reply` out of
|
|
191
|
+
* its own: one shape to reproduce rather than a format string buried in a `case`, and the
|
|
192
|
+
* `repomap` conformance suite compares the rendered reply without standing up a server. No
|
|
193
|
+
* float is ever rendered here — `repomap`'s trap (8); every number on the header line is an
|
|
194
|
+
* integer, and `String(1.0)` is `'1'` where `repr(1.0)` is `'1.0'`.
|
|
195
|
+
*/
|
|
196
|
+
export function repoMapReply(result) {
|
|
197
|
+
const focus = result.focus.length > 0
|
|
198
|
+
? result.focus.join(', ')
|
|
199
|
+
: '(none) — plain centrality over the whole tree';
|
|
200
|
+
const head = `repo map: ${result.nodes} files scanned, ${result.definitions} definitions, ` +
|
|
201
|
+
`${result.edges} edges.\n` +
|
|
202
|
+
`focus: ${focus}\n` +
|
|
203
|
+
`budget: ${result.budget} UTF-8 bytes; listing ${result.listingBytes} bytes; ` +
|
|
204
|
+
`rendered ${result.filesRendered} files, ` +
|
|
205
|
+
`${result.definitionsRendered} definitions.`;
|
|
206
|
+
const body = result.text !== '' ? result.text : REPO_MAP_EMPTY;
|
|
207
|
+
return `${head}\n\n${body}\n\n${REPO_MAP_TAIL}`;
|
|
208
|
+
}
|
|
209
|
+
/** `bool | None` after validation: the tagged bool, or `null` for the default. */
|
|
210
|
+
function asBool(value) {
|
|
211
|
+
return value && value.t === 'bool' ? value.v : null;
|
|
212
|
+
}
|
|
150
213
|
/**
|
|
151
214
|
* Run `body` and, if it throws, record the exception's TYPE before letting it fly on.
|
|
152
215
|
*
|
|
@@ -173,6 +236,87 @@ function recordRaise(log, tool, body) {
|
|
|
173
236
|
throw e;
|
|
174
237
|
}
|
|
175
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* The last `docread.extract` result this server produced, and what it was OF.
|
|
241
|
+
*
|
|
242
|
+
* The port of `mcpserver._DocumentCache` (register entry (i), `docs/roadmap-toolbox.md` row
|
|
243
|
+
* 8). `bantamkit_read` re-parsed the whole document on EVERY call, so a caller paging a
|
|
244
|
+
* 12,001-row sheet in 200-row pages parsed the workbook once per page — paging was O(N^2) in
|
|
245
|
+
* the row window. MEASURED on this machine over a 1,553,944-byte 12,001-row xlsx driven
|
|
246
|
+
* through a real client with `docread.extract` counted by a loader hook: 68 tool calls, **68**
|
|
247
|
+
* parses, 4.128 s for the walk. After, over the same fixture and the same counter: **1**
|
|
248
|
+
* parse, 0.104 s (0.103 / 0.108 on two repeats). The committed, rerunnable form of the same
|
|
249
|
+
* count is `test/document-cache-and-bounds.test.mjs`, at 7 calls rather than 68.
|
|
250
|
+
*
|
|
251
|
+
* ONE entry, deliberately, and for the reference's reason. A single entry evicts whenever two
|
|
252
|
+
* callers alternate between two documents, and that case then costs exactly one parse per
|
|
253
|
+
* call — which is what the code this replaces cost for EVERY case, so the cache cannot make
|
|
254
|
+
* any caller slower than it was (`test/document-cache-and-bounds.test.mjs` counts the
|
|
255
|
+
* alternating walk and pins the number). What more entries would cost is resident memory: a
|
|
256
|
+
* `Document` holds its whole rendering as strings, bounded per document by
|
|
257
|
+
* `docread.TEXT_MAX_BYTES` and `docread.XLSX_MAX_TEXT_BYTES` (16 MiB each), and every extra
|
|
258
|
+
* entry multiplies that ceiling by one.
|
|
259
|
+
*
|
|
260
|
+
* **The key is (realpath, size, mtimeNs), and it has one hole — stated, not implied.** A
|
|
261
|
+
* rewrite that lands inside a single filesystem timestamp tick AND leaves the byte count
|
|
262
|
+
* unchanged is indistinguishable from no rewrite at all, and would be served from the stale
|
|
263
|
+
* parse. `mtimeNs` is nanosecond-SHAPED and not nanosecond-GRAINED: what it reports is
|
|
264
|
+
* whatever the filesystem stored, which on HFS+ is one second and on APFS/ext4 is finer but
|
|
265
|
+
* not unbounded. The alternative — hashing the bytes — would re-read the file this cache
|
|
266
|
+
* exists to avoid re-reading, which is the whole cost on the large documents that motivate
|
|
267
|
+
* it. So the hole stays, and the test that proves the key works changes the SIZE rather than
|
|
268
|
+
* racing the clock, because a test that raced it would be measuring the filesystem.
|
|
269
|
+
*
|
|
270
|
+
* THE CACHED VALUE IS THE RESULT, NEVER A READER. `docread.extract` hands its own `ZipReader`
|
|
271
|
+
* down to `extractXlsx`/`extractDocx` so an archive is read from disk once instead of twice,
|
|
272
|
+
* and that reader's lifetime ends with the `extract` call that opened it. This sits one layer
|
|
273
|
+
* above and holds a finished `Document`; no file handle outlives a call because of it.
|
|
274
|
+
*/
|
|
275
|
+
class DocumentCache {
|
|
276
|
+
key = null;
|
|
277
|
+
doc = null;
|
|
278
|
+
/** The cached document for `key`, or `null` — a `null` key never matches. */
|
|
279
|
+
get(key) {
|
|
280
|
+
if (key === null || key !== this.key) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
return this.doc;
|
|
284
|
+
}
|
|
285
|
+
put(key, doc) {
|
|
286
|
+
if (key === null) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.key = key;
|
|
290
|
+
this.doc = doc;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* What identifies the bytes at `path` — or `null`, which means "do not cache".
|
|
295
|
+
*
|
|
296
|
+
* `statSync` FOLLOWS symlinks, which is the same file the reader is about to open, and
|
|
297
|
+
* `{bigint: true}` is not decoration: `Stats.mtimeMs` is a float and has already lost the
|
|
298
|
+
* sub-millisecond digits the reference's `st_mtime_ns` keeps, so a key built from it would
|
|
299
|
+
* have a coarser hole than the one documented above. `size` and `mtimeNs` are read from the
|
|
300
|
+
* bigint view for the same reason.
|
|
301
|
+
*
|
|
302
|
+
* Any error here returns `null` rather than refusing: this is a cache key, and the refusal a
|
|
303
|
+
* caller reads must be the one `docread.extract` raises in the reader's own words, not one
|
|
304
|
+
* this function invented from a different syscall's errno. That covers the one shape the
|
|
305
|
+
* reference does not have — `os.path.realpath` is non-strict in CPython and answers even for
|
|
306
|
+
* a path that does not resolve, while `realpathSync` throws. A path that stats but will not
|
|
307
|
+
* resolve is therefore uncached here and cached there; nothing a client can observe moves,
|
|
308
|
+
* because the reply is the reader's either way.
|
|
309
|
+
*/
|
|
310
|
+
function documentKey(path) {
|
|
311
|
+
try {
|
|
312
|
+
const stat = statSync(path, { bigint: true });
|
|
313
|
+
// NUL cannot occur in a path on either platform, so the join is unambiguous.
|
|
314
|
+
return `${realpathSync(path)}\0${stat.size}\0${stat.mtimeNs}`;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
176
320
|
/**
|
|
177
321
|
* Run a shiftwork handler and record its `result` — the register's OWN verdict.
|
|
178
322
|
*
|
|
@@ -230,7 +374,7 @@ function recordResult(log, tool, call) {
|
|
|
230
374
|
* byte-compares, and would make the log move when nothing the tool did moved. `noted` and
|
|
231
375
|
* `notedDict` below therefore run last, on the way out.
|
|
232
376
|
*/
|
|
233
|
-
function runTool(name, memory, args, version, log) {
|
|
377
|
+
function runTool(name, memory, args, version, log, documents) {
|
|
234
378
|
/**
|
|
235
379
|
* The footer for right now — recomputed per call, never cached.
|
|
236
380
|
*
|
|
@@ -367,6 +511,29 @@ function runTool(name, memory, args, version, log) {
|
|
|
367
511
|
});
|
|
368
512
|
return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
|
|
369
513
|
}
|
|
514
|
+
case 'memory_dream': {
|
|
515
|
+
// Consolidate what the project layer and the machine-wide profile layer both hold
|
|
516
|
+
// under one name (job45, roadmap row 5). `dryRun` DEFAULTS TO TRUE and the default
|
|
517
|
+
// lives HERE rather than in the component, exactly as the reference's handler carries
|
|
518
|
+
// it: a client that omits the argument sends nothing, `asBool` answers `null`, and
|
|
519
|
+
// turning that into the SAFE answer is this handler's job. It is the only tool on
|
|
520
|
+
// this surface that writes into the user's home directory, and the only one whose
|
|
521
|
+
// effect is machine-wide — a fact archived out of the profile store stops answering
|
|
522
|
+
// for every other project on this machine with no store of its own.
|
|
523
|
+
//
|
|
524
|
+
// THE RECORD IS A DECISION. `DreamOutcome.status` is read off `DreamResult.applied`,
|
|
525
|
+
// `.overBudget` and `.changes` — never off the reply, whose prose is allowed to
|
|
526
|
+
// improve without moving a single byte of the event log.
|
|
527
|
+
const dryRun = asBool(args.get('dry_run'));
|
|
528
|
+
const outcome = recordRaise(log, 'memory_dream', () => memory.dreamOutcome(dryRun === null ? true : dryRun));
|
|
529
|
+
log.record('memory_dream', outcome.status, {
|
|
530
|
+
absolutised: outcome.absolutised,
|
|
531
|
+
consumed: outcome.consumed,
|
|
532
|
+
dry_run: outcome.dryRun,
|
|
533
|
+
merged: outcome.merged,
|
|
534
|
+
});
|
|
535
|
+
return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
|
|
536
|
+
}
|
|
370
537
|
case 'bantamkit_read': {
|
|
371
538
|
// The reader on the MCP surface (job43): `docread` digests, `contract` words it. The
|
|
372
539
|
// handler makes the SAME `contract` calls the reference's `bantamkit_read` makes,
|
|
@@ -403,37 +570,46 @@ function runTool(name, memory, args, version, log) {
|
|
|
403
570
|
// `document_error` like the encrypted one. Until job43 G2 this arm ran `extract`
|
|
404
571
|
// outside `recordRaise` with a hand-copied catch.
|
|
405
572
|
return recordRaise(log, 'bantamkit_read', () => {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
573
|
+
// (i): the parse is cached on (realpath, size, mtimeNs), so a paging walk over one
|
|
574
|
+
// unchanged document parses it once. `documentKey` answering `null` means "do not
|
|
575
|
+
// cache" and leaves this arm exactly as it was.
|
|
576
|
+
const key = documentKey(path);
|
|
577
|
+
let doc = documents.get(key);
|
|
578
|
+
if (doc === null) {
|
|
579
|
+
try {
|
|
580
|
+
doc = docread.extract(path);
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
// `except (docread.DocumentReadError, OSError)`. A Node fs error is CPython's
|
|
584
|
+
// `OSError` with the sentence rebuilt by `asPyOSError` — `[Errno 13] Permission
|
|
585
|
+
// denied: '<path>'` for a file this process may not open — through the CRT arm,
|
|
586
|
+
// because `open()` is the call the reference makes. A `BadZipFile` never reaches
|
|
587
|
+
// this arm on either side: `docread.ts`'s `zipKind`/`openZip` catch it exactly
|
|
588
|
+
// where `docread.py`'s `_zip_kind`/`_open` do, and what leaves them is the
|
|
589
|
+
// sentence naming what the reader saw, never the zip module's.
|
|
590
|
+
// A `PyOSError` the reader raised itself is already the reference's sentence —
|
|
591
|
+
// `[Errno 22] Invalid argument` from a zip whose member offset is negative, with
|
|
592
|
+
// NO filename, because the `seek` that fails there has none. Re-wrapping it would
|
|
593
|
+
// append `: '<path>'`.
|
|
594
|
+
// ONLY a filesystem rejection is an `OSError`: numeric `errno` AND the `syscall`
|
|
595
|
+
// it came from. A string `code` alone is not one — `Z_DATA_ERROR`,
|
|
596
|
+
// `ERR_INVALID_ARG_VALUE` and `ERR_STRING_TOO_LONG` all carry one, and until job43
|
|
597
|
+
// G2 each was printed as a fabricated `[Errno 0] …` sentence where the reference
|
|
598
|
+
// raises (the corrupt stream) or says `no such file` (the NUL byte, which
|
|
599
|
+
// `docread.statPath` now answers before this arm is reached).
|
|
600
|
+
const refusal = e instanceof docread.DocumentReadError || e instanceof PyOSError
|
|
601
|
+
? e
|
|
602
|
+
: isFsError(e)
|
|
603
|
+
? asPyOSError(e, path, undefined, 'crt')
|
|
604
|
+
: null;
|
|
605
|
+
if (refusal === null)
|
|
606
|
+
throw e;
|
|
607
|
+
log.record('bantamkit_read', 'refused-unreadable');
|
|
608
|
+
return { value: { t: 'str', v: noted(documentError(refusal)) }, wrapped: true };
|
|
609
|
+
}
|
|
610
|
+
// A refusal is NOT cached: the entry holds a parse, and a path that has no parse
|
|
611
|
+
// must reach the reader again next call so the reply stays the reader's own.
|
|
612
|
+
documents.put(key, doc);
|
|
437
613
|
}
|
|
438
614
|
const detail = { kind: doc.kind, parts: doc.parts.length };
|
|
439
615
|
if (part === null) {
|
|
@@ -555,6 +731,67 @@ function runTool(name, memory, args, version, log) {
|
|
|
555
731
|
return { value: { t: 'str', v: noted(result.asJson()) }, wrapped: true };
|
|
556
732
|
});
|
|
557
733
|
}
|
|
734
|
+
case 'repo_map': {
|
|
735
|
+
// The ranked definition map on the MCP surface (job45 row 10): `repomap` measures,
|
|
736
|
+
// this serves it. Byte for byte the reference's `repo_map` handler.
|
|
737
|
+
//
|
|
738
|
+
// THE THREE REFUSALS LIVE HERE AND NOT IN `repomap.ts`, and that is deliberate.
|
|
739
|
+
// `repoMap()` over a root that does not exist answers an EMPTY map on both runtimes
|
|
740
|
+
// — `os.walk` yields nothing for a missing directory and `walkSources`' `readdirSync`
|
|
741
|
+
// catch does the same — which is the right answer for a library and the wrong one for
|
|
742
|
+
// a tool: a caller who typed the path wrong would be told the tree holds no source.
|
|
743
|
+
// So the argument checks are the SURFACE's, and the engine J45-9/J45-10 proved
|
|
744
|
+
// byte-identical is not touched.
|
|
745
|
+
//
|
|
746
|
+
// THE `statSync` ARM IS `Path.exists()` / `Path.is_dir()`, NOT A SHORTCUT FOR THEM.
|
|
747
|
+
// Both pathlib predicates SWALLOW the not-here errno family and re-raise anything
|
|
748
|
+
// else, so `EXISTS_IGNORED` below is that family spelled out: a dangling symlink and
|
|
749
|
+
// `a-file.py/sub` are "no such directory" on both runtimes, while a permission
|
|
750
|
+
// failure flies to `recordRaise` rather than being dressed up as a missing tree.
|
|
751
|
+
//
|
|
752
|
+
// THE RECORD IS A DECISION AND HOLDS NO PATH. `root` is what the operator typed and
|
|
753
|
+
// `focus` is the name of the file they are editing; neither is a decision this
|
|
754
|
+
// handler made, so neither is written down. The counts are.
|
|
755
|
+
const root = asText(args.get('root'));
|
|
756
|
+
const focusArg = args.get('focus');
|
|
757
|
+
const focus = focusArg !== undefined && focusArg.t === 'list'
|
|
758
|
+
? focusArg.v.map((item) => (item.t === 'str' ? item.v : ''))
|
|
759
|
+
: [];
|
|
760
|
+
const budgetArg = args.get('budget');
|
|
761
|
+
const budget = budgetArg !== undefined && budgetArg.t === 'int' ? Number(budgetArg.v) : repomap.DEFAULT_BUDGET;
|
|
762
|
+
return recordRaise(log, 'repo_map', () => {
|
|
763
|
+
const refuse = (detail) => {
|
|
764
|
+
log.record('repo_map', 'refused');
|
|
765
|
+
return { value: { t: 'str', v: noted(toolFailed('repo_map', detail)) }, wrapped: true };
|
|
766
|
+
};
|
|
767
|
+
if (root === '')
|
|
768
|
+
return refuse('root must not be empty; name the directory to map');
|
|
769
|
+
if (budget < 0)
|
|
770
|
+
return refuse(`budget must not be negative; got ${budget}`);
|
|
771
|
+
let stat = null;
|
|
772
|
+
try {
|
|
773
|
+
stat = statSync(root);
|
|
774
|
+
}
|
|
775
|
+
catch (e) {
|
|
776
|
+
const code = e?.code;
|
|
777
|
+
if (!EXISTS_IGNORED.has(String(code)))
|
|
778
|
+
throw asPyOSError(e, root);
|
|
779
|
+
}
|
|
780
|
+
if (stat === null)
|
|
781
|
+
return refuse(`no such directory: ${root}`);
|
|
782
|
+
if (!stat.isDirectory())
|
|
783
|
+
return refuse(`${root} is a file, not a directory to map`);
|
|
784
|
+
const result = repomap.repoMap(root, { focus, budget });
|
|
785
|
+
log.record('repo_map', 'mapped', {
|
|
786
|
+
definitions: result.definitions,
|
|
787
|
+
edges: result.edges,
|
|
788
|
+
files_rendered: result.filesRendered,
|
|
789
|
+
listing_bytes: result.listingBytes,
|
|
790
|
+
nodes: result.nodes,
|
|
791
|
+
});
|
|
792
|
+
return { value: { t: 'str', v: noted(repoMapReply(result)) }, wrapped: true };
|
|
793
|
+
});
|
|
794
|
+
}
|
|
558
795
|
default:
|
|
559
796
|
// `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
|
|
560
797
|
// handler turns into an isError result rather than a JSON-RPC error.
|
|
@@ -589,6 +826,10 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
589
826
|
},
|
|
590
827
|
instructions: loadSkill('memory'),
|
|
591
828
|
});
|
|
829
|
+
// Per SERVER, not per process: two servers in one interpreter (the tests build several)
|
|
830
|
+
// must not answer each other's files, and the entry dies with the server rather than
|
|
831
|
+
// outliving it in a module global. The reference does the same, inside `build_server`.
|
|
832
|
+
const documents = new DocumentCache();
|
|
592
833
|
// Startup, not call time: a tool without a manifest, or with the wrong surface, must stop
|
|
593
834
|
// the process before a client ever sees a `tools/list`. The reference gets this from
|
|
594
835
|
// `MCPServer(..., tools=[...])`; here it is one eager pass.
|
|
@@ -697,7 +938,7 @@ export function buildServer(memory, wire, version, log = EventLog.fromEnv(memory
|
|
|
697
938
|
if (model === undefined)
|
|
698
939
|
throw new BantamError(`Unknown tool: ${name}`);
|
|
699
940
|
const bound = validateArguments(model, args);
|
|
700
|
-
const { value, wrapped } = runTool(name, memory, bound, version, log);
|
|
941
|
+
const { value, wrapped } = runTool(name, memory, bound, version, log, documents);
|
|
701
942
|
// `_convert_to_content` runs BEFORE the `{"result": ...}` wrap, so a str tool's text
|
|
702
943
|
// block is the RAW STRING and a dict tool's is `to_json(..., indent=2)`.
|
|
703
944
|
text = value.t === 'str' ? value.v : sdkJson(value, 2);
|
package/dist/memory/cli.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* `docs/memory.md` states, as a design decision, that `lint`, `archived` and `restore`
|
|
6
6
|
* are **not** agent tools — "lifecycle is an operator decision, not a model decision" —
|
|
7
|
-
* and the
|
|
7
|
+
* and the thirteen-tool surface holds to it; `compact` is the one exception since job42
|
|
8
8
|
* (`memory_compact`, the on-refusal path the model reaches itself). `runtime-py` made that
|
|
9
9
|
* position coherent in August 2026 by giving the operator `python -m bantamkit.memory`.
|
|
10
10
|
* `runtime-ts` did not. MEASURED 2026-08-24 before this file existed: `node dist/cli.js -h`
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
import { BantamError } from '../errors.js';
|
|
68
68
|
import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs, pyIntStrict, } from '../pyargparse.js';
|
|
69
69
|
import { discoverProjectStore } from './layers.js';
|
|
70
|
-
import { pyJoin } from './pyfs.js';
|
|
70
|
+
import { pyJoin, PyOSError } from './pyfs.js';
|
|
71
71
|
import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyText, } from './store.js';
|
|
72
72
|
/** The Node operator's spelling of the reference's `python -m bantamkit.memory`. */
|
|
73
73
|
const PROG = 'bantamkit-memory';
|
|
@@ -314,6 +314,40 @@ function cmdArchived(store) {
|
|
|
314
314
|
out(` ${name}`);
|
|
315
315
|
return 0;
|
|
316
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* job44 (y): an uncaught OS exception used to reach the operator here as a raw stack trace.
|
|
319
|
+
* Measured 2026-09-05 on macOS, both entrances: `archive/` at 0o555 (the guards pass,
|
|
320
|
+
* `pyMkdirParents` is a no-op on a directory that is already there, and the MOVE is refused)
|
|
321
|
+
* and `index.md` as a directory (the move succeeds, `rebuildIndex` raises, the rollback
|
|
322
|
+
* restores the store correctly, and the exception then escapes `main` the same way). Both
|
|
323
|
+
* already exited 1 — Node's default for an uncaught exception — so only the TEXT differed:
|
|
324
|
+
* a `node:fs` / `pyfs.js` stack ending in `PyOSError` here, a CPython traceback ending in
|
|
325
|
+
* `PermissionError: [Errno 13]` on the reference. `PyOSError` does not extend `BantamError`,
|
|
326
|
+
* so it reached neither this catch nor the top-level `BantamError` arm below — it fell all
|
|
327
|
+
* the way to `throw error` and out of the process.
|
|
328
|
+
*
|
|
329
|
+
* THE SENTENCE DELIBERATELY DOES NOT INTERPOLATE THE UNDERLYING ERROR, the same reason
|
|
330
|
+
* `cmdLint`'s budget sentence is not the store's own message: `PyOSError`'s text is a
|
|
331
|
+
* translation of Node's `errno`/`code`, and `OSError`'s is CPython's `strerror` — the two
|
|
332
|
+
* are never going to read the same, so printing either would just move the divergence from
|
|
333
|
+
* a stack trace into a sentence. RECONCILED WITH U7's published wording (job44 handoff).
|
|
334
|
+
*
|
|
335
|
+
* "NOTHING UNDER `store.root` CHANGED" IS A CLAIM, NOT A FLOURISH, and it was FALSE on the
|
|
336
|
+
* reference until U7's own reconciliation: `restore`'s final `_rebuild_index()` used to sit
|
|
337
|
+
* OUTSIDE its only `try`, so the same `index.md`-is-a-directory fault this comment describes
|
|
338
|
+
* left a restored fact stuck in `facts/`, gone from `archive/`, with no rollback at all.
|
|
339
|
+
* MEASURED the same way on this side before printing this sentence, not reasoned from the
|
|
340
|
+
* code shape: driving that exact fault against the pre-reconciliation `restore` here left
|
|
341
|
+
* `facts/alpha.md` present and `archive/` empty afterwards — the same hole, open on both
|
|
342
|
+
* runtimes. `restore`'s two-step tail is now ONE `try` (`checkIndexBudget` and the closing
|
|
343
|
+
* `rebuildIndex` together) for exactly that reason, so this sentence is true when it prints.
|
|
344
|
+
* `archive` never had the hole — its one write step was inside its only `try` from the start
|
|
345
|
+
* — and is untouched by that fix.
|
|
346
|
+
*/
|
|
347
|
+
function osErrorSentence(command, name, store) {
|
|
348
|
+
return (`${command} failed: a filesystem error stopped the move of '${name}'; ` +
|
|
349
|
+
`nothing under ${store.root} changed`);
|
|
350
|
+
}
|
|
317
351
|
function cmdArchive(store, args) {
|
|
318
352
|
try {
|
|
319
353
|
store.archive(args.name);
|
|
@@ -323,6 +357,10 @@ function cmdArchive(store, args) {
|
|
|
323
357
|
err(`archive failed: ${error.message}`);
|
|
324
358
|
return 1;
|
|
325
359
|
}
|
|
360
|
+
if (error instanceof PyOSError) {
|
|
361
|
+
err(osErrorSentence('archive', args.name, store));
|
|
362
|
+
return 1;
|
|
363
|
+
}
|
|
326
364
|
throw error;
|
|
327
365
|
}
|
|
328
366
|
out(`archived '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
|
|
@@ -342,6 +380,10 @@ function cmdRestore(store, args) {
|
|
|
342
380
|
err(`restore failed: ${error.message}`);
|
|
343
381
|
return 1;
|
|
344
382
|
}
|
|
383
|
+
if (error instanceof PyOSError) {
|
|
384
|
+
err(osErrorSentence('restore', args.name, store));
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
345
387
|
throw error;
|
|
346
388
|
}
|
|
347
389
|
out(`restored '${args.name}' — index now ${size(store)}/${store.indexBudget} bytes`);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type StoreBinding } from './layers.js';
|
|
2
2
|
import { MemoryStore } from './store.js';
|
|
3
|
+
import type { DreamResult } from './dream.js';
|
|
3
4
|
/**
|
|
4
5
|
* The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
|
|
5
6
|
*
|
|
@@ -97,6 +98,30 @@ export interface RecallOutcome {
|
|
|
97
98
|
readonly source: string | null;
|
|
98
99
|
readonly unreadable: number;
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* What `dream` DID, beside the sentence it says about it.
|
|
103
|
+
*
|
|
104
|
+
* The same seam as `SaveOutcome`, `RecallOutcome` and `CompactOutcome`: `status` is read off
|
|
105
|
+
* a decision the pass already made — `DreamResult.applied`, `.overBudget`, `.changes` — and
|
|
106
|
+
* never off the reply. `result` carries the whole diff for a caller that wants the numbers
|
|
107
|
+
* rather than the prose.
|
|
108
|
+
*
|
|
109
|
+
* `status` is one of `consolidated`, `previewed`, `nothing-to-consolidate`,
|
|
110
|
+
* `refused-budget`, `no-profile-layer`.
|
|
111
|
+
*/
|
|
112
|
+
export interface DreamOutcome {
|
|
113
|
+
readonly reply: string;
|
|
114
|
+
readonly status: string;
|
|
115
|
+
readonly dryRun: boolean;
|
|
116
|
+
readonly merged: number;
|
|
117
|
+
readonly consumed: number;
|
|
118
|
+
readonly absolutised: number;
|
|
119
|
+
readonly superseded: number;
|
|
120
|
+
readonly indexBefore: number;
|
|
121
|
+
readonly indexAfter: number;
|
|
122
|
+
readonly budget: number;
|
|
123
|
+
readonly result: DreamResult | null;
|
|
124
|
+
}
|
|
100
125
|
export declare class Memory {
|
|
101
126
|
readonly store: MemoryStore;
|
|
102
127
|
readonly k: number;
|
|
@@ -140,7 +165,7 @@ export declare class Memory {
|
|
|
140
165
|
* fact. The floor is the operator's configured default, not a constant, so a consumer who
|
|
141
166
|
* really wants top-1 says so once at construction.
|
|
142
167
|
*/
|
|
143
|
-
recall(query: string, k?: number | null): string;
|
|
168
|
+
recall(query: string, k?: number | null, minRatio?: number): string;
|
|
144
169
|
/**
|
|
145
170
|
* `recall`, carrying the walk it performed as numbers rather than as prose.
|
|
146
171
|
*
|
|
@@ -157,8 +182,18 @@ export declare class Memory {
|
|
|
157
182
|
* refuses to list contributes nothing and raises the `unreadable` count instead of being
|
|
158
183
|
* scored as empty; that distinction is the whole subject of `nothingToReport` below and
|
|
159
184
|
* must not be undone here.
|
|
185
|
+
*
|
|
186
|
+
* `minRatio` (roadmap #6) rides through to every layer's `MemoryStore.recall` unchanged,
|
|
187
|
+
* so the gate is measured against EACH LAYER's own best score and never across layers: a
|
|
188
|
+
* profile fact does not have to out-score the project store's top hit to be admitted,
|
|
189
|
+
* because the two stores are answering as two stores. Its default is
|
|
190
|
+
* `RECALL_MIN_SCORE_RATIO` = 0.0, which keeps every fact `recall` was going to return,
|
|
191
|
+
* and nothing on the tool path passes anything else today. A ratio outside `[0.0, 1.0]`
|
|
192
|
+
* raises out of the FIRST layer, which is the writable project store, so it surfaces as
|
|
193
|
+
* the error it is rather than as an `unreadable` count — the read-only-layer `catch`
|
|
194
|
+
* below would otherwise file a caller's bad argument as a corrupt grant.
|
|
160
195
|
*/
|
|
161
|
-
recallOutcome(query: string, k?: number | null): RecallOutcome;
|
|
196
|
+
recallOutcome(query: string, k?: number | null, minRatio?: number): RecallOutcome;
|
|
162
197
|
/**
|
|
163
198
|
* Archive the stalest facts until a fact as large as the biggest one kept will fit.
|
|
164
199
|
*
|
|
@@ -179,6 +214,42 @@ export declare class Memory {
|
|
|
179
214
|
* is — `MemoryStore.compact` builds it with `pyJoin`.
|
|
180
215
|
*/
|
|
181
216
|
compactOutcome(reserve?: number | null): CompactOutcome;
|
|
217
|
+
/** Consolidate what the project and profile layers hold under the same name. */
|
|
218
|
+
dream(dryRun?: boolean): string;
|
|
219
|
+
/**
|
|
220
|
+
* `dream`, with the decision it took carried beside the sentence it wrote.
|
|
221
|
+
*
|
|
222
|
+
* ONLY THE PROFILE LAYER IS CONSUMED. `layers` also carries read-only GRANTS, and a grant
|
|
223
|
+
* is another operator's store: consolidating a fact out of one is not this person's move
|
|
224
|
+
* to make, so `dream` never looks at them. The label is matched exactly (`profile`), never
|
|
225
|
+
* by prefix, because a grant is labelled `extra:<name>` and a prefix match on a directory
|
|
226
|
+
* called `profile-something` would reach one.
|
|
227
|
+
*
|
|
228
|
+
* A `Memory` constructed directly — not through `layered` — has no profile layer at all,
|
|
229
|
+
* and that is `no-profile-layer` rather than an error: there is nothing to consolidate
|
|
230
|
+
* ACROSS when only one layer is bound.
|
|
231
|
+
*
|
|
232
|
+
* `dryRun` DEFAULTS TO TRUE. This is the only op in this component that writes into the
|
|
233
|
+
* user's home directory, and it is the only one whose effect is machine-wide: a fact
|
|
234
|
+
* archived out of the profile store stops answering for every other project on this
|
|
235
|
+
* machine that has no store of its own. A destructive consolidation nobody can preview is
|
|
236
|
+
* not shippable, so the safe call is the short one.
|
|
237
|
+
*/
|
|
238
|
+
dreamOutcome(dryRun?: boolean): DreamOutcome;
|
|
239
|
+
/**
|
|
240
|
+
* The whole diff as prose: what merged, what was dated, what moved, what it cost.
|
|
241
|
+
*
|
|
242
|
+
* THE HONESTY SENTENCE AT THE END IS NOT DECORATION. Row 5 was planned as a token saving
|
|
243
|
+
* and J45-1 measured that it is not one: the profile store has no `index.md` on disk, its
|
|
244
|
+
* index is derived at read time, and nothing loads it into a prompt — so deduplicating it
|
|
245
|
+
* frees approximately zero prompt bytes. The value is that one ruling now has one copy
|
|
246
|
+
* instead of two that can disagree, and the reply says which of those two things the
|
|
247
|
+
* operator just bought.
|
|
248
|
+
*
|
|
249
|
+
* `formatFixed3` and not `toFixed(3)` for the two similarity figures: see the header of
|
|
250
|
+
* `dream.ts`. CPython's `.3f` rounds ties to EVEN and they are reachable.
|
|
251
|
+
*/
|
|
252
|
+
private static dreamReply;
|
|
182
253
|
/**
|
|
183
254
|
* `(index bytes, budget)` for the writable project store; bytes may be `null`.
|
|
184
255
|
*
|