bantamkit-mcp 0.30.0 → 0.32.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.
@@ -40,15 +40,21 @@
40
40
  * partial identity would agree with every other build that lost the same input.
41
41
  */
42
42
  import { createHash } from 'node:crypto';
43
- import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
44
- import { dirname, join } from 'node:path';
43
+ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, statSync } from 'node:fs';
44
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
45
45
  import { fileURLToPath } from 'node:url';
46
46
  import { AssetNotFound, assetsRoot } from '../assets.js';
47
47
  import { sortedPathParts } from '../memory/pyfs.js';
48
48
  import { PyOSError, asPyOSError } from '../memory/pyfs.js';
49
49
  export const SERVER_NAME = 'bantamkit';
50
- /** A build fact that could not be derived, carrying WHY. Never becomes a value. */
51
- class Undetermined extends Error {
50
+ /**
51
+ * A build fact that could not be derived, carrying WHY. Never becomes a value.
52
+ *
53
+ * Exported since job46: `bantamkit_status` has to distinguish "this install could not be
54
+ * classified" — which is a REPORTED gap and not a degraded server — from a real fault, and
55
+ * `instanceof` is the only way to do that without matching on a message.
56
+ */
57
+ export class Undetermined extends Error {
52
58
  }
53
59
  /**
54
60
  * The shape an underivable fact takes on the wire — `RB-P51`, unchanged from Python.
@@ -221,6 +227,437 @@ function assetsFingerprint() {
221
227
  throw new Undetermined(`asset pack at ${root} contains no files`);
222
228
  return { digest: walked.digest, files: walked.files, root };
223
229
  }
230
+ // ---------------------------------------------------------------------------------------
231
+ // WHICH INSTALL SHAPE IS RUNNING — offline, from this server's own location.
232
+ //
233
+ // THE DEFECT IS A NODE DEFECT AND IT IS MEASURED (`docs/roadmap-agent-stack.md` AS-7). A
234
+ // Claude Desktop entry sat on 0.25.0 from 2026-08-24 through five releases with nothing in
235
+ // the config, the logs or any tool reply saying so — and its `package.json` declared the
236
+ // dependency as `"bantamkit-mcp": "file:/private/tmp/.../scratchpad/bantamkit-mcp-0.25.0.tgz"`,
237
+ // a local tarball in a temp directory that no longer existed. An update run where that install
238
+ // lives is a no-op BY CONSTRUCTION, and nothing anywhere said which of those two things was
239
+ // wrong. Four of those caches are still on this machine under `~/.npm/_npx/*/`.
240
+ //
241
+ // WHY THIS IS A REFUSAL AND NOT A LOOKUP. The origin is written down by the installer, on this
242
+ // disk, in `node_modules/.package-lock.json` — npm's hidden lockfile, the Node counterpart of
243
+ // PEP 610's `direct_url.json`. Whether that path still exists is a `stat`. NO NETWORK IS
244
+ // TOUCHED ON ANY PATH BELOW: comparing the running version against a registry is AS-7(b), a
245
+ // separate unit, gated behind this one, and deliberately opt-in because an offline toolbox
246
+ // must not grow a network call in its health check.
247
+ //
248
+ // AMENDED 2026-09-11, J46-31: AS-7(b) HAS SHIPPED, as `--update`, and the first sentence
249
+ // above is still exactly true — that is the point of recording it here. The registry
250
+ // comparison lives in `selfupdate.ts`, reached only from the flag in `cli.ts`, never from any
251
+ // path below and never from `bantamkit_status`. The clause that is now dated is "gated behind
252
+ // this one": the user reversed AS-7 on 2026-09-11 and the gate was overridden rather than met.
253
+ // "Deliberately opt-in" survived the reversal intact and is the reason `--update` is a CLI
254
+ // flag and not an MCP tool — see the ruling appended to AS-7.
255
+ //
256
+ // SHAPE IS LOCATION, NEVER IDENTITY. It is reported for the same reason `package_path` and
257
+ // `interpreter` are — it is what a person acts on — and it is kept OUT of `build_id` for the
258
+ // same reason they are: one build installed two ways is ONE build, and
259
+ // `test/install-shape.test.mjs` recomputes the hash from its five named inputs to prove it.
260
+ //
261
+ // WHAT NPM ACTUALLY WRITES, MEASURED AGAINST npm 11.6.2 BEFORE ANY OF THIS WAS WRITTEN — every
262
+ // branch below is a record that was read off this machine, not a shape recalled from docs:
263
+ //
264
+ // tarball `{"resolved": "file:../x.tgz"}` — `file:` then a RAW path, relative to the
265
+ // directory that owns `node_modules`, and NOT percent-encoded (measured with a
266
+ // space in the directory name, which is the case that actually turns up).
267
+ // `file:` dir `{"resolved": "../src", "link": true}` — no `file:` prefix at all, and the
268
+ // package directory under `node_modules` is a real symlink.
269
+ // registry `{"resolved": "https://registry.npmjs.org/…"}`.
270
+ // global `<npm prefix -g>/lib/node_modules` carries NO `.package-lock.json` at all,
271
+ // and npm 7+ writes no `_resolved` into the installed `package.json` either.
272
+ // npx the cache project's own `package.json` carries `"_npx": {"packages": [...]}`.
273
+ // ---------------------------------------------------------------------------------------
274
+ /**
275
+ * The closed vocabulary, spelled the same way in both runtimes, and it answers ONE question:
276
+ * what would updating this install even mean?
277
+ *
278
+ * registry came from a package index (npm here, PyPI there). Reinstall by name.
279
+ * local-file came from a path on THIS machine — an archive or a directory whose contents
280
+ * were copied in. The path is recorded and may be gone.
281
+ * linked a directory on this machine is still the source being read: a `file:` dir or
282
+ * an `npm link` symlink here, an editable install there. Update that tree.
283
+ * checkout no installer recorded this tree at all. Git.
284
+ * ephemeral a temporary environment discarded after the run. Nothing to update.
285
+ *
286
+ * `ephemeral` IS ANSWERED HERE AND NEVER BY `runtime-py`, which is the one divergence in this
287
+ * surface: npm writes an `_npx` marker into the cache project's own `package.json`, so this is
288
+ * a RECORD, while a `pipx run` or `uvx` environment is not distinguishable from an ordinary
289
+ * venv without pattern-matching cache directory names — a guess, and that surface does not
290
+ * guess. The word is declared on BOTH sides anyway so a consumer of either runtime handles ONE
291
+ * set of five rather than two sets it has to reconcile.
292
+ */
293
+ export const INSTALL_SHAPES = ['registry', 'local-file', 'linked', 'checkout', 'ephemeral'];
294
+ const NODE_MODULES = 'node_modules';
295
+ const REGISTRY = () => ({
296
+ shape: 'registry',
297
+ source: null,
298
+ sourceReason: 'a registry install records no origin path on this machine: npm writes a local origin ' +
299
+ 'into `node_modules/.package-lock.json` only for a `file:` path or a link, and this ' +
300
+ 'install has none. Reinstall by name to move it.',
301
+ });
302
+ const CHECKOUT_REASON = 'no installer recorded this tree, so there is no origin path to check — the source IS ' +
303
+ '`package_path`, and it is updated where it was cloned.';
304
+ const EPHEMERAL_REASON = 'an ephemeral install records no origin path on this machine: npm resolves the `npx` cache ' +
305
+ 'again on the next run rather than updating it in place, so there is nothing here to ' +
306
+ 'refresh. Change the spec the host command line names to move it.';
307
+ /** Parsed JSON from a file, or `null` when the file is missing, unreadable or not an object. */
308
+ function readJsonObject(path) {
309
+ let raw;
310
+ try {
311
+ raw = readFileSync(path, 'utf8');
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ let parsed;
317
+ try {
318
+ parsed = JSON.parse(raw);
319
+ }
320
+ catch {
321
+ return null;
322
+ }
323
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
324
+ ? parsed
325
+ : null;
326
+ }
327
+ /**
328
+ * A recorded origin as a path on this machine, or `null` when it names neither.
329
+ *
330
+ * TWO FORMS, AND THE DIFFERENCE IS MEASURED. npm writes `file:` followed by a RAW path — a
331
+ * space in a person's own directory arrives as a space, not as `%20`, so decoding that form
332
+ * would corrupt any real filename containing a literal `%`. The `file://` URL form IS
333
+ * percent-encoded, carries a leading slash before a Windows drive letter that is not part of
334
+ * the path, and may name `localhost`; `url.fileURLToPath` handles it, and is not used here
335
+ * because it throws on the bare form npm actually writes and would have to be guarded either
336
+ * way. A `file://` URL with a real authority (`file://otherhost/share`) names a path on a
337
+ * machine that is not this one, so it is not a local origin and the caller is told so.
338
+ */
339
+ function localOriginPath(resolved, base) {
340
+ if (!resolved.startsWith('file:'))
341
+ return null;
342
+ const rest = resolved.slice('file:'.length);
343
+ if (!rest.startsWith('//'))
344
+ return resolve(base, rest);
345
+ let path = rest.slice(2);
346
+ if (path.startsWith('localhost/'))
347
+ path = path.slice('localhost'.length);
348
+ if (!path.startsWith('/'))
349
+ return null;
350
+ path = decodeURIComponent(path);
351
+ // `/C:/x` is `C:/x`; a POSIX path never matches this shape.
352
+ if (path.length > 2 && /[A-Za-z]/.test(path[1]) && path[2] === ':')
353
+ path = path.slice(1);
354
+ return resolve(base, path);
355
+ }
356
+ /** `Path.is_relative_to`, tolerating a `parent` that is a symlink or does not exist. */
357
+ function isWithin(child, parent) {
358
+ for (const candidate of [parent, safeRealpath(parent)]) {
359
+ if (candidate === null)
360
+ continue;
361
+ const step = relative(candidate, child);
362
+ if (step === '' || (!step.startsWith('..') && !step.startsWith(`${sep}`)))
363
+ return true;
364
+ }
365
+ return false;
366
+ }
367
+ /** `Path.is_symlink()`: false for a missing path and for anything that is not a link. */
368
+ function isSymlink(path) {
369
+ try {
370
+ return lstatSync(path).isSymbolicLink();
371
+ }
372
+ catch (e) {
373
+ if (e.code === undefined)
374
+ throw e;
375
+ return false;
376
+ }
377
+ }
378
+ function safeRealpath(path) {
379
+ try {
380
+ return realpathSync.native(path);
381
+ }
382
+ catch {
383
+ return null;
384
+ }
385
+ }
386
+ /**
387
+ * The package that OWNS the running file: the nearest `package.json` above it.
388
+ *
389
+ * The nearest one and not a named one, so a rename of the published package does not silently
390
+ * turn every install into a `checkout`. It is the same question `_dist_owns` asks on the
391
+ * Python side — which distribution put the file that is running on disk — answered the way
392
+ * Node's own resolver answers it.
393
+ */
394
+ function packageRootFor(startDir) {
395
+ let here = resolve(startDir);
396
+ for (;;) {
397
+ const manifest = readJsonObject(join(here, 'package.json'));
398
+ if (manifest !== null) {
399
+ const name = manifest['name'];
400
+ return { root: here, name: typeof name === 'string' ? name : '' };
401
+ }
402
+ const parent = dirname(here);
403
+ if (parent === here)
404
+ return null;
405
+ here = parent;
406
+ }
407
+ }
408
+ /**
409
+ * Every `node_modules` this package sits under, nearest first, with the lockfile key each one
410
+ * would use.
411
+ *
412
+ * There can be more than one: a nested `a/node_modules/b/node_modules/c` is keyed from the
413
+ * OUTERMOST project, which is the only place npm writes the hidden lockfile, while a plain
414
+ * install is keyed from the nearest. Both are tried rather than guessed between.
415
+ */
416
+ function nodeModulesAncestors(packageRoot) {
417
+ const found = [];
418
+ let here = dirname(packageRoot);
419
+ for (;;) {
420
+ if (basename(here) === NODE_MODULES) {
421
+ const projectRoot = dirname(here);
422
+ found.push({
423
+ projectRoot,
424
+ nodeModules: here,
425
+ key: relative(projectRoot, packageRoot).split(sep).join('/'),
426
+ });
427
+ }
428
+ const parent = dirname(here);
429
+ if (parent === here)
430
+ return found;
431
+ here = parent;
432
+ }
433
+ }
434
+ /** npm's own record for this package, from the hidden lockfile, or `null` when there is none. */
435
+ function lockfileEntry(place) {
436
+ const lock = readJsonObject(join(place.nodeModules, '.package-lock.json'));
437
+ const packages = lock?.['packages'];
438
+ if (typeof packages !== 'object' || packages === null)
439
+ return null;
440
+ const entry = packages[place.key];
441
+ return typeof entry === 'object' && entry !== null && !Array.isArray(entry)
442
+ ? entry
443
+ : null;
444
+ }
445
+ /** npm's own marker for an `npx` cache project — a record it writes, not a directory name. */
446
+ function isNpxProject(projectRoot) {
447
+ const manifest = readJsonObject(join(projectRoot, 'package.json'));
448
+ return manifest !== null && typeof manifest['_npx'] === 'object' && manifest['_npx'] !== null;
449
+ }
450
+ /** The origin npm wrote down, as one of the five words. */
451
+ function originFromEntry(entry, projectRoot) {
452
+ const raw = entry['resolved'];
453
+ const recorded = typeof raw === 'string' ? raw : '';
454
+ if (entry['link'] === true && recorded !== '') {
455
+ // A link entry carries a bare relative path with no `file:` prefix at all (measured).
456
+ return { shape: 'linked', source: resolve(projectRoot, recorded), sourceReason: '' };
457
+ }
458
+ // AN http(s) ORIGIN IS `registry` AND NOT A REFUSAL, which is where this parts company with
459
+ // the reference. There, an http `direct_url.json` is underivable; here the registry IS an
460
+ // https URL, and telling a published tarball URL from the configured index would mean
461
+ // knowing which index was configured — a guess. Either way the origin is REMOTE, so it can
462
+ // never be the dangling-local-path defect this surface exists for, and the route out is a
463
+ // reinstall by name.
464
+ if (/^https?:\/\//i.test(recorded))
465
+ return REGISTRY();
466
+ const path = localOriginPath(recorded, projectRoot);
467
+ if (path !== null)
468
+ return { shape: 'local-file', source: path, sourceReason: '' };
469
+ if (recorded === '')
470
+ return REGISTRY();
471
+ throw new Undetermined(`this install records its origin as ${recorded}, which is neither a package index nor a ` +
472
+ `path on this machine, so its shape is not one of ${INSTALL_SHAPES.join(', ')}. A git ` +
473
+ 'or http origin is updated by reinstalling from that same URL.');
474
+ }
475
+ /** Every path a symlink chain passes through, starting with the one it was handed. */
476
+ function symlinkChain(start) {
477
+ const chain = [];
478
+ let here = resolve(start);
479
+ for (let step = 0; step < 40; step += 1) {
480
+ chain.push(here);
481
+ let target;
482
+ try {
483
+ target = readlinkSync(here);
484
+ }
485
+ catch {
486
+ break;
487
+ }
488
+ here = resolve(dirname(here), target);
489
+ if (chain.includes(here))
490
+ break;
491
+ }
492
+ return chain;
493
+ }
494
+ /** Does this path pass through `node_modules/<name>` — the entry npm's link would occupy? */
495
+ function passesThroughEntry(path, name) {
496
+ const parts = path.split(/[\\/]/);
497
+ const wanted = name.split('/');
498
+ for (let i = 0; i < parts.length; i += 1) {
499
+ if (parts[i] !== NODE_MODULES)
500
+ continue;
501
+ if (wanted.every((segment, j) => parts[i + 1 + j] === segment))
502
+ return true;
503
+ }
504
+ return false;
505
+ }
506
+ /**
507
+ * Is this tree being SERVED through an `npm link`, even though it does not sit in node_modules?
508
+ *
509
+ * MEASURED, AND IT IS THE REASON THIS FUNCTION EXISTS AT ALL. Node realpaths ESM specifiers,
510
+ * so a linked install's `import.meta.url` names the SOURCE tree and the `node_modules` ancestor
511
+ * is gone from it — which would make every `npm link` and every `file:` directory dependency
512
+ * report `checkout`. `process.argv[1]` is the half Node does NOT resolve: driving the same file
513
+ * through a `file:` dir install printed `import.meta.url = …/src/dist/index.js` beside
514
+ * `argv[1] = …/node_modules/bantamkit-mcp/dist/index.js`. So the pair is the evidence, and both
515
+ * halves are location — neither is server state, and both are fixed for the life of a process.
516
+ *
517
+ * The entry has to actually BE this tree (its realpath lands inside the package root) and it
518
+ * has to pass through `node_modules/<this package>` (npm's link, not somebody else's): a
519
+ * checkout keeps its own `node_modules` for devDependencies, so the bare segment proves nothing.
520
+ */
521
+ function servedThroughNodeModules(entryPath, packageRoot, name) {
522
+ if (name === '')
523
+ return false;
524
+ const chain = symlinkChain(entryPath);
525
+ const last = chain[chain.length - 1];
526
+ if (last === undefined)
527
+ return false;
528
+ const landing = safeRealpath(last);
529
+ if (landing === null || !isWithin(landing, packageRoot))
530
+ return false;
531
+ return chain.some((step) => passesThroughEntry(step, name));
532
+ }
533
+ /**
534
+ * The whole diagnosis, over a running file and the path the process was launched through.
535
+ *
536
+ * Taken as ARGUMENTS rather than read from the process so that every shape can be built as a
537
+ * real `node_modules` tree with a real hidden lockfile, a real symlink and a really-absent
538
+ * tarball, and read back through the same code the server runs. A stubbed filesystem here
539
+ * would assert this function's own reasoning back at it, and the entire question is what npm
540
+ * actually writes down.
541
+ */
542
+ export function deriveInstall(runningFile, entryPath) {
543
+ const owner = packageRootFor(dirname(resolve(runningFile)));
544
+ if (owner === null) {
545
+ throw new Undetermined(`no package.json owns ${runningFile}, so there is no installer record to read and this ` +
546
+ 'build cannot say where it came from.');
547
+ }
548
+ const places = nodeModulesAncestors(owner.root);
549
+ for (const place of places) {
550
+ const entry = lockfileEntry(place);
551
+ if (entry === null)
552
+ continue;
553
+ return withEnvironment(originFromEntry(entry, place.projectRoot), places);
554
+ }
555
+ // Under `node_modules` with no record anywhere: the global-install shape measured above. The
556
+ // ABSENCE is positive evidence, because the two alternatives both leave a trace — a `file:`
557
+ // origin in the lockfile, or a symlink where the package directory should be — and an
558
+ // installed copy that left neither came from an index.
559
+ if (places.length > 0) {
560
+ // A package directory that IS a symlink is npm's link, whatever the lockfile says or fails
561
+ // to say. Reached only where the symlink survived to here — Node realpaths ESM specifiers,
562
+ // so in practice that is `--preserve-symlinks` — and it is checked before falling back to
563
+ // the absence-is-evidence branch, because an absence proves nothing next to a symlink.
564
+ const linkedTarget = safeRealpath(owner.root);
565
+ if (linkedTarget !== null && isSymlink(owner.root)) {
566
+ return { shape: 'linked', source: linkedTarget, sourceReason: '' };
567
+ }
568
+ return withEnvironment(REGISTRY(), places);
569
+ }
570
+ if (entryPath !== null && servedThroughNodeModules(entryPath, owner.root, owner.name)) {
571
+ return { shape: 'linked', source: owner.root, sourceReason: '' };
572
+ }
573
+ return { shape: 'checkout', source: null, sourceReason: CHECKOUT_REASON };
574
+ }
575
+ /**
576
+ * `ephemeral` is the ENVIRONMENT and the origin is the ORIGIN — two axes, and only one word.
577
+ *
578
+ * The environment wins the word, because it is the half that is true whatever the origin was:
579
+ * an `npx` cache is not updated in place at all, so what has to change is the spec on the
580
+ * host's command line. The origin is NOT lost with it — `source` and `install_source_exists`
581
+ * still carry it, and `install-source-missing` still fires and still names the path. Collapsing
582
+ * the two would have thrown away exactly the fact AS-7 was filed about, since the measured
583
+ * incident is both at once: an `npx` cache filled from a tarball that no longer exists.
584
+ */
585
+ function withEnvironment(origin, places) {
586
+ if (!places.some((place) => isNpxProject(place.projectRoot)))
587
+ return origin;
588
+ return {
589
+ shape: 'ephemeral',
590
+ source: origin.source,
591
+ sourceReason: origin.source === null ? EPHEMERAL_REASON : '',
592
+ };
593
+ }
594
+ /**
595
+ * The errnos that mean "not there" rather than "could not look" — the reference's
596
+ * `pathlib._ignore_error` set, which is what makes `Path.exists()` return `False` instead of
597
+ * raising. Everything else is a check that did not happen, and saying `false` for one of those
598
+ * would report a deleted origin where the truth is a directory nobody may read.
599
+ */
600
+ const NOT_THERE = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP', 'EINVAL', 'ENAMETOOLONG']);
601
+ /**
602
+ * Is the recorded origin still on disk? `present: null` when the check itself could not be made.
603
+ *
604
+ * `existsSync` is deliberately not used: it swallows EVERY error and answers `false`, so an
605
+ * origin under a directory the server may not read would be reported as DELETED — a wrong
606
+ * answer where the reference gives none. This is the half that is re-read on every tool call,
607
+ * so it is one `stat` and nothing else.
608
+ */
609
+ export function originStat(path) {
610
+ try {
611
+ statSync(path);
612
+ return { present: true, reason: '' };
613
+ }
614
+ catch (e) {
615
+ const code = e.code;
616
+ if (code === undefined)
617
+ throw e;
618
+ if (NOT_THERE.has(code))
619
+ return { present: false, reason: '' };
620
+ return {
621
+ present: null,
622
+ reason: `the recorded origin path could not be checked: ${e.message}`,
623
+ };
624
+ }
625
+ }
626
+ let installOnce = null;
627
+ /**
628
+ * Which install shape is running. Importable, and NOT owned by the status path.
629
+ *
630
+ * DERIVED ONCE, because the bytes that were imported cannot change under a process. The footer
631
+ * runs `degradedConditions` on every tool call and `docs/status.md` promises what that costs;
632
+ * walking `node_modules` for a hidden lockfile is not on that list. So the SHAPE is memoised
633
+ * and only the EXISTENCE of the recorded path is re-read — which is the half that can actually
634
+ * change while a server is running, and the half the measured defect is about. The failure is
635
+ * memoised too: a derivation that already failed would fail identically a second time.
636
+ *
637
+ * Deliberately a module-level function taking no arguments and touching no server state: the
638
+ * `--update` flag planned as J46-29 has to be shape-aware — two of the five shapes have no
639
+ * registry route at all — and it must be able to ask this question from the CLI, before a
640
+ * memory store or a transport exists, without building an MCP server to do it.
641
+ */
642
+ export function currentInstall() {
643
+ if (installOnce === null) {
644
+ try {
645
+ installOnce = {
646
+ install: deriveInstall(fileURLToPath(import.meta.url), process.argv[1] ?? null),
647
+ error: null,
648
+ };
649
+ }
650
+ catch (e) {
651
+ if (!(e instanceof Undetermined))
652
+ throw e;
653
+ installOnce = { install: null, error: e };
654
+ }
655
+ }
656
+ if (installOnce.install === null) {
657
+ throw installOnce.error ?? new Undetermined('the install shape was not derived');
658
+ }
659
+ return installOnce.install;
660
+ }
224
661
  /** `sha256(json.dumps(inputs, sort_keys=True, separators=(",", ":")))` — the identity. */
225
662
  export function buildIdFor(inputs) {
226
663
  const canonical = `{${Object.keys(inputs)
@@ -280,6 +717,31 @@ export function buildIdentity(version, sdkVersion) {
280
717
  // chosen by the operator or resolved by the package is a fact a caller comparing two
281
718
  // endpoints needs. A bool, so it is never confusable with a missing one.
282
719
  identity.set('assets_root_from_env', Boolean(process.env.BANTAMKIT_ASSETS));
720
+ // Location too, and the same rule: which install SHAPE is running says what updating this
721
+ // server would even mean, and says nothing about which build it is. A `file:` install whose
722
+ // tarball was deleted answers with a path that no longer exists — the measured defect AS-7
723
+ // filed — and `install_source_exists` is the bit that says so. No network is consulted for
724
+ // any of the three.
725
+ try {
726
+ const install = currentInstall();
727
+ identity.set('install_shape', install.shape);
728
+ if (install.source === null) {
729
+ identity.set('install_source', unavailable(install.sourceReason));
730
+ identity.set('install_source_exists', unavailable(`a ${install.shape} install records no origin path, so there is nothing here to check for.`));
731
+ }
732
+ else {
733
+ identity.set('install_source', install.source);
734
+ const origin = originStat(install.source);
735
+ identity.set('install_source_exists', origin.present === null ? unavailable(origin.reason) : origin.present);
736
+ }
737
+ }
738
+ catch (e) {
739
+ if (!(e instanceof Undetermined))
740
+ throw e;
741
+ for (const field of ['install_shape', 'install_source', 'install_source_exists']) {
742
+ identity.set(field, unavailable(e.message));
743
+ }
744
+ }
283
745
  identity.set('git_commit', unavailable(GIT_COMMIT_REFUSAL));
284
746
  identity.set('interpreter', process.execPath || unavailable('process.execPath is empty; this process cannot name its own binary'));
285
747
  identity.set('node_version', process.versions.node);
@@ -148,6 +148,16 @@ export const ARG_MODELS = {
148
148
  model: 'repo_mapArguments',
149
149
  fields: [req('root', 'str'), opt('focus', 'listStr'), opt('budget', 'int')],
150
150
  },
151
+ // `token_ledger(root: str, model: str | None = None, prices: str | None = None)` on the
152
+ // reference (job46, AS-1(c)). All three are the strict `str` every other string field is —
153
+ // `123` is `string_type`, not a coerced `'123'` — and an explicit `null` is the default for
154
+ // the two optional ones. There is no `int` here at all, which is why this entry is the
155
+ // shortest on the surface: the tool's every number is READ off the corpus rather than
156
+ // passed in, so there is no lax-int arm to measure.
157
+ token_ledger: {
158
+ model: 'token_ledgerArguments',
159
+ fields: [req('root', 'str'), opt('model', 'str'), opt('prices', 'str')],
160
+ },
151
161
  skill_audit: {
152
162
  model: 'skill_auditArguments',
153
163
  fields: [
@@ -8,12 +8,13 @@ import type { RawStdioTransport } from './transport.js';
8
8
  *
9
9
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
10
10
  * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
11
- * `skill_audit` after that, `memory_dream` after that and `repo_map` after that, for the
11
+ * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
12
+ * `token_ledger` after that, for the
12
13
  * same reason the reference appends all six: registration order IS the served order, and
13
14
  * appending is the only edit that leaves the other twelve where every existing declaration
14
15
  * says they are.
15
16
  */
16
- export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "bantamkit_read", "skill_audit", "memory_dream", "repo_map"];
17
+ export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "validate_json", "shiftwork_clock_in", "shiftwork_clock_out", "shiftwork_status", "build_identity", "bantamkit_status", "memory_compact", "bantamkit_read", "skill_audit", "memory_dream", "repo_map", "token_ledger"];
17
18
  /**
18
19
  * The advertisement for one tool, out of its manifest, with the surface checked.
19
20
  *
@@ -33,6 +33,8 @@ import { bantamkitReadUnknownPart, documentError, documentManifest, documentOffs
33
33
  import * as docread from '../docread.js';
34
34
  import * as repomap from '../repomap.js';
35
35
  import * as skillaudit from '../skillaudit.js';
36
+ import { PriceTableError } from '../pricing.js';
37
+ import * as tokenledger from '../tokenledger.js';
36
38
  import { EventLog } from '../eventlog.js';
37
39
  import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
38
40
  import { fromJs, parseJson, reprValue, toJs } from '../pyjson.js';
@@ -46,7 +48,8 @@ import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEM
46
48
  *
47
49
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
48
50
  * 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
51
+ * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
52
+ * `token_ledger` after that, for the
50
53
  * same reason the reference appends all six: registration order IS the served order, and
51
54
  * appending is the only edit that leaves the other twelve where every existing declaration
52
55
  * says they are.
@@ -65,6 +68,7 @@ export const MCP_TOOLS = [
65
68
  'skill_audit',
66
69
  'memory_dream',
67
70
  'repo_map',
71
+ 'token_ledger',
68
72
  ];
69
73
  /**
70
74
  * The advertisement for one tool, out of its manifest, with the surface checked.
@@ -792,6 +796,59 @@ function runTool(name, memory, args, version, log, documents) {
792
796
  return { value: { t: 'str', v: noted(repoMapReply(result)) }, wrapped: true };
793
797
  });
794
798
  }
799
+ case 'token_ledger': {
800
+ // The transcript ledger on the MCP surface (job46, AS-1(c)): `tokenledger` measures,
801
+ // this serves it. Byte for byte the reference's `token_ledger` handler.
802
+ //
803
+ // THE REPLY IS A JSON DOCUMENT AND NOT PROSE, the same shape and for the same reason as
804
+ // `skill_audit` above: a caller comparing `totals` before and after a change, or
805
+ // deciding whether `omissions` explain a total that looks too small, has to read
806
+ // numbers rather than parse a sentence back out of English. `asJson` is the reply
807
+ // verbatim and the only string this layer authors is the refusal.
808
+ //
809
+ // THE FOUR REFUSALS ARE ARGUMENT FAILURES — an empty `root`, a `root` that is missing,
810
+ // a `root` that is a file, an empty `model` — so they go through `toolFailed`, and
811
+ // unlike `repo_map` above they live in the MODULE rather than here: `read()` over a
812
+ // missing root does not answer an empty ledger that a caller could mistake for a real
813
+ // one, it refuses, so there is nothing for this layer to add.
814
+ //
815
+ // `PriceTableError` is caught BESIDE `TokenLedgerError` and is not the same kind of
816
+ // thing: it is an operator configuration fault reached only when `model` names a price
817
+ // table that will not load. It is still an argument failure from the CALLER's side —
818
+ // it names the path they passed — so it is refused rather than crashing the request.
819
+ //
820
+ // THE RECORD IS A DECISION, NEVER A REPLY. `read` carries the four counts the host
821
+ // cannot see and `refused` carries nothing at all, and no token count is recorded
822
+ // either: the event log is a record of what this server DID, and the numbers are the
823
+ // reply.
824
+ const root = asText(args.get('root'));
825
+ const modelArg = args.get('model');
826
+ const model = modelArg !== undefined && modelArg.t === 'str' ? modelArg.v : null;
827
+ const pricesArg = args.get('prices');
828
+ const prices = pricesArg !== undefined && pricesArg.t === 'str' ? pricesArg.v : null;
829
+ return recordRaise(log, 'token_ledger', () => {
830
+ let result;
831
+ try {
832
+ result = tokenledger.read(root, { model, prices });
833
+ }
834
+ catch (e) {
835
+ // `except (tokenledger.TokenLedgerError, PriceTableError, OSError)`. Both classes
836
+ // extend `BantamError` and both already carry the reference's own sentence.
837
+ if (!(e instanceof tokenledger.TokenLedgerError) && !(e instanceof PriceTableError)) {
838
+ throw e;
839
+ }
840
+ log.record('token_ledger', 'refused');
841
+ return { value: { t: 'str', v: noted(toolFailed('token_ledger', e)) }, wrapped: true };
842
+ }
843
+ log.record('token_ledger', 'read', {
844
+ transcripts: result.transcripts,
845
+ lines: result.lines,
846
+ requests: result.requests,
847
+ sessions: result.sessions.length,
848
+ });
849
+ return { value: { t: 'str', v: noted(tokenledger.asJson(result)) }, wrapped: true };
850
+ });
851
+ }
795
852
  default:
796
853
  // `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
797
854
  // handler turns into an isError result rather than a JSON-RPC error.