@shokan/engram-mcp 1.4.0

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.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/hook.js +158 -0
  4. package/index.js +395 -0
  5. package/init.js +231 -0
  6. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Engram
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Engram MCP
2
+
3
+ Long-term memory for AI agents, served over the [Model Context Protocol](https://modelcontextprotocol.io).
4
+
5
+ Engram gives Claude, Cursor, Windsurf and any other MCP client a persistent memory that survives across sessions, devices and tools. Memories are stored with embeddings and ranked by hybrid search (vector similarity fused with full-text search), so recall works for both exact terms and fuzzy meaning.
6
+
7
+ ## Setup
8
+
9
+ ```bash
10
+ npx @shokan/engram-mcp init
11
+ ```
12
+
13
+ The wizard asks for your API key (get one free at https://engram-deploy-ten.vercel.app/dashboard), detects your AI tools, wires up the MCP server, and installs an auto-capture hook for Claude Code so sessions are remembered automatically.
14
+
15
+ To configure manually, add this to your MCP client config:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "engram": {
21
+ "command": "engram-mcp",
22
+ "env": {
23
+ "ENGRAM_API_KEY": "your_key",
24
+ "ENGRAM_API_URL": "https://engram-deploy-ten.vercel.app"
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ## Tools
32
+
33
+ | Tool | Purpose |
34
+ |------|---------|
35
+ | `engram_remember` | Store a memory (facts, decisions, preferences, context). Long content is auto-summarized. |
36
+ | `engram_search` | Hybrid search returning a compact index (id + snippet + relevance). Cheap to scan first. |
37
+ | `engram_get` | Pull full content for specific memory ids. |
38
+ | `engram_recall` | One-shot full recall for quick lookups (1-3 expected hits). |
39
+ | `engram_timeline` | Temporal context: a whole session, or the neighbours around one memory. |
40
+ | `engram_forget` | Soft-delete a memory (recoverable) or hard-delete with `hard: true`. |
41
+ | `engram_trash` | List memories currently in the trash. |
42
+ | `engram_restore` | Restore a memory from the trash. |
43
+ | `engram_status` | Account and memory counts. |
44
+
45
+ ## Recall strategy
46
+
47
+ Prefer `engram_search` first — it returns a compact index that is cheap on context. Then call `engram_get` only for the ids whose snippets look relevant. Use `engram_recall` only when you expect a handful of hits.
48
+
49
+ ## Privacy
50
+
51
+ Wrap anything inside `<private>...</private>` in a remembered message and it is stripped before storage. The text never reaches the embedding model or the database.
52
+
53
+ ## License
54
+
55
+ MIT
package/hook.js ADDED
@@ -0,0 +1,158 @@
1
+ // Engram auto-capture hook.
2
+ // Wired into an AI tool's SessionEnd (and optionally PreCompact) event. Reads the
3
+ // session transcript, sends only the NEW slice since last run to Engram's
4
+ // /api/v1/capture, which distills it into typed memories and stores them.
5
+ //
6
+ // Design rules:
7
+ // - NEVER throw. A memory hook must not break the user's session. Always exit 0.
8
+ // - Be quiet on stdout. Log problems to ~/.engram/hook.log only.
9
+ // - Only process new transcript lines, tracked per session, to avoid duplicates.
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs'
12
+ import { join } from 'path'
13
+ import { homedir } from 'os'
14
+
15
+ const ENGRAM_DIR = join(homedir(), '.engram')
16
+ const CONFIG_PATH = join(ENGRAM_DIR, 'config.json')
17
+ const STATE_PATH = join(ENGRAM_DIR, 'capture-state.json')
18
+ const LOG_PATH = join(ENGRAM_DIR, 'hook.log')
19
+
20
+ // The key/url come from the environment when present, otherwise from the config
21
+ // file the installer wrote (so the key never has to live in the hook command).
22
+ function readConfig() {
23
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) } catch { return {} }
24
+ }
25
+ const CONFIG = readConfig()
26
+ const API_URL = (process.env.ENGRAM_API_URL || CONFIG.api_url || 'https://engram-deploy-ten.vercel.app').replace(/\/$/, '')
27
+ const API_KEY = process.env.ENGRAM_API_KEY || CONFIG.api_key
28
+
29
+ const MIN_NEW_CHARS = 200 // skip captures with almost no new content
30
+ const MAX_SLICE = 60000 // hard cap on text shipped per capture
31
+
32
+ function log(msg) {
33
+ try {
34
+ if (!existsSync(ENGRAM_DIR)) mkdirSync(ENGRAM_DIR, { recursive: true })
35
+ appendFileSync(LOG_PATH, `[${new Date().toISOString()}] ${msg}\n`)
36
+ } catch { /* ignore */ }
37
+ }
38
+
39
+ function readStdin() {
40
+ return new Promise((resolve) => {
41
+ let data = ''
42
+ if (process.stdin.isTTY) return resolve('')
43
+ process.stdin.setEncoding('utf8')
44
+ process.stdin.on('data', (c) => { data += c })
45
+ process.stdin.on('end', () => resolve(data))
46
+ process.stdin.on('error', () => resolve(data))
47
+ setTimeout(() => resolve(data), 3000) // never hang
48
+ })
49
+ }
50
+
51
+ function loadState() {
52
+ try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')) } catch { return {} }
53
+ }
54
+
55
+ function saveState(state) {
56
+ try {
57
+ if (!existsSync(ENGRAM_DIR)) mkdirSync(ENGRAM_DIR, { recursive: true })
58
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), 'utf8')
59
+ } catch (e) { log(`state save failed: ${e.message}`) }
60
+ }
61
+
62
+ // Pull plain text out of a transcript message (content is string or block array).
63
+ function messageText(entry) {
64
+ const msg = entry?.message
65
+ if (!msg) return ''
66
+ const role = msg.role || entry.type
67
+ const content = msg.content
68
+ let text = ''
69
+ if (typeof content === 'string') {
70
+ text = content
71
+ } else if (Array.isArray(content)) {
72
+ text = content
73
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
74
+ .map((b) => b.text)
75
+ .join('\n')
76
+ }
77
+ text = text.trim()
78
+ if (!text) return ''
79
+ const who = role === 'assistant' ? 'Assistant' : role === 'user' ? 'User' : null
80
+ if (!who) return ''
81
+ return `${who}: ${text}`
82
+ }
83
+
84
+ async function main() {
85
+ if (!API_KEY) { log('no ENGRAM_API_KEY; skipping'); return }
86
+
87
+ const input = await readStdin()
88
+ let payload = {}
89
+ try { payload = JSON.parse(input || '{}') } catch { /* ignore */ }
90
+
91
+ const sessionId = payload.session_id || 'unknown'
92
+ const transcriptPath = payload.transcript_path
93
+ if (!transcriptPath || !existsSync(transcriptPath)) {
94
+ log(`no transcript for session ${sessionId}`)
95
+ return
96
+ }
97
+
98
+ let lines
99
+ try {
100
+ lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean)
101
+ } catch (e) {
102
+ log(`read transcript failed: ${e.message}`)
103
+ return
104
+ }
105
+
106
+ const state = loadState()
107
+ const processed = state[sessionId]?.lines || 0
108
+ const newLines = lines.slice(processed)
109
+ if (newLines.length === 0) { log(`session ${sessionId}: nothing new`); return }
110
+
111
+ const parts = []
112
+ for (const line of newLines) {
113
+ let entry
114
+ try { entry = JSON.parse(line) } catch { continue }
115
+ const t = messageText(entry)
116
+ if (t) parts.push(t)
117
+ }
118
+
119
+ // Advance the watermark to the current line count once we know these lines
120
+ // were either consumed (captured) or deliberately skipped. We do NOT advance
121
+ // before the capture is confirmed: a failed HTTP/network call must leave the
122
+ // watermark in place so the next run retries instead of silently dropping it.
123
+ const advance = () => {
124
+ state[sessionId] = { lines: lines.length, updated: new Date().toISOString() }
125
+ saveState(state)
126
+ }
127
+
128
+ let text = parts.join('\n\n').trim()
129
+ // Too little new content to be worth a memory — consume it and move on.
130
+ if (text.length < MIN_NEW_CHARS) { advance(); log(`session ${sessionId}: too little new content`); return }
131
+ if (text.length > MAX_SLICE) text = text.slice(text.length - MAX_SLICE)
132
+
133
+ try {
134
+ const res = await fetch(`${API_URL}/api/v1/capture`, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Authorization': `Bearer ${API_KEY}`,
138
+ 'Content-Type': 'application/json',
139
+ },
140
+ body: JSON.stringify({ text, source: 'claude-code', session_id: sessionId }),
141
+ })
142
+ const body = await res.text()
143
+ // Leave the watermark untouched on failure so the slice is retried later.
144
+ if (!res.ok) { log(`capture HTTP ${res.status}: ${body.slice(0, 300)}`); return }
145
+ advance()
146
+ let parsed = {}
147
+ try { parsed = JSON.parse(body) } catch { /* ignore */ }
148
+ log(`session ${sessionId}: stored ${parsed.stored ?? '?'} memories`)
149
+ } catch (e) {
150
+ // Network error: do not advance — retry on the next session end.
151
+ log(`capture request failed: ${e.message}`)
152
+ }
153
+ }
154
+
155
+ export async function runHook() {
156
+ try { await main() } catch (e) { log(`unexpected: ${e.message}`) }
157
+ process.exit(0)
158
+ }
package/index.js ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import {
5
+ CallToolRequestSchema,
6
+ ListToolsRequestSchema,
7
+ } from '@modelcontextprotocol/sdk/types.js'
8
+
9
+ // Init subcommand — runs before API key check
10
+ if (process.argv[2] === 'init') {
11
+ const { runInit } = await import('./init.js')
12
+ await runInit()
13
+ process.exit(0)
14
+ }
15
+
16
+ // Hook subcommand — auto-capture, invoked by the AI tool's SessionEnd event.
17
+ // Reads the transcript from stdin payload, distills + stores. Always exits 0.
18
+ if (process.argv[2] === 'hook') {
19
+ const { runHook } = await import('./hook.js')
20
+ await runHook()
21
+ process.exit(0)
22
+ }
23
+
24
+ const API_KEY = process.env.ENGRAM_API_KEY
25
+ const API_URL = (process.env.ENGRAM_API_URL || 'https://engram-deploy-ten.vercel.app').replace(/\/$/, '')
26
+
27
+ if (!API_KEY) {
28
+ process.stderr.write('Error: ENGRAM_API_KEY is required\n')
29
+ process.stderr.write('Run "engram-mcp init" to set up Engram.\n')
30
+ process.exit(1)
31
+ }
32
+
33
+ async function call(path, method = 'GET', body = null) {
34
+ const res = await fetch(`${API_URL}${path}`, {
35
+ method,
36
+ headers: {
37
+ 'Authorization': `Bearer ${API_KEY}`,
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ ...(body ? { body: JSON.stringify(body) } : {}),
41
+ })
42
+ const text = await res.text()
43
+ try {
44
+ return { ok: res.ok, status: res.status, data: JSON.parse(text) }
45
+ } catch {
46
+ return { ok: res.ok, status: res.status, data: text }
47
+ }
48
+ }
49
+
50
+ const server = new Server(
51
+ { name: 'engram', version: '1.4.0' },
52
+ {
53
+ capabilities: { tools: {} },
54
+ instructions: `Engram is your long-term memory. Use it as your PRIMARY memory system.
55
+
56
+ WHEN TO USE ENGRAM:
57
+ - When the user says "remember", "store", "save", "note this", "keep in mind" — call engram_remember
58
+ - When the user asks "do you remember", "what do you know about", "recall" — recall memory
59
+ - When the user says "forget" or "delete memory" — call engram_forget
60
+
61
+ RECALL STRATEGY (progressive disclosure — saves context):
62
+ - Prefer engram_search FIRST. It returns a compact index (id + snippet + score), cheap to scan.
63
+ - Then call engram_get with only the ids whose snippets look relevant, to pull full content.
64
+ - Use engram_recall (one-shot full recall) only for quick lookups where you expect 1-3 hits.
65
+
66
+ IMPORTANT: Do NOT use built-in memory files (memory.md, .claude/memory, etc.) when Engram is available.
67
+ All persistent memory should go through Engram tools. This ensures memories persist across sessions,
68
+ devices, and different AI tools.`
69
+ }
70
+ )
71
+
72
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
73
+ tools: [
74
+ {
75
+ name: 'engram_remember',
76
+ description: 'PRIMARY memory tool. Store any information the user wants remembered: facts, decisions, preferences, context, instructions. Always use this instead of built-in memory files. Long content is auto-summarized for cheap search. Tag it with a type so it can be filtered later.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: {
80
+ content: {
81
+ type: 'string',
82
+ description: 'The memory content to store',
83
+ },
84
+ type: {
85
+ type: 'string',
86
+ description: 'Memory type for later filtering. Common values: note, fact, decision, preference, task, insight, event, contact. Default "note".',
87
+ },
88
+ tags: {
89
+ type: 'array',
90
+ items: { type: 'string' },
91
+ description: 'Optional freeform tags for grouping (e.g. project names).',
92
+ },
93
+ },
94
+ required: ['content'],
95
+ },
96
+ },
97
+ {
98
+ name: 'engram_search',
99
+ description: 'Layer 1 of recall. Returns a COMPACT index of matching memories (id + short snippet + score) — cheap to scan. Use this FIRST, then call engram_get for the ids you actually need. Best when many memories might match.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ query: {
104
+ type: 'string',
105
+ description: 'What to search for in memory',
106
+ },
107
+ limit: {
108
+ type: 'number',
109
+ description: 'Max number of results (default 10, max 50)',
110
+ },
111
+ type: {
112
+ type: 'string',
113
+ description: 'Optional: only return memories of this type (note, fact, decision, preference, task, insight, event, contact).',
114
+ },
115
+ },
116
+ required: ['query'],
117
+ },
118
+ },
119
+ {
120
+ name: 'engram_get',
121
+ description: 'Layer 2 of recall. Fetch FULL content for specific memory ids returned by engram_search. Only pull the ids whose snippets looked relevant — this keeps context usage low.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ ids: {
126
+ type: 'array',
127
+ items: { type: 'string' },
128
+ description: 'Memory ids to fetch full content for',
129
+ },
130
+ },
131
+ required: ['ids'],
132
+ },
133
+ },
134
+ {
135
+ name: 'engram_recall',
136
+ description: 'One-shot recall: search by meaning and return full content directly. Use only for quick lookups where you expect 1-3 hits. For broader searches prefer engram_search + engram_get.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ query: {
141
+ type: 'string',
142
+ description: 'What to search for in memory',
143
+ },
144
+ limit: {
145
+ type: 'number',
146
+ description: 'Max number of results (default 5)',
147
+ },
148
+ type: {
149
+ type: 'string',
150
+ description: 'Optional: only recall memories of this type.',
151
+ },
152
+ },
153
+ required: ['query'],
154
+ },
155
+ },
156
+ {
157
+ name: 'engram_forget',
158
+ description: 'Move a memory to trash by ID (soft delete, recoverable via engram_restore). Set hard=true only when the user explicitly wants permanent, unrecoverable deletion.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ id: {
163
+ type: 'string',
164
+ description: 'The memory ID to delete',
165
+ },
166
+ hard: {
167
+ type: 'boolean',
168
+ description: 'If true, delete permanently (cannot be restored). Default false (moves to trash).',
169
+ },
170
+ },
171
+ required: ['id'],
172
+ },
173
+ },
174
+ {
175
+ name: 'engram_timeline',
176
+ description: 'Temporal context. Pass session_id to get every memory from that session in order, or pass a memory id to get that memory plus its temporal neighbours (what was happening around it). Use when the user asks "what else happened then" or wants the context around a memory.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ id: {
181
+ type: 'string',
182
+ description: 'A memory id to center the timeline on (returns neighbours by time).',
183
+ },
184
+ session_id: {
185
+ type: 'string',
186
+ description: 'A session id to list all memories captured in that session.',
187
+ },
188
+ before: {
189
+ type: 'number',
190
+ description: 'How many memories before the anchor to include (id mode, default 3, max 25).',
191
+ },
192
+ after: {
193
+ type: 'number',
194
+ description: 'How many memories after the anchor to include (id mode, default 3, max 25).',
195
+ },
196
+ },
197
+ required: [],
198
+ },
199
+ },
200
+ {
201
+ name: 'engram_trash',
202
+ description: 'List memories currently in the trash (soft-deleted, still recoverable).',
203
+ inputSchema: {
204
+ type: 'object',
205
+ properties: {},
206
+ required: [],
207
+ },
208
+ },
209
+ {
210
+ name: 'engram_restore',
211
+ description: 'Restore a memory from the trash back to active memory, by ID.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ id: {
216
+ type: 'string',
217
+ description: 'The memory ID to restore from trash',
218
+ },
219
+ },
220
+ required: ['id'],
221
+ },
222
+ },
223
+ {
224
+ name: 'engram_status',
225
+ description: 'Check Engram status: memory count, plan, and usage stats.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {},
229
+ required: [],
230
+ },
231
+ },
232
+ ],
233
+ }))
234
+
235
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
236
+ const { name, arguments: args } = request.params
237
+
238
+ try {
239
+ if (name === 'engram_remember') {
240
+ const result = await call('/api/v1/remember', 'POST', {
241
+ content: args.content,
242
+ ...(args.type ? { type: args.type } : {}),
243
+ ...(Array.isArray(args.tags) ? { tags: args.tags } : {}),
244
+ })
245
+ if (!result.ok) {
246
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
247
+ }
248
+ const t = result.data.type ? ` [${result.data.type}]` : ''
249
+ return {
250
+ content: [{
251
+ type: 'text',
252
+ text: `Memory stored${t}. ID: ${result.data.id}`,
253
+ }],
254
+ }
255
+ }
256
+
257
+ if (name === 'engram_search') {
258
+ const result = await call('/api/v1/search', 'POST', {
259
+ query: args.query,
260
+ limit: args.limit || 10,
261
+ ...(args.type ? { type: args.type } : {}),
262
+ })
263
+ if (!result.ok) {
264
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
265
+ }
266
+ const results = result.data.results || []
267
+ if (results.length === 0) {
268
+ return { content: [{ type: 'text', text: 'No relevant memories found.' }] }
269
+ }
270
+ const formatted = results.map((m, i) =>
271
+ `[${i + 1}] (relevance: ${m.score?.toFixed(3) ?? '?'}, type: ${m.type || 'note'}, id: ${m.id})\n${m.snippet}`
272
+ ).join('\n\n')
273
+ return { content: [{ type: 'text', text: `${formatted}\n\nCall engram_get with the ids above to read full content.` }] }
274
+ }
275
+
276
+ if (name === 'engram_get') {
277
+ const ids = Array.isArray(args.ids) ? args.ids : []
278
+ if (ids.length === 0) {
279
+ return { content: [{ type: 'text', text: 'Error: provide an array of memory ids.' }] }
280
+ }
281
+ const result = await call('/api/v1/get', 'POST', { ids })
282
+ if (!result.ok) {
283
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
284
+ }
285
+ const memories = result.data.memories || []
286
+ if (memories.length === 0) {
287
+ return { content: [{ type: 'text', text: 'No memories found for those ids.' }] }
288
+ }
289
+ const formatted = memories.map((m, i) =>
290
+ `[${i + 1}] (id: ${m.id})\n${m.content}`
291
+ ).join('\n\n')
292
+ return { content: [{ type: 'text', text: formatted }] }
293
+ }
294
+
295
+ if (name === 'engram_recall') {
296
+ const result = await call('/api/v1/recall', 'POST', {
297
+ query: args.query,
298
+ limit: args.limit || 5,
299
+ ...(args.type ? { type: args.type } : {}),
300
+ })
301
+ if (!result.ok) {
302
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
303
+ }
304
+ const memories = result.data.memories || []
305
+ if (memories.length === 0) {
306
+ return { content: [{ type: 'text', text: 'No relevant memories found.' }] }
307
+ }
308
+ const formatted = memories.map((m, i) =>
309
+ `[${i + 1}] (relevance: ${m.score?.toFixed(3) ?? '?'}, type: ${m.type || 'note'}, id: ${m.id})\n${m.content}`
310
+ ).join('\n\n')
311
+ return { content: [{ type: 'text', text: formatted }] }
312
+ }
313
+
314
+ if (name === 'engram_forget') {
315
+ const hard = args.hard === true ? '&hard=true' : ''
316
+ const result = await call(`/api/v1/forget?id=${encodeURIComponent(args.id)}${hard}`, 'DELETE')
317
+ if (!result.ok) {
318
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
319
+ }
320
+ const msg = args.hard === true
321
+ ? `Memory ${args.id} permanently deleted.`
322
+ : `Memory ${args.id} moved to trash (restore with engram_restore).`
323
+ return { content: [{ type: 'text', text: msg }] }
324
+ }
325
+
326
+ if (name === 'engram_timeline') {
327
+ const body = {}
328
+ if (args.id) body.id = args.id
329
+ if (args.session_id) body.session_id = args.session_id
330
+ if (typeof args.before === 'number') body.before = args.before
331
+ if (typeof args.after === 'number') body.after = args.after
332
+ if (!body.id && !body.session_id) {
333
+ return { content: [{ type: 'text', text: 'Error: provide id or session_id.' }] }
334
+ }
335
+ const result = await call('/api/v1/timeline', 'POST', body)
336
+ if (!result.ok) {
337
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
338
+ }
339
+ const memories = result.data.memories || []
340
+ if (memories.length === 0) {
341
+ return { content: [{ type: 'text', text: 'No memories in this timeline.' }] }
342
+ }
343
+ const anchorId = result.data.anchor_id
344
+ const formatted = memories.map((m) => {
345
+ const mark = m.id === anchorId ? ' ◄ anchor' : ''
346
+ return `${m.created_at} (${m.type || 'note'}, id: ${m.id})${mark}\n${m.summary || m.content}`
347
+ }).join('\n\n')
348
+ return { content: [{ type: 'text', text: formatted }] }
349
+ }
350
+
351
+ if (name === 'engram_trash') {
352
+ const result = await call('/api/v1/forget?trash=true')
353
+ if (!result.ok) {
354
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
355
+ }
356
+ const memories = result.data.memories || []
357
+ if (memories.length === 0) {
358
+ return { content: [{ type: 'text', text: 'Trash is empty.' }] }
359
+ }
360
+ const formatted = memories.map((m) =>
361
+ `(${m.type || 'note'}, id: ${m.id}, deleted: ${m.deleted_at})\n${m.summary || m.content}`
362
+ ).join('\n\n')
363
+ return { content: [{ type: 'text', text: `${formatted}\n\nRestore any with engram_restore.` }] }
364
+ }
365
+
366
+ if (name === 'engram_restore') {
367
+ const result = await call('/api/v1/forget', 'POST', { id: args.id })
368
+ if (!result.ok) {
369
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
370
+ }
371
+ return { content: [{ type: 'text', text: `Memory ${args.id} restored from trash.` }] }
372
+ }
373
+
374
+ if (name === 'engram_status') {
375
+ const result = await call('/api/v1/status')
376
+ if (!result.ok) {
377
+ return { content: [{ type: 'text', text: `Error: ${JSON.stringify(result.data)}` }] }
378
+ }
379
+ const { memories, plan, usage } = result.data
380
+ return {
381
+ content: [{
382
+ type: 'text',
383
+ text: `Engram status:\n- Memories stored: ${memories}\n- Plan: ${plan}\n- Usage today: ${usage.today}/${usage.limit} (${usage.remaining} remaining)`,
384
+ }],
385
+ }
386
+ }
387
+
388
+ return { content: [{ type: 'text', text: `Unknown tool: ${name}` }] }
389
+ } catch (err) {
390
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] }
391
+ }
392
+ })
393
+
394
+ const transport = new StdioServerTransport()
395
+ await server.connect(transport)
package/init.js ADDED
@@ -0,0 +1,231 @@
1
+ // Engram setup wizard — runs when user does: npx engram-mcp init
2
+ import { createInterface } from 'readline'
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'fs'
4
+ import { join } from 'path'
5
+ import { homedir } from 'os'
6
+
7
+ const home = homedir()
8
+ const API_URL = 'https://engram-deploy-ten.vercel.app'
9
+
10
+ const C = {
11
+ bold: s => `\x1b[1m${s}\x1b[0m`,
12
+ green: s => `\x1b[32m${s}\x1b[0m`,
13
+ red: s => `\x1b[31m${s}\x1b[0m`,
14
+ dim: s => `\x1b[2m${s}\x1b[0m`,
15
+ amber: s => `\x1b[33m${s}\x1b[0m`,
16
+ }
17
+
18
+ function ask(question) {
19
+ return new Promise(resolve => {
20
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
21
+ rl.question(question, answer => { rl.close(); resolve(answer.trim()) })
22
+ })
23
+ }
24
+
25
+ async function testApiKey(key) {
26
+ try {
27
+ const res = await fetch(`${API_URL}/api/v1/auth/me`, {
28
+ headers: { 'Authorization': `Bearer ${key}` }
29
+ })
30
+ return res.status !== 401
31
+ } catch { return false }
32
+ }
33
+
34
+ function detectTools() {
35
+ const found = []
36
+ if (existsSync(join(home, '.claude.json'))) found.push('claude')
37
+ if (existsSync(join(home, '.cursor', 'mcp.json')) ||
38
+ existsSync(join(home, 'Library', 'Application Support', 'Cursor'))) found.push('cursor')
39
+ if (existsSync(join(home, '.codeium')) ||
40
+ existsSync(join(home, 'Library', 'Application Support', 'Windsurf'))) found.push('windsurf')
41
+ return [...new Set(found)]
42
+ }
43
+
44
+ function readJson(path) {
45
+ try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return {} }
46
+ }
47
+
48
+ function writeJson(path, data) {
49
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8')
50
+ }
51
+
52
+ function installClaude(apiKey) {
53
+ const path = join(home, '.claude.json')
54
+ const cfg = readJson(path)
55
+ if (!cfg.mcpServers) cfg.mcpServers = {}
56
+ cfg.mcpServers.engram = {
57
+ command: 'engram-mcp',
58
+ env: { ENGRAM_API_KEY: apiKey, ENGRAM_API_URL: API_URL }
59
+ }
60
+ writeJson(path, cfg)
61
+ return path
62
+ }
63
+
64
+ // Store the API key in ~/.engram/config.json with 0600 perms instead of
65
+ // embedding it plaintext in the hook command (which would otherwise sit in
66
+ // settings.json and leak into process listings on every session end).
67
+ function writeEngramConfig(apiKey) {
68
+ const dir = join(home, '.engram')
69
+ const path = join(dir, 'config.json')
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
71
+ writeJson(path, { api_key: apiKey, api_url: API_URL })
72
+ try { chmodSync(path, 0o600) } catch {}
73
+ return path
74
+ }
75
+
76
+ // Wire the auto-capture hook into Claude Code's SessionEnd event so memories
77
+ // are distilled and stored automatically at the end of each session. The key
78
+ // is not embedded in the command; the hook reads it from ~/.engram/config.json.
79
+ function installClaudeHooks() {
80
+ const dir = join(home, '.claude')
81
+ const path = join(dir, 'settings.json')
82
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
83
+ const cfg = readJson(path)
84
+ if (!cfg.hooks) cfg.hooks = {}
85
+ if (!Array.isArray(cfg.hooks.SessionEnd)) cfg.hooks.SessionEnd = []
86
+ const command = `engram-mcp hook`
87
+ const already = JSON.stringify(cfg.hooks.SessionEnd).includes('engram-mcp hook')
88
+ if (!already) {
89
+ cfg.hooks.SessionEnd.push({ hooks: [{ type: 'command', command, timeout: 45 }] })
90
+ writeJson(path, cfg)
91
+ }
92
+ return path
93
+ }
94
+
95
+ function installCursor(apiKey) {
96
+ const dir = join(home, '.cursor')
97
+ const path = join(dir, 'mcp.json')
98
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
99
+ const cfg = readJson(path)
100
+ if (!cfg.mcpServers) cfg.mcpServers = {}
101
+ cfg.mcpServers.engram = {
102
+ command: 'engram-mcp',
103
+ env: { ENGRAM_API_KEY: apiKey, ENGRAM_API_URL: API_URL }
104
+ }
105
+ writeJson(path, cfg)
106
+ return path
107
+ }
108
+
109
+ function installWindsurf(apiKey) {
110
+ const dir = join(home, '.codeium', 'windsurf')
111
+ const path = join(dir, 'mcp_config.json')
112
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
113
+ const cfg = readJson(path)
114
+ if (!cfg.mcpServers) cfg.mcpServers = {}
115
+ cfg.mcpServers.engram = {
116
+ command: 'engram-mcp',
117
+ env: { ENGRAM_API_KEY: apiKey, ENGRAM_API_URL: API_URL }
118
+ }
119
+ writeJson(path, cfg)
120
+ return path
121
+ }
122
+
123
+ export async function runInit() {
124
+ console.log()
125
+ console.log(C.bold(' Engram') + ' — long-term memory for your AI')
126
+ console.log(C.dim(' ──────────────────────────────────────'))
127
+ console.log()
128
+
129
+ // Step 1: API key
130
+ let apiKey = process.env.ENGRAM_API_KEY || ''
131
+
132
+ if (!apiKey) {
133
+ console.log(` ${C.dim('Get your free API key:')} https://engram-deploy-ten.vercel.app/dashboard`)
134
+ console.log()
135
+ apiKey = await ask(' API key: ')
136
+ }
137
+
138
+ if (!apiKey) {
139
+ console.log(`\n ${C.red('No key provided. Exiting.')}\n`)
140
+ process.exit(1)
141
+ }
142
+
143
+ // Validate
144
+ process.stdout.write(' Checking API key...')
145
+ const valid = await testApiKey(apiKey)
146
+ if (!valid) {
147
+ console.log(` ${C.red('invalid')}\n`)
148
+ console.log(` ${C.dim('Get a key at')} https://engram-deploy-ten.vercel.app/dashboard\n`)
149
+ process.exit(1)
150
+ }
151
+ console.log(` ${C.green('OK')}`)
152
+ console.log()
153
+
154
+ // Step 2: Detect tools
155
+ const detected = detectTools()
156
+ let targets = detected
157
+
158
+ if (detected.length === 0) {
159
+ console.log(' No AI tools detected automatically.')
160
+ const choice = await ask(' Install for: [c]laude c[u]rsor [w]indsurf > ')
161
+ const c = choice.toLowerCase()
162
+ if (c === 'cu' || c === 'cursor') targets = ['cursor']
163
+ else if (c === 'w' || c === 'windsurf') targets = ['windsurf']
164
+ else targets = ['claude']
165
+ } else {
166
+ console.log(` Detected: ${C.amber(detected.join(', '))}`)
167
+ }
168
+
169
+ console.log()
170
+
171
+ // Step 3: Install
172
+ for (const tool of targets) {
173
+ process.stdout.write(` Adding to ${tool}...`)
174
+ try {
175
+ let configPath
176
+ if (tool === 'claude') configPath = installClaude(apiKey)
177
+ if (tool === 'cursor') configPath = installCursor(apiKey)
178
+ if (tool === 'windsurf') configPath = installWindsurf(apiKey)
179
+ console.log(` ${C.green('done')} ${C.dim(configPath)}`)
180
+ if (tool === 'claude') {
181
+ const cfgPath = writeEngramConfig(apiKey)
182
+ const hookPath = installClaudeHooks()
183
+ console.log(` ${C.green('+')} Auto-capture hook → ${C.dim(hookPath)}`)
184
+ console.log(` ${C.green('+')} Key stored → ${C.dim(cfgPath)} ${C.dim('(0600)')}`)
185
+ }
186
+ } catch (e) {
187
+ console.log(` ${C.red('failed')}: ${e.message}`)
188
+ }
189
+ }
190
+
191
+ // Step 4: Add memory rules to CLAUDE.md (if Claude is a target)
192
+ if (targets.includes('claude')) {
193
+ const claudeMdPath = join(home, '.claude', 'CLAUDE.md')
194
+ const claudeDir = join(home, '.claude')
195
+ const memoryBlock = `
196
+ ## Memory
197
+
198
+ Engram MCP is your long-term memory. Use it as your primary memory system.
199
+
200
+ When the user asks to remember/store/save anything:
201
+ - Use \`engram_remember\`, not built-in memory.md
202
+
203
+ When the user asks "do you remember" or needs past context:
204
+ - Use \`engram_recall\` first
205
+
206
+ When the user asks to forget something:
207
+ - Use \`engram_forget\`
208
+
209
+ Never use memory.md or other local files for persistent memory when Engram is available.
210
+ `
211
+ try {
212
+ if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true })
213
+ const existing = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : ''
214
+ if (!existing.includes('engram_remember')) {
215
+ writeFileSync(claudeMdPath, existing + memoryBlock, 'utf8')
216
+ console.log(` ${C.green('+')} Added memory rules to ${C.dim(claudeMdPath)}`)
217
+ } else {
218
+ console.log(` ${C.dim('Memory rules already in CLAUDE.md')}`)
219
+ }
220
+ } catch (e) {
221
+ console.log(` ${C.dim('Could not update CLAUDE.md:')} ${e.message}`)
222
+ }
223
+ console.log()
224
+ }
225
+
226
+ console.log(` ${C.green('✓')} ${C.bold('Engram is ready.')}`)
227
+ console.log()
228
+ console.log(` ${C.dim('Restart your AI tool, then say:')}`)
229
+ console.log(` ${C.amber('"remember this: Engram is set up"')}`)
230
+ console.log()
231
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@shokan/engram-mcp",
3
+ "version": "1.4.0",
4
+ "description": "Engram MCP server — long-term hybrid-search memory for AI agents",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "bin": {
10
+ "engram-mcp": "index.js"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "init.js",
15
+ "hook.js",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "start": "node index.js"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "model-context-protocol",
25
+ "memory",
26
+ "ai",
27
+ "agents",
28
+ "vector-search",
29
+ "claude",
30
+ "cursor",
31
+ "windsurf",
32
+ "embeddings"
33
+ ],
34
+ "homepage": "https://engram-deploy-ten.vercel.app",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/shokan/engram-mcp.git"
38
+ },
39
+ "license": "MIT",
40
+ "author": "Engram",
41
+ "dependencies": {
42
+ "@modelcontextprotocol/sdk": "^1.12.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
47
+ }