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.
@@ -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
  /**
@@ -33,6 +33,8 @@ import { statSync } from 'node:fs';
33
33
  import { join } from 'node:path';
34
34
  import { AssetNotFound, assetsRoot } from '../assets.js';
35
35
  import { Memory } from '../memory/component.js';
36
+ import { INDEX_PRESSURE_PERCENT } from '../memory/store.js';
37
+ import { Undetermined, currentInstall, originStat } from './identity.js';
36
38
  /**
37
39
  * The tool AND the prompt answer to this one name. Deliberately the same word: the operator
38
40
  * who read `bantamkit_status` in a footer must find it in their prompt menu without
@@ -68,14 +70,13 @@ export const STATUS_PROMPT_TAIL = 'Show me that report as it stands. If it says
68
70
  export const SERVED_PROMPTS = 1;
69
71
  export const SERVED_RESOURCE_TEMPLATES = 2;
70
72
  /**
71
- * Percent of the index budget that has to be SPENT before the store is called degraded.
72
- *
73
- * 90 and not 100 because the useful moment is before the refusal, not after it: at 100% the
74
- * next `memory_save` has already failed and the operator has already seen the error. An
75
- * INTEGER percent, compared by cross-multiplication below, so the two runtimes cannot land
76
- * on opposite sides of the line through a float they rounded differently.
73
+ * `INDEX_PRESSURE_PERCENT` is re-exported here, where it used to be DEFINED, so that
74
+ * `import { INDEX_PRESSURE_PERCENT } from './mcp/status.js'` keeps resolving. It moved down
75
+ * to `memory/store.ts` in job46 (J46-5, mirroring the reference's J46-4):
76
+ * `MemoryStore.compact` is the remedy the sentence below names, and it cannot clear a warning
77
+ * whose line it cannot see. The comment that says why the number is 90 moved with it.
77
78
  */
78
- export const INDEX_PRESSURE_PERCENT = 90;
79
+ export { INDEX_PRESSURE_PERCENT };
79
80
  /** `1 prompt` / `2 prompts`. Spelled once so the two runtimes cannot disagree twice. */
80
81
  export function plural(count, word) {
81
82
  return count === 1 ? `${count} ${word}` : `${count} ${word}s`;
@@ -221,6 +222,20 @@ export function unreadableLayerCondition(memory) {
221
222
  * not a divergence and is not fixed here; it is registered in `docs/porting.md`. The sentence
222
223
  * says "archive or shorten facts", which is what the operator has to do either way.
223
224
  *
225
+ * AMENDMENT (job46, J46-5): THE PARAGRAPH ABOVE IS A RECORD OF A DEFECT THAT IS NOW CLOSED,
226
+ * and it is left standing because it is the measurement that motivated the fix. What it says
227
+ * was true until this job: the band was real, and on this machine's own project store
228
+ * (101 facts, index.md 21819 bytes of a 24000-byte budget = 90.91%, largest index line 361
229
+ * bytes so the old target was 23639 = 98.50%) `compact()` answered `archived: []` with this
230
+ * very sentence on screen. What has changed is the second half — "is not fixed here". It is
231
+ * fixed now, in the layer that owns it: `MemoryStore.compact`'s default `reserve` is measured
232
+ * from `undegradedIndexCeiling(budget)` rather than from `budget`, so running the command
233
+ * this sentence names clears the condition this sentence reports. Both runtimes moved in the
234
+ * same job (the reference in `87cc1f7`), because a band that closed on one side only would be
235
+ * the divergence the paragraph above correctly said this was not. The register entry's own
236
+ * closure is not written here — that is a `docs/porting.md` edit, and it waits on a
237
+ * conformance case comparing the two answers.
238
+ *
224
239
  * THIS SENTENCE IS FOR THE OPERATOR; THE MODEL HAS ITS OWN REMEDY. Since job42 the model
225
240
  * can call `memory_compact` (the ninth served tool) when a save is actually refused, and
226
241
  * the refused-budget reply in `memory/component.ts` names it. That does not change this
@@ -261,13 +276,71 @@ export function eventLogCondition(log) {
261
276
  'whether its directory is writable.',
262
277
  };
263
278
  }
279
+ /**
280
+ * This install came from a path on this machine, and that path is gone.
281
+ *
282
+ * THE ONE CONDITION THAT NAMES A PATH, and the exception is deliberate. Every other sentence
283
+ * here refuses one because `assetsRoot()` resolves differently in the two runtimes by
284
+ * construction, so a path would be an uncomparable value bought for nothing. This path is not
285
+ * the server's own location — it is the origin THE INSTALLER WROTE DOWN, it is the entire
286
+ * actionable content of the finding (AS-7's measured case is a tarball under a
287
+ * `/private/tmp/.../scratchpad` that no longer exists), and a sentence saying "something is
288
+ * missing" without saying what would be a sentence nobody can act on.
289
+ *
290
+ * THE REMEDY IS THE ONE THAT ACTUALLY MOVES SOMETHING. J46-4 spent a unit removing a condition
291
+ * whose remedy exited 0 having changed nothing, and this is exactly the shape that invites
292
+ * another: `npm update` where a dangling `file:` install lives is a no-op BY CONSTRUCTION. So
293
+ * the sentence sends the reader at a reinstall BY NAME from a registry, which replaces the
294
+ * install rather than trying to refresh it in place. `bantamkit` is the PRODUCT name and is
295
+ * copied verbatim from the reference — it is not the npm package id, and turning it into one
296
+ * is how `git_commit` diverged on a noun.
297
+ *
298
+ * THE DERIVED HALF IS MEMOISED AND THIS HALF IS NOT: the shape cannot change under a running
299
+ * process, the path's existence can, and it is the one that has to be read now.
300
+ */
301
+ export function installSourceCondition(install) {
302
+ if (install === null || install.source === null)
303
+ return null;
304
+ // `present: null` is a check that could not be made, and the reference reports no condition
305
+ // for one either: an origin nobody may stat is not, on that evidence, an origin that is gone.
306
+ if (originStat(install.source).present !== false)
307
+ return null;
308
+ return {
309
+ key: 'install-source-missing',
310
+ sentence: `this server was installed from ${install.source}, which no longer exists, so nothing ` +
311
+ 'can be refreshed in place there — reinstall bantamkit by name from a package registry ' +
312
+ 'and restart the server.',
313
+ };
314
+ }
315
+ /**
316
+ * The shape, or nothing. A shape that could not be derived is a REPORTED gap.
317
+ *
318
+ * `buildIdentity` is where that gap is named, with its reason. It is not a degraded condition:
319
+ * an install this code cannot classify is not, on that evidence, an install that is broken, and
320
+ * a footer on every tool call saying otherwise would be the noise `degradedNotice` exists to
321
+ * avoid.
322
+ */
323
+ function currentInstallOrNull() {
324
+ try {
325
+ return currentInstall();
326
+ }
327
+ catch (e) {
328
+ if (!(e instanceof Undetermined))
329
+ throw e;
330
+ return null;
331
+ }
332
+ }
264
333
  /**
265
334
  * Everything wrong right now, worst first. An empty array means healthy.
266
335
  *
267
336
  * ORDER IS SEVERITY AND IT IS LOAD-BEARING, because the footer shows the first one: a pack
268
337
  * that vanished breaks every asset-backed surface; an unreadable layer makes recall ANSWER
269
338
  * WRONGLY rather than fail; a full index refuses the next save; a broken event log costs
270
- * diagnostics only.
339
+ * diagnostics only; and a dangling install origin costs nothing AT ALL right now — the server
340
+ * is serving correctly, and what is broken is the next attempt to update it. That is why it is
341
+ * last despite being the one that went unnoticed for five releases
342
+ * (`docs/roadmap-agent-stack.md` AS-7): severity here is what is failing, not what has been
343
+ * failing longest.
271
344
  *
272
345
  * EVERY CONDITION IS OBSERVED, NOT INFERRED — no heartbeat, no timer, no last-seen timestamp.
273
346
  * Each one is a state a test can construct and then watch this report: delete the pack, make a
@@ -276,8 +349,10 @@ export function eventLogCondition(log) {
276
349
  * claimed.
277
350
  *
278
351
  * THE COST, because this runs on every tool call: one `stat` for the index, one `stat` for the
279
- * pack, one directory listing per memory layer (two to four), and a field read for the log. No
280
- * fact file is opened and no index is parsed.
352
+ * pack, one directory listing per memory layer (two to four), a field read for the log, and one
353
+ * `stat` for the install origin. No fact file is opened, no index is parsed, and
354
+ * `node_modules` is NOT walked for a hidden lockfile — `currentInstall` does that once per
355
+ * process, because the bytes that were imported cannot change under a running one.
281
356
  */
282
357
  export function degradedConditions(memory, log) {
283
358
  return [
@@ -285,6 +360,7 @@ export function degradedConditions(memory, log) {
285
360
  unreadableLayerCondition(memory),
286
361
  indexPressureCondition(memory),
287
362
  eventLogCondition(log),
363
+ installSourceCondition(currentInstallOrNull()),
288
364
  ].filter((condition) => condition !== null);
289
365
  }
290
366
  /**
@@ -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 thirteen-tool surface holds to it; `compact` is the one exception since job42
7
+ * and the fourteen-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`
@@ -25,6 +25,8 @@
25
25
  * lines carry a `[project] ` tag that the `--store` form never emits. A port validated
26
26
  * against only one of them ships a string the deployment never produces.
27
27
  */
28
+ import { realpathSync } from 'node:fs';
29
+ import { resolve } from 'node:path';
28
30
  import { BantamError } from '../errors.js';
29
31
  import { pyStrip } from './factfile.js';
30
32
  import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
@@ -60,6 +62,55 @@ export function normalizeName(name) {
60
62
  export function profileStore() {
61
63
  return pyJoin(pyHome(), ...PROJECT_STORE);
62
64
  }
65
+ /**
66
+ * Whether two paths name ONE directory, symlinks and `/var` vs `/private/var` included.
67
+ *
68
+ * Port of `_same_directory` (`runtime-py/src/bantamkit/memory/component.py`, J47-1,
69
+ * 0844ccb). Realpath, not string equality, and that distinction is measured rather than
70
+ * tidy: on macOS the walk up from a cwd under `/var` returns `/private/var/...` while
71
+ * `pyHome()` returns `/var/...`, so two spellings of one directory compare unequal as
72
+ * strings. The Stop hook's `samePath` (`tools/hooks/bantamkit-hook.mjs`) compares the same
73
+ * two roots the same way and for the same reason — it carried the identical defect, and
74
+ * J47-7 (`a109f99`) brought it to the same `realpathSync.native`, following this function
75
+ * rather than leading it. When the resolution itself
76
+ * fails, an absolute-path comparison is the honest fallback: it can only under-report a
77
+ * match, never invent one.
78
+ *
79
+ * AMENDED 2026-09-11 (J47-3B). The paragraph above stands as the reason this function
80
+ * resolves rather than compares strings, and the fallback's behaviour is unchanged. Two
81
+ * things it says are now known to be wrong about `fs.realpathSync` in particular, and
82
+ * measured: (1) it is NOT the twin of `os.path.realpath`. `realpathSync` hands its argument
83
+ * to `path.resolve` before it resolves anything, and `path.resolve` pops `..` LEXICALLY —
84
+ * before the symlink in front of it is followed. `os.path.realpath` and the kernel pop it
85
+ * AFTER. So a `HOME` spelled `<bed>/link/..`, where `link -> <bed>/deep/real`, is
86
+ * `<bed>/deep` to the reference and `<bed>` to Node: `realpathSync` then threw ENOENT on a
87
+ * directory that is there, the fallback under-reported, and `layered` bound one directory as
88
+ * two layers — the 2026-09-10 self-merge, still live on this side until now. (2) "it can
89
+ * only under-report a match, never invent one" was offered as reassurance, and
90
+ * under-reporting is the DANGEROUS direction here: a missed match IS the duplicate layer.
91
+ *
92
+ * The mechanism is now `realpathSync.native` — libuv's `uv_fs_realpath`, i.e. the platform's
93
+ * own `realpath(3)` / `GetFinalPathNameByHandle` — chosen because it pops `..` in the
94
+ * kernel's order and therefore agrees with `os.path.realpath` byte for byte on that bed,
95
+ * because it keeps the macOS `/var` vs `/private/var` resolution the original was written
96
+ * for, and because it adds no path arithmetic of this port's own. `statSync` identity
97
+ * (`dev` + `ino`) was the other candidate and was not taken: `ino` is not dependable on
98
+ * every Windows filesystem, and this runtime has to answer the same on Windows.
99
+ *
100
+ * The fallback stays `resolve(a) === resolve(b)`, and the under-report it can still produce
101
+ * is provably harmless where `layered` calls this: the native call throws only for a path
102
+ * that is not on disk, and `binding.path` always is — `layered` constructs that store, which
103
+ * creates it, before the guard runs — so a throwing side is a directory that does not exist,
104
+ * and that cannot be the same directory as one that does.
105
+ */
106
+ function sameDirectory(a, b) {
107
+ try {
108
+ return realpathSync.native(a) === realpathSync.native(b);
109
+ }
110
+ catch {
111
+ return resolve(a) === resolve(b);
112
+ }
113
+ }
63
114
  /** The name a layer answers under: the PROJECT directory, not the store directory. */
64
115
  export function layerLabel(root) {
65
116
  if (pyName(pyParent(root)) === '.bantamkit')
@@ -110,11 +161,21 @@ export class Memory {
110
161
  false,
111
162
  ]);
112
163
  }
113
- mem.layers.push([
114
- 'profile',
115
- new MemoryStore(profileStore(), { k, create: false, ...(today ? { today } : {}) }),
116
- false,
117
- ]);
164
+ // ONE DIRECTORY IS ONE LAYER. The walk above starts at the cwd and climbs, so a
165
+ // session with no `.bantamkit` anywhere above it resolves `~/.bantamkit/memory` —
166
+ // the profile store — as its PROJECT store. Binding that directory a second time
167
+ // gave `dream` the same store twice: every fact collided with itself, was merged
168
+ // into itself, and the "profile copy" that was archived was the same file. It
169
+ // archived 20 of 20 of the user's real facts on 2026-09-10. Such a session has one
170
+ // layer, and `dreamOutcome` already has a true thing to say about that.
171
+ const profileRoot = profileStore();
172
+ if (!sameDirectory(profileRoot, binding.path)) {
173
+ mem.layers.push([
174
+ 'profile',
175
+ new MemoryStore(profileRoot, { k, create: false, ...(today ? { today } : {}) }),
176
+ false,
177
+ ]);
178
+ }
118
179
  return mem;
119
180
  }
120
181
  /** The labels, in order. `_layers` is private in the reference too; this is for the tests. */
@@ -18,6 +18,40 @@ export declare const DUPLICATE_JACCARD = 0.5;
18
18
  export declare const RECALL_MIN_SCORE_RATIO = 0;
19
19
  /** See the reference's comment: measured against a real store, not chosen. */
20
20
  export declare const DEFAULT_INDEX_BUDGET = 24000;
21
+ /**
22
+ * Percent of the index budget that has to be SPENT before the store is called degraded.
23
+ *
24
+ * 90 and not 100 because the useful moment is before the refusal, not after it: at 100% the
25
+ * next `memory_save` has already failed and the operator has already seen the error. An
26
+ * INTEGER percent, compared by cross-multiplication where it is read, so the two runtimes
27
+ * cannot land on opposite sides of the line through a float they rounded differently.
28
+ *
29
+ * AMENDMENT (job46, J46-5, mirroring the reference's J46-4). It used to live in
30
+ * `mcp/status.ts`, next to the only thing that read it, and that is exactly what
31
+ * `docs/porting.md`'s register item 7 is about: the report warned at THIS line while the
32
+ * remedy it named — `compact` — aimed at a different one, so between the two the command
33
+ * exited 0 having archived nothing. `compact` has to know where the warning is to be able to
34
+ * clear it, and `mcp/status.ts` is above this layer, so the number moved DOWN to the layer
35
+ * both readers can reach. Same name, same value, same integer comparison; `mcp/status.ts`
36
+ * imports it and re-exports the name rather than spelling a second 90.
37
+ */
38
+ export declare const INDEX_PRESSURE_PERCENT = 90;
39
+ /**
40
+ * The largest index size `INDEX_PRESSURE_PERCENT` does NOT call degraded, in bytes.
41
+ *
42
+ * Integer arithmetic only, and the identity it holds is
43
+ * `size > undegradedIndexCeiling(b)` exactly when `size * 100 >= PERCENT * b` — the
44
+ * cross-multiplied comparison `mcp/status.ts`'s `indexPressureCondition` writes. The two
45
+ * spellings are pinned against each other by a boundary sweep in `test/server.test.mjs`,
46
+ * because they are two spellings and a test is the only thing that can keep them one line.
47
+ *
48
+ * `Math.floor` AND NOT `Math.trunc`, because the reference spells this `//` — floor division,
49
+ * which rounds toward -infinity — and `Math.trunc` rounds toward zero. The two differ on
50
+ * every negative budget, and a budget is not guaranteed positive by anything in this
51
+ * function's own signature. `test/store.test.mjs` sweeps both signs against a BigInt oracle
52
+ * rather than against a second copy of this expression.
53
+ */
54
+ export declare function undegradedIndexCeiling(budget: number): number;
21
55
  export declare class MemoryValidationError extends BantamError {
22
56
  }
23
57
  export declare class MemoryBudgetExceeded extends BantamError {
@@ -245,6 +279,28 @@ export declare class MemoryStore {
245
279
  * store currently holds (capped at half the budget), so the headroom bought is "a fact as
246
280
  * big as your biggest one will fit", measured from this store's own data.
247
281
  *
282
+ * AMENDMENT (job46, J46-5, the port of the reference's J46-4), and it supersedes the
283
+ * sentence above about where that reserve is measured FROM. WAS: `budget - largest index
284
+ * line`. NOW: that same largest line plus the headroom `INDEX_PRESSURE_PERCENT` demands, so
285
+ * the target is `undegradedIndexCeiling(budget) - largest index line`. THE REASON IS
286
+ * `docs/porting.md`'s register item 7: the degraded report warns at 90% of the budget and
287
+ * names THIS command, while the old target sat at `budget - largest line`, so on any store
288
+ * whose biggest line is under a tenth of its budget the command the operator was told to
289
+ * run archived nothing and the warning stayed up. MEASURED on a read-only copy of this
290
+ * machine's project store (101 facts, index.md 21819 bytes of 24000 = 90.91%, largest index
291
+ * line 361 bytes, so the old target was 23639 = 98.50%): `compact()` answered
292
+ * `archived: []` and `index-budget-low` was still firing afterwards — the same two answers
293
+ * the reference gave on the same store, which is why the register calls this a defect of
294
+ * the reference and NOT a divergence. The paragraph above is why the fix is a substitution
295
+ * and not a new number — "a loop that stopped at 'fits' would archive nothing at the only
296
+ * moment the remedy is ever named" is the same argument one line lower down, so the reserve
297
+ * is measured from the line the WARNING draws instead of the one the REFUSAL draws.
298
+ *
299
+ * WHAT THIS DOES NOT CHANGE, deliberately: the eviction ORDER (`byEviction`), the
300
+ * half-the-budget cap, and an EXPLICIT `reserve`. A caller that passes one gets the
301
+ * arithmetic it always got, byte for byte — every eviction-order node in `store.test.mjs`
302
+ * passes one for exactly that reason, and so may any caller that needs the old default back.
303
+ *
248
304
  * `facts()` runs FIRST, before any rename, so an unreadable store moves nothing.
249
305
  *
250
306
  * THE MOVE IS `os.replace`, AND THE REFERENCE'S IS NOW TOO — the difference this comment
@@ -119,6 +119,42 @@ export const RECALL_MIN_SCORE_RATIO = 0.0;
119
119
  const MIN_RATIO_RANGE = 'recall min-score ratio must be between 0.0 and 1.0';
120
120
  /** See the reference's comment: measured against a real store, not chosen. */
121
121
  export const DEFAULT_INDEX_BUDGET = 24_000;
122
+ /**
123
+ * Percent of the index budget that has to be SPENT before the store is called degraded.
124
+ *
125
+ * 90 and not 100 because the useful moment is before the refusal, not after it: at 100% the
126
+ * next `memory_save` has already failed and the operator has already seen the error. An
127
+ * INTEGER percent, compared by cross-multiplication where it is read, so the two runtimes
128
+ * cannot land on opposite sides of the line through a float they rounded differently.
129
+ *
130
+ * AMENDMENT (job46, J46-5, mirroring the reference's J46-4). It used to live in
131
+ * `mcp/status.ts`, next to the only thing that read it, and that is exactly what
132
+ * `docs/porting.md`'s register item 7 is about: the report warned at THIS line while the
133
+ * remedy it named — `compact` — aimed at a different one, so between the two the command
134
+ * exited 0 having archived nothing. `compact` has to know where the warning is to be able to
135
+ * clear it, and `mcp/status.ts` is above this layer, so the number moved DOWN to the layer
136
+ * both readers can reach. Same name, same value, same integer comparison; `mcp/status.ts`
137
+ * imports it and re-exports the name rather than spelling a second 90.
138
+ */
139
+ export const INDEX_PRESSURE_PERCENT = 90;
140
+ /**
141
+ * The largest index size `INDEX_PRESSURE_PERCENT` does NOT call degraded, in bytes.
142
+ *
143
+ * Integer arithmetic only, and the identity it holds is
144
+ * `size > undegradedIndexCeiling(b)` exactly when `size * 100 >= PERCENT * b` — the
145
+ * cross-multiplied comparison `mcp/status.ts`'s `indexPressureCondition` writes. The two
146
+ * spellings are pinned against each other by a boundary sweep in `test/server.test.mjs`,
147
+ * because they are two spellings and a test is the only thing that can keep them one line.
148
+ *
149
+ * `Math.floor` AND NOT `Math.trunc`, because the reference spells this `//` — floor division,
150
+ * which rounds toward -infinity — and `Math.trunc` rounds toward zero. The two differ on
151
+ * every negative budget, and a budget is not guaranteed positive by anything in this
152
+ * function's own signature. `test/store.test.mjs` sweeps both signs against a BigInt oracle
153
+ * rather than against a second copy of this expression.
154
+ */
155
+ export function undegradedIndexCeiling(budget) {
156
+ return Math.floor((INDEX_PRESSURE_PERCENT * budget - 1) / 100);
157
+ }
122
158
  // The second half of the "unreadable" sentence, one per directory this store lists — and
123
159
  // then the same distinction one syscall down, for the stats `restore` does instead of a
124
160
  // listing. They are separate strings because the failures do different damage, and an error
@@ -635,6 +671,28 @@ export class MemoryStore {
635
671
  * store currently holds (capped at half the budget), so the headroom bought is "a fact as
636
672
  * big as your biggest one will fit", measured from this store's own data.
637
673
  *
674
+ * AMENDMENT (job46, J46-5, the port of the reference's J46-4), and it supersedes the
675
+ * sentence above about where that reserve is measured FROM. WAS: `budget - largest index
676
+ * line`. NOW: that same largest line plus the headroom `INDEX_PRESSURE_PERCENT` demands, so
677
+ * the target is `undegradedIndexCeiling(budget) - largest index line`. THE REASON IS
678
+ * `docs/porting.md`'s register item 7: the degraded report warns at 90% of the budget and
679
+ * names THIS command, while the old target sat at `budget - largest line`, so on any store
680
+ * whose biggest line is under a tenth of its budget the command the operator was told to
681
+ * run archived nothing and the warning stayed up. MEASURED on a read-only copy of this
682
+ * machine's project store (101 facts, index.md 21819 bytes of 24000 = 90.91%, largest index
683
+ * line 361 bytes, so the old target was 23639 = 98.50%): `compact()` answered
684
+ * `archived: []` and `index-budget-low` was still firing afterwards — the same two answers
685
+ * the reference gave on the same store, which is why the register calls this a defect of
686
+ * the reference and NOT a divergence. The paragraph above is why the fix is a substitution
687
+ * and not a new number — "a loop that stopped at 'fits' would archive nothing at the only
688
+ * moment the remedy is ever named" is the same argument one line lower down, so the reserve
689
+ * is measured from the line the WARNING draws instead of the one the REFUSAL draws.
690
+ *
691
+ * WHAT THIS DOES NOT CHANGE, deliberately: the eviction ORDER (`byEviction`), the
692
+ * half-the-budget cap, and an EXPLICIT `reserve`. A caller that passes one gets the
693
+ * arithmetic it always got, byte for byte — every eviction-order node in `store.test.mjs`
694
+ * passes one for exactly that reason, and so may any caller that needs the old default back.
695
+ *
638
696
  * `facts()` runs FIRST, before any rename, so an unreadable store moves nothing.
639
697
  *
640
698
  * THE MOVE IS `os.replace`, AND THE REFERENCE'S IS NOW TOO — the difference this comment
@@ -689,8 +747,19 @@ export class MemoryStore {
689
747
  sizes.set(pyHashKey(fact.name), Buffer.byteLength(this.indexLine(fact), 'utf8'));
690
748
  }
691
749
  const all = [...sizes.values()];
692
- if (reserve === null)
693
- reserve = all.length === 0 ? 0 : Math.max(...all);
750
+ if (reserve === null) {
751
+ // The default reserve is measured from the WARNING LINE, not from the budget. One
752
+ // substitution, and it is the whole of `docs/porting.md` item 7: the sentence that
753
+ // names this command fires at `INDEX_PRESSURE_PERCENT`, so a remedy that only reaches
754
+ // `budget - largest line` is a no-op everywhere between them. `undegradedIndexCeiling`
755
+ // is that line; `+ largest line` keeps this method's own promise on the other side of
756
+ // it, unchanged in words: "a fact as big as your biggest one will fit" — before the
757
+ // index is degraded AGAIN, rather than before it is over budget.
758
+ reserve =
759
+ this.indexBudget -
760
+ undegradedIndexCeiling(this.indexBudget) +
761
+ (all.length === 0 ? 0 : Math.max(...all));
762
+ }
694
763
  // `min(reserve, self.index_budget // 2)`: floor division, and the budget is >= 1 here
695
764
  // because both CLIs refuse a smaller one at the edge.
696
765
  reserve = Math.max(0, Math.min(reserve, Math.floor(this.indexBudget / 2)));
@@ -0,0 +1,100 @@
1
+ import { BantamError } from './errors.js';
2
+ export declare const PRICES_ENV = "BANTAMKIT_PRICES";
3
+ export declare const PRICE_SCHEMA_VERSION = 1;
4
+ export declare const CURRENCY = "USD";
5
+ export declare const RATE_UNIT = "micro_usd_per_million_tokens";
6
+ /** A rate is micro-USD per this many tokens. */
7
+ export declare const TOKENS_PER_RATE_UNIT = 1000000;
8
+ /** Micro-USD in one USD. Also the rounding divisor, hence `HALF` below. */
9
+ export declare const MICROS_PER_UNIT = 1000000;
10
+ /** The largest integer both runtimes represent exactly. */
11
+ export declare const MAX_SAFE_INT: number;
12
+ /**
13
+ * The four classes the host's own `usage` block distinguishes, in the order the ledger
14
+ * accumulates them. This tuple is the unit of pricing; see property 1 in the reference.
15
+ */
16
+ export declare const TOKEN_CLASSES: readonly ["input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens"];
17
+ export type TokenClass = (typeof TOKEN_CLASSES)[number];
18
+ export interface Rate {
19
+ recorded: string;
20
+ source: string;
21
+ [cls: string]: string | number;
22
+ }
23
+ export interface PriceTable {
24
+ schema_version: number;
25
+ currency: string;
26
+ unit: string;
27
+ note?: string;
28
+ rates: Record<string, Rate>;
29
+ }
30
+ /** A cost, or a refusal. Never both, and never a zero standing in for a refusal. */
31
+ export type PriceResult = {
32
+ unavailable: string;
33
+ } | {
34
+ model: string;
35
+ currency: string;
36
+ micros: number;
37
+ amount: string;
38
+ breakdown: Record<string, number>;
39
+ rate: Rate;
40
+ };
41
+ /**
42
+ * The table on disk is malformed.
43
+ *
44
+ * This THROWS rather than returning `{unavailable}`, and the split is deliberate: a broken
45
+ * price table is an operator configuration fault that must stop and name itself, while an
46
+ * unpriced model is a normal, expected answer that a caller keeps working past.
47
+ */
48
+ export declare class PriceTableError extends BantamError {
49
+ }
50
+ /**
51
+ * `$BANTAMKIT_PRICES` verbatim if set, else the shipped table in the asset pack.
52
+ *
53
+ * The override wins even when it is wrong -- same arm order and same reasoning as
54
+ * `assetsRoot()`: an override that points nowhere must fail naming itself rather than fall
55
+ * through to a table the operator did not ask for.
56
+ */
57
+ export declare function priceTablePath(): string;
58
+ /**
59
+ * Read and validate a price table. `path` is used VERBATIM in every message it appears in,
60
+ * never normalised, because `Path('./x')` is `x` on the reference side and `./x` here and the
61
+ * fault sentence would then differ over a path neither runtime chose.
62
+ */
63
+ export declare function loadPriceTable(path?: string): PriceTable;
64
+ /**
65
+ * The decode both runtimes must agree on, in one place so the gate can reach it.
66
+ *
67
+ * This side is already the strict one -- `JSON.parse` rejects the bare `NaN` / `Infinity` /
68
+ * `-Infinity` tokens that `json.loads` accepts -- so the function is a plain `JSON.parse`
69
+ * and the reference is what had to move. It exists as a named arm anyway, because a rule
70
+ * that lives in only one of two runtimes is a rule the conformance harness cannot reach.
71
+ */
72
+ export declare function decodePriceJson(text: string): unknown;
73
+ /**
74
+ * Every rule the table must satisfy, each with its own sentence.
75
+ *
76
+ * Returns a NORMALISED table: the top-level keys in a fixed order, rate names in code-point
77
+ * order, and every count narrowed. See the reference for the ONE thing normalisation cannot
78
+ * buy -- the iteration order of `rates` for a model name JavaScript reads as an array index.
79
+ */
80
+ export declare function validatePriceTable(raw: unknown): PriceTable;
81
+ /**
82
+ * Micro-USD as a fixed-6-decimal string, built from integers only.
83
+ *
84
+ * Non-negative input only; every producer in this module is a sum of non-negative products.
85
+ */
86
+ export declare function formatMicros(micros: number): string;
87
+ /**
88
+ * The cost of one `usage` block under one model's rate, or a named refusal.
89
+ *
90
+ * `table` must be a table `validatePriceTable` returned.
91
+ *
92
+ * THE ORDER OF THE CHECKS IS PART OF THE CONTRACT. The usage is validated BEFORE the model is
93
+ * looked up, so a caller who passed a malformed usage is told that, rather than being sent to
94
+ * edit a price table over a bug in the call.
95
+ *
96
+ * THE USAGE MUST CARRY ALL FOUR CLASSES AND NOTHING ELSE. See the reference: a token class
97
+ * silently ignored is money silently dropped, and a class silently defaulted to zero is money
98
+ * silently invented.
99
+ */
100
+ export declare function priceTokens(table: PriceTable, model: string, usage: unknown): PriceResult;