@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.
@@ -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 storage = process.env.ENGRAM_STORAGE || path.join(os.homedir(), '.engram');
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
- process.env.ENGRAM_MCP_BIN ||
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 gate(payload) {
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 < 2) return [];
118
+ if (scores.length < 1) return [];
57
119
  const median = scores[Math.floor(scores.length / 2)];
58
120
  const top = scores[0];
59
- const topEvidence = payload.results[0].evidence || 'weak';
60
- if (median > 0 && top / median >= 1.8 && topEvidence !== 'weak') {
61
- return payload.results.slice(0, 3);
62
- }
63
- return [];
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: {
@@ -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 topEvidence = payload.results[0].evidence || 'weak';
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.1.8-alpha",
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.1.8-alpha",
36
- "@engramlabs/mcp-linux-arm64-gnu": "0.1.8-alpha",
37
- "@engramlabs/mcp-darwin-arm64": "0.1.8-alpha",
38
- "@engramlabs/mcp-darwin-x64": "0.1.8-alpha",
39
- "@engramlabs/mcp-win32-x64": "0.1.8-alpha"
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"