bantamkit-mcp 0.35.4 → 0.35.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "memory_recall",
3
- "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns at most k matching facts.",
3
+ "description": "Search persistent memory. Call BEFORE starting a task that resembles past work. Returns the top word matches, never fewer than the store default of 3 when that many match; a query that is exactly a fact's name returns that fact alone.",
4
4
  "surfaces": [
5
5
  "agent",
6
6
  "mcp"
@@ -18,7 +18,8 @@
18
18
  "k": {
19
19
  "type": "integer",
20
20
  "minimum": 1,
21
- "maximum": 5
21
+ "maximum": 5,
22
+ "description": "Facts wanted. Raised to the store default of 3 when smaller; never lowered below it."
22
23
  }
23
24
  }
24
25
  },
@@ -48,7 +48,7 @@ import path from 'node:path';
48
48
  import { fileURLToPath } from 'node:url';
49
49
  import { Memory } from './memory/component.js';
50
50
  import { discoverProjectStore, resolveProjectStore } from './memory/layers.js';
51
- import { DURABLE_TYPES, MemoryStore, pyEqualValue, pyText, tokens } from './memory/store.js';
51
+ import { DURABLE_TYPES, MemoryStore, RECALL_MIN_SCORE_RATIO, pyEqualValue, pyText, tokens } from './memory/store.js';
52
52
  import { keptManifest, keptPrefix } from './npminstall.js';
53
53
  import { decide as decideUpdate, KEY as UPDATE_KEY, loadRecord, PROGRAM as UPDATE_PROGRAM, recordPath, STATE_AVAILABLE, } from './updatecheck.js';
54
54
  /**
@@ -227,15 +227,36 @@ function capLines(text, max) {
227
227
  }
228
228
  return out.join('\n');
229
229
  }
230
+ /**
231
+ * The fact names this CONTEXT has already been shown, `name -> ISO stamp` (job64, J64-2).
232
+ *
233
+ * Lives under `ledger.injected[<transcript>]`, beside `reads`, so it inherits the ledger's
234
+ * reset points: `PostCompact` and `SessionStart source=compact` unlink the file, which is
235
+ * exactly when the model's window has been rebuilt and a name it was shown is gone again.
236
+ * The context key is the same one `reads` uses — `transcript_path` when the host sends it,
237
+ * `session_id` otherwise — because a subagent shares the parent's `session_id` (J64-0, Q3
238
+ * step 11) and therefore this FILE, while its window has never held what the parent was
239
+ * shown. A missing or malformed entry is "nothing seen", never an exception, for the reason
240
+ * `readLedger` gives. Both lookups here answer for OWN keys only (job64, J64-8): the objects
241
+ * are plain, read back from JSON, and keyed by user data, so a bare `[key]` read would hand
242
+ * back `Object.prototype`'s members for a key that happens to be one of its names.
243
+ */
244
+ function injectedSeen(ledger, transcript) {
245
+ const everyone = ledger.injected;
246
+ if (!everyone || typeof everyone !== 'object' || Array.isArray(everyone))
247
+ return {};
248
+ const mine = Object.hasOwn(everyone, transcript) ? everyone[transcript] : undefined;
249
+ return mine && typeof mine === 'object' && !Array.isArray(mine) ? mine : {};
250
+ }
230
251
  function ledgerPath(run, sessionId) {
231
252
  return path.join(run.state, `ledger-${String(sessionId || 'nosession').replace(/[^\w-]/g, '_')}.json`);
232
253
  }
233
254
  function readLedger(run, sessionId) {
234
255
  try {
235
256
  const parsed = JSON.parse(fs.readFileSync(ledgerPath(run, sessionId), 'utf8'));
236
- if (!parsed || typeof parsed !== 'object')
257
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
237
258
  return { reads: {} };
238
- if (!parsed.reads || typeof parsed.reads !== 'object')
259
+ if (!parsed.reads || typeof parsed.reads !== 'object' || Array.isArray(parsed.reads))
239
260
  parsed.reads = {};
240
261
  return parsed;
241
262
  }
@@ -849,6 +870,21 @@ function sessionStart(run, input) {
849
870
  /* none */
850
871
  }
851
872
  }
873
+ else if (input.source === 'clear') {
874
+ // `/clear` empties the window the way a compaction does, and the host MAY keep the
875
+ // `session_id` across it (the real log cannot settle this: 4 kept, 12 changed, 15 unknown
876
+ // over 31 clears, with concurrent sessions confounding every count —
877
+ // `.shiftwork/notes-job64/J64-2.md`). So the names this session was shown are forgotten
878
+ // here either way; if the id changed there is no ledger and this is a no-op. Only the
879
+ // seen-set: whether a re-read after `/clear` should still be refused is the read ledger's
880
+ // own question, and `resume` restores the window, so it resets nothing. No ledger is
881
+ // created when none exists (job64, J64-2).
882
+ const ledger = readLedger(run, input.session_id);
883
+ if ('injected' in ledger) {
884
+ delete ledger.injected;
885
+ writeLedger(run, input.session_id, ledger);
886
+ }
887
+ }
852
888
  // `profileFacts` keeps its old meaning — files in the store — so older records stay
853
889
  // comparable; `profileInjected` / `profileDropped` are what the block carried and did
854
890
  // not, and `dropRule` is the order the drop followed. The project trio appears only when
@@ -959,7 +995,12 @@ function userPromptSubmit(run, input) {
959
995
  return;
960
996
  }
961
997
  const m = Memory.layered(input.cwd || process.cwd());
962
- const o = m.recallOutcome(prompt, 3);
998
+ // `stamp = false` (job64, J64-1): an injection is the HOOK reading the store, not the model
999
+ // asking for a fact, so it must not date `last_recalled`. Before this it did, on every
1000
+ // prompt, for up to three files — and the rules keyed on that date (compaction's
1001
+ // stalest-first, the SessionStart drop rule, the Stop dream's store fingerprint) were
1002
+ // reading this arm's traffic. An explicit `memory_recall` still stamps.
1003
+ const o = m.recallOutcome(prompt, 3, RECALL_MIN_SCORE_RATIO, false);
963
1004
  if (o.status !== 'answered') {
964
1005
  log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
965
1006
  return;
@@ -972,17 +1013,65 @@ function userPromptSubmit(run, input) {
972
1013
  log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
973
1014
  return;
974
1015
  }
975
- const ctx = capLines(`[bantamkit recall — memories that match this prompt; call mcp__bantamkit__memory_recall with {"query":"<name>"} for the body]\n${heads.join('\n')}`, PROMPT_INJECT_MAX);
1016
+ // WHAT THIS CONTEXT HAS ALREADY BEEN SHOWN IS NOT SHOWN AGAIN (job64, J64-2). Measured
1017
+ // 2026-09-25: 333 of 598 injections in a week repeated a name injected earlier in the same
1018
+ // session. The top 3 are still asked for (RB-P1's floor stays) and the seen ones are
1019
+ // DROPPED, not refilled from rank 4 onward: what leaves is always a subset of what the
1020
+ // un-deduped arm would have sent, so its precision can only rise, and a repeated prompt
1021
+ // says nothing rather than walking down the ranking on every repeat. The seen-set is the
1022
+ // session ledger's, so it is forgotten exactly when the window is (compaction, `/clear`).
1023
+ const transcript = String(input.transcript_path || input.session_id || '');
1024
+ const ledger = readLedger(run, input.session_id);
1025
+ const seen = injectedSeen(ledger, transcript);
1026
+ const fresh = [];
1027
+ const suppressed = [];
1028
+ for (const line of heads) {
1029
+ const name = RECALL_HEADER.exec(line)?.[2] ?? '';
1030
+ // OWN keys only (job64, J64-8). `seen` is a plain object read back from JSON, and `in`
1031
+ // walks `Object.prototype` — so `name in seen` was true for a fact named `constructor`
1032
+ // (the one prototype key `NAME_RE` admits) on a context that had been shown nothing, and
1033
+ // the port withheld it on the first prompt while the reference injected it.
1034
+ if (Object.hasOwn(seen, name))
1035
+ suppressed.push(name);
1036
+ else
1037
+ fresh.push(line);
1038
+ }
1039
+ if (fresh.length === 0) {
1040
+ // Everything picked was already in the window. Nothing is emitted, and the record says
1041
+ // WHY with the names, so an audit can count what dedupe withheld.
1042
+ log(run, {
1043
+ event: 'UserPromptSubmit',
1044
+ action: 'suppress',
1045
+ hits: heads.length,
1046
+ source: o.source,
1047
+ session: input.session_id ?? null,
1048
+ prompt: promptFingerprint(prompt),
1049
+ suppressed,
1050
+ });
1051
+ return;
1052
+ }
1053
+ const ctx = capLines(`[bantamkit recall — memories that match this prompt; call mcp__bantamkit__memory_recall with {"query":"<name>"} for the body]\n${fresh.join('\n')}`, PROMPT_INJECT_MAX);
976
1054
  // `injected` is read back off `ctx`, NOT off `heads`. The byte cap drops whole lines, so a
977
1055
  // header that `recallOutcome` picked need not have left the process — and roadmap #6 asks
978
1056
  // "was an INJECTED name later used", a question a name the model never saw would poison.
979
1057
  // `hits` keeps its old meaning (headers picked, pre-cap) so the 487 records written before
980
- // this change stay comparable; `dropped` is the difference the old shape could not show.
1058
+ // this change stay comparable; `dropped` is the difference the old shape could not show,
1059
+ // counted over the unseen headers only, so that `hits == injected + dropped + suppressed`
1060
+ // on every record.
981
1061
  const queryTokens = tokens(prompt);
982
1062
  const injected = ctx
983
1063
  .split('\n')
984
1064
  .map((l) => scoreHeader(l, queryTokens))
985
1065
  .filter((x) => x !== null);
1066
+ // Remembered AFTER the cap, off `injected`: a name the cap cut never reached the window and
1067
+ // must be eligible next time.
1068
+ const at = new Date().toISOString();
1069
+ for (const x of injected)
1070
+ seen[x.name] = at;
1071
+ if (!ledger.injected || typeof ledger.injected !== 'object' || Array.isArray(ledger.injected))
1072
+ ledger.injected = {};
1073
+ ledger.injected[transcript] = seen;
1074
+ writeLedger(run, input.session_id, ledger);
986
1075
  log(run, {
987
1076
  event: 'UserPromptSubmit',
988
1077
  action: 'inject',
@@ -994,7 +1083,8 @@ function userPromptSubmit(run, input) {
994
1083
  session: input.session_id ?? null,
995
1084
  prompt: promptFingerprint(prompt),
996
1085
  injected,
997
- dropped: heads.length - injected.length,
1086
+ dropped: fresh.length - injected.length,
1087
+ suppressed,
998
1088
  });
999
1089
  emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
1000
1090
  }
@@ -1730,6 +1820,42 @@ function postCompact(run, input) {
1730
1820
  function samePath(a, b) {
1731
1821
  return realDir(a) === realDir(b);
1732
1822
  }
1823
+ /**
1824
+ * sha256 of a fact file with the frontmatter's `last_recalled:` line left out.
1825
+ *
1826
+ * THE DATE IS NOT CONTENT. Every recall rewrites that one line (and the file's mtime), so a
1827
+ * fingerprint that saw it re-armed the dream preview on every `memory_recall` — measured
1828
+ * 2026-09-25 (job64): 103 of 236 previews in a week reported the identical `wouldMerge 14 /
1829
+ * wouldConsume 14`. Name, description, type, created, links and the body all stay in.
1830
+ *
1831
+ * Byte-exact and identical in both runtimes (`_fact_content_digest` in
1832
+ * `runtime-py/src/bantamkit/hookadapter.py`): the buffer is walked as latin1 so every byte
1833
+ * is its own character, the split is on `\n`, a trailing `\r` is ignored only for the two
1834
+ * comparisons (a Windows-written file keeps its bytes in the hash), and only the lines between
1835
+ * the first two `---` fences are frontmatter — a body line that happens to start with
1836
+ * `last_recalled:` is still content.
1837
+ */
1838
+ function factContentDigest(data) {
1839
+ const h = createHash('sha256');
1840
+ let fences = 0;
1841
+ for (const line of data.toString('latin1').split('\n')) {
1842
+ const bare = line.endsWith('\r') ? line.slice(0, -1) : line;
1843
+ if (bare === '---' && fences < 2)
1844
+ fences++;
1845
+ else if (fences === 1 && bare.startsWith('last_recalled:'))
1846
+ continue;
1847
+ h.update(line, 'latin1');
1848
+ h.update('\n', 'latin1');
1849
+ }
1850
+ return h.digest('hex');
1851
+ }
1852
+ /**
1853
+ * One hash over both layers: each root's name, then every `facts/*.md` as name + content
1854
+ * digest. Adding, removing or renaming a fact moves it; editing any field but `last_recalled`
1855
+ * moves it; a recall (which rewrites only that line and the mtime) does not. Neither size nor
1856
+ * mtime is in it any more: J64-0 measured that a same-day re-stamp moves the mtime ALONE, so
1857
+ * no stat field could be kept as a content signal (`.shiftwork/notes-job64/J64-0.md`, Q4).
1858
+ */
1733
1859
  function storeFingerprint(roots) {
1734
1860
  const h = createHash('sha256');
1735
1861
  for (const root of roots) {
@@ -1745,14 +1871,14 @@ function storeFingerprint(roots) {
1745
1871
  /* a layer with no facts/ contributes its name and nothing else */
1746
1872
  }
1747
1873
  for (const n of names) {
1748
- let st;
1874
+ let data;
1749
1875
  try {
1750
- st = fs.statSync(path.join(root, 'facts', n));
1876
+ data = fs.readFileSync(path.join(root, 'facts', n));
1751
1877
  }
1752
1878
  catch {
1753
1879
  continue;
1754
1880
  }
1755
- h.update(`${n}\u0000${st.size}\u0000${st.mtimeMs}\u0000`);
1881
+ h.update(`${n}\u0000${factContentDigest(data)}\u0000`);
1756
1882
  }
1757
1883
  }
1758
1884
  return h.digest('hex');
@@ -97,6 +97,13 @@ export interface RecallOutcome {
97
97
  readonly candidates: number;
98
98
  readonly source: string | null;
99
99
  readonly unreadable: number;
100
+ /**
101
+ * What the exact-name walk decided (job64, J64-4), read off the branch and not off the
102
+ * reply: `'hit'` when the query WAS a fact's name and that fact alone is the reply, `'miss'`
103
+ * when the query was shaped like a name, no layer held it, and the reply leads with the line
104
+ * that says so, `null` for every other query — the walk that has always run, unchanged.
105
+ */
106
+ readonly lookup: string | null;
100
107
  }
101
108
  /**
102
109
  * What `dream` DID, beside the sentence it says about it.
@@ -203,8 +210,45 @@ export declare class Memory {
203
210
  * raises out of the FIRST layer, which is the writable project store, so it surfaces as
204
211
  * the error it is rather than as an `unreadable` count — the read-only-layer `catch`
205
212
  * below would otherwise file a caller's bad argument as a corrupt grant.
213
+ *
214
+ * `stamp` (job64, J64-1) is whether a hit in the WRITABLE layer gets its `last_recalled`
215
+ * dated. It defaults to true, which is what every explicit recall — the `memory_recall`
216
+ * tool, the memory CLI, `Memory.recall` — has always done, and those callers do not pass
217
+ * it. The one caller that passes false is the hook's `UserPromptSubmit` arm: an automatic
218
+ * injection is not the model asking for a fact, and dating it as one was measured
219
+ * (2026-09-25) to stamp 25 of 42 facts in one store as "recalled today", so every rule
220
+ * keyed on `last_recalled` — compaction's stalest-first, SessionStart's drop rule — was
221
+ * reading injection traffic instead of model demand. Read-only layers are never stamped
222
+ * whatever this says.
223
+ *
224
+ * THE EXACT-NAME WALK (job64, J64-4) runs first, and only for a query in the name shape
225
+ * (`isNameShaped`: `pyStrip`ped, `NAME_RE`-valid, and carrying a hyphen — a bare word is a
226
+ * word search even when a fact is named by it). Measured 2026-09-25 on the operator's own stores: 4 of
227
+ * 28 `memory_recall` calls whose query was exactly a fact name came back with OTHER facts.
228
+ * One of those names existed in the PROFILE layer and was never reached, because the
229
+ * project layer's fuzzy hits filled the budget and the loop below breaks before the next
230
+ * layer is read — a name lookup is a request for one known fact, and the `k`-floor (RB-P1,
231
+ * ruled to stay 2026-09-25) has nothing to say about it. So: every layer is asked, in
232
+ * precedence order, `store.lookup(name)`; the first that holds it answers ALONE, stamped
233
+ * only if it is the writable layer, and no other layer is read after it. If no layer holds
234
+ * it, the reply leads with `missLine` and the ordinary walk follows, byte for byte what it
235
+ * would have said alone. Every query outside the shape never enters this block. The range
236
+ * check on `minRatio` runs BEFORE the walk for the same reason
237
+ * it runs before any file is read in `MemoryStore.recall`: a caller's bad argument must
238
+ * surface as the error it is, not be swallowed by an exact hit.
239
+ */
240
+ recallOutcome(query: string, k?: number | null, minRatio?: number, stamp?: boolean): RecallOutcome;
241
+ /**
242
+ * The exact-name walk: the first layer holding a fact named `name` answers alone.
243
+ *
244
+ * The counters mean what they mean in `recallOutcome`, over the layers this walk READ —
245
+ * `reached` stops at the layer that answered, `candidates` counts the facts in the layers
246
+ * up to and including it, and a corrupt read-only layer raises `unreadable` and is skipped
247
+ * exactly as the scoring walk skips it. `null` means no layer holds the name, and the
248
+ * caller falls through to the scoring walk with its own, fresh counters: the numbers a miss
249
+ * logs are the numbers the ordinary walk logs.
206
250
  */
207
- recallOutcome(query: string, k?: number | null, minRatio?: number): RecallOutcome;
251
+ private lookupByName;
208
252
  /**
209
253
  * Archive the stalest facts until a fact as large as the biggest one kept will fit.
210
254
  *
@@ -31,8 +31,20 @@ import { BantamError } from '../errors.js';
31
31
  import { pyStrip } from './factfile.js';
32
32
  import { countFacts, loadGrants, MEMORY_DIR_ENV, PROJECT_STORE, resolveProjectStore, } from './layers.js';
33
33
  import { pyHome, pyJoin, pyName, pyParent, pyResolve, PyOSError, PyUnicodeDecodeError } from './pyfs.js';
34
- import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, } from './store.js';
34
+ import { DEFAULT_INDEX_BUDGET, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, MIN_RATIO_RANGE, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, } from './store.js';
35
35
  import { dream as runDream, formatFixed3, SUPERSEDED_HEADING } from './dream.js';
36
+ // THE NAME SHAPE (job64, J64-4), spelled once here and once in the reference: a recall query,
37
+ // after `pyStrip` (Python's `str.strip()`), that the store's own `NAME_RE` would accept as a
38
+ // fact name AND that carries a hyphen. Anchored without the reference's trailing-newline
39
+ // quirk, because the strip has already removed one — `_NAME_SHAPE.fullmatch` on the other side
40
+ // is the same set. A query in this shape is looked up BY NAME in every layer before any
41
+ // scoring happens, and a miss on it is said out loud. The hyphen is the whole distinction:
42
+ // `deploy` is a word anyone might search, and stays the word search it has always been even
43
+ // when a fact happens to be named `deploy` (the layered dedupe test pins that);
44
+ // `deploy-command` is a name somebody typed from an index.
45
+ const NAME_SHAPE = /^[a-z0-9][a-z0-9-]*$/;
46
+ const isNameShaped = (query) => query.includes('-') && NAME_SHAPE.test(query);
47
+ const missLine = (name) => `no fact named '${name}' in any layer bound here; matching by words instead:`;
36
48
  /**
37
49
  * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
38
50
  *
@@ -303,9 +315,46 @@ export class Memory {
303
315
  * raises out of the FIRST layer, which is the writable project store, so it surfaces as
304
316
  * the error it is rather than as an `unreadable` count — the read-only-layer `catch`
305
317
  * below would otherwise file a caller's bad argument as a corrupt grant.
318
+ *
319
+ * `stamp` (job64, J64-1) is whether a hit in the WRITABLE layer gets its `last_recalled`
320
+ * dated. It defaults to true, which is what every explicit recall — the `memory_recall`
321
+ * tool, the memory CLI, `Memory.recall` — has always done, and those callers do not pass
322
+ * it. The one caller that passes false is the hook's `UserPromptSubmit` arm: an automatic
323
+ * injection is not the model asking for a fact, and dating it as one was measured
324
+ * (2026-09-25) to stamp 25 of 42 facts in one store as "recalled today", so every rule
325
+ * keyed on `last_recalled` — compaction's stalest-first, SessionStart's drop rule — was
326
+ * reading injection traffic instead of model demand. Read-only layers are never stamped
327
+ * whatever this says.
328
+ *
329
+ * THE EXACT-NAME WALK (job64, J64-4) runs first, and only for a query in the name shape
330
+ * (`isNameShaped`: `pyStrip`ped, `NAME_RE`-valid, and carrying a hyphen — a bare word is a
331
+ * word search even when a fact is named by it). Measured 2026-09-25 on the operator's own stores: 4 of
332
+ * 28 `memory_recall` calls whose query was exactly a fact name came back with OTHER facts.
333
+ * One of those names existed in the PROFILE layer and was never reached, because the
334
+ * project layer's fuzzy hits filled the budget and the loop below breaks before the next
335
+ * layer is read — a name lookup is a request for one known fact, and the `k`-floor (RB-P1,
336
+ * ruled to stay 2026-09-25) has nothing to say about it. So: every layer is asked, in
337
+ * precedence order, `store.lookup(name)`; the first that holds it answers ALONE, stamped
338
+ * only if it is the writable layer, and no other layer is read after it. If no layer holds
339
+ * it, the reply leads with `missLine` and the ordinary walk follows, byte for byte what it
340
+ * would have said alone. Every query outside the shape never enters this block. The range
341
+ * check on `minRatio` runs BEFORE the walk for the same reason
342
+ * it runs before any file is read in `MemoryStore.recall`: a caller's bad argument must
343
+ * surface as the error it is, not be swallowed by an exact hit.
306
344
  */
307
- recallOutcome(query, k = null, minRatio = RECALL_MIN_SCORE_RATIO) {
345
+ recallOutcome(query, k = null, minRatio = RECALL_MIN_SCORE_RATIO, stamp = true) {
346
+ // `not 0.0 <= min_ratio <= 1.0`: NaN fails the same chain on both sides.
347
+ if (!(minRatio >= 0 && minRatio <= 1))
348
+ throw new MemoryValidationError(MIN_RATIO_RANGE);
308
349
  const budget = k === null ? this.k : Math.max(k, this.k);
350
+ const name = pyStrip(query);
351
+ let lookup = null;
352
+ if (isNameShaped(name)) {
353
+ const named = this.lookupByName(name, budget, stamp);
354
+ if (named !== null)
355
+ return named;
356
+ lookup = 'miss';
357
+ }
309
358
  const picked = [];
310
359
  const seen = new Set();
311
360
  let reached = 0;
@@ -317,7 +366,7 @@ export class Memory {
317
366
  reached += 1;
318
367
  let facts;
319
368
  try {
320
- facts = store.recall(query, budget, writable, minRatio);
369
+ facts = store.recall(query, budget, writable && stamp, minRatio);
321
370
  }
322
371
  catch (e) {
323
372
  if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
@@ -354,18 +403,74 @@ export class Memory {
354
403
  returned: picked.length,
355
404
  candidates,
356
405
  unreadable,
406
+ lookup,
357
407
  };
408
+ const lead = lookup === 'miss' ? `${missLine(name)}\n\n` : '';
358
409
  if (picked.length === 0) {
359
410
  const [status, reply] = this.nothingToReport();
360
- return { reply, status, source: null, ...counts };
411
+ return { reply: lead + reply, status, source: null, ...counts };
361
412
  }
362
413
  return {
363
- reply: picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
414
+ reply: lead + picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
364
415
  status: 'answered',
365
416
  source: picked[0][0].split(':', 1)[0],
366
417
  ...counts,
367
418
  };
368
419
  }
420
+ /**
421
+ * The exact-name walk: the first layer holding a fact named `name` answers alone.
422
+ *
423
+ * The counters mean what they mean in `recallOutcome`, over the layers this walk READ —
424
+ * `reached` stops at the layer that answered, `candidates` counts the facts in the layers
425
+ * up to and including it, and a corrupt read-only layer raises `unreadable` and is skipped
426
+ * exactly as the scoring walk skips it. `null` means no layer holds the name, and the
427
+ * caller falls through to the scoring walk with its own, fresh counters: the numbers a miss
428
+ * logs are the numbers the ordinary walk logs.
429
+ */
430
+ lookupByName(name, budget, stamp) {
431
+ let reached = 0;
432
+ let unreadable = 0;
433
+ let candidates = 0;
434
+ for (const [label, store, writable] of this.layers) {
435
+ reached += 1;
436
+ let fact;
437
+ try {
438
+ fact = store.lookup(name, writable && stamp);
439
+ }
440
+ catch (e) {
441
+ if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
442
+ throw e;
443
+ }
444
+ if (writable)
445
+ throw e; // the project layer failing is a real error, as in the scoring walk
446
+ unreadable += 1;
447
+ continue;
448
+ }
449
+ try {
450
+ candidates += countFacts(store.root);
451
+ }
452
+ catch (e) {
453
+ // It listed a moment ago; a race must not fail the recall for a log field.
454
+ if (!(e instanceof PyOSError))
455
+ throw e;
456
+ }
457
+ if (fact !== null) {
458
+ return {
459
+ reply: this.format(label, fact),
460
+ status: 'answered',
461
+ source: label.split(':', 1)[0],
462
+ budget,
463
+ layers: this.layers.length,
464
+ reached,
465
+ returned: 1,
466
+ candidates,
467
+ unreadable,
468
+ lookup: 'hit',
469
+ };
470
+ }
471
+ }
472
+ return null;
473
+ }
369
474
  /**
370
475
  * Archive the stalest facts until a fact as large as the biggest one kept will fit.
371
476
  *
@@ -92,6 +92,7 @@ export declare const VALID_TYPES: readonly ["feedback", "project", "reference",
92
92
  export declare const DURABLE_TYPES: readonly ["feedback", "user"];
93
93
  export declare const DUPLICATE_JACCARD = 0.5;
94
94
  export declare const RECALL_MIN_SCORE_RATIO = 0;
95
+ export declare const MIN_RATIO_RANGE = "recall min-score ratio must be between 0.0 and 1.0";
95
96
  /** See the reference's comment: measured against a real store, not chosen. */
96
97
  export declare const DEFAULT_INDEX_BUDGET = 24000;
97
98
  /**
@@ -356,6 +357,23 @@ export declare class MemoryStore {
356
357
  * it is killed on both sides.
357
358
  */
358
359
  recall(query: string, k?: number | null, stamp?: boolean, minRatio?: number): Fact[];
360
+ /**
361
+ * The fact whose `name` field is exactly `name`, or `null`; no scoring, no ranking.
362
+ *
363
+ * WHY THIS EXISTS (job64, J64-4): `component.Memory` walks the layers looking for a fact
364
+ * the model asked for BY NAME, and `recall` cannot answer that question — it scores by
365
+ * token overlap, ties are broken by name, and every hit inside `k` is stamped, so there is
366
+ * no `k` that returns "the one named X and dates only that one". This is the public
367
+ * primitive that keeps `facts()`/`stamp()` private (see `factCount`'s docstring for why
368
+ * they are) and `internals()` at its one caller.
369
+ *
370
+ * `===` against the `name` field as `facts()` resolved it: a hand-edited `name: 7` is a
371
+ * number here and an `int` in the reference, and neither equals a string, so it is never an
372
+ * exact hit on either side. Two files claiming one name answer the first in `factPaths`
373
+ * order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
374
+ * `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
375
+ */
376
+ lookup(name: string, stamp?: boolean): Fact | null;
359
377
  /**
360
378
  * `len(store._facts())` at the operator CLI, which is the only caller.
361
379
  *
@@ -211,7 +211,11 @@ export const RECALL_MIN_SCORE_RATIO = 0.0;
211
211
  // Spelled once because both runtimes raise it verbatim. The offending value is NOT
212
212
  // interpolated: Python renders `2.0` as `2.0` and JavaScript renders it as `2`, so a
213
213
  // sentence carrying the number would be a divergence manufactured by float formatting.
214
- const MIN_RATIO_RANGE = 'recall min-score ratio must be between 0.0 and 1.0';
214
+ // PUBLIC SINCE J64-4, ON BOTH SIDES, for the same reason `tokens` is: `component.Memory` runs
215
+ // the range check itself now, before an exact-name walk reads a file, and it must throw the
216
+ // store's sentence and not a second spelling of it. The reference spells it `MIN_RATIO_RANGE`
217
+ // beside its private alias.
218
+ export const MIN_RATIO_RANGE = 'recall min-score ratio must be between 0.0 and 1.0';
215
219
  /** See the reference's comment: measured against a real store, not chosen. */
216
220
  export const DEFAULT_INDEX_BUDGET = 24_000;
217
221
  /**
@@ -778,6 +782,32 @@ export class MemoryStore {
778
782
  this.stamp(fact);
779
783
  return hits;
780
784
  }
785
+ /**
786
+ * The fact whose `name` field is exactly `name`, or `null`; no scoring, no ranking.
787
+ *
788
+ * WHY THIS EXISTS (job64, J64-4): `component.Memory` walks the layers looking for a fact
789
+ * the model asked for BY NAME, and `recall` cannot answer that question — it scores by
790
+ * token overlap, ties are broken by name, and every hit inside `k` is stamped, so there is
791
+ * no `k` that returns "the one named X and dates only that one". This is the public
792
+ * primitive that keeps `facts()`/`stamp()` private (see `factCount`'s docstring for why
793
+ * they are) and `internals()` at its one caller.
794
+ *
795
+ * `===` against the `name` field as `facts()` resolved it: a hand-edited `name: 7` is a
796
+ * number here and an `int` in the reference, and neither equals a string, so it is never an
797
+ * exact hit on either side. Two files claiming one name answer the first in `factPaths`
798
+ * order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
799
+ * `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
800
+ */
801
+ lookup(name, stamp = true) {
802
+ for (const fact of this.facts()) {
803
+ if (typeof fact.name === 'string' && fact.name === name) {
804
+ if (stamp)
805
+ this.stamp(fact);
806
+ return fact;
807
+ }
808
+ }
809
+ return null;
810
+ }
781
811
  /**
782
812
  * `len(store._facts())` at the operator CLI, which is the only caller.
783
813
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bantamkit-mcp",
3
- "version": "0.35.4",
3
+ "version": "0.35.6",
4
4
  "description": "Memory MCP server for Claude Code, Cursor, VS Code Copilot and Claude Desktop: per-person memory, JSON Schema validation, shift-work tools. Pure Node; install once with npx, run offline.",
5
5
  "keywords": [
6
6
  "mcp",