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.
- package/assets/tools/memory_recall.json +3 -2
- package/dist/hookadapter.js +149 -14
- 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,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 `{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
1883
|
+
let data;
|
|
1749
1884
|
try {
|
|
1750
|
-
|
|
1885
|
+
data = fs.readFileSync(path.join(root, 'facts', n));
|
|
1751
1886
|
}
|
|
1752
1887
|
catch {
|
|
1753
1888
|
continue;
|
|
1754
1889
|
}
|
|
1755
|
-
h.update(`${n}\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
|
-
|
|
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.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",
|