bantamkit-mcp 0.35.5 → 0.35.7

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,8 +48,8 @@ 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';
52
- import { keptManifest, keptPrefix } from './npminstall.js';
51
+ import { DURABLE_TYPES, MemoryStore, RECALL_MIN_SCORE_RATIO, pyEqualValue, pyText, tokens } from './memory/store.js';
52
+ import { keptCli, 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
  /**
55
55
  * The directory `p` names, as the KERNEL names it. `realpathSync.native`, not `realpathSync`.
@@ -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
  }
@@ -684,8 +705,15 @@ const UPDATE_LINE_MAX = 500;
684
705
  * "then reconnect the host" is measured reason 1 in `selfupdate.py:13-20`: a running server
685
706
  * keeps serving the code it loaded at startup, so an updated install does nothing for THIS
686
707
  * session.
708
+ *
709
+ * THE COMMAND IS THE KEPT INSTALL'S OWN `cli.js`, RUN WITH NODE — not the bare `{program}`
710
+ * (2026-09-26). `--install` puts `bantamkit-mcp` in `<prefix>/node_modules/.bin`, which is on
711
+ * nobody's PATH, so the bare word this line used to print answered `command not found` on the
712
+ * machine it was measured on. `node "<cli>"` is what a host is already told to run
713
+ * (`npminstall.keptCli`), needs no PATH and no `.cmd` shim on Windows, and the double quotes
714
+ * hold a home directory with a space in it in zsh, bash, cmd and PowerShell alike.
687
715
  */
688
- const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `{program} --update`, then reconnect the host.';
716
+ const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `node "{cli}" --update`, then reconnect the host.';
689
717
  /** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
690
718
  function fillLine(template, values) {
691
719
  return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
@@ -705,11 +733,12 @@ function fillLine(template, values) {
705
733
  * second thing to keep in step with two runtimes. There is no comparator in this file.
706
734
  */
707
735
  function keptInstall(home) {
708
- const manifest = keptManifest(keptPrefix(home));
736
+ const prefix = keptPrefix(home);
737
+ const manifest = keptManifest(prefix);
709
738
  try {
710
739
  const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
711
740
  const version = parsed && typeof parsed.version === 'string' ? parsed.version.trim() : '';
712
- return version === '' ? null : { path: manifest, version };
741
+ return version === '' ? null : { path: manifest, cli: keptCli(prefix), version };
713
742
  }
714
743
  catch {
715
744
  return null;
@@ -739,6 +768,7 @@ function updateSignal(run) {
739
768
  program: UPDATE_PROGRAM,
740
769
  installed: kept.version,
741
770
  path: kept.path,
771
+ cli: kept.cli,
742
772
  latest,
743
773
  }), UPDATE_LINE_MAX);
744
774
  }
@@ -849,6 +879,21 @@ function sessionStart(run, input) {
849
879
  /* none */
850
880
  }
851
881
  }
882
+ else if (input.source === 'clear') {
883
+ // `/clear` empties the window the way a compaction does, and the host MAY keep the
884
+ // `session_id` across it (the real log cannot settle this: 4 kept, 12 changed, 15 unknown
885
+ // over 31 clears, with concurrent sessions confounding every count —
886
+ // `.shiftwork/notes-job64/J64-2.md`). So the names this session was shown are forgotten
887
+ // here either way; if the id changed there is no ledger and this is a no-op. Only the
888
+ // seen-set: whether a re-read after `/clear` should still be refused is the read ledger's
889
+ // own question, and `resume` restores the window, so it resets nothing. No ledger is
890
+ // created when none exists (job64, J64-2).
891
+ const ledger = readLedger(run, input.session_id);
892
+ if ('injected' in ledger) {
893
+ delete ledger.injected;
894
+ writeLedger(run, input.session_id, ledger);
895
+ }
896
+ }
852
897
  // `profileFacts` keeps its old meaning — files in the store — so older records stay
853
898
  // comparable; `profileInjected` / `profileDropped` are what the block carried and did
854
899
  // not, and `dropRule` is the order the drop followed. The project trio appears only when
@@ -959,7 +1004,12 @@ function userPromptSubmit(run, input) {
959
1004
  return;
960
1005
  }
961
1006
  const m = Memory.layered(input.cwd || process.cwd());
962
- const o = m.recallOutcome(prompt, 3);
1007
+ // `stamp = false` (job64, J64-1): an injection is the HOOK reading the store, not the model
1008
+ // asking for a fact, so it must not date `last_recalled`. Before this it did, on every
1009
+ // prompt, for up to three files — and the rules keyed on that date (compaction's
1010
+ // stalest-first, the SessionStart drop rule, the Stop dream's store fingerprint) were
1011
+ // reading this arm's traffic. An explicit `memory_recall` still stamps.
1012
+ const o = m.recallOutcome(prompt, 3, RECALL_MIN_SCORE_RATIO, false);
963
1013
  if (o.status !== 'answered') {
964
1014
  log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
965
1015
  return;
@@ -972,17 +1022,65 @@ function userPromptSubmit(run, input) {
972
1022
  log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
973
1023
  return;
974
1024
  }
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);
1025
+ // WHAT THIS CONTEXT HAS ALREADY BEEN SHOWN IS NOT SHOWN AGAIN (job64, J64-2). Measured
1026
+ // 2026-09-25: 333 of 598 injections in a week repeated a name injected earlier in the same
1027
+ // session. The top 3 are still asked for (RB-P1's floor stays) and the seen ones are
1028
+ // DROPPED, not refilled from rank 4 onward: what leaves is always a subset of what the
1029
+ // un-deduped arm would have sent, so its precision can only rise, and a repeated prompt
1030
+ // says nothing rather than walking down the ranking on every repeat. The seen-set is the
1031
+ // session ledger's, so it is forgotten exactly when the window is (compaction, `/clear`).
1032
+ const transcript = String(input.transcript_path || input.session_id || '');
1033
+ const ledger = readLedger(run, input.session_id);
1034
+ const seen = injectedSeen(ledger, transcript);
1035
+ const fresh = [];
1036
+ const suppressed = [];
1037
+ for (const line of heads) {
1038
+ const name = RECALL_HEADER.exec(line)?.[2] ?? '';
1039
+ // OWN keys only (job64, J64-8). `seen` is a plain object read back from JSON, and `in`
1040
+ // walks `Object.prototype` — so `name in seen` was true for a fact named `constructor`
1041
+ // (the one prototype key `NAME_RE` admits) on a context that had been shown nothing, and
1042
+ // the port withheld it on the first prompt while the reference injected it.
1043
+ if (Object.hasOwn(seen, name))
1044
+ suppressed.push(name);
1045
+ else
1046
+ fresh.push(line);
1047
+ }
1048
+ if (fresh.length === 0) {
1049
+ // Everything picked was already in the window. Nothing is emitted, and the record says
1050
+ // WHY with the names, so an audit can count what dedupe withheld.
1051
+ log(run, {
1052
+ event: 'UserPromptSubmit',
1053
+ action: 'suppress',
1054
+ hits: heads.length,
1055
+ source: o.source,
1056
+ session: input.session_id ?? null,
1057
+ prompt: promptFingerprint(prompt),
1058
+ suppressed,
1059
+ });
1060
+ return;
1061
+ }
1062
+ 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
1063
  // `injected` is read back off `ctx`, NOT off `heads`. The byte cap drops whole lines, so a
977
1064
  // header that `recallOutcome` picked need not have left the process — and roadmap #6 asks
978
1065
  // "was an INJECTED name later used", a question a name the model never saw would poison.
979
1066
  // `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.
1067
+ // this change stay comparable; `dropped` is the difference the old shape could not show,
1068
+ // counted over the unseen headers only, so that `hits == injected + dropped + suppressed`
1069
+ // on every record.
981
1070
  const queryTokens = tokens(prompt);
982
1071
  const injected = ctx
983
1072
  .split('\n')
984
1073
  .map((l) => scoreHeader(l, queryTokens))
985
1074
  .filter((x) => x !== null);
1075
+ // Remembered AFTER the cap, off `injected`: a name the cap cut never reached the window and
1076
+ // must be eligible next time.
1077
+ const at = new Date().toISOString();
1078
+ for (const x of injected)
1079
+ seen[x.name] = at;
1080
+ if (!ledger.injected || typeof ledger.injected !== 'object' || Array.isArray(ledger.injected))
1081
+ ledger.injected = {};
1082
+ ledger.injected[transcript] = seen;
1083
+ writeLedger(run, input.session_id, ledger);
986
1084
  log(run, {
987
1085
  event: 'UserPromptSubmit',
988
1086
  action: 'inject',
@@ -994,7 +1092,8 @@ function userPromptSubmit(run, input) {
994
1092
  session: input.session_id ?? null,
995
1093
  prompt: promptFingerprint(prompt),
996
1094
  injected,
997
- dropped: heads.length - injected.length,
1095
+ dropped: fresh.length - injected.length,
1096
+ suppressed,
998
1097
  });
999
1098
  emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
1000
1099
  }
@@ -1730,6 +1829,42 @@ function postCompact(run, input) {
1730
1829
  function samePath(a, b) {
1731
1830
  return realDir(a) === realDir(b);
1732
1831
  }
1832
+ /**
1833
+ * sha256 of a fact file with the frontmatter's `last_recalled:` line left out.
1834
+ *
1835
+ * THE DATE IS NOT CONTENT. Every recall rewrites that one line (and the file's mtime), so a
1836
+ * fingerprint that saw it re-armed the dream preview on every `memory_recall` — measured
1837
+ * 2026-09-25 (job64): 103 of 236 previews in a week reported the identical `wouldMerge 14 /
1838
+ * wouldConsume 14`. Name, description, type, created, links and the body all stay in.
1839
+ *
1840
+ * Byte-exact and identical in both runtimes (`_fact_content_digest` in
1841
+ * `runtime-py/src/bantamkit/hookadapter.py`): the buffer is walked as latin1 so every byte
1842
+ * is its own character, the split is on `\n`, a trailing `\r` is ignored only for the two
1843
+ * comparisons (a Windows-written file keeps its bytes in the hash), and only the lines between
1844
+ * the first two `---` fences are frontmatter — a body line that happens to start with
1845
+ * `last_recalled:` is still content.
1846
+ */
1847
+ function factContentDigest(data) {
1848
+ const h = createHash('sha256');
1849
+ let fences = 0;
1850
+ for (const line of data.toString('latin1').split('\n')) {
1851
+ const bare = line.endsWith('\r') ? line.slice(0, -1) : line;
1852
+ if (bare === '---' && fences < 2)
1853
+ fences++;
1854
+ else if (fences === 1 && bare.startsWith('last_recalled:'))
1855
+ continue;
1856
+ h.update(line, 'latin1');
1857
+ h.update('\n', 'latin1');
1858
+ }
1859
+ return h.digest('hex');
1860
+ }
1861
+ /**
1862
+ * One hash over both layers: each root's name, then every `facts/*.md` as name + content
1863
+ * digest. Adding, removing or renaming a fact moves it; editing any field but `last_recalled`
1864
+ * moves it; a recall (which rewrites only that line and the mtime) does not. Neither size nor
1865
+ * mtime is in it any more: J64-0 measured that a same-day re-stamp moves the mtime ALONE, so
1866
+ * no stat field could be kept as a content signal (`.shiftwork/notes-job64/J64-0.md`, Q4).
1867
+ */
1733
1868
  function storeFingerprint(roots) {
1734
1869
  const h = createHash('sha256');
1735
1870
  for (const root of roots) {
@@ -1745,14 +1880,14 @@ function storeFingerprint(roots) {
1745
1880
  /* a layer with no facts/ contributes its name and nothing else */
1746
1881
  }
1747
1882
  for (const n of names) {
1748
- let st;
1883
+ let data;
1749
1884
  try {
1750
- st = fs.statSync(path.join(root, 'facts', n));
1885
+ data = fs.readFileSync(path.join(root, 'facts', n));
1751
1886
  }
1752
1887
  catch {
1753
1888
  continue;
1754
1889
  }
1755
- h.update(`${n}\u0000${st.size}\u0000${st.mtimeMs}\u0000`);
1890
+ h.update(`${n}\u0000${factContentDigest(data)}\u0000`);
1756
1891
  }
1757
1892
  }
1758
1893
  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.5",
3
+ "version": "0.35.7",
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",