bantamkit-mcp 0.29.2 → 0.31.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.
@@ -126,6 +126,38 @@ export const ARG_MODELS = {
126
126
  // `dict[str, str]`, and the reason `dictStr` exists beside it: a `str` field is STRICT even
127
127
  // in lax mode, so `{'a': 1}` is `versions.a` / `string_type` where the same value under
128
128
  // `usage` would validate.
129
+ // `dry_run: bool | None = None` on the reference (job45 row 5). LAX bool, and every arm
130
+ // below was MEASURED against the real stdio server, not read off pydantic's docs
131
+ // (`scratchpad` probe, 33 inputs): `0`/`1` and `0.0`/`1.0` coerce; any OTHER integral
132
+ // number — `2`, `-1`, `2.0` — is `bool_parsing`; a FRACTIONAL float is `bool_type`, a
133
+ // different frame from the integral one and the reason the float arm is split; a string
134
+ // is matched case-insensitively against two closed sets and is NOT stripped first, so
135
+ // `'true'` validates and `' true '` is `bool_parsing`; `''` is `bool_parsing`; a list or a
136
+ // dict is `bool_type`; and an explicit `null` is the default.
137
+ memory_dream: { model: 'memory_dreamArguments', fields: [opt('dry_run', 'bool')] },
138
+ // `repo_map(root: str, focus: list[str] | None = None, budget: int | None = None)` on
139
+ // the reference (job45 row 10). `root` is the strict `str` every other string field is —
140
+ // `123` is `string_type`. `focus` is the same `list[str]` as `skill_audit`'s `enabled`,
141
+ // so a non-list is `list_type` and a list holding a non-string is `focus.0` /
142
+ // `string_type`. `budget` is the lax `int` that `k`, `reserve` and `limit` are: `'2'`,
143
+ // `True` and `3.0` validate, `'2.5'` is `int_parsing`, `2.5` is `int_from_float`, and an
144
+ // explicit `null` is the default. No `le`, because the reference binds none — the
145
+ // manifest's `minimum: 0` is advisory to the client and the handler's own refusal is the
146
+ // negative one, exactly as `skill_audit`'s `budget` works.
147
+ repo_map: {
148
+ model: 'repo_mapArguments',
149
+ fields: [req('root', 'str'), opt('focus', 'listStr'), opt('budget', 'int')],
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
+ },
129
161
  skill_audit: {
130
162
  model: 'skill_auditArguments',
131
163
  fields: [
@@ -178,6 +210,13 @@ function truncateRepr(text) {
178
210
  tail += 1;
179
211
  return `${decoder.decode(bytes.slice(0, head))}...${decoder.decode(bytes.slice(tail))}`;
180
212
  }
213
+ /**
214
+ * The two closed sets pydantic-core matches a string against for a lax `bool`, lowercased.
215
+ *
216
+ * No stripping: `' true '` is `bool_parsing` on the reference, measured over the wire.
217
+ */
218
+ const BOOL_TRUE = new Set(['1', 'on', 't', 'true', 'y', 'yes']);
219
+ const BOOL_FALSE = new Set(['0', 'off', 'f', 'false', 'n', 'no']);
181
220
  /** i64, which is the width pydantic parses an int into before it complains about size. */
182
221
  const I64_MAX = 9223372036854775807n;
183
222
  const I64_MIN = -9223372036854775808n;
@@ -258,6 +297,45 @@ function checkField(spec, value) {
258
297
  }
259
298
  return bad.length > 0 ? bad : { value };
260
299
  }
300
+ case 'bool': {
301
+ const parsing = () => [
302
+ {
303
+ loc: spec.name,
304
+ type: 'bool_parsing',
305
+ msg: 'Input should be a valid boolean, unable to interpret input',
306
+ input: value,
307
+ },
308
+ ];
309
+ const wrongType = () => [
310
+ { loc: spec.name, type: 'bool_type', msg: 'Input should be a valid boolean', input: value },
311
+ ];
312
+ if (value.t === 'bool')
313
+ return { value };
314
+ if (value.t === 'int') {
315
+ if (value.v === 0n || value.v === 1n)
316
+ return { value: { t: 'bool', v: value.v === 1n } };
317
+ return parsing();
318
+ }
319
+ if (value.t === 'float') {
320
+ // A FRACTIONAL float is `bool_type` and an out-of-range integral one is
321
+ // `bool_parsing`. Measured, and not a distinction anyone would guess: `0.5` and
322
+ // `2.0` are both floats and they refuse under different frames.
323
+ if (!Number.isInteger(value.v))
324
+ return wrongType();
325
+ if (value.v === 0 || value.v === 1)
326
+ return { value: { t: 'bool', v: value.v === 1 } };
327
+ return parsing();
328
+ }
329
+ if (value.t === 'str') {
330
+ const key = value.v.toLowerCase();
331
+ if (BOOL_TRUE.has(key))
332
+ return { value: { t: 'bool', v: true } };
333
+ if (BOOL_FALSE.has(key))
334
+ return { value: { t: 'bool', v: false } };
335
+ return parsing();
336
+ }
337
+ return wrongType();
338
+ }
261
339
  case 'int': {
262
340
  const checked = checkInt(spec.name, value);
263
341
  if (!('value' in checked))
@@ -1,17 +1,20 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import * as repomap from '../repomap.js';
2
3
  import { EventLog } from '../eventlog.js';
3
4
  import type { Memory } from '../memory/component.js';
4
5
  import type { RawStdioTransport } from './transport.js';
5
6
  /**
6
- * The eleven, in the order `build_server` lists them — which is the order `tools/list` emits.
7
+ * The thirteen, in the order `build_server` lists them — which is the order `tools/list` emits.
7
8
  *
8
9
  * `bantamkit_status` went LAST rather than first, `memory_compact` after it rather than
9
- * beside `memory_save` where a reader would look for it, `bantamkit_read` after that and
10
- * `skill_audit` after that, for the same reason the reference appends all four: registration
11
- * order IS the served order, and appending is the only edit that leaves the other ten where
12
- * every existing declaration says they are.
10
+ * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
11
+ * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
12
+ * `token_ledger` after that, for the
13
+ * same reason the reference appends all six: registration order IS the served order, and
14
+ * appending is the only edit that leaves the other twelve where every existing declaration
15
+ * says they are.
13
16
  */
14
- 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"];
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"];
15
18
  /**
16
19
  * The advertisement for one tool, out of its manifest, with the surface checked.
17
20
  *
@@ -28,6 +31,34 @@ export declare const MCP_TOOLS: readonly ["memory_save", "memory_recall", "valid
28
31
  * that detects drift. Registered here, not fixed here; `runtime-py` is the reference.
29
32
  */
30
33
  export declare function fromManifest(name: string): Record<string, unknown>;
34
+ /**
35
+ * The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
36
+ * byte-identical to the reference's `REPO_MAP_TAIL`.
37
+ *
38
+ * Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
39
+ * and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
40
+ * is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
41
+ * PRECISION feature. A surface that let a caller believe the map is a token saving would
42
+ * say the one thing the measurement forbids, so the refutation travels with every answer
43
+ * rather than living only in a doc nobody reads at call time.
44
+ *
45
+ * The second sentence is the budget's unit, for the same reason: `DEFAULT_BUDGET = 4000` is
46
+ * "1 K tokens" only at the char/4 convention, whose error bar is unmeasured because
47
+ * measuring it needs the tokenizer the pure-node ruling forbids.
48
+ */
49
+ export declare const REPO_MAP_TAIL: string;
50
+ /** What a listing says when there is nothing to list. The reference's `REPO_MAP_EMPTY`. */
51
+ export declare const REPO_MAP_EMPTY = "(nothing listed: no file under this root scanned into a definition)";
52
+ /**
53
+ * The `repo_map` tool's prose, byte for byte, from the structured result.
54
+ *
55
+ * Split out of the handler for the same reason the reference splits `repo_map_reply` out of
56
+ * its own: one shape to reproduce rather than a format string buried in a `case`, and the
57
+ * `repomap` conformance suite compares the rendered reply without standing up a server. No
58
+ * float is ever rendered here — `repomap`'s trap (8); every number on the header line is an
59
+ * integer, and `String(1.0)` is `'1'` where `repr(1.0)` is `'1.0'`.
60
+ */
61
+ export declare function repoMapReply(result: repomap.RepoMap): string;
31
62
  /**
32
63
  * Assemble the MCP server around one Memory instance (the per-person state).
33
64
  *
@@ -31,7 +31,10 @@ 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';
36
+ import { PriceTableError } from '../pricing.js';
37
+ import * as tokenledger from '../tokenledger.js';
35
38
  import { EventLog } from '../eventlog.js';
36
39
  import { PyOSError, asPyOSError, pyReadText } from '../memory/pyfs.js';
37
40
  import { fromJs, parseJson, reprValue, toJs } from '../pyjson.js';
@@ -41,13 +44,15 @@ import { ARG_MODELS, PyValidationFailure, validateArguments } from './pyargs.js'
41
44
  import { sdkJson } from './sdkjson.js';
42
45
  import { degradedConditions, degradedNotice, SERVED_PROMPTS, SERVED_RESOURCE_TEMPLATES, statusReport, STATUS_NAME, STATUS_PROMPT_DESCRIPTION, STATUS_PROMPT_TAIL, STATUS_PROMPT_TITLE, } from './status.js';
43
46
  /**
44
- * The eleven, in the order `build_server` lists them — which is the order `tools/list` emits.
47
+ * The thirteen, in the order `build_server` lists them — which is the order `tools/list` emits.
45
48
  *
46
49
  * `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 and
48
- * `skill_audit` after that, for the same reason the reference appends all four: registration
49
- * order IS the served order, and appending is the only edit that leaves the other ten where
50
- * every existing declaration says they are.
50
+ * beside `memory_save` where a reader would look for it, `bantamkit_read` after that,
51
+ * `skill_audit` after that, `memory_dream` after that, `repo_map` after that and
52
+ * `token_ledger` after that, for the
53
+ * same reason the reference appends all six: registration order IS the served order, and
54
+ * appending is the only edit that leaves the other twelve where every existing declaration
55
+ * says they are.
51
56
  */
52
57
  export const MCP_TOOLS = [
53
58
  'memory_save',
@@ -61,6 +66,9 @@ export const MCP_TOOLS = [
61
66
  'memory_compact',
62
67
  'bantamkit_read',
63
68
  'skill_audit',
69
+ 'memory_dream',
70
+ 'repo_map',
71
+ 'token_ledger',
64
72
  ];
65
73
  /**
66
74
  * The advertisement for one tool, out of its manifest, with the surface checked.
@@ -147,6 +155,65 @@ const asText = (value) => (value && value.t === 'str' ? value.v : '');
147
155
  function asInt(value) {
148
156
  return value && value.t === 'int' ? Number(value.v) : null;
149
157
  }
158
+ /**
159
+ * The errno family `Path.exists()` and `Path.is_dir()` answer `False` for instead of
160
+ * raising — CPython `pathlib`'s `_IGNORED_ERRNOS`, spelled as Node's `code` strings.
161
+ *
162
+ * Not a guess and not a superset: anything OUTSIDE this set makes both predicates raise on
163
+ * the reference, so it must reach `recordRaise` here too. A `statSync` wrapped in a bare
164
+ * `catch { }` would turn a permission failure into "no such directory", which is a
165
+ * different sentence about a different problem.
166
+ */
167
+ const EXISTS_IGNORED = new Set(['ENOENT', 'ENOTDIR', 'EBADF', 'ELOOP']);
168
+ /**
169
+ * The last paragraph of every `repo_map` reply, refusal excepted. FIXED AND MANDATORY, and
170
+ * byte-identical to the reference's `REPO_MAP_TAIL`.
171
+ *
172
+ * Roadmap row 10's build gate was "build only after #4 shows discovery tokens dominate",
173
+ * and #4 REFUTED it: discovery is 0.114 % of real prompt tokens because 97.8 % of the bill
174
+ * is `cache_read`. The feature ships on an explicit ruling to build it anyway, as a
175
+ * PRECISION feature. A surface that let a caller believe the map is a token saving would
176
+ * say the one thing the measurement forbids, so the refutation travels with every answer
177
+ * rather than living only in a doc nobody reads at call time.
178
+ *
179
+ * The second sentence is the budget's unit, for the same reason: `DEFAULT_BUDGET = 4000` is
180
+ * "1 K tokens" only at the char/4 convention, whose error bar is unmeasured because
181
+ * measuring it needs the tokenizer the pure-node ruling forbids.
182
+ */
183
+ export const REPO_MAP_TAIL = 'This is a precision pass, not a token saving: this feature\'s build gate was REFUTED ' +
184
+ 'by measurement — discovery is 0.114% of real prompt tokens, because 97.8% of the ' +
185
+ 'bill is cache_read — so a map does not make a session cheaper. What it buys is the ' +
186
+ 'right file found sooner.\n' +
187
+ 'The budget above is UTF-8 BYTES of listing, not tokens: neither runtime carries a ' +
188
+ 'model tokenizer and this tool will not pretend to one.';
189
+ /** What a listing says when there is nothing to list. The reference's `REPO_MAP_EMPTY`. */
190
+ export const REPO_MAP_EMPTY = '(nothing listed: no file under this root scanned into a definition)';
191
+ /**
192
+ * The `repo_map` tool's prose, byte for byte, from the structured result.
193
+ *
194
+ * Split out of the handler for the same reason the reference splits `repo_map_reply` out of
195
+ * its own: one shape to reproduce rather than a format string buried in a `case`, and the
196
+ * `repomap` conformance suite compares the rendered reply without standing up a server. No
197
+ * float is ever rendered here — `repomap`'s trap (8); every number on the header line is an
198
+ * integer, and `String(1.0)` is `'1'` where `repr(1.0)` is `'1.0'`.
199
+ */
200
+ export function repoMapReply(result) {
201
+ const focus = result.focus.length > 0
202
+ ? result.focus.join(', ')
203
+ : '(none) — plain centrality over the whole tree';
204
+ const head = `repo map: ${result.nodes} files scanned, ${result.definitions} definitions, ` +
205
+ `${result.edges} edges.\n` +
206
+ `focus: ${focus}\n` +
207
+ `budget: ${result.budget} UTF-8 bytes; listing ${result.listingBytes} bytes; ` +
208
+ `rendered ${result.filesRendered} files, ` +
209
+ `${result.definitionsRendered} definitions.`;
210
+ const body = result.text !== '' ? result.text : REPO_MAP_EMPTY;
211
+ return `${head}\n\n${body}\n\n${REPO_MAP_TAIL}`;
212
+ }
213
+ /** `bool | None` after validation: the tagged bool, or `null` for the default. */
214
+ function asBool(value) {
215
+ return value && value.t === 'bool' ? value.v : null;
216
+ }
150
217
  /**
151
218
  * Run `body` and, if it throws, record the exception's TYPE before letting it fly on.
152
219
  *
@@ -448,6 +515,29 @@ function runTool(name, memory, args, version, log, documents) {
448
515
  });
449
516
  return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
450
517
  }
518
+ case 'memory_dream': {
519
+ // Consolidate what the project layer and the machine-wide profile layer both hold
520
+ // under one name (job45, roadmap row 5). `dryRun` DEFAULTS TO TRUE and the default
521
+ // lives HERE rather than in the component, exactly as the reference's handler carries
522
+ // it: a client that omits the argument sends nothing, `asBool` answers `null`, and
523
+ // turning that into the SAFE answer is this handler's job. It is the only tool on
524
+ // this surface that writes into the user's home directory, and the only one whose
525
+ // effect is machine-wide — a fact archived out of the profile store stops answering
526
+ // for every other project on this machine with no store of its own.
527
+ //
528
+ // THE RECORD IS A DECISION. `DreamOutcome.status` is read off `DreamResult.applied`,
529
+ // `.overBudget` and `.changes` — never off the reply, whose prose is allowed to
530
+ // improve without moving a single byte of the event log.
531
+ const dryRun = asBool(args.get('dry_run'));
532
+ const outcome = recordRaise(log, 'memory_dream', () => memory.dreamOutcome(dryRun === null ? true : dryRun));
533
+ log.record('memory_dream', outcome.status, {
534
+ absolutised: outcome.absolutised,
535
+ consumed: outcome.consumed,
536
+ dry_run: outcome.dryRun,
537
+ merged: outcome.merged,
538
+ });
539
+ return { value: { t: 'str', v: noted(outcome.reply) }, wrapped: true };
540
+ }
451
541
  case 'bantamkit_read': {
452
542
  // The reader on the MCP surface (job43): `docread` digests, `contract` words it. The
453
543
  // handler makes the SAME `contract` calls the reference's `bantamkit_read` makes,
@@ -645,6 +735,120 @@ function runTool(name, memory, args, version, log, documents) {
645
735
  return { value: { t: 'str', v: noted(result.asJson()) }, wrapped: true };
646
736
  });
647
737
  }
738
+ case 'repo_map': {
739
+ // The ranked definition map on the MCP surface (job45 row 10): `repomap` measures,
740
+ // this serves it. Byte for byte the reference's `repo_map` handler.
741
+ //
742
+ // THE THREE REFUSALS LIVE HERE AND NOT IN `repomap.ts`, and that is deliberate.
743
+ // `repoMap()` over a root that does not exist answers an EMPTY map on both runtimes
744
+ // — `os.walk` yields nothing for a missing directory and `walkSources`' `readdirSync`
745
+ // catch does the same — which is the right answer for a library and the wrong one for
746
+ // a tool: a caller who typed the path wrong would be told the tree holds no source.
747
+ // So the argument checks are the SURFACE's, and the engine J45-9/J45-10 proved
748
+ // byte-identical is not touched.
749
+ //
750
+ // THE `statSync` ARM IS `Path.exists()` / `Path.is_dir()`, NOT A SHORTCUT FOR THEM.
751
+ // Both pathlib predicates SWALLOW the not-here errno family and re-raise anything
752
+ // else, so `EXISTS_IGNORED` below is that family spelled out: a dangling symlink and
753
+ // `a-file.py/sub` are "no such directory" on both runtimes, while a permission
754
+ // failure flies to `recordRaise` rather than being dressed up as a missing tree.
755
+ //
756
+ // THE RECORD IS A DECISION AND HOLDS NO PATH. `root` is what the operator typed and
757
+ // `focus` is the name of the file they are editing; neither is a decision this
758
+ // handler made, so neither is written down. The counts are.
759
+ const root = asText(args.get('root'));
760
+ const focusArg = args.get('focus');
761
+ const focus = focusArg !== undefined && focusArg.t === 'list'
762
+ ? focusArg.v.map((item) => (item.t === 'str' ? item.v : ''))
763
+ : [];
764
+ const budgetArg = args.get('budget');
765
+ const budget = budgetArg !== undefined && budgetArg.t === 'int' ? Number(budgetArg.v) : repomap.DEFAULT_BUDGET;
766
+ return recordRaise(log, 'repo_map', () => {
767
+ const refuse = (detail) => {
768
+ log.record('repo_map', 'refused');
769
+ return { value: { t: 'str', v: noted(toolFailed('repo_map', detail)) }, wrapped: true };
770
+ };
771
+ if (root === '')
772
+ return refuse('root must not be empty; name the directory to map');
773
+ if (budget < 0)
774
+ return refuse(`budget must not be negative; got ${budget}`);
775
+ let stat = null;
776
+ try {
777
+ stat = statSync(root);
778
+ }
779
+ catch (e) {
780
+ const code = e?.code;
781
+ if (!EXISTS_IGNORED.has(String(code)))
782
+ throw asPyOSError(e, root);
783
+ }
784
+ if (stat === null)
785
+ return refuse(`no such directory: ${root}`);
786
+ if (!stat.isDirectory())
787
+ return refuse(`${root} is a file, not a directory to map`);
788
+ const result = repomap.repoMap(root, { focus, budget });
789
+ log.record('repo_map', 'mapped', {
790
+ definitions: result.definitions,
791
+ edges: result.edges,
792
+ files_rendered: result.filesRendered,
793
+ listing_bytes: result.listingBytes,
794
+ nodes: result.nodes,
795
+ });
796
+ return { value: { t: 'str', v: noted(repoMapReply(result)) }, wrapped: true };
797
+ });
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
+ }
648
852
  default:
649
853
  // `tool_manager.call_tool` raises `ToolError(f"Unknown tool: {name}")`, which the
650
854
  // handler turns into an isError result rather than a JSON-RPC error.
@@ -1,5 +1,7 @@
1
1
  import type { EventLog } from '../eventlog.js';
2
2
  import { Memory } from '../memory/component.js';
3
+ import { INDEX_PRESSURE_PERCENT } from '../memory/store.js';
4
+ import type { Install } from './identity.js';
3
5
  /**
4
6
  * The tool AND the prompt answer to this one name. Deliberately the same word: the operator
5
7
  * who read `bantamkit_status` in a footer must find it in their prompt menu without
@@ -31,14 +33,13 @@ export declare const STATUS_PROMPT_TAIL: string;
31
33
  export declare const SERVED_PROMPTS = 1;
32
34
  export declare const SERVED_RESOURCE_TEMPLATES = 2;
33
35
  /**
34
- * Percent of the index budget that has to be SPENT before the store is called degraded.
35
- *
36
- * 90 and not 100 because the useful moment is before the refusal, not after it: at 100% the
37
- * next `memory_save` has already failed and the operator has already seen the error. An
38
- * INTEGER percent, compared by cross-multiplication below, so the two runtimes cannot land
39
- * on opposite sides of the line through a float they rounded differently.
36
+ * `INDEX_PRESSURE_PERCENT` is re-exported here, where it used to be DEFINED, so that
37
+ * `import { INDEX_PRESSURE_PERCENT } from './mcp/status.js'` keeps resolving. It moved down
38
+ * to `memory/store.ts` in job46 (J46-5, mirroring the reference's J46-4):
39
+ * `MemoryStore.compact` is the remedy the sentence below names, and it cannot clear a warning
40
+ * whose line it cannot see. The comment that says why the number is 90 moved with it.
40
41
  */
41
- export declare const INDEX_PRESSURE_PERCENT = 90;
42
+ export { INDEX_PRESSURE_PERCENT };
42
43
  /**
43
44
  * One thing that is wrong, carried in the two forms the two surfaces need.
44
45
  *
@@ -135,6 +136,20 @@ export declare function unreadableLayerCondition(memory: Memory): Condition | nu
135
136
  * not a divergence and is not fixed here; it is registered in `docs/porting.md`. The sentence
136
137
  * says "archive or shorten facts", which is what the operator has to do either way.
137
138
  *
139
+ * AMENDMENT (job46, J46-5): THE PARAGRAPH ABOVE IS A RECORD OF A DEFECT THAT IS NOW CLOSED,
140
+ * and it is left standing because it is the measurement that motivated the fix. What it says
141
+ * was true until this job: the band was real, and on this machine's own project store
142
+ * (101 facts, index.md 21819 bytes of a 24000-byte budget = 90.91%, largest index line 361
143
+ * bytes so the old target was 23639 = 98.50%) `compact()` answered `archived: []` with this
144
+ * very sentence on screen. What has changed is the second half — "is not fixed here". It is
145
+ * fixed now, in the layer that owns it: `MemoryStore.compact`'s default `reserve` is measured
146
+ * from `undegradedIndexCeiling(budget)` rather than from `budget`, so running the command
147
+ * this sentence names clears the condition this sentence reports. Both runtimes moved in the
148
+ * same job (the reference in `87cc1f7`), because a band that closed on one side only would be
149
+ * the divergence the paragraph above correctly said this was not. The register entry's own
150
+ * closure is not written here — that is a `docs/porting.md` edit, and it waits on a
151
+ * conformance case comparing the two answers.
152
+ *
138
153
  * THIS SENTENCE IS FOR THE OPERATOR; THE MODEL HAS ITS OWN REMEDY. Since job42 the model
139
154
  * can call `memory_compact` (the ninth served tool) when a save is actually refused, and
140
155
  * the refused-budget reply in `memory/component.ts` names it. That does not change this
@@ -152,13 +167,40 @@ export declare function indexPressureCondition(memory: Memory): Condition | null
152
167
  * BOTH on and known to have lost a record is a problem anyone can act on.
153
168
  */
154
169
  export declare function eventLogCondition(log: EventLog): Condition | null;
170
+ /**
171
+ * This install came from a path on this machine, and that path is gone.
172
+ *
173
+ * THE ONE CONDITION THAT NAMES A PATH, and the exception is deliberate. Every other sentence
174
+ * here refuses one because `assetsRoot()` resolves differently in the two runtimes by
175
+ * construction, so a path would be an uncomparable value bought for nothing. This path is not
176
+ * the server's own location — it is the origin THE INSTALLER WROTE DOWN, it is the entire
177
+ * actionable content of the finding (AS-7's measured case is a tarball under a
178
+ * `/private/tmp/.../scratchpad` that no longer exists), and a sentence saying "something is
179
+ * missing" without saying what would be a sentence nobody can act on.
180
+ *
181
+ * THE REMEDY IS THE ONE THAT ACTUALLY MOVES SOMETHING. J46-4 spent a unit removing a condition
182
+ * whose remedy exited 0 having changed nothing, and this is exactly the shape that invites
183
+ * another: `npm update` where a dangling `file:` install lives is a no-op BY CONSTRUCTION. So
184
+ * the sentence sends the reader at a reinstall BY NAME from a registry, which replaces the
185
+ * install rather than trying to refresh it in place. `bantamkit` is the PRODUCT name and is
186
+ * copied verbatim from the reference — it is not the npm package id, and turning it into one
187
+ * is how `git_commit` diverged on a noun.
188
+ *
189
+ * THE DERIVED HALF IS MEMOISED AND THIS HALF IS NOT: the shape cannot change under a running
190
+ * process, the path's existence can, and it is the one that has to be read now.
191
+ */
192
+ export declare function installSourceCondition(install: Install | null): Condition | null;
155
193
  /**
156
194
  * Everything wrong right now, worst first. An empty array means healthy.
157
195
  *
158
196
  * ORDER IS SEVERITY AND IT IS LOAD-BEARING, because the footer shows the first one: a pack
159
197
  * that vanished breaks every asset-backed surface; an unreadable layer makes recall ANSWER
160
198
  * WRONGLY rather than fail; a full index refuses the next save; a broken event log costs
161
- * diagnostics only.
199
+ * diagnostics only; and a dangling install origin costs nothing AT ALL right now — the server
200
+ * is serving correctly, and what is broken is the next attempt to update it. That is why it is
201
+ * last despite being the one that went unnoticed for five releases
202
+ * (`docs/roadmap-agent-stack.md` AS-7): severity here is what is failing, not what has been
203
+ * failing longest.
162
204
  *
163
205
  * EVERY CONDITION IS OBSERVED, NOT INFERRED — no heartbeat, no timer, no last-seen timestamp.
164
206
  * Each one is a state a test can construct and then watch this report: delete the pack, make a
@@ -167,8 +209,10 @@ export declare function eventLogCondition(log: EventLog): Condition | null;
167
209
  * claimed.
168
210
  *
169
211
  * THE COST, because this runs on every tool call: one `stat` for the index, one `stat` for the
170
- * pack, one directory listing per memory layer (two to four), and a field read for the log. No
171
- * fact file is opened and no index is parsed.
212
+ * pack, one directory listing per memory layer (two to four), a field read for the log, and one
213
+ * `stat` for the install origin. No fact file is opened, no index is parsed, and
214
+ * `node_modules` is NOT walked for a hidden lockfile — `currentInstall` does that once per
215
+ * process, because the bytes that were imported cannot change under a running one.
172
216
  */
173
217
  export declare function degradedConditions(memory: Memory, log: EventLog): Condition[];
174
218
  /**