@hbarefoot/engram 1.0.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.
@@ -0,0 +1,668 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+
8
+ import { loadConfig, getDatabasePath, getModelsPath } from '../config/index.js';
9
+ import { initDatabase, createMemory, createMemoryWithDedup, getMemory, deleteMemory, getStats } from '../memory/store.js';
10
+ import { recallMemories, formatRecallResults } from '../memory/recall.js';
11
+ import { recordFeedback, getFeedbackStats } from '../memory/feedback.js';
12
+ import { generateContext } from '../memory/context.js';
13
+ import { validateContent } from '../extract/secrets.js';
14
+ import { extractMemory } from '../extract/rules.js';
15
+ import * as logger from '../utils/logger.js';
16
+
17
+ /**
18
+ * MCP Server for Engram
19
+ * Provides 4 tools: engram_remember, engram_recall, engram_forget, engram_status
20
+ */
21
+ export class EngramMCPServer {
22
+ constructor(config) {
23
+ this.config = config;
24
+ this.db = null;
25
+ this.server = new Server(
26
+ {
27
+ name: 'engram',
28
+ version: '1.0.0'
29
+ },
30
+ {
31
+ capabilities: {
32
+ tools: {}
33
+ }
34
+ }
35
+ );
36
+
37
+ this.setupToolHandlers();
38
+ }
39
+
40
+ /**
41
+ * Initialize database connection
42
+ */
43
+ initializeDatabase() {
44
+ if (!this.db) {
45
+ const dbPath = getDatabasePath(this.config);
46
+ this.db = initDatabase(dbPath);
47
+ logger.info('MCP Server database initialized', { path: dbPath });
48
+ }
49
+ return this.db;
50
+ }
51
+
52
+ /**
53
+ * Setup MCP tool handlers
54
+ */
55
+ setupToolHandlers() {
56
+ // List available tools
57
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
58
+ return {
59
+ tools: [
60
+ {
61
+ name: 'engram_remember',
62
+ description: 'Store a memory/fact/preference/pattern that should be remembered across sessions. Use this when you learn something important about the user, their project, their preferences, infrastructure, or workflow patterns.',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ content: {
67
+ type: 'string',
68
+ description: 'The memory to store. Be specific and factual. Good: "User prefers Fastify over Express for Node.js APIs". Bad: "User likes stuff".'
69
+ },
70
+ category: {
71
+ type: 'string',
72
+ enum: ['preference', 'fact', 'pattern', 'decision', 'outcome'],
73
+ description: 'Type of memory. preference=user likes/dislikes, fact=objective truth about their setup, pattern=recurring workflow, decision=choice they made and why, outcome=result of an action',
74
+ default: 'fact'
75
+ },
76
+ entity: {
77
+ type: 'string',
78
+ description: 'What this memory is about (e.g., "nginx", "deployment", "coding-style", "project-api"). Helps with retrieval.'
79
+ },
80
+ confidence: {
81
+ type: 'number',
82
+ description: 'How confident you are this is accurate (0.0-1.0). Default 0.8. Use 1.0 for things the user explicitly stated. Use 0.5-0.7 for inferred preferences.',
83
+ default: 0.8
84
+ },
85
+ namespace: {
86
+ type: 'string',
87
+ description: 'Project or scope for this memory. Use "default" for general memories, or a project name for project-specific ones.',
88
+ default: 'default'
89
+ },
90
+ tags: {
91
+ type: 'array',
92
+ items: { type: 'string' },
93
+ description: 'Optional tags for categorization'
94
+ },
95
+ force: {
96
+ type: 'boolean',
97
+ description: 'Bypass deduplication check. If true, memory will be stored even if a similar one exists. Default: false',
98
+ default: false
99
+ }
100
+ },
101
+ required: ['content']
102
+ }
103
+ },
104
+ {
105
+ name: 'engram_recall',
106
+ description: 'Retrieve relevant memories for the current context. Call this at the start of a session or when you need to remember something about the user, their project, or their preferences. Returns the most relevant memories ranked by similarity and recency.',
107
+ inputSchema: {
108
+ type: 'object',
109
+ properties: {
110
+ query: {
111
+ type: 'string',
112
+ description: 'What you want to remember. Can be a question ("what is their deployment setup?") or a topic ("docker configuration"). Be specific for better results.'
113
+ },
114
+ limit: {
115
+ type: 'number',
116
+ description: 'Maximum memories to return (1-20). Default 5. Keep low to avoid context pollution.',
117
+ default: 5
118
+ },
119
+ category: {
120
+ type: 'string',
121
+ enum: ['preference', 'fact', 'pattern', 'decision', 'outcome'],
122
+ description: 'Optional: filter by memory type'
123
+ },
124
+ namespace: {
125
+ type: 'string',
126
+ description: 'Optional: filter by project/scope. Omit to search all namespaces.'
127
+ },
128
+ threshold: {
129
+ type: 'number',
130
+ description: 'Minimum relevance score (0.0-1.0). Default 0.3. Increase to get fewer, more relevant results.',
131
+ default: 0.3
132
+ },
133
+ time_filter: {
134
+ type: 'object',
135
+ description: 'Filter memories by time range. Supports relative times like "3 days ago", "last week", or ISO dates.',
136
+ properties: {
137
+ after: {
138
+ type: 'string',
139
+ description: 'Start time - ISO date (2024-01-01) or relative (3 days ago, last week, yesterday)'
140
+ },
141
+ before: {
142
+ type: 'string',
143
+ description: 'End time - ISO date or relative (today, now)'
144
+ },
145
+ period: {
146
+ type: 'string',
147
+ enum: ['today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month', 'this_year', 'last_year'],
148
+ description: 'Shorthand for common time periods'
149
+ }
150
+ }
151
+ }
152
+ },
153
+ required: ['query']
154
+ }
155
+ },
156
+ {
157
+ name: 'engram_forget',
158
+ description: 'Remove a specific memory by ID. Use when a memory is outdated, incorrect, or the user asks you to forget something.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ memory_id: {
163
+ type: 'string',
164
+ description: 'The ID of the memory to remove (returned by engram_recall)'
165
+ }
166
+ },
167
+ required: ['memory_id']
168
+ }
169
+ },
170
+ {
171
+ name: 'engram_feedback',
172
+ description: 'Provide feedback on a recalled memory to help improve future recall accuracy. Positive feedback increases a memory\'s relevance score; negative feedback decreases it.',
173
+ inputSchema: {
174
+ type: 'object',
175
+ properties: {
176
+ memory_id: {
177
+ type: 'string',
178
+ description: 'The ID of the memory to provide feedback on (returned by engram_recall)'
179
+ },
180
+ helpful: {
181
+ type: 'boolean',
182
+ description: 'Was this memory helpful in the current context? true = helpful, false = not helpful'
183
+ },
184
+ context: {
185
+ type: 'string',
186
+ description: 'Optional: describe the context or query that prompted this feedback'
187
+ }
188
+ },
189
+ required: ['memory_id', 'helpful']
190
+ }
191
+ },
192
+ {
193
+ name: 'engram_context',
194
+ description: 'Generate a pre-formatted context block of relevant memories to inject into your system prompt or conversation. Use this at the start of a session to load relevant user context.',
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ query: {
199
+ type: 'string',
200
+ description: 'Optional query to filter relevant memories. If omitted, returns top memories by access frequency and recency.'
201
+ },
202
+ namespace: {
203
+ type: 'string',
204
+ description: 'Namespace to pull context from (default: "default")',
205
+ default: 'default'
206
+ },
207
+ limit: {
208
+ type: 'number',
209
+ description: 'Maximum memories to include (1-25). Default 10.',
210
+ default: 10
211
+ },
212
+ format: {
213
+ type: 'string',
214
+ enum: ['markdown', 'xml', 'json', 'plain'],
215
+ description: 'Output format. markdown=human-readable, xml=structured, json=programmatic, plain=raw text',
216
+ default: 'markdown'
217
+ },
218
+ include_metadata: {
219
+ type: 'boolean',
220
+ description: 'Include memory IDs and confidence scores in output',
221
+ default: false
222
+ },
223
+ categories: {
224
+ type: 'array',
225
+ items: { type: 'string' },
226
+ description: 'Filter by categories (e.g., ["preference", "fact"])'
227
+ },
228
+ max_tokens: {
229
+ type: 'number',
230
+ description: 'Approximate token budget. Will truncate to fit. Default 1000.',
231
+ default: 1000
232
+ }
233
+ }
234
+ }
235
+ },
236
+ {
237
+ name: 'engram_status',
238
+ description: 'Check Engram health and stats. Returns memory count, database size, embedding model status, and configuration.',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {}
242
+ }
243
+ }
244
+ ]
245
+ };
246
+ });
247
+
248
+ // Handle tool calls
249
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
250
+ try {
251
+ const { name, arguments: args } = request.params;
252
+
253
+ switch (name) {
254
+ case 'engram_remember':
255
+ return await this.handleRemember(args);
256
+ case 'engram_recall':
257
+ return await this.handleRecall(args);
258
+ case 'engram_forget':
259
+ return await this.handleForget(args);
260
+ case 'engram_feedback':
261
+ return await this.handleFeedback(args);
262
+ case 'engram_context':
263
+ return await this.handleContext(args);
264
+ case 'engram_status':
265
+ return await this.handleStatus(args);
266
+ default:
267
+ throw new Error(`Unknown tool: ${name}`);
268
+ }
269
+ } catch (error) {
270
+ logger.error('Tool execution error', { error: error.message, stack: error.stack });
271
+ return {
272
+ content: [
273
+ {
274
+ type: 'text',
275
+ text: `Error: ${error.message}`
276
+ }
277
+ ]
278
+ };
279
+ }
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Handle engram_remember tool
285
+ */
286
+ async handleRemember(args) {
287
+ const db = this.initializeDatabase();
288
+ const { content, category, entity, confidence, namespace, tags, force } = args;
289
+
290
+ logger.info('Remember requested', { category, entity, namespace, force });
291
+
292
+ // Validate content for secrets
293
+ const validation = validateContent(content, {
294
+ autoRedact: this.config.security?.secretDetection !== false
295
+ });
296
+
297
+ if (!validation.valid) {
298
+ const errorMsg = `Cannot store memory: ${validation.errors.join(', ')}`;
299
+ logger.warn('Memory rejected due to secrets', { errors: validation.errors });
300
+
301
+ return {
302
+ content: [
303
+ {
304
+ type: 'text',
305
+ text: errorMsg
306
+ }
307
+ ]
308
+ };
309
+ }
310
+
311
+ // Auto-extract category and entity if not provided
312
+ let memoryData = {
313
+ content: validation.content, // Use potentially redacted content
314
+ category: category || 'fact',
315
+ entity: entity,
316
+ confidence: confidence !== undefined ? confidence : 0.8,
317
+ namespace: namespace || 'default',
318
+ tags: tags || [],
319
+ source: 'mcp'
320
+ };
321
+
322
+ // Extract category/entity if not provided
323
+ if (!entity || !category) {
324
+ const extracted = extractMemory(validation.content, {
325
+ source: 'mcp',
326
+ namespace: namespace || 'default'
327
+ });
328
+
329
+ if (!entity) {
330
+ memoryData.entity = extracted.entity;
331
+ }
332
+ if (!category) {
333
+ memoryData.category = extracted.category;
334
+ }
335
+ }
336
+
337
+ // Generate embedding
338
+ try {
339
+ const { generateEmbedding } = await import('../embed/index.js');
340
+ const modelsPath = getModelsPath(this.config);
341
+ const embedding = await generateEmbedding(validation.content, modelsPath);
342
+ memoryData.embedding = embedding;
343
+ logger.debug('Embedding generated for memory');
344
+ } catch (error) {
345
+ logger.warn('Failed to generate embedding, storing without it', { error: error.message });
346
+ }
347
+
348
+ // Store memory with deduplication check
349
+ const result = createMemoryWithDedup(db, memoryData, { force: force || false });
350
+
351
+ let responseText;
352
+
353
+ switch (result.status) {
354
+ case 'duplicate':
355
+ responseText = `Similar memory already exists (${(result.similarity * 100).toFixed(1)}% match)\n\nExisting ID: ${result.id}\nExisting content: ${result.existingContent}\n\nUse force: true to store anyway.`;
356
+ logger.info('Duplicate memory rejected', { existingId: result.id, similarity: result.similarity });
357
+ break;
358
+
359
+ case 'merged':
360
+ responseText = `Memory merged with existing (${(result.similarity * 100).toFixed(1)}% match)\n\nID: ${result.id}\nCategory: ${result.memory.category}\nEntity: ${result.memory.entity || 'none'}\nConfidence: ${result.memory.confidence}\nNamespace: ${result.memory.namespace}\n\nMerged content: ${result.memory.content}`;
361
+ logger.info('Memory merged', { id: result.id, similarity: result.similarity });
362
+ break;
363
+
364
+ case 'created':
365
+ default:
366
+ responseText = `Memory stored successfully!\n\nID: ${result.id}\nCategory: ${result.memory.category}\nEntity: ${result.memory.entity || 'none'}\nConfidence: ${result.memory.confidence}\nNamespace: ${result.memory.namespace}`;
367
+ logger.info('Memory stored', { id: result.id, category: result.memory.category });
368
+ break;
369
+ }
370
+
371
+ if (validation.warnings && validation.warnings.length > 0) {
372
+ responseText += `\n\nWarnings: ${validation.warnings.join(', ')}`;
373
+ }
374
+
375
+ return {
376
+ content: [
377
+ {
378
+ type: 'text',
379
+ text: responseText
380
+ }
381
+ ]
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Handle engram_recall tool
387
+ */
388
+ async handleRecall(args) {
389
+ const db = this.initializeDatabase();
390
+ const { query, limit = 5, category, namespace, threshold = 0.3, time_filter } = args;
391
+
392
+ logger.info('Recall requested', { query, limit, category, namespace, threshold, time_filter });
393
+
394
+ const modelsPath = getModelsPath(this.config);
395
+
396
+ // Recall memories
397
+ const memories = await recallMemories(
398
+ db,
399
+ query,
400
+ { limit, category, namespace, threshold, time_filter },
401
+ modelsPath
402
+ );
403
+
404
+ // Format results
405
+ const formattedResults = formatRecallResults(memories);
406
+
407
+ return {
408
+ content: [
409
+ {
410
+ type: 'text',
411
+ text: formattedResults
412
+ }
413
+ ]
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Handle engram_forget tool
419
+ */
420
+ async handleForget(args) {
421
+ const db = this.initializeDatabase();
422
+ const { memory_id } = args;
423
+
424
+ logger.info('Forget requested', { memory_id });
425
+
426
+ // Check if memory exists
427
+ const memory = getMemory(db, memory_id);
428
+
429
+ if (!memory) {
430
+ return {
431
+ content: [
432
+ {
433
+ type: 'text',
434
+ text: `Memory not found: ${memory_id}`
435
+ }
436
+ ]
437
+ };
438
+ }
439
+
440
+ // Delete the memory
441
+ const deleted = deleteMemory(db, memory_id);
442
+
443
+ if (deleted) {
444
+ logger.info('Memory deleted', { id: memory_id });
445
+ return {
446
+ content: [
447
+ {
448
+ type: 'text',
449
+ text: `Memory deleted successfully: ${memory_id}\n\nContent: ${memory.content}`
450
+ }
451
+ ]
452
+ };
453
+ } else {
454
+ return {
455
+ content: [
456
+ {
457
+ type: 'text',
458
+ text: `Failed to delete memory: ${memory_id}`
459
+ }
460
+ ]
461
+ };
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Handle engram_feedback tool
467
+ */
468
+ async handleFeedback(args) {
469
+ const db = this.initializeDatabase();
470
+ const { memory_id, helpful, context } = args;
471
+
472
+ logger.info('Feedback requested', { memory_id, helpful, context });
473
+
474
+ // Check if memory exists
475
+ const memory = getMemory(db, memory_id);
476
+
477
+ if (!memory) {
478
+ return {
479
+ content: [
480
+ {
481
+ type: 'text',
482
+ text: `Memory not found: ${memory_id}`
483
+ }
484
+ ]
485
+ };
486
+ }
487
+
488
+ // Record the feedback
489
+ const result = recordFeedback(db, memory_id, helpful, context);
490
+
491
+ const helpfulText = helpful ? 'helpful' : 'not helpful';
492
+ let responseText = `Feedback recorded: memory marked as ${helpfulText}\n\n`;
493
+ responseText += `Memory ID: ${memory_id}\n`;
494
+ responseText += `Feedback Score: ${result.feedbackScore.toFixed(2)} (${result.helpfulCount} helpful, ${result.unhelpfulCount} unhelpful)\n`;
495
+
496
+ if (result.confidenceAdjusted) {
497
+ responseText += `\nConfidence adjusted to: ${result.newConfidence.toFixed(2)}`;
498
+ }
499
+
500
+ logger.info('Feedback recorded', {
501
+ memoryId: memory_id,
502
+ helpful,
503
+ feedbackScore: result.feedbackScore,
504
+ confidenceAdjusted: result.confidenceAdjusted
505
+ });
506
+
507
+ return {
508
+ content: [
509
+ {
510
+ type: 'text',
511
+ text: responseText
512
+ }
513
+ ]
514
+ };
515
+ }
516
+
517
+ /**
518
+ * Handle engram_context tool
519
+ */
520
+ async handleContext(args) {
521
+ const db = this.initializeDatabase();
522
+ const {
523
+ query,
524
+ namespace = 'default',
525
+ limit = 10,
526
+ format = 'markdown',
527
+ include_metadata = false,
528
+ categories,
529
+ max_tokens = 1000
530
+ } = args;
531
+
532
+ logger.info('Context requested', { query, namespace, limit, format });
533
+
534
+ const modelsPath = getModelsPath(this.config);
535
+
536
+ // Generate context
537
+ const result = await generateContext(db, {
538
+ query,
539
+ namespace,
540
+ limit: Math.min(limit, 25),
541
+ format,
542
+ include_metadata,
543
+ categories,
544
+ max_tokens
545
+ }, modelsPath);
546
+
547
+ return {
548
+ content: [
549
+ {
550
+ type: 'text',
551
+ text: result.content
552
+ }
553
+ ]
554
+ };
555
+ }
556
+
557
+ /**
558
+ * Handle engram_status tool
559
+ */
560
+ async handleStatus() {
561
+ const db = this.initializeDatabase();
562
+
563
+ logger.info('Status requested');
564
+
565
+ // Get database stats
566
+ const stats = getStats(db);
567
+
568
+ // Get model info
569
+ const modelsPath = getModelsPath(this.config);
570
+ let modelInfo;
571
+ try {
572
+ const { getModelInfo } = await import('../embed/index.js');
573
+ modelInfo = getModelInfo(modelsPath);
574
+ } catch (error) {
575
+ modelInfo = {
576
+ name: 'unknown',
577
+ available: false,
578
+ error: error.message
579
+ };
580
+ }
581
+
582
+ // Build status response
583
+ const statusText = `Engram Status
584
+
585
+ 📊 Memory Statistics:
586
+ - Total memories: ${stats.total}
587
+ - With embeddings: ${stats.withEmbeddings}
588
+ - By category: ${Object.entries(stats.byCategory).map(([k, v]) => `${k}=${v}`).join(', ')}
589
+ - By namespace: ${Object.entries(stats.byNamespace).map(([k, v]) => `${k}=${v}`).join(', ')}
590
+
591
+ 🤖 Embedding Model:
592
+ - Name: ${modelInfo.name}
593
+ - Available: ${modelInfo.available ? 'Yes' : 'No'}
594
+ - Cached: ${modelInfo.cached ? 'Yes' : 'No'}
595
+ - Size: ${modelInfo.sizeMB} MB
596
+ - Path: ${modelInfo.path}
597
+
598
+ ⚙️ Configuration:
599
+ - Data directory: ${this.config.dataDir}
600
+ - Default namespace: ${this.config.defaults.namespace}
601
+ - Recall limit: ${this.config.defaults.recallLimit}
602
+ - Confidence threshold: ${this.config.defaults.confidenceThreshold}
603
+ - Secret detection: ${this.config.security.secretDetection ? 'Enabled' : 'Disabled'}
604
+ `;
605
+
606
+ return {
607
+ content: [
608
+ {
609
+ type: 'text',
610
+ text: statusText
611
+ }
612
+ ]
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Start the MCP server
618
+ */
619
+ async start() {
620
+ logger.info('Starting Engram MCP server...');
621
+
622
+ const transport = new StdioServerTransport();
623
+ await this.server.connect(transport);
624
+
625
+ logger.info('Engram MCP server started successfully');
626
+ }
627
+
628
+ /**
629
+ * Close the server
630
+ */
631
+ async close() {
632
+ if (this.db) {
633
+ this.db.close();
634
+ logger.info('Database connection closed');
635
+ }
636
+
637
+ await this.server.close();
638
+ logger.info('MCP server closed');
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Start the MCP server
644
+ */
645
+ export async function startMCPServer(configPath) {
646
+ try {
647
+ const config = loadConfig(configPath);
648
+ const server = new EngramMCPServer(config);
649
+
650
+ // Handle shutdown gracefully
651
+ process.on('SIGINT', async () => {
652
+ logger.info('Received SIGINT, shutting down...');
653
+ await server.close();
654
+ process.exit(0);
655
+ });
656
+
657
+ process.on('SIGTERM', async () => {
658
+ logger.info('Received SIGTERM, shutting down...');
659
+ await server.close();
660
+ process.exit(0);
661
+ });
662
+
663
+ await server.start();
664
+ } catch (error) {
665
+ logger.error('Failed to start MCP server', { error: error.message, stack: error.stack });
666
+ process.exit(1);
667
+ }
668
+ }