@199-bio/engram 0.1.0 → 0.3.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.
@@ -47,6 +47,7 @@ export interface Relation {
47
47
 
48
48
  export class EngramDatabase {
49
49
  private db: Database.Database;
50
+ private stmtCache: Map<string, Database.Statement> = new Map();
50
51
 
51
52
  constructor(dbPath: string) {
52
53
  // Ensure directory exists
@@ -56,8 +57,15 @@ export class EngramDatabase {
56
57
  }
57
58
 
58
59
  this.db = new Database(dbPath);
59
- this.db.pragma("journal_mode = WAL"); // Better concurrent access
60
+
61
+ // Performance optimizations - all improve speed with no quality trade-off
62
+ this.db.pragma("journal_mode = WAL"); // Better concurrent access
63
+ this.db.pragma("synchronous = NORMAL"); // Faster writes, WAL provides safety
64
+ this.db.pragma("cache_size = -64000"); // 64MB cache (negative = KB)
65
+ this.db.pragma("mmap_size = 268435456"); // 256MB memory-mapped I/O
66
+ this.db.pragma("temp_store = MEMORY"); // Keep temp tables in RAM
60
67
  this.db.pragma("foreign_keys = ON");
68
+
61
69
  this.initialize();
62
70
  }
63
71
 
@@ -165,8 +173,7 @@ export class EngramDatabase {
165
173
  }
166
174
 
167
175
  getMemory(id: string): Memory | null {
168
- const stmt = this.db.prepare("SELECT * FROM memories WHERE id = ?");
169
- const row = stmt.get(id) as Record<string, unknown> | undefined;
176
+ const row = this.stmt("SELECT * FROM memories WHERE id = ?").get(id) as Record<string, unknown> | undefined;
170
177
  return row ? this.rowToMemory(row) : null;
171
178
  }
172
179
 
@@ -198,35 +205,27 @@ export class EngramDatabase {
198
205
  }
199
206
 
200
207
  touchMemory(id: string): void {
201
- const stmt = this.db.prepare(`
202
- UPDATE memories
203
- SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP
204
- WHERE id = ?
205
- `);
206
- stmt.run(id);
208
+ this.stmt(`UPDATE memories SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP WHERE id = ?`).run(id);
207
209
  }
208
210
 
209
211
  getAllMemories(limit: number = 1000): Memory[] {
210
- const stmt = this.db.prepare("SELECT * FROM memories ORDER BY timestamp DESC LIMIT ?");
211
- const rows = stmt.all(limit) as Record<string, unknown>[];
212
+ const rows = this.stmt("SELECT * FROM memories ORDER BY timestamp DESC LIMIT ?").all(limit) as Record<string, unknown>[];
212
213
  return rows.map((row) => this.rowToMemory(row));
213
214
  }
214
215
 
215
216
  // ============ BM25 Search ============
216
217
 
217
218
  searchBM25(query: string, limit: number = 20): Array<Memory & { score: number }> {
218
- const stmt = this.db.prepare(`
219
+ // Escape special FTS5 characters and format query
220
+ const escapedQuery = this.escapeFTS5Query(query);
221
+ const rows = this.stmt(`
219
222
  SELECT m.*, bm25(memories_fts) as score
220
223
  FROM memories_fts fts
221
224
  JOIN memories m ON fts.rowid = m.rowid
222
225
  WHERE memories_fts MATCH ?
223
226
  ORDER BY score
224
227
  LIMIT ?
225
- `);
226
-
227
- // Escape special FTS5 characters and format query
228
- const escapedQuery = this.escapeFTS5Query(query);
229
- const rows = stmt.all(escapedQuery, limit) as Array<Record<string, unknown>>;
228
+ `).all(escapedQuery, limit) as Array<Record<string, unknown>>;
230
229
 
231
230
  return rows.map((row) => ({
232
231
  ...this.rowToMemory(row),
@@ -262,14 +261,12 @@ export class EngramDatabase {
262
261
  }
263
262
 
264
263
  getEntity(id: string): Entity | null {
265
- const stmt = this.db.prepare("SELECT * FROM entities WHERE id = ?");
266
- const row = stmt.get(id) as Record<string, unknown> | undefined;
264
+ const row = this.stmt("SELECT * FROM entities WHERE id = ?").get(id) as Record<string, unknown> | undefined;
267
265
  return row ? this.rowToEntity(row) : null;
268
266
  }
269
267
 
270
268
  findEntityByName(name: string): Entity | null {
271
- const stmt = this.db.prepare("SELECT * FROM entities WHERE LOWER(name) = LOWER(?)");
272
- const row = stmt.get(name) as Record<string, unknown> | undefined;
269
+ const row = this.stmt("SELECT * FROM entities WHERE LOWER(name) = LOWER(?)").get(name) as Record<string, unknown> | undefined;
273
270
  return row ? this.rowToEntity(row) : null;
274
271
  }
275
272
 
@@ -329,8 +326,7 @@ export class EngramDatabase {
329
326
  }
330
327
 
331
328
  getObservation(id: string): Observation | null {
332
- const stmt = this.db.prepare("SELECT * FROM observations WHERE id = ?");
333
- const row = stmt.get(id) as Record<string, unknown> | undefined;
329
+ const row = this.stmt("SELECT * FROM observations WHERE id = ?").get(id) as Record<string, unknown> | undefined;
334
330
  return row ? this.rowToObservation(row) : null;
335
331
  }
336
332
 
@@ -369,8 +365,7 @@ export class EngramDatabase {
369
365
  }
370
366
 
371
367
  getRelation(id: string): Relation | null {
372
- const stmt = this.db.prepare("SELECT * FROM relations WHERE id = ?");
373
- const row = stmt.get(id) as Record<string, unknown> | undefined;
368
+ const row = this.stmt("SELECT * FROM relations WHERE id = ?").get(id) as Record<string, unknown> | undefined;
374
369
  return row ? this.rowToRelation(row) : null;
375
370
  }
376
371
 
@@ -466,12 +461,16 @@ export class EngramDatabase {
466
461
  relations: number;
467
462
  observations: number;
468
463
  } {
469
- const memories = (this.db.prepare("SELECT COUNT(*) as count FROM memories").get() as { count: number }).count;
470
- const entities = (this.db.prepare("SELECT COUNT(*) as count FROM entities").get() as { count: number }).count;
471
- const relations = (this.db.prepare("SELECT COUNT(*) as count FROM relations").get() as { count: number }).count;
472
- const observations = (this.db.prepare("SELECT COUNT(*) as count FROM observations").get() as { count: number }).count;
464
+ // Single query for all stats - much faster than 4 separate queries
465
+ const row = this.stmt(`
466
+ SELECT
467
+ (SELECT COUNT(*) FROM memories) as memories,
468
+ (SELECT COUNT(*) FROM entities) as entities,
469
+ (SELECT COUNT(*) FROM relations) as relations,
470
+ (SELECT COUNT(*) FROM observations) as observations
471
+ `).get() as { memories: number; entities: number; relations: number; observations: number };
473
472
 
474
- return { memories, entities, relations, observations };
473
+ return row;
475
474
  }
476
475
 
477
476
  // ============ Utilities ============
@@ -480,6 +479,18 @@ export class EngramDatabase {
480
479
  this.db.close();
481
480
  }
482
481
 
482
+ /**
483
+ * Get a cached prepared statement - avoids re-parsing SQL
484
+ */
485
+ private stmt(sql: string): Database.Statement {
486
+ let cached = this.stmtCache.get(sql);
487
+ if (!cached) {
488
+ cached = this.db.prepare(sql);
489
+ this.stmtCache.set(sql, cached);
490
+ }
491
+ return cached;
492
+ }
493
+
483
494
  private rowToMemory(row: Record<string, unknown>): Memory {
484
495
  return {
485
496
  id: row.id as string,
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Engram Web Interface
3
+ * Local web server for browsing, searching, and editing memories
4
+ */
5
+
6
+ import http from "http";
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import { EngramDatabase } from "../storage/database.js";
11
+ import { KnowledgeGraph } from "../graph/knowledge-graph.js";
12
+ import { HybridSearch } from "../retrieval/hybrid.js";
13
+ import { ColBERTRetriever, SimpleRetriever } from "../retrieval/colbert.js";
14
+
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ const STATIC_DIR = path.join(__dirname, "..", "..", "src", "web", "static");
19
+
20
+ const MIME_TYPES: Record<string, string> = {
21
+ ".html": "text/html",
22
+ ".css": "text/css",
23
+ ".js": "application/javascript",
24
+ ".json": "application/json",
25
+ ".png": "image/png",
26
+ ".svg": "image/svg+xml",
27
+ };
28
+
29
+ interface WebServerOptions {
30
+ db: EngramDatabase;
31
+ graph: KnowledgeGraph;
32
+ search: HybridSearch;
33
+ port?: number;
34
+ }
35
+
36
+ export class EngramWebServer {
37
+ private server: http.Server | null = null;
38
+ private db: EngramDatabase;
39
+ private graph: KnowledgeGraph;
40
+ private search: HybridSearch;
41
+ private port: number;
42
+
43
+ constructor(options: WebServerOptions) {
44
+ this.db = options.db;
45
+ this.graph = options.graph;
46
+ this.search = options.search;
47
+ this.port = options.port || 3847;
48
+ }
49
+
50
+ async start(): Promise<string> {
51
+ if (this.server) {
52
+ return `http://localhost:${this.port}`;
53
+ }
54
+
55
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
56
+
57
+ return new Promise((resolve, reject) => {
58
+ this.server!.listen(this.port, () => {
59
+ const url = `http://localhost:${this.port}`;
60
+ console.error(`[Engram] Web interface running at ${url}`);
61
+ resolve(url);
62
+ });
63
+
64
+ this.server!.on("error", reject);
65
+ });
66
+ }
67
+
68
+ stop(): void {
69
+ if (this.server) {
70
+ this.server.close();
71
+ this.server = null;
72
+ }
73
+ }
74
+
75
+ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
76
+ const url = new URL(req.url || "/", `http://localhost:${this.port}`);
77
+ const pathname = url.pathname;
78
+
79
+ // CORS headers for local development
80
+ res.setHeader("Access-Control-Allow-Origin", "*");
81
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
82
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
83
+
84
+ if (req.method === "OPTIONS") {
85
+ res.writeHead(204);
86
+ res.end();
87
+ return;
88
+ }
89
+
90
+ try {
91
+ // API routes
92
+ if (pathname.startsWith("/api/")) {
93
+ await this.handleAPI(req, res, pathname, url);
94
+ return;
95
+ }
96
+
97
+ // Static files
98
+ await this.serveStatic(req, res, pathname);
99
+ } catch (error) {
100
+ console.error("[Engram Web] Error:", error);
101
+ res.writeHead(500, { "Content-Type": "application/json" });
102
+ res.end(JSON.stringify({ error: "Internal server error" }));
103
+ }
104
+ }
105
+
106
+ private async handleAPI(
107
+ req: http.IncomingMessage,
108
+ res: http.ServerResponse,
109
+ pathname: string,
110
+ url: URL
111
+ ): Promise<void> {
112
+ const method = req.method || "GET";
113
+ const body = method !== "GET" ? await this.parseBody(req) : null;
114
+
115
+ res.setHeader("Content-Type", "application/json");
116
+
117
+ // GET /api/stats
118
+ if (pathname === "/api/stats" && method === "GET") {
119
+ const stats = this.db.getStats();
120
+ res.end(JSON.stringify(stats));
121
+ return;
122
+ }
123
+
124
+ // GET /api/memories
125
+ if (pathname === "/api/memories" && method === "GET") {
126
+ const query = url.searchParams.get("q");
127
+ const limit = parseInt(url.searchParams.get("limit") || "50");
128
+
129
+ if (query) {
130
+ const results = await this.search.search(query, { limit });
131
+ res.end(JSON.stringify({
132
+ memories: results.map(r => ({
133
+ ...r.memory,
134
+ score: r.score,
135
+ sources: r.sources,
136
+ })),
137
+ }));
138
+ } else {
139
+ const memories = this.db.getAllMemories(limit);
140
+ res.end(JSON.stringify({ memories }));
141
+ }
142
+ return;
143
+ }
144
+
145
+ // POST /api/memories
146
+ if (pathname === "/api/memories" && method === "POST") {
147
+ const { content, source, importance } = body as any;
148
+ const memory = this.db.createMemory(content, source || "web", importance || 0.5);
149
+ await this.search.indexMemory(memory);
150
+ const { entities, observations } = this.graph.extractAndStore(content, memory.id);
151
+ res.writeHead(201);
152
+ res.end(JSON.stringify({ memory, entities_extracted: entities.length, observations_created: observations.length }));
153
+ return;
154
+ }
155
+
156
+ // PUT /api/memories/:id
157
+ const memoryMatch = pathname.match(/^\/api\/memories\/([a-f0-9-]+)$/);
158
+ if (memoryMatch && method === "PUT") {
159
+ const id = memoryMatch[1];
160
+ const { content, importance } = body as any;
161
+ const updated = this.db.updateMemory(id, { content, importance });
162
+ if (updated) {
163
+ res.end(JSON.stringify({ memory: updated }));
164
+ } else {
165
+ res.writeHead(404);
166
+ res.end(JSON.stringify({ error: "Memory not found" }));
167
+ }
168
+ return;
169
+ }
170
+
171
+ // DELETE /api/memories/:id
172
+ if (memoryMatch && method === "DELETE") {
173
+ const id = memoryMatch[1];
174
+ await this.search.removeFromIndex(id);
175
+ const deleted = this.db.deleteMemory(id);
176
+ res.end(JSON.stringify({ success: deleted }));
177
+ return;
178
+ }
179
+
180
+ // GET /api/entities
181
+ if (pathname === "/api/entities" && method === "GET") {
182
+ const type = url.searchParams.get("type") as any;
183
+ const limit = parseInt(url.searchParams.get("limit") || "100");
184
+ const entities = this.graph.listEntities(type || undefined, limit);
185
+ res.end(JSON.stringify({ entities }));
186
+ return;
187
+ }
188
+
189
+ // GET /api/entities/:name
190
+ const entityMatch = pathname.match(/^\/api\/entities\/(.+)$/);
191
+ if (entityMatch && method === "GET") {
192
+ const name = decodeURIComponent(entityMatch[1]);
193
+ const details = this.graph.getEntityDetails(name);
194
+ if (details) {
195
+ res.end(JSON.stringify(details));
196
+ } else {
197
+ res.writeHead(404);
198
+ res.end(JSON.stringify({ error: "Entity not found" }));
199
+ }
200
+ return;
201
+ }
202
+
203
+ // GET /api/graph
204
+ if (pathname === "/api/graph" && method === "GET") {
205
+ const entities = this.graph.listEntities(undefined, 500);
206
+ const nodes = entities.map(e => ({
207
+ id: e.id,
208
+ label: e.name,
209
+ type: e.type,
210
+ }));
211
+
212
+ // Get all relations
213
+ const edges: Array<{ from: string; to: string; label: string }> = [];
214
+ for (const entity of entities) {
215
+ const relations = this.db.getEntityRelations(entity.id, "from");
216
+ for (const rel of relations) {
217
+ edges.push({
218
+ from: rel.from_entity,
219
+ to: rel.to_entity,
220
+ label: rel.type,
221
+ });
222
+ }
223
+ }
224
+
225
+ res.end(JSON.stringify({ nodes, edges }));
226
+ return;
227
+ }
228
+
229
+ // 404 for unknown API routes
230
+ res.writeHead(404);
231
+ res.end(JSON.stringify({ error: "Not found" }));
232
+ }
233
+
234
+ private async serveStatic(
235
+ req: http.IncomingMessage,
236
+ res: http.ServerResponse,
237
+ pathname: string
238
+ ): Promise<void> {
239
+ // Default to index.html
240
+ if (pathname === "/" || pathname === "") {
241
+ pathname = "/index.html";
242
+ }
243
+
244
+ const filePath = path.join(STATIC_DIR, pathname);
245
+
246
+ // Security: prevent directory traversal
247
+ if (!filePath.startsWith(STATIC_DIR)) {
248
+ res.writeHead(403);
249
+ res.end("Forbidden");
250
+ return;
251
+ }
252
+
253
+ try {
254
+ const content = fs.readFileSync(filePath);
255
+ const ext = path.extname(filePath);
256
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
257
+
258
+ res.writeHead(200, { "Content-Type": contentType });
259
+ res.end(content);
260
+ } catch {
261
+ res.writeHead(404);
262
+ res.end("Not found");
263
+ }
264
+ }
265
+
266
+ private parseBody(req: http.IncomingMessage): Promise<unknown> {
267
+ return new Promise((resolve, reject) => {
268
+ let data = "";
269
+ req.on("data", (chunk) => (data += chunk));
270
+ req.on("end", () => {
271
+ try {
272
+ resolve(data ? JSON.parse(data) : {});
273
+ } catch (e) {
274
+ reject(e);
275
+ }
276
+ });
277
+ req.on("error", reject);
278
+ });
279
+ }
280
+ }