@engramlabs/mcp 0.1.8-alpha → 0.2.0-alpha
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/hooks/engram-recall-pi.cjs +81 -14
- package/hooks/recall-context.cjs +47 -4
- package/package.json +6 -6
|
@@ -14,18 +14,67 @@ const fs = require('node:fs');
|
|
|
14
14
|
const os = require('node:os');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
|
|
17
|
+
/** Repo-pinned scope: <repo>/.pi/engram.json {"project": "...", "storage": "..."}
|
|
18
|
+
* overrides the cwd-basename default (env vars still win). */
|
|
19
|
+
function repoConfig() {
|
|
20
|
+
let dir = __dirname;
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
const f = path.join(dir, '.pi', 'engram.json');
|
|
23
|
+
if (fs.existsSync(f)) {
|
|
24
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch {}
|
|
25
|
+
}
|
|
26
|
+
const parent = path.dirname(dir);
|
|
27
|
+
if (parent === dir) break;
|
|
28
|
+
dir = parent;
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
module.exports = (pi) => {
|
|
18
|
-
const
|
|
34
|
+
const repo = repoConfig();
|
|
35
|
+
const storage =
|
|
36
|
+
process.env.ENGRAM_STORAGE || repo.storage || path.join(os.homedir(), '.engram');
|
|
19
37
|
const project =
|
|
20
|
-
process.env.ENGRAM_PROJECT || path.basename(process.cwd()) || 'default';
|
|
38
|
+
process.env.ENGRAM_PROJECT || repo.project || path.basename(process.cwd()) || 'default';
|
|
21
39
|
const hooksOff = process.env.ENGRAM_HOOKS === '0';
|
|
22
40
|
|
|
41
|
+
function resolveBinary() {
|
|
42
|
+
// Ordered: env override → repo build (release, then debug — whichever
|
|
43
|
+
// is freshest for development) → npm platform package.
|
|
44
|
+
if (process.env.ENGRAM_MCP_BIN) return process.env.ENGRAM_MCP_BIN;
|
|
45
|
+
// Walk up from this extension to find the repo's target/ dir.
|
|
46
|
+
let dir = __dirname;
|
|
47
|
+
for (let i = 0; i < 8; i++) {
|
|
48
|
+
const rel = path.join(dir, 'target');
|
|
49
|
+
if (fs.existsSync(path.join(rel, 'release', 'engram-mcp')))
|
|
50
|
+
return path.join(rel, 'release', 'engram-mcp');
|
|
51
|
+
if (fs.existsSync(path.join(rel, 'debug', 'engram-mcp')))
|
|
52
|
+
return path.join(rel, 'debug', 'engram-mcp');
|
|
53
|
+
const parent = path.dirname(dir);
|
|
54
|
+
if (parent === dir) break;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const { platform, arch } = process;
|
|
59
|
+
const suffix =
|
|
60
|
+
platform === 'darwin' && arch === 'arm64' ? 'mcp-darwin-arm64' :
|
|
61
|
+
platform === 'darwin' && arch === 'x64' ? 'mcp-darwin-x64' :
|
|
62
|
+
platform === 'win32' ? 'mcp-win32-x64' :
|
|
63
|
+
platform === 'linux' && arch === 'arm64' ? 'mcp-linux-arm64-gnu' :
|
|
64
|
+
'mcp-linux-x64-gnu';
|
|
65
|
+
const { createRequire } = require('node:module');
|
|
66
|
+
const req = createRequire(__filename);
|
|
67
|
+
const manifest = req.resolve(`@engramlabs/${suffix}/package.json`);
|
|
68
|
+
const bin = path.join(path.dirname(manifest), 'engram-mcp');
|
|
69
|
+
if (fs.existsSync(bin)) return bin;
|
|
70
|
+
} catch {}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
23
74
|
function recallOnce(query, limit = 5) {
|
|
24
75
|
return new Promise((resolve) => {
|
|
25
|
-
const bin =
|
|
26
|
-
|
|
27
|
-
path.join(os.homedir(), 'projects', 'mem-alpha', 'target', 'release', 'engram-mcp');
|
|
28
|
-
if (!fs.existsSync(bin)) return resolve(null);
|
|
76
|
+
const bin = resolveBinary();
|
|
77
|
+
if (!bin) return resolve(null);
|
|
29
78
|
const child = spawn(
|
|
30
79
|
bin,
|
|
31
80
|
['recall', '--storage', storage, '--project', project, '--no-vector', '--json', '--limit', String(limit), '--query', query],
|
|
@@ -48,19 +97,37 @@ module.exports = (pi) => {
|
|
|
48
97
|
});
|
|
49
98
|
}
|
|
50
99
|
|
|
51
|
-
function
|
|
100
|
+
function termOverlap(query, text) {
|
|
101
|
+
const STOP = new Set(['the','a','an','is','are','was','do','does','did','i','you','we','it','in','on','for','of','to','and','or','how','what','why','when','with','my','me','this','that','right']);
|
|
102
|
+
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9s/-]+/g, ' ').split(/[\s/-]+/).map((w) => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w)).filter((w) => w.length > 2 && !STOP.has(w));
|
|
103
|
+
const q = norm(query);
|
|
104
|
+
if (q.length === 0) return 0;
|
|
105
|
+
const t = new Set(norm(text));
|
|
106
|
+
let hit = 0;
|
|
107
|
+
for (const w of q) if (t.has(w)) hit++;
|
|
108
|
+
return hit / q.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Gate v2: overlap OR separation (see recall-context.cjs for the measured
|
|
112
|
+
// rationale — separation alone over-silences on real corpora).
|
|
113
|
+
function gate(payload, query) {
|
|
52
114
|
if (!payload || !payload.ok || !Array.isArray(payload.results) || payload.results.length === 0) {
|
|
53
115
|
return [];
|
|
54
116
|
}
|
|
55
117
|
const scores = payload.results.map((r) => Number(r.score) || 0).sort((a, b) => b - a);
|
|
56
|
-
if (scores.length <
|
|
118
|
+
if (scores.length < 1) return [];
|
|
57
119
|
const median = scores[Math.floor(scores.length / 2)];
|
|
58
120
|
const top = scores[0];
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
121
|
+
const topResult = payload.results[0];
|
|
122
|
+
const topEvidence = topResult.evidence || 'weak';
|
|
123
|
+
const MEMORY_LANES = new Set(['memory', 'belief', 'document']);
|
|
124
|
+
const overlap = termOverlap(query, topResult.content || '');
|
|
125
|
+
const sepPass = scores.length >= 2 && median > 0 && top / median >= 1.8 && topEvidence !== 'weak';
|
|
126
|
+
const overlapPass =
|
|
127
|
+
topEvidence !== 'weak' &&
|
|
128
|
+
(MEMORY_LANES.has(topResult.kind) || topEvidence === 'exact') &&
|
|
129
|
+
overlap >= 0.35;
|
|
130
|
+
return sepPass || overlapPass ? payload.results.slice(0, 3) : [];
|
|
64
131
|
}
|
|
65
132
|
|
|
66
133
|
function block(items) {
|
|
@@ -79,7 +146,7 @@ module.exports = (pi) => {
|
|
|
79
146
|
const words = prompt.split(/\s+/).filter(Boolean);
|
|
80
147
|
if (words.length < 3 || prompt.startsWith('/')) return;
|
|
81
148
|
const payload = await recallOnce(prompt);
|
|
82
|
-
const items = gate(payload);
|
|
149
|
+
const items = gate(payload, prompt);
|
|
83
150
|
if (items.length === 0) return;
|
|
84
151
|
return {
|
|
85
152
|
message: {
|
package/hooks/recall-context.cjs
CHANGED
|
@@ -118,6 +118,33 @@ function heartbeat(storage, event, fields) {
|
|
|
118
118
|
} catch {}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Fraction of query content-words present in the hit's text (0..1).
|
|
123
|
+
* Deterministic, scale-free, works on real corpora where BM25 scores
|
|
124
|
+
* compress. Light normalization: lowercase, split hyphens/paths, strip
|
|
125
|
+
* plural 's' and trailing punctuation. Stopwords removed.
|
|
126
|
+
*/
|
|
127
|
+
function termOverlap(query, text) {
|
|
128
|
+
const STOP = new Set([
|
|
129
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'do', 'does', 'did', 'i',
|
|
130
|
+
'you', 'we', 'it', 'in', 'on', 'for', 'of', 'to', 'and', 'or', 'how',
|
|
131
|
+
'what', 'why', 'when', 'with', 'my', 'me', 'this', 'that', 'right',
|
|
132
|
+
]);
|
|
133
|
+
const norm = (s) =>
|
|
134
|
+
String(s)
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9s/-]+/g, ' ')
|
|
137
|
+
.split(/[\s/-]+/)
|
|
138
|
+
.map((w) => (w.length > 3 && w.endsWith('s') ? w.slice(0, -1) : w))
|
|
139
|
+
.filter((w) => w.length > 2 && !STOP.has(w));
|
|
140
|
+
const q = norm(query);
|
|
141
|
+
if (q.length === 0) return 0;
|
|
142
|
+
const t = new Set(norm(text));
|
|
143
|
+
let hit = 0;
|
|
144
|
+
for (const w of q) if (t.has(w)) hit++;
|
|
145
|
+
return hit / q.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
121
148
|
function main() {
|
|
122
149
|
const t0 = Date.now();
|
|
123
150
|
if (process.env.ENGRAM_HOOKS === '0') process.exit(0);
|
|
@@ -199,13 +226,29 @@ function main() {
|
|
|
199
226
|
// average-of-two convention would cap 2-hit separation at 1.33x).
|
|
200
227
|
const median = scores.length >= 2 ? scores[Math.floor(scores.length / 2)] : 0;
|
|
201
228
|
const top = scores[0];
|
|
202
|
-
const
|
|
229
|
+
const topResult = payload.results[0];
|
|
230
|
+
const topEvidence = topResult.evidence || 'weak';
|
|
231
|
+
|
|
232
|
+
// Gate v2 — overlap OR separation (measured on the 9.7k-entity self-index
|
|
233
|
+
// 2026-10-19: lexical scores compress on real corpora, so pure separation
|
|
234
|
+
// silenced CORRECT top hits (sep 1.04x) while junk code entities matching
|
|
235
|
+
// one query word scored HIGHER separation (1.55x). Query-term overlap is
|
|
236
|
+
// the discriminative signal: 0.58 for the right memory, 0.06-0.08 for junk.
|
|
237
|
+
// Separation stays as the semantic path (vector-mode hits don't overlap
|
|
238
|
+
// lexically with reworded queries).
|
|
239
|
+
const MEMORY_LANES = new Set(['memory', 'belief', 'document']);
|
|
240
|
+
const overlap = termOverlap(query, topResult.content || '');
|
|
241
|
+
const MIN_OVERLAP = parseFloat(process.env.ENGRAM_RECALL_MIN_OVERLAP || '0.35');
|
|
242
|
+
const sepPass =
|
|
243
|
+
scores.length >= 2 && median > 0 && top / median >= MIN_SEP && topEvidence !== 'weak';
|
|
244
|
+
const overlapPass =
|
|
245
|
+
topEvidence !== 'weak' &&
|
|
246
|
+
(MEMORY_LANES.has(topResult.kind) || topEvidence === 'exact') &&
|
|
247
|
+
overlap >= MIN_OVERLAP;
|
|
203
248
|
|
|
204
249
|
// Session packs: any real hit qualifies (it's once per session, and the
|
|
205
250
|
// query is a fixed discovery probe, not a user prompt).
|
|
206
|
-
const passes = isSessionStart
|
|
207
|
-
? scores.length >= 1
|
|
208
|
-
: scores.length >= 2 && median > 0 && top / median >= MIN_SEP && topEvidence !== 'weak';
|
|
251
|
+
const passes = isSessionStart ? scores.length >= 1 : sepPass || overlapPass;
|
|
209
252
|
if (!passes) {
|
|
210
253
|
heartbeat(storage, 'recall', { ok: true, gated: true, hits: payload.hit_count });
|
|
211
254
|
process.exit(0); // silence beats a wrong pointer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@engramlabs/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0-alpha",
|
|
4
4
|
"description": "Local-first agentic memory: the engram MCP server launcher. Memory + knowledge graph + beliefs + code intelligence for any MCP client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"local-first"
|
|
33
33
|
],
|
|
34
34
|
"optionalDependencies": {
|
|
35
|
-
"@engramlabs/mcp-linux-x64-gnu": "0.
|
|
36
|
-
"@engramlabs/mcp-linux-arm64-gnu": "0.
|
|
37
|
-
"@engramlabs/mcp-darwin-arm64": "0.
|
|
38
|
-
"@engramlabs/mcp-darwin-x64": "0.
|
|
39
|
-
"@engramlabs/mcp-win32-x64": "0.
|
|
35
|
+
"@engramlabs/mcp-linux-x64-gnu": "0.2.0-alpha",
|
|
36
|
+
"@engramlabs/mcp-linux-arm64-gnu": "0.2.0-alpha",
|
|
37
|
+
"@engramlabs/mcp-darwin-arm64": "0.2.0-alpha",
|
|
38
|
+
"@engramlabs/mcp-darwin-x64": "0.2.0-alpha",
|
|
39
|
+
"@engramlabs/mcp-win32-x64": "0.2.0-alpha"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"test": "node --test test/*.test.cjs"
|