@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.
- package/README.md +5 -4
- package/dist/index.js +108 -2
- package/dist/retrieval/hybrid.d.ts.map +1 -1
- package/dist/storage/database.d.ts.map +1 -1
- package/dist/web/server.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/index.ts +112 -2
- package/src/retrieval/hybrid.ts +19 -3
- package/src/storage/database.ts +41 -30
- package/src/web/server.ts +280 -0
- package/src/web/static/app.js +362 -0
- package/src/web/static/index.html +95 -0
- package/src/web/static/style.css +457 -0
package/src/storage/database.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
+
}
|