@199-bio/engram 0.2.0 → 0.3.1

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,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
+ }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Engram Web Interface
3
+ * Vanilla JavaScript - no build step required
4
+ */
5
+
6
+ const API_BASE = '';
7
+
8
+ // State
9
+ let currentView = 'memories';
10
+ let editingMemoryId = null;
11
+
12
+ // DOM Elements
13
+ const views = {
14
+ memories: document.getElementById('memories-view'),
15
+ entities: document.getElementById('entities-view'),
16
+ graph: document.getElementById('graph-view'),
17
+ };
18
+
19
+ const statsEl = document.getElementById('stats');
20
+ const memoriesList = document.getElementById('memories-list');
21
+ const entitiesList = document.getElementById('entities-list');
22
+ const graphContainer = document.getElementById('graph-container');
23
+ const searchInput = document.getElementById('search-input');
24
+ const entityTypeFilter = document.getElementById('entity-type-filter');
25
+
26
+ // Modal elements
27
+ const modal = document.getElementById('modal');
28
+ const modalTitle = document.getElementById('modal-title');
29
+ const modalForm = document.getElementById('modal-form');
30
+ const modalContentInput = document.getElementById('modal-content-input');
31
+ const modalSource = document.getElementById('modal-source');
32
+ const modalImportance = document.getElementById('modal-importance');
33
+ const importanceValue = document.getElementById('importance-value');
34
+
35
+ const entityModal = document.getElementById('entity-modal');
36
+ const entityModalTitle = document.getElementById('entity-modal-title');
37
+ const entityModalBody = document.getElementById('entity-modal-body');
38
+
39
+ // API helpers
40
+ async function api(path, options = {}) {
41
+ const res = await fetch(`${API_BASE}${path}`, {
42
+ headers: { 'Content-Type': 'application/json' },
43
+ ...options,
44
+ body: options.body ? JSON.stringify(options.body) : undefined,
45
+ });
46
+ return res.json();
47
+ }
48
+
49
+ // Format date
50
+ function formatDate(dateStr) {
51
+ const date = new Date(dateStr);
52
+ return date.toLocaleDateString('en-GB', {
53
+ day: 'numeric',
54
+ month: 'short',
55
+ year: 'numeric',
56
+ hour: '2-digit',
57
+ minute: '2-digit',
58
+ });
59
+ }
60
+
61
+ // Load stats
62
+ async function loadStats() {
63
+ const stats = await api('/api/stats');
64
+ statsEl.textContent = `${stats.memories} memories \u00b7 ${stats.entities} entities \u00b7 ${stats.relations} relations`;
65
+ }
66
+
67
+ // Load memories
68
+ async function loadMemories(query = '') {
69
+ const path = query ? `/api/memories?q=${encodeURIComponent(query)}` : '/api/memories';
70
+ const data = await api(path);
71
+
72
+ if (data.memories.length === 0) {
73
+ memoriesList.innerHTML = '<div class="empty-state">No memories found</div>';
74
+ return;
75
+ }
76
+
77
+ memoriesList.innerHTML = data.memories.map(m => `
78
+ <div class="list-item memory-item" data-id="${m.id}">
79
+ <div class="content">${escapeHtml(m.content)}</div>
80
+ <div class="meta">
81
+ <span>${formatDate(m.timestamp)}</span>
82
+ <span>${m.source}</span>
83
+ <span>importance: ${m.importance}</span>
84
+ ${m.score ? `<span class="score">${m.score.toFixed(4)}</span>` : ''}
85
+ </div>
86
+ <div class="actions">
87
+ <button class="edit-btn" data-id="${m.id}">Edit</button>
88
+ <button class="delete-btn" data-id="${m.id}">Delete</button>
89
+ </div>
90
+ </div>
91
+ `).join('');
92
+
93
+ // Attach event listeners
94
+ memoriesList.querySelectorAll('.edit-btn').forEach(btn => {
95
+ btn.addEventListener('click', (e) => {
96
+ e.stopPropagation();
97
+ editMemory(btn.dataset.id);
98
+ });
99
+ });
100
+
101
+ memoriesList.querySelectorAll('.delete-btn').forEach(btn => {
102
+ btn.addEventListener('click', (e) => {
103
+ e.stopPropagation();
104
+ deleteMemory(btn.dataset.id);
105
+ });
106
+ });
107
+ }
108
+
109
+ // Load entities
110
+ async function loadEntities(type = '') {
111
+ const path = type ? `/api/entities?type=${type}` : '/api/entities';
112
+ const data = await api(path);
113
+
114
+ if (data.entities.length === 0) {
115
+ entitiesList.innerHTML = '<div class="empty-state">No entities found</div>';
116
+ return;
117
+ }
118
+
119
+ entitiesList.innerHTML = data.entities.map(e => `
120
+ <div class="list-item entity-item" data-name="${escapeHtml(e.name)}">
121
+ <div class="name">${escapeHtml(e.name)}</div>
122
+ <div class="type">${e.type}</div>
123
+ </div>
124
+ `).join('');
125
+
126
+ // Attach event listeners
127
+ entitiesList.querySelectorAll('.entity-item').forEach(item => {
128
+ item.addEventListener('click', () => {
129
+ showEntityDetails(item.dataset.name);
130
+ });
131
+ });
132
+ }
133
+
134
+ // Show entity details
135
+ async function showEntityDetails(name) {
136
+ const data = await api(`/api/entities/${encodeURIComponent(name)}`);
137
+
138
+ entityModalTitle.textContent = data.name;
139
+
140
+ let html = `<p><strong>Type:</strong> ${data.type}</p>`;
141
+
142
+ if (data.observations && data.observations.length > 0) {
143
+ html += `<h3>Observations</h3><ul>`;
144
+ data.observations.forEach(o => {
145
+ html += `<li>${escapeHtml(o.content)}</li>`;
146
+ });
147
+ html += `</ul>`;
148
+ }
149
+
150
+ if (data.relationsFrom && data.relationsFrom.length > 0) {
151
+ html += `<h3>Relationships (outgoing)</h3><ul>`;
152
+ data.relationsFrom.forEach(r => {
153
+ html += `<li>${r.type} \u2192 ${escapeHtml(r.targetEntity?.name || r.to)}</li>`;
154
+ });
155
+ html += `</ul>`;
156
+ }
157
+
158
+ if (data.relationsTo && data.relationsTo.length > 0) {
159
+ html += `<h3>Relationships (incoming)</h3><ul>`;
160
+ data.relationsTo.forEach(r => {
161
+ html += `<li>${escapeHtml(r.sourceEntity?.name || r.from)} \u2192 ${r.type}</li>`;
162
+ });
163
+ html += `</ul>`;
164
+ }
165
+
166
+ entityModalBody.innerHTML = html;
167
+ entityModal.classList.remove('hidden');
168
+ }
169
+
170
+ // Load graph
171
+ async function loadGraph() {
172
+ const data = await api('/api/graph');
173
+
174
+ // Simple visualization using CSS
175
+ if (data.nodes.length === 0) {
176
+ graphContainer.innerHTML = '<div class="empty-state">No entities in graph</div>';
177
+ return;
178
+ }
179
+
180
+ // Create a simple text-based visualization
181
+ let html = '<div style="padding: 2rem; font-size: 0.875rem;">';
182
+ html += '<p style="margin-bottom: 1rem; color: var(--text-muted);">Knowledge graph visualization. Click entities to see details.</p>';
183
+
184
+ // Group by type
185
+ const byType = {};
186
+ data.nodes.forEach(n => {
187
+ if (!byType[n.type]) byType[n.type] = [];
188
+ byType[n.type].push(n);
189
+ });
190
+
191
+ for (const [type, nodes] of Object.entries(byType)) {
192
+ html += `<div style="margin-bottom: 1.5rem;">`;
193
+ html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">${type}</h3>`;
194
+ html += `<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">`;
195
+ nodes.forEach(n => {
196
+ html += `<span class="graph-node" data-name="${escapeHtml(n.label)}" style="padding: 0.375rem 0.75rem; background: var(--bg-tertiary); cursor: pointer;">${escapeHtml(n.label)}</span>`;
197
+ });
198
+ html += `</div></div>`;
199
+ }
200
+
201
+ if (data.edges.length > 0) {
202
+ html += `<div style="margin-top: 2rem;">`;
203
+ html += `<h3 style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">Relationships</h3>`;
204
+ html += `<ul style="list-style: none;">`;
205
+ data.edges.forEach(e => {
206
+ const fromNode = data.nodes.find(n => n.id === e.from);
207
+ const toNode = data.nodes.find(n => n.id === e.to);
208
+ if (fromNode && toNode) {
209
+ html += `<li style="padding: 0.25rem 0; color: var(--text-secondary);">${escapeHtml(fromNode.label)} <span style="color: var(--accent);">\u2192 ${e.label} \u2192</span> ${escapeHtml(toNode.label)}</li>`;
210
+ }
211
+ });
212
+ html += `</ul></div>`;
213
+ }
214
+
215
+ html += '</div>';
216
+ graphContainer.innerHTML = html;
217
+
218
+ // Attach click handlers
219
+ graphContainer.querySelectorAll('.graph-node').forEach(node => {
220
+ node.addEventListener('click', () => {
221
+ showEntityDetails(node.dataset.name);
222
+ });
223
+ });
224
+ }
225
+
226
+ // Edit memory
227
+ async function editMemory(id) {
228
+ const data = await api('/api/memories');
229
+ const memory = data.memories.find(m => m.id === id);
230
+ if (!memory) return;
231
+
232
+ editingMemoryId = id;
233
+ modalTitle.textContent = 'Edit Memory';
234
+ modalContentInput.value = memory.content;
235
+ modalSource.value = memory.source;
236
+ modalImportance.value = memory.importance;
237
+ importanceValue.textContent = memory.importance;
238
+ modal.classList.remove('hidden');
239
+ }
240
+
241
+ // Delete memory
242
+ async function deleteMemory(id) {
243
+ if (!confirm('Delete this memory?')) return;
244
+
245
+ await api(`/api/memories/${id}`, { method: 'DELETE' });
246
+ await loadMemories(searchInput.value);
247
+ await loadStats();
248
+ }
249
+
250
+ // Save memory
251
+ async function saveMemory() {
252
+ const content = modalContentInput.value.trim();
253
+ if (!content) return;
254
+
255
+ const body = {
256
+ content,
257
+ source: modalSource.value || 'web',
258
+ importance: parseFloat(modalImportance.value),
259
+ };
260
+
261
+ if (editingMemoryId) {
262
+ await api(`/api/memories/${editingMemoryId}`, { method: 'PUT', body });
263
+ } else {
264
+ await api('/api/memories', { method: 'POST', body });
265
+ }
266
+
267
+ closeModal();
268
+ await loadMemories(searchInput.value);
269
+ await loadStats();
270
+ }
271
+
272
+ // Close modal
273
+ function closeModal() {
274
+ modal.classList.add('hidden');
275
+ editingMemoryId = null;
276
+ modalContentInput.value = '';
277
+ modalSource.value = 'web';
278
+ modalImportance.value = '0.5';
279
+ importanceValue.textContent = '0.5';
280
+ }
281
+
282
+ // Escape HTML
283
+ function escapeHtml(str) {
284
+ if (!str) return '';
285
+ return str
286
+ .replace(/&/g, '&amp;')
287
+ .replace(/</g, '&lt;')
288
+ .replace(/>/g, '&gt;')
289
+ .replace(/"/g, '&quot;')
290
+ .replace(/'/g, '&#039;');
291
+ }
292
+
293
+ // Switch view
294
+ function switchView(view) {
295
+ currentView = view;
296
+
297
+ // Update nav buttons
298
+ document.querySelectorAll('.nav-btn').forEach(btn => {
299
+ btn.classList.toggle('active', btn.dataset.view === view);
300
+ });
301
+
302
+ // Update views
303
+ Object.entries(views).forEach(([name, el]) => {
304
+ el.classList.toggle('active', name === view);
305
+ });
306
+
307
+ // Load data for view
308
+ if (view === 'memories') loadMemories(searchInput.value);
309
+ if (view === 'entities') loadEntities(entityTypeFilter.value);
310
+ if (view === 'graph') loadGraph();
311
+ }
312
+
313
+ // Event listeners
314
+ document.querySelectorAll('.nav-btn').forEach(btn => {
315
+ btn.addEventListener('click', () => switchView(btn.dataset.view));
316
+ });
317
+
318
+ document.getElementById('search-btn').addEventListener('click', () => {
319
+ loadMemories(searchInput.value);
320
+ });
321
+
322
+ searchInput.addEventListener('keypress', (e) => {
323
+ if (e.key === 'Enter') loadMemories(searchInput.value);
324
+ });
325
+
326
+ entityTypeFilter.addEventListener('change', () => {
327
+ loadEntities(entityTypeFilter.value);
328
+ });
329
+
330
+ document.getElementById('add-memory-btn').addEventListener('click', () => {
331
+ editingMemoryId = null;
332
+ modalTitle.textContent = 'Add Memory';
333
+ modal.classList.remove('hidden');
334
+ });
335
+
336
+ document.getElementById('modal-cancel').addEventListener('click', closeModal);
337
+
338
+ modalForm.addEventListener('submit', (e) => {
339
+ e.preventDefault();
340
+ saveMemory();
341
+ });
342
+
343
+ modalImportance.addEventListener('input', () => {
344
+ importanceValue.textContent = modalImportance.value;
345
+ });
346
+
347
+ document.getElementById('entity-modal-close').addEventListener('click', () => {
348
+ entityModal.classList.add('hidden');
349
+ });
350
+
351
+ // Close modals on backdrop click
352
+ modal.addEventListener('click', (e) => {
353
+ if (e.target === modal) closeModal();
354
+ });
355
+
356
+ entityModal.addEventListener('click', (e) => {
357
+ if (e.target === entityModal) entityModal.classList.add('hidden');
358
+ });
359
+
360
+ // Initialize
361
+ loadStats();
362
+ loadMemories();