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.
@@ -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 eleven-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`
@@ -1,5 +1,6 @@
1
1
  import { type StoreBinding } from './layers.js';
2
2
  import { MemoryStore } from './store.js';
3
+ import type { DreamResult } from './dream.js';
3
4
  /**
4
5
  * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
5
6
  *
@@ -97,6 +98,30 @@ export interface RecallOutcome {
97
98
  readonly source: string | null;
98
99
  readonly unreadable: number;
99
100
  }
101
+ /**
102
+ * What `dream` DID, beside the sentence it says about it.
103
+ *
104
+ * The same seam as `SaveOutcome`, `RecallOutcome` and `CompactOutcome`: `status` is read off
105
+ * a decision the pass already made — `DreamResult.applied`, `.overBudget`, `.changes` — and
106
+ * never off the reply. `result` carries the whole diff for a caller that wants the numbers
107
+ * rather than the prose.
108
+ *
109
+ * `status` is one of `consolidated`, `previewed`, `nothing-to-consolidate`,
110
+ * `refused-budget`, `no-profile-layer`.
111
+ */
112
+ export interface DreamOutcome {
113
+ readonly reply: string;
114
+ readonly status: string;
115
+ readonly dryRun: boolean;
116
+ readonly merged: number;
117
+ readonly consumed: number;
118
+ readonly absolutised: number;
119
+ readonly superseded: number;
120
+ readonly indexBefore: number;
121
+ readonly indexAfter: number;
122
+ readonly budget: number;
123
+ readonly result: DreamResult | null;
124
+ }
100
125
  export declare class Memory {
101
126
  readonly store: MemoryStore;
102
127
  readonly k: number;
@@ -140,7 +165,7 @@ export declare class Memory {
140
165
  * fact. The floor is the operator's configured default, not a constant, so a consumer who
141
166
  * really wants top-1 says so once at construction.
142
167
  */
143
- recall(query: string, k?: number | null): string;
168
+ recall(query: string, k?: number | null, minRatio?: number): string;
144
169
  /**
145
170
  * `recall`, carrying the walk it performed as numbers rather than as prose.
146
171
  *
@@ -157,8 +182,18 @@ export declare class Memory {
157
182
  * refuses to list contributes nothing and raises the `unreadable` count instead of being
158
183
  * scored as empty; that distinction is the whole subject of `nothingToReport` below and
159
184
  * must not be undone here.
185
+ *
186
+ * `minRatio` (roadmap #6) rides through to every layer's `MemoryStore.recall` unchanged,
187
+ * so the gate is measured against EACH LAYER's own best score and never across layers: a
188
+ * profile fact does not have to out-score the project store's top hit to be admitted,
189
+ * because the two stores are answering as two stores. Its default is
190
+ * `RECALL_MIN_SCORE_RATIO` = 0.0, which keeps every fact `recall` was going to return,
191
+ * and nothing on the tool path passes anything else today. A ratio outside `[0.0, 1.0]`
192
+ * raises out of the FIRST layer, which is the writable project store, so it surfaces as
193
+ * the error it is rather than as an `unreadable` count — the read-only-layer `catch`
194
+ * below would otherwise file a caller's bad argument as a corrupt grant.
160
195
  */
161
- recallOutcome(query: string, k?: number | null): RecallOutcome;
196
+ recallOutcome(query: string, k?: number | null, minRatio?: number): RecallOutcome;
162
197
  /**
163
198
  * Archive the stalest facts until a fact as large as the biggest one kept will fit.
164
199
  *
@@ -179,6 +214,42 @@ export declare class Memory {
179
214
  * is — `MemoryStore.compact` builds it with `pyJoin`.
180
215
  */
181
216
  compactOutcome(reserve?: number | null): CompactOutcome;
217
+ /** Consolidate what the project and profile layers hold under the same name. */
218
+ dream(dryRun?: boolean): string;
219
+ /**
220
+ * `dream`, with the decision it took carried beside the sentence it wrote.
221
+ *
222
+ * ONLY THE PROFILE LAYER IS CONSUMED. `layers` also carries read-only GRANTS, and a grant
223
+ * is another operator's store: consolidating a fact out of one is not this person's move
224
+ * to make, so `dream` never looks at them. The label is matched exactly (`profile`), never
225
+ * by prefix, because a grant is labelled `extra:<name>` and a prefix match on a directory
226
+ * called `profile-something` would reach one.
227
+ *
228
+ * A `Memory` constructed directly — not through `layered` — has no profile layer at all,
229
+ * and that is `no-profile-layer` rather than an error: there is nothing to consolidate
230
+ * ACROSS when only one layer is bound.
231
+ *
232
+ * `dryRun` DEFAULTS TO TRUE. This is the only op in this component that writes into the
233
+ * user's home directory, and it is the only one whose effect is machine-wide: a fact
234
+ * archived out of the profile store stops answering for every other project on this
235
+ * machine that has no store of its own. A destructive consolidation nobody can preview is
236
+ * not shippable, so the safe call is the short one.
237
+ */
238
+ dreamOutcome(dryRun?: boolean): DreamOutcome;
239
+ /**
240
+ * The whole diff as prose: what merged, what was dated, what moved, what it cost.
241
+ *
242
+ * THE HONESTY SENTENCE AT THE END IS NOT DECORATION. Row 5 was planned as a token saving
243
+ * and J45-1 measured that it is not one: the profile store has no `index.md` on disk, its
244
+ * index is derived at read time, and nothing loads it into a prompt — so deduplicating it
245
+ * frees approximately zero prompt bytes. The value is that one ruling now has one copy
246
+ * instead of two that can disagree, and the reply says which of those two things the
247
+ * operator just bought.
248
+ *
249
+ * `formatFixed3` and not `toFixed(3)` for the two similarity figures: see the header of
250
+ * `dream.ts`. CPython's `.3f` rounds ties to EVEN and they are reachable.
251
+ */
252
+ private static dreamReply;
182
253
  /**
183
254
  * `(index bytes, budget)` for the writable project store; bytes may be `null`.
184
255
  *
@@ -29,7 +29,8 @@ import { BantamError } from '../errors.js';
29
29
  import { pyStrip } from './factfile.js';
30
30
  import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
31
31
  import { pyHome, pyJoin, pyName, pyParent, pyResolve, PyOSError, PyUnicodeDecodeError } from './pyfs.js';
32
- import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyHashKey, pyText, } from './store.js';
32
+ import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, } from './store.js';
33
+ import { dream as runDream, formatFixed3, SUPERSEDED_HEADING } from './dream.js';
33
34
  /**
34
35
  * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
35
36
  *
@@ -177,8 +178,8 @@ export class Memory {
177
178
  * fact. The floor is the operator's configured default, not a constant, so a consumer who
178
179
  * really wants top-1 says so once at construction.
179
180
  */
180
- recall(query, k = null) {
181
- return this.recallOutcome(query, k).reply;
181
+ recall(query, k = null, minRatio = RECALL_MIN_SCORE_RATIO) {
182
+ return this.recallOutcome(query, k, minRatio).reply;
182
183
  }
183
184
  /**
184
185
  * `recall`, carrying the walk it performed as numbers rather than as prose.
@@ -196,8 +197,18 @@ export class Memory {
196
197
  * refuses to list contributes nothing and raises the `unreadable` count instead of being
197
198
  * scored as empty; that distinction is the whole subject of `nothingToReport` below and
198
199
  * must not be undone here.
200
+ *
201
+ * `minRatio` (roadmap #6) rides through to every layer's `MemoryStore.recall` unchanged,
202
+ * so the gate is measured against EACH LAYER's own best score and never across layers: a
203
+ * profile fact does not have to out-score the project store's top hit to be admitted,
204
+ * because the two stores are answering as two stores. Its default is
205
+ * `RECALL_MIN_SCORE_RATIO` = 0.0, which keeps every fact `recall` was going to return,
206
+ * and nothing on the tool path passes anything else today. A ratio outside `[0.0, 1.0]`
207
+ * raises out of the FIRST layer, which is the writable project store, so it surfaces as
208
+ * the error it is rather than as an `unreadable` count — the read-only-layer `catch`
209
+ * below would otherwise file a caller's bad argument as a corrupt grant.
199
210
  */
200
- recallOutcome(query, k = null) {
211
+ recallOutcome(query, k = null, minRatio = RECALL_MIN_SCORE_RATIO) {
201
212
  const budget = k === null ? this.k : Math.max(k, this.k);
202
213
  const picked = [];
203
214
  const seen = new Set();
@@ -210,7 +221,7 @@ export class Memory {
210
221
  reached += 1;
211
222
  let facts;
212
223
  try {
213
- facts = store.recall(query, budget, writable);
224
+ facts = store.recall(query, budget, writable, minRatio);
214
225
  }
215
226
  catch (e) {
216
227
  if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
@@ -312,6 +323,147 @@ export class Memory {
312
323
  budget: result.budget,
313
324
  };
314
325
  }
326
+ /** Consolidate what the project and profile layers hold under the same name. */
327
+ dream(dryRun = true) {
328
+ return this.dreamOutcome(dryRun).reply;
329
+ }
330
+ /**
331
+ * `dream`, with the decision it took carried beside the sentence it wrote.
332
+ *
333
+ * ONLY THE PROFILE LAYER IS CONSUMED. `layers` also carries read-only GRANTS, and a grant
334
+ * is another operator's store: consolidating a fact out of one is not this person's move
335
+ * to make, so `dream` never looks at them. The label is matched exactly (`profile`), never
336
+ * by prefix, because a grant is labelled `extra:<name>` and a prefix match on a directory
337
+ * called `profile-something` would reach one.
338
+ *
339
+ * A `Memory` constructed directly — not through `layered` — has no profile layer at all,
340
+ * and that is `no-profile-layer` rather than an error: there is nothing to consolidate
341
+ * ACROSS when only one layer is bound.
342
+ *
343
+ * `dryRun` DEFAULTS TO TRUE. This is the only op in this component that writes into the
344
+ * user's home directory, and it is the only one whose effect is machine-wide: a fact
345
+ * archived out of the profile store stops answering for every other project on this
346
+ * machine that has no store of its own. A destructive consolidation nobody can preview is
347
+ * not shippable, so the safe call is the short one.
348
+ */
349
+ dreamOutcome(dryRun = true) {
350
+ const profile = this.layers.find(([label]) => label === 'profile')?.[1] ?? null;
351
+ if (profile === null) {
352
+ return {
353
+ reply: 'nothing to consolidate: no profile layer is bound, so the project ' +
354
+ `store ${this.store.root} is the only layer there is.`,
355
+ status: 'no-profile-layer',
356
+ dryRun,
357
+ merged: 0,
358
+ consumed: 0,
359
+ absolutised: 0,
360
+ superseded: 0,
361
+ indexBefore: 0,
362
+ indexAfter: 0,
363
+ budget: this.store.indexBudget,
364
+ result: null,
365
+ };
366
+ }
367
+ const result = runDream(this.store, profile, dryRun);
368
+ let status;
369
+ if (result.overBudget)
370
+ status = 'refused-budget';
371
+ else if (result.changes === 0)
372
+ status = 'nothing-to-consolidate';
373
+ else if (result.applied)
374
+ status = 'consolidated';
375
+ else
376
+ status = 'previewed';
377
+ return {
378
+ reply: Memory.dreamReply(status, result),
379
+ status,
380
+ dryRun: result.dryRun,
381
+ merged: result.merged.length,
382
+ consumed: result.consumed.length,
383
+ absolutised: result.absolutised.length,
384
+ superseded: result.superseded.length,
385
+ indexBefore: result.indexBefore,
386
+ indexAfter: result.indexAfter,
387
+ budget: result.budget,
388
+ result,
389
+ };
390
+ }
391
+ /**
392
+ * The whole diff as prose: what merged, what was dated, what moved, what it cost.
393
+ *
394
+ * THE HONESTY SENTENCE AT THE END IS NOT DECORATION. Row 5 was planned as a token saving
395
+ * and J45-1 measured that it is not one: the profile store has no `index.md` on disk, its
396
+ * index is derived at read time, and nothing loads it into a prompt — so deduplicating it
397
+ * frees approximately zero prompt bytes. The value is that one ruling now has one copy
398
+ * instead of two that can disagree, and the reply says which of those two things the
399
+ * operator just bought.
400
+ *
401
+ * `formatFixed3` and not `toFixed(3)` for the two similarity figures: see the header of
402
+ * `dream.ts`. CPython's `.3f` rounds ties to EVEN and they are reachable.
403
+ */
404
+ static dreamReply(status, result) {
405
+ const lines = [];
406
+ if (status === 'nothing-to-consolidate') {
407
+ lines.push(`nothing to consolidate: ${result.projectRoot} and ${result.profileRoot} ` +
408
+ 'hold no fact under the same name, and no project fact carries a relative ' +
409
+ 'date this pass will resolve.');
410
+ }
411
+ else {
412
+ const identical = result.merged.filter((merge) => merge.kind === 'identical').length;
413
+ const diverged = result.merged.length - identical;
414
+ const verb = status === 'consolidated' ? 'consolidated' : 'would consolidate';
415
+ lines.push(`${verb} ${result.merged.length} cross-layer name collision(s) — ${identical} ` +
416
+ `byte-identical, ${diverged} diverged and unioned — and rewrote ` +
417
+ `${result.rewritten.length} project fact(s). The survivor stays in the ` +
418
+ `project layer; the profile copy moves to ${result.archiveDir} and is NOT ` +
419
+ 'deleted: restore it by name.');
420
+ }
421
+ for (const merge of result.merged) {
422
+ lines.push(`- ${pyText(merge.name)} (${merge.kind}, name+description similarity ` +
423
+ `${formatFixed3(merge.jaccard)}) — body ${merge.bodyBefore} -> ${merge.bodyAfter} ` +
424
+ `bytes, ${merge.blocksAdded} block(s) kept from the ${merge.consumedLayer} ` +
425
+ `copy, survivor in ${merge.survivorLayer}`);
426
+ }
427
+ for (const record of result.superseded) {
428
+ lines.push(`- superseded '${record.subject}': the ${record.lostLayer} copy ` +
429
+ `(${record.lostDate}) lost to the ${record.keptLayer} copy ` +
430
+ `(${record.keptDate}); the older claim is kept verbatim under ` +
431
+ `'${SUPERSEDED_HEADING}'`);
432
+ }
433
+ for (const hit of result.absolutised) {
434
+ lines.push(`- dated ${pyText(hit.name)} (${hit.layer}): '${hit.term}' -> ${hit.resolved}, ` +
435
+ `resolved against that fact's own mtime ${hit.basis}, not today`);
436
+ }
437
+ for (const hit of result.unresolved) {
438
+ lines.push(`- left alone in ${pyText(hit.name)} (${hit.layer}): '${hit.term}' — no exact day ` +
439
+ 'follows from an mtime, so nothing was substituted');
440
+ }
441
+ for (const [name, reason] of result.refused) {
442
+ lines.push(`- refused ${pyText(name)}: ${reason}`);
443
+ }
444
+ for (const pair of result.similarUnmerged) {
445
+ lines.push(`- similar but NOT merged: ${pyText(pair.projectName)} (project) and ` +
446
+ `${pyText(pair.profileName)} (profile) score ${formatFixed3(pair.jaccard)}; this pass merges ` +
447
+ 'on name equality only, so nothing was done about it');
448
+ }
449
+ lines.push(`project index ${result.indexBefore} -> ${result.indexAfter} bytes against a ` +
450
+ `${result.budget}-byte budget; the profile index (derived, no file on disk) ` +
451
+ `${result.profileIndexBefore} -> ${result.profileIndexAfter}; fact bytes ` +
452
+ `across both layers ${result.factBytesBefore} -> ${result.factBytesAfter}.`);
453
+ if (status === 'refused-budget') {
454
+ lines.push(`NOTHING WAS WRITTEN: the merged index would be ${result.indexAfter} bytes ` +
455
+ `against a ${result.budget}-byte budget. Call \`memory_compact\` to archive ` +
456
+ 'the stalest facts, then run this again.');
457
+ }
458
+ else if (result.dryRun) {
459
+ lines.push('DRY RUN: nothing was written. Re-run with dry_run false to apply it.');
460
+ }
461
+ lines.push('This is a correctness pass, not a token saving: the profile index is derived ' +
462
+ 'at read time and is not loaded from a file, so consolidating it frees ' +
463
+ 'approximately no prompt bytes. What it buys is one copy of a ruling instead ' +
464
+ 'of two that can diverge.');
465
+ return lines.join('\n');
466
+ }
315
467
  /**
316
468
  * `(index bytes, budget)` for the writable project store; bytes may be `null`.
317
469
  *