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.
- package/assets/tools/memory_recall.json +3 -2
- package/dist/hookadapter.js +136 -10
- package/dist/memory/component.d.ts +45 -1
- package/dist/memory/component.js +110 -5
- package/dist/memory/store.d.ts +18 -0
- package/dist/memory/store.js +31 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
},
|
package/dist/hookadapter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1874
|
+
let data;
|
|
1749
1875
|
try {
|
|
1750
|
-
|
|
1876
|
+
data = fs.readFileSync(path.join(root, 'facts', n));
|
|
1751
1877
|
}
|
|
1752
1878
|
catch {
|
|
1753
1879
|
continue;
|
|
1754
1880
|
}
|
|
1755
|
-
h.update(`${n}\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
|
-
|
|
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
|
*
|
package/dist/memory/component.js
CHANGED
|
@@ -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
|
*
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|