@199-bio/engram 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,558 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Engram - High-quality personal memory for AI assistants
4
+ *
5
+ * Local-first MCP server with ColBERT + BM25 hybrid search
6
+ * and a lightweight knowledge graph.
7
+ */
8
+
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import {
12
+ CallToolRequestSchema,
13
+ ListToolsRequestSchema,
14
+ } from "@modelcontextprotocol/sdk/types.js";
15
+ import path from "path";
16
+ import os from "os";
17
+
18
+ import { EngramDatabase } from "./storage/database.js";
19
+ import { KnowledgeGraph } from "./graph/knowledge-graph.js";
20
+ import { createRetriever } from "./retrieval/colbert.js";
21
+ import { HybridSearch } from "./retrieval/hybrid.js";
22
+
23
+ // ============ Configuration ============
24
+
25
+ const DB_PATH = process.env.ENGRAM_DB_PATH
26
+ ? path.resolve(process.env.ENGRAM_DB_PATH.replace("~", os.homedir()))
27
+ : path.join(os.homedir(), ".engram");
28
+
29
+ const DB_FILE = path.join(DB_PATH, "engram.db");
30
+
31
+ // ============ Initialize Components ============
32
+
33
+ let db: EngramDatabase;
34
+ let graph: KnowledgeGraph;
35
+ let search: HybridSearch;
36
+
37
+ async function initialize(): Promise<void> {
38
+ console.error(`[Engram] Initializing with database at ${DB_FILE}`);
39
+
40
+ db = new EngramDatabase(DB_FILE);
41
+ graph = new KnowledgeGraph(db);
42
+
43
+ const retriever = await createRetriever(DB_PATH);
44
+ search = new HybridSearch(db, graph, retriever);
45
+
46
+ // Rebuild index with existing memories
47
+ const stats = db.getStats();
48
+ if (stats.memories > 0) {
49
+ console.error(`[Engram] Indexing ${stats.memories} existing memories...`);
50
+ await search.rebuildIndex();
51
+ }
52
+
53
+ console.error(`[Engram] Ready. Stats: ${JSON.stringify(stats)}`);
54
+ }
55
+
56
+ // ============ MCP Server ============
57
+
58
+ const server = new Server(
59
+ {
60
+ name: "engram",
61
+ version: "0.1.0",
62
+ },
63
+ {
64
+ capabilities: {
65
+ tools: {},
66
+ },
67
+ }
68
+ );
69
+
70
+ // Tool definitions
71
+ const TOOLS = [
72
+ {
73
+ name: "remember",
74
+ description:
75
+ "Store a new memory. Automatically extracts entities and relationships for the knowledge graph.",
76
+ inputSchema: {
77
+ type: "object" as const,
78
+ properties: {
79
+ content: {
80
+ type: "string",
81
+ description: "The memory content to store",
82
+ },
83
+ source: {
84
+ type: "string",
85
+ description: "Source of the memory (e.g., 'conversation', 'note', 'import')",
86
+ default: "conversation",
87
+ },
88
+ importance: {
89
+ type: "number",
90
+ description: "Importance score from 0 to 1 (higher = more important)",
91
+ minimum: 0,
92
+ maximum: 1,
93
+ default: 0.5,
94
+ },
95
+ },
96
+ required: ["content"],
97
+ },
98
+ },
99
+ {
100
+ name: "recall",
101
+ description:
102
+ "Retrieve relevant memories using hybrid search (BM25 keywords + semantic + knowledge graph). Returns the most relevant memories for a query.",
103
+ inputSchema: {
104
+ type: "object" as const,
105
+ properties: {
106
+ query: {
107
+ type: "string",
108
+ description: "What to search for - can be a question, keywords, or natural language",
109
+ },
110
+ limit: {
111
+ type: "number",
112
+ description: "Maximum number of memories to return",
113
+ default: 5,
114
+ },
115
+ include_graph: {
116
+ type: "boolean",
117
+ description: "Whether to expand search using knowledge graph connections",
118
+ default: true,
119
+ },
120
+ },
121
+ required: ["query"],
122
+ },
123
+ },
124
+ {
125
+ name: "forget",
126
+ description: "Remove a specific memory by its ID",
127
+ inputSchema: {
128
+ type: "object" as const,
129
+ properties: {
130
+ id: {
131
+ type: "string",
132
+ description: "The memory ID to remove",
133
+ },
134
+ },
135
+ required: ["id"],
136
+ },
137
+ },
138
+ {
139
+ name: "create_entity",
140
+ description: "Create a new entity in the knowledge graph (person, place, concept, etc.)",
141
+ inputSchema: {
142
+ type: "object" as const,
143
+ properties: {
144
+ name: {
145
+ type: "string",
146
+ description: "Entity name (e.g., 'John', 'Paris', 'Machine Learning')",
147
+ },
148
+ type: {
149
+ type: "string",
150
+ enum: ["person", "place", "concept", "event", "organization"],
151
+ description: "Type of entity",
152
+ },
153
+ },
154
+ required: ["name", "type"],
155
+ },
156
+ },
157
+ {
158
+ name: "observe",
159
+ description: "Add an observation or fact about an entity",
160
+ inputSchema: {
161
+ type: "object" as const,
162
+ properties: {
163
+ entity: {
164
+ type: "string",
165
+ description: "Entity name to add observation to",
166
+ },
167
+ observation: {
168
+ type: "string",
169
+ description: "The fact or observation to record",
170
+ },
171
+ confidence: {
172
+ type: "number",
173
+ description: "Confidence in this observation (0-1)",
174
+ default: 1.0,
175
+ },
176
+ },
177
+ required: ["entity", "observation"],
178
+ },
179
+ },
180
+ {
181
+ name: "relate",
182
+ description: "Create a relationship between two entities in the knowledge graph",
183
+ inputSchema: {
184
+ type: "object" as const,
185
+ properties: {
186
+ from: {
187
+ type: "string",
188
+ description: "Source entity name",
189
+ },
190
+ to: {
191
+ type: "string",
192
+ description: "Target entity name",
193
+ },
194
+ relation: {
195
+ type: "string",
196
+ description: "Type of relationship (e.g., 'sibling', 'works_at', 'knows', 'located_in')",
197
+ },
198
+ },
199
+ required: ["from", "to", "relation"],
200
+ },
201
+ },
202
+ {
203
+ name: "query_entity",
204
+ description: "Get detailed information about an entity including all observations and relationships",
205
+ inputSchema: {
206
+ type: "object" as const,
207
+ properties: {
208
+ entity: {
209
+ type: "string",
210
+ description: "Entity name to query",
211
+ },
212
+ },
213
+ required: ["entity"],
214
+ },
215
+ },
216
+ {
217
+ name: "list_entities",
218
+ description: "List all entities, optionally filtered by type",
219
+ inputSchema: {
220
+ type: "object" as const,
221
+ properties: {
222
+ type: {
223
+ type: "string",
224
+ enum: ["person", "place", "concept", "event", "organization"],
225
+ description: "Filter by entity type (optional)",
226
+ },
227
+ limit: {
228
+ type: "number",
229
+ description: "Maximum number of entities to return",
230
+ default: 50,
231
+ },
232
+ },
233
+ },
234
+ },
235
+ {
236
+ name: "stats",
237
+ description: "Get memory statistics (counts of memories, entities, relations, observations)",
238
+ inputSchema: {
239
+ type: "object" as const,
240
+ properties: {},
241
+ },
242
+ },
243
+ ];
244
+
245
+ // List available tools
246
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
247
+ tools: TOOLS,
248
+ }));
249
+
250
+ // Handle tool calls
251
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
252
+ const { name, arguments: args } = request.params;
253
+
254
+ try {
255
+ switch (name) {
256
+ case "remember": {
257
+ const { content, source = "conversation", importance = 0.5 } = args as {
258
+ content: string;
259
+ source?: string;
260
+ importance?: number;
261
+ };
262
+
263
+ // Create memory
264
+ const memory = db.createMemory(content, source, importance);
265
+
266
+ // Index for semantic search
267
+ await search.indexMemory(memory);
268
+
269
+ // Extract and store entities/relationships
270
+ const { entities, observations } = graph.extractAndStore(content, memory.id);
271
+
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text" as const,
276
+ text: JSON.stringify({
277
+ success: true,
278
+ memory_id: memory.id,
279
+ entities_extracted: entities.map((e) => e.name),
280
+ observations_created: observations.length,
281
+ }, null, 2),
282
+ },
283
+ ],
284
+ };
285
+ }
286
+
287
+ case "recall": {
288
+ const { query, limit = 5, include_graph = true } = args as {
289
+ query: string;
290
+ limit?: number;
291
+ include_graph?: boolean;
292
+ };
293
+
294
+ const results = await search.search(query, {
295
+ limit,
296
+ includeGraph: include_graph,
297
+ });
298
+
299
+ const formatted = results.map((r) => ({
300
+ id: r.memory.id,
301
+ content: r.memory.content,
302
+ source: r.memory.source,
303
+ timestamp: r.memory.timestamp.toISOString(),
304
+ relevance_score: r.score.toFixed(4),
305
+ matched_via: Object.entries(r.sources)
306
+ .filter(([, v]) => v !== undefined)
307
+ .map(([k]) => k)
308
+ .join(", "),
309
+ }));
310
+
311
+ return {
312
+ content: [
313
+ {
314
+ type: "text" as const,
315
+ text: JSON.stringify({
316
+ query,
317
+ results: formatted,
318
+ count: formatted.length,
319
+ }, null, 2),
320
+ },
321
+ ],
322
+ };
323
+ }
324
+
325
+ case "forget": {
326
+ const { id } = args as { id: string };
327
+
328
+ const memory = db.getMemory(id);
329
+ if (!memory) {
330
+ return {
331
+ content: [
332
+ {
333
+ type: "text" as const,
334
+ text: JSON.stringify({ success: false, error: "Memory not found" }),
335
+ },
336
+ ],
337
+ };
338
+ }
339
+
340
+ // Remove from semantic index
341
+ await search.removeFromIndex(id);
342
+
343
+ // Delete from database
344
+ db.deleteMemory(id);
345
+
346
+ return {
347
+ content: [
348
+ {
349
+ type: "text" as const,
350
+ text: JSON.stringify({ success: true, deleted_id: id }),
351
+ },
352
+ ],
353
+ };
354
+ }
355
+
356
+ case "create_entity": {
357
+ const { name: entityName, type } = args as {
358
+ name: string;
359
+ type: "person" | "place" | "concept" | "event" | "organization";
360
+ };
361
+
362
+ const entity = graph.getOrCreateEntity(entityName, type);
363
+
364
+ return {
365
+ content: [
366
+ {
367
+ type: "text" as const,
368
+ text: JSON.stringify({
369
+ success: true,
370
+ entity: {
371
+ id: entity.id,
372
+ name: entity.name,
373
+ type: entity.type,
374
+ },
375
+ }, null, 2),
376
+ },
377
+ ],
378
+ };
379
+ }
380
+
381
+ case "observe": {
382
+ const { entity: entityName, observation, confidence = 1.0 } = args as {
383
+ entity: string;
384
+ observation: string;
385
+ confidence?: number;
386
+ };
387
+
388
+ // Ensure entity exists
389
+ const entity = graph.getOrCreateEntity(entityName, "person");
390
+
391
+ // Add observation
392
+ const obs = graph.addObservation(entity.id, observation, undefined, confidence);
393
+
394
+ return {
395
+ content: [
396
+ {
397
+ type: "text" as const,
398
+ text: JSON.stringify({
399
+ success: true,
400
+ entity: entity.name,
401
+ observation_id: obs.id,
402
+ }, null, 2),
403
+ },
404
+ ],
405
+ };
406
+ }
407
+
408
+ case "relate": {
409
+ const { from, to, relation } = args as {
410
+ from: string;
411
+ to: string;
412
+ relation: string;
413
+ };
414
+
415
+ // Ensure both entities exist
416
+ const fromEntity = graph.getOrCreateEntity(from, "person");
417
+ const toEntity = graph.getOrCreateEntity(to, "person");
418
+
419
+ // Create relation
420
+ const rel = graph.relate(fromEntity.id, toEntity.id, relation);
421
+
422
+ return {
423
+ content: [
424
+ {
425
+ type: "text" as const,
426
+ text: JSON.stringify({
427
+ success: true,
428
+ relation: {
429
+ id: rel.id,
430
+ from: fromEntity.name,
431
+ to: toEntity.name,
432
+ type: rel.type,
433
+ },
434
+ }, null, 2),
435
+ },
436
+ ],
437
+ };
438
+ }
439
+
440
+ case "query_entity": {
441
+ const { entity: entityName } = args as { entity: string };
442
+
443
+ const details = graph.getEntityDetails(entityName);
444
+
445
+ if (!details) {
446
+ return {
447
+ content: [
448
+ {
449
+ type: "text" as const,
450
+ text: JSON.stringify({ success: false, error: "Entity not found" }),
451
+ },
452
+ ],
453
+ };
454
+ }
455
+
456
+ return {
457
+ content: [
458
+ {
459
+ type: "text" as const,
460
+ text: JSON.stringify({
461
+ entity: {
462
+ id: details.id,
463
+ name: details.name,
464
+ type: details.type,
465
+ created_at: details.created_at.toISOString(),
466
+ },
467
+ observations: details.observations.map((o) => ({
468
+ content: o.content.substring(0, 200) + (o.content.length > 200 ? "..." : ""),
469
+ confidence: o.confidence,
470
+ valid_from: o.valid_from.toISOString(),
471
+ })),
472
+ relations_from: details.relationsFrom.map((r) => ({
473
+ type: r.type,
474
+ to: r.targetEntity.name,
475
+ })),
476
+ relations_to: details.relationsTo.map((r) => ({
477
+ type: r.type,
478
+ from: r.sourceEntity.name,
479
+ })),
480
+ }, null, 2),
481
+ },
482
+ ],
483
+ };
484
+ }
485
+
486
+ case "list_entities": {
487
+ const { type, limit = 50 } = args as {
488
+ type?: "person" | "place" | "concept" | "event" | "organization";
489
+ limit?: number;
490
+ };
491
+
492
+ const entities = graph.listEntities(type, limit);
493
+
494
+ return {
495
+ content: [
496
+ {
497
+ type: "text" as const,
498
+ text: JSON.stringify({
499
+ entities: entities.map((e) => ({
500
+ id: e.id,
501
+ name: e.name,
502
+ type: e.type,
503
+ })),
504
+ count: entities.length,
505
+ }, null, 2),
506
+ },
507
+ ],
508
+ };
509
+ }
510
+
511
+ case "stats": {
512
+ const stats = db.getStats();
513
+
514
+ return {
515
+ content: [
516
+ {
517
+ type: "text" as const,
518
+ text: JSON.stringify({
519
+ ...stats,
520
+ database_path: DB_FILE,
521
+ }, null, 2),
522
+ },
523
+ ],
524
+ };
525
+ }
526
+
527
+ default:
528
+ throw new Error(`Unknown tool: ${name}`);
529
+ }
530
+ } catch (error) {
531
+ const message = error instanceof Error ? error.message : String(error);
532
+ return {
533
+ content: [
534
+ {
535
+ type: "text" as const,
536
+ text: JSON.stringify({ success: false, error: message }),
537
+ },
538
+ ],
539
+ isError: true,
540
+ };
541
+ }
542
+ });
543
+
544
+ // ============ Main ============
545
+
546
+ async function main() {
547
+ await initialize();
548
+
549
+ const transport = new StdioServerTransport();
550
+ await server.connect(transport);
551
+
552
+ console.error("[Engram] MCP server running on stdio");
553
+ }
554
+
555
+ main().catch((error) => {
556
+ console.error("[Engram] Fatal error:", error);
557
+ process.exit(1);
558
+ });