@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,499 @@
1
+ import Fastify from 'fastify';
2
+ import { loadConfig, getDatabasePath, getModelsPath } from '../config/index.js';
3
+ import { initDatabase, createMemory, getMemory, deleteMemory, listMemories, getStats } from '../memory/store.js';
4
+ import { recallMemories } from '../memory/recall.js';
5
+ import { consolidate, getConflicts } from '../memory/consolidate.js';
6
+ import { validateContent } from '../extract/secrets.js';
7
+ import { extractMemory } from '../extract/rules.js';
8
+ import { exportToStatic } from '../export/static.js';
9
+ import * as logger from '../utils/logger.js';
10
+
11
+ /**
12
+ * Create and configure the Fastify REST API server
13
+ * @param {Object} config - Engram configuration
14
+ * @returns {Object} Fastify instance
15
+ */
16
+ export function createRESTServer(config) {
17
+ const fastify = Fastify({
18
+ logger: false, // Use our own logger
19
+ trustProxy: true
20
+ });
21
+
22
+ // Initialize database
23
+ const db = initDatabase(getDatabasePath(config));
24
+ const modelsPath = getModelsPath(config);
25
+
26
+ // CORS support
27
+ fastify.addHook('onRequest', async (request, reply) => {
28
+ reply.header('Access-Control-Allow-Origin', '*');
29
+ reply.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
30
+ reply.header('Access-Control-Allow-Headers', 'Content-Type');
31
+ });
32
+
33
+ // Handle OPTIONS requests
34
+ fastify.options('/*', async (request, reply) => {
35
+ reply.code(204).send();
36
+ });
37
+
38
+ // Health check endpoint
39
+ fastify.get('/health', async (request, reply) => {
40
+ return {
41
+ status: 'healthy',
42
+ timestamp: new Date().toISOString(),
43
+ uptime: process.uptime()
44
+ };
45
+ });
46
+
47
+ // System status endpoint
48
+ fastify.get('/api/status', async (request, reply) => {
49
+ try {
50
+ const stats = getStats(db);
51
+
52
+ // Get model info
53
+ let modelInfo;
54
+ try {
55
+ const { getModelInfo } = await import('../embed/index.js');
56
+ modelInfo = getModelInfo(modelsPath);
57
+ } catch (error) {
58
+ modelInfo = {
59
+ name: 'unknown',
60
+ available: false,
61
+ error: error.message
62
+ };
63
+ }
64
+
65
+ return {
66
+ status: 'ok',
67
+ memory: {
68
+ total: stats.total,
69
+ withEmbeddings: stats.withEmbeddings,
70
+ byCategory: stats.byCategory,
71
+ byNamespace: stats.byNamespace
72
+ },
73
+ model: {
74
+ name: modelInfo.name,
75
+ available: modelInfo.available,
76
+ cached: modelInfo.cached,
77
+ size: modelInfo.sizeMB,
78
+ path: modelInfo.path
79
+ },
80
+ config: {
81
+ dataDir: config.dataDir,
82
+ defaultNamespace: config.defaults.namespace,
83
+ recallLimit: config.defaults.recallLimit,
84
+ secretDetection: config.security.secretDetection
85
+ }
86
+ };
87
+ } catch (error) {
88
+ logger.error('Status endpoint error', { error: error.message });
89
+ reply.code(500);
90
+ return { error: error.message };
91
+ }
92
+ });
93
+
94
+ // Create memory endpoint
95
+ fastify.post('/api/memories', async (request, reply) => {
96
+ try {
97
+ const { content, category, entity, confidence, namespace, tags } = request.body;
98
+
99
+ if (!content) {
100
+ reply.code(400);
101
+ return { error: 'Content is required' };
102
+ }
103
+
104
+ // Validate content for secrets
105
+ const validation = validateContent(content, {
106
+ autoRedact: config.security?.secretDetection !== false
107
+ });
108
+
109
+ if (!validation.valid) {
110
+ reply.code(400);
111
+ return {
112
+ error: 'Cannot store memory',
113
+ details: validation.errors,
114
+ warnings: validation.warnings
115
+ };
116
+ }
117
+
118
+ // Auto-extract category and entity if not provided
119
+ let memoryData = {
120
+ content: validation.content,
121
+ category: category || 'fact',
122
+ entity: entity,
123
+ confidence: confidence !== undefined ? confidence : 0.8,
124
+ namespace: namespace || 'default',
125
+ tags: tags || [],
126
+ source: 'api'
127
+ };
128
+
129
+ if (!entity || !category) {
130
+ const extracted = extractMemory(validation.content, {
131
+ source: 'api',
132
+ namespace: namespace || 'default'
133
+ });
134
+
135
+ if (!entity) {
136
+ memoryData.entity = extracted.entity;
137
+ }
138
+ if (!category) {
139
+ memoryData.category = extracted.category;
140
+ }
141
+ }
142
+
143
+ // Generate embedding
144
+ try {
145
+ const { generateEmbedding } = await import('../embed/index.js');
146
+ const embedding = await generateEmbedding(validation.content, modelsPath);
147
+ memoryData.embedding = embedding;
148
+ } catch (error) {
149
+ logger.warn('Failed to generate embedding', { error: error.message });
150
+ }
151
+
152
+ // Store memory
153
+ const memory = createMemory(db, memoryData);
154
+
155
+ logger.info('Memory created via API', { id: memory.id, category: memory.category });
156
+
157
+ return {
158
+ success: true,
159
+ memory: {
160
+ id: memory.id,
161
+ content: memory.content,
162
+ category: memory.category,
163
+ entity: memory.entity,
164
+ confidence: memory.confidence,
165
+ namespace: memory.namespace,
166
+ tags: memory.tags,
167
+ createdAt: memory.created_at
168
+ },
169
+ warnings: validation.warnings
170
+ };
171
+ } catch (error) {
172
+ logger.error('Create memory error', { error: error.message });
173
+ reply.code(500);
174
+ return { error: error.message };
175
+ }
176
+ });
177
+
178
+ // List memories endpoint
179
+ fastify.get('/api/memories', async (request, reply) => {
180
+ try {
181
+ const { limit = 50, offset = 0, category, namespace } = request.query;
182
+
183
+ const memories = listMemories(db, {
184
+ limit: parseInt(limit),
185
+ offset: parseInt(offset),
186
+ category,
187
+ namespace
188
+ });
189
+
190
+ return {
191
+ success: true,
192
+ memories: memories.map(m => ({
193
+ id: m.id,
194
+ content: m.content,
195
+ category: m.category,
196
+ entity: m.entity,
197
+ confidence: m.confidence,
198
+ namespace: m.namespace,
199
+ tags: m.tags,
200
+ accessCount: m.access_count,
201
+ createdAt: m.created_at,
202
+ lastAccessed: m.last_accessed
203
+ })),
204
+ pagination: {
205
+ limit: parseInt(limit),
206
+ offset: parseInt(offset),
207
+ total: memories.length
208
+ }
209
+ };
210
+ } catch (error) {
211
+ logger.error('List memories error', { error: error.message });
212
+ reply.code(500);
213
+ return { error: error.message };
214
+ }
215
+ });
216
+
217
+ // Search/recall memories endpoint
218
+ fastify.post('/api/memories/search', async (request, reply) => {
219
+ try {
220
+ const { query, limit = 5, category, namespace, threshold = 0.3 } = request.body;
221
+
222
+ if (!query) {
223
+ reply.code(400);
224
+ return { error: 'Query is required' };
225
+ }
226
+
227
+ const memories = await recallMemories(
228
+ db,
229
+ query,
230
+ { limit, category, namespace, threshold },
231
+ modelsPath
232
+ );
233
+
234
+ return {
235
+ success: true,
236
+ query,
237
+ memories: memories.map(m => ({
238
+ id: m.id,
239
+ content: m.content,
240
+ category: m.category,
241
+ entity: m.entity,
242
+ confidence: m.confidence,
243
+ namespace: m.namespace,
244
+ tags: m.tags,
245
+ score: m.score,
246
+ scoreBreakdown: m.scoreBreakdown,
247
+ accessCount: m.access_count,
248
+ createdAt: m.created_at,
249
+ lastAccessed: m.last_accessed
250
+ }))
251
+ };
252
+ } catch (error) {
253
+ logger.error('Search memories error', { error: error.message });
254
+ reply.code(500);
255
+ return { error: error.message };
256
+ }
257
+ });
258
+
259
+ // Get single memory endpoint
260
+ fastify.get('/api/memories/:id', async (request, reply) => {
261
+ try {
262
+ const { id } = request.params;
263
+ const memory = getMemory(db, id);
264
+
265
+ if (!memory) {
266
+ reply.code(404);
267
+ return { error: 'Memory not found' };
268
+ }
269
+
270
+ return {
271
+ success: true,
272
+ memory: {
273
+ id: memory.id,
274
+ content: memory.content,
275
+ category: memory.category,
276
+ entity: memory.entity,
277
+ confidence: memory.confidence,
278
+ namespace: memory.namespace,
279
+ tags: memory.tags,
280
+ accessCount: memory.access_count,
281
+ decayRate: memory.decay_rate,
282
+ createdAt: memory.created_at,
283
+ updatedAt: memory.updated_at,
284
+ lastAccessed: memory.last_accessed,
285
+ hasEmbedding: !!memory.embedding
286
+ }
287
+ };
288
+ } catch (error) {
289
+ logger.error('Get memory error', { error: error.message });
290
+ reply.code(500);
291
+ return { error: error.message };
292
+ }
293
+ });
294
+
295
+ // Delete memory endpoint
296
+ fastify.delete('/api/memories/:id', async (request, reply) => {
297
+ try {
298
+ const { id } = request.params;
299
+
300
+ // Check if memory exists
301
+ const memory = getMemory(db, id);
302
+ if (!memory) {
303
+ reply.code(404);
304
+ return { error: 'Memory not found' };
305
+ }
306
+
307
+ // Delete the memory
308
+ const deleted = deleteMemory(db, id);
309
+
310
+ if (deleted) {
311
+ logger.info('Memory deleted via API', { id });
312
+ return {
313
+ success: true,
314
+ message: 'Memory deleted successfully',
315
+ deletedMemory: {
316
+ id: memory.id,
317
+ content: memory.content
318
+ }
319
+ };
320
+ } else {
321
+ reply.code(500);
322
+ return { error: 'Failed to delete memory' };
323
+ }
324
+ } catch (error) {
325
+ logger.error('Delete memory error', { error: error.message });
326
+ reply.code(500);
327
+ return { error: error.message };
328
+ }
329
+ });
330
+
331
+ // Consolidate endpoint
332
+ fastify.post('/api/consolidate', async (request, reply) => {
333
+ try {
334
+ const {
335
+ detectDuplicates = true,
336
+ detectContradictions = true,
337
+ applyDecay = true,
338
+ cleanupStale = false
339
+ } = request.body || {};
340
+
341
+ const results = await consolidate(db, {
342
+ detectDuplicates,
343
+ detectContradictions,
344
+ applyDecay,
345
+ cleanupStale
346
+ });
347
+
348
+ logger.info('Consolidation completed via API', results);
349
+
350
+ return {
351
+ success: true,
352
+ results: {
353
+ duplicatesRemoved: results.duplicatesRemoved,
354
+ contradictionsDetected: results.contradictionsDetected,
355
+ memoriesDecayed: results.memoriesDecayed,
356
+ staleMemoriesCleaned: results.staleMemoriesCleaned,
357
+ duration: results.duration
358
+ }
359
+ };
360
+ } catch (error) {
361
+ logger.error('Consolidate error', { error: error.message });
362
+ reply.code(500);
363
+ return { error: error.message };
364
+ }
365
+ });
366
+
367
+ // Get conflicts endpoint
368
+ fastify.get('/api/conflicts', async (request, reply) => {
369
+ try {
370
+ const conflicts = getConflicts(db);
371
+
372
+ return {
373
+ success: true,
374
+ conflicts: conflicts.map(conflict => ({
375
+ conflictId: conflict.conflictId,
376
+ memories: conflict.memories.map(m => ({
377
+ id: m.id,
378
+ content: m.content,
379
+ category: m.category,
380
+ entity: m.entity,
381
+ confidence: m.confidence,
382
+ createdAt: m.created_at
383
+ }))
384
+ }))
385
+ };
386
+ } catch (error) {
387
+ logger.error('Get conflicts error', { error: error.message });
388
+ reply.code(500);
389
+ return { error: error.message };
390
+ }
391
+ });
392
+
393
+ // Get installation info endpoint
394
+ fastify.get('/api/installation-info', async (request, reply) => {
395
+ try {
396
+ const path = await import('path');
397
+ const { fileURLToPath } = await import('url');
398
+ const fs = await import('fs');
399
+
400
+ // Determine installation path
401
+ const __filename = fileURLToPath(import.meta.url);
402
+ const __dirname = path.dirname(__filename);
403
+ const installationPath = path.resolve(__dirname, '../../bin/engram.js');
404
+
405
+ // Verify the path exists
406
+ const exists = fs.existsSync(installationPath);
407
+
408
+ return {
409
+ success: true,
410
+ installation: {
411
+ binPath: installationPath,
412
+ exists,
413
+ platform: process.platform,
414
+ nodeVersion: process.version
415
+ }
416
+ };
417
+ } catch (error) {
418
+ logger.error('Installation info error', { error: error.message });
419
+ reply.code(500);
420
+ return { error: error.message };
421
+ }
422
+ });
423
+
424
+ // Export to static context endpoint
425
+ fastify.post('/api/export/static', async (request, reply) => {
426
+ try {
427
+ const {
428
+ namespace,
429
+ format = 'markdown',
430
+ categories,
431
+ min_confidence = 0.5,
432
+ min_access = 0,
433
+ include_low_feedback = false,
434
+ group_by = 'category',
435
+ header,
436
+ footer
437
+ } = request.body;
438
+
439
+ if (!namespace) {
440
+ reply.code(400);
441
+ return { error: 'Namespace is required' };
442
+ }
443
+
444
+ const result = exportToStatic(db, {
445
+ namespace,
446
+ format,
447
+ categories,
448
+ minConfidence: min_confidence,
449
+ minAccess: min_access,
450
+ includeLowFeedback: include_low_feedback,
451
+ groupBy: group_by,
452
+ header,
453
+ footer
454
+ });
455
+
456
+ return {
457
+ success: true,
458
+ content: result.content,
459
+ filename: result.filename,
460
+ stats: result.stats
461
+ };
462
+ } catch (error) {
463
+ logger.error('Export error', { error: error.message });
464
+ reply.code(500);
465
+ return { error: error.message };
466
+ }
467
+ });
468
+
469
+ // Cleanup on shutdown
470
+ fastify.addHook('onClose', async (instance) => {
471
+ if (db) {
472
+ db.close();
473
+ logger.info('Database connection closed');
474
+ }
475
+ });
476
+
477
+ return fastify;
478
+ }
479
+
480
+ /**
481
+ * Start the REST API server
482
+ * @param {Object} config - Engram configuration
483
+ * @param {number} port - Port to listen on
484
+ * @returns {Promise<Object>} Running Fastify instance
485
+ */
486
+ export async function startRESTServer(config, port = 3838) {
487
+ try {
488
+ const fastify = createRESTServer(config);
489
+
490
+ await fastify.listen({ port, host: '0.0.0.0' });
491
+
492
+ logger.info('REST API server started', { port, url: `http://localhost:${port}` });
493
+
494
+ return fastify;
495
+ } catch (error) {
496
+ logger.error('Failed to start REST server', { error: error.message });
497
+ throw error;
498
+ }
499
+ }
@@ -0,0 +1,9 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ /**
4
+ * Generate a new UUIDv4
5
+ * @returns {string} A new UUID
6
+ */
7
+ export function generateId() {
8
+ return randomUUID();
9
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Log levels
3
+ */
4
+ export const LOG_LEVELS = {
5
+ DEBUG: 0,
6
+ INFO: 1,
7
+ WARN: 2,
8
+ ERROR: 3
9
+ };
10
+
11
+ /**
12
+ * Current log level (defaults to INFO)
13
+ */
14
+ let currentLevel = LOG_LEVELS.INFO;
15
+
16
+ /**
17
+ * Set the log level
18
+ * @param {number} level - Log level from LOG_LEVELS
19
+ */
20
+ export function setLogLevel(level) {
21
+ currentLevel = level;
22
+ }
23
+
24
+ /**
25
+ * Format a log message
26
+ * @param {string} level - Log level name
27
+ * @param {string} message - Log message
28
+ * @param {Object} [meta] - Optional metadata
29
+ * @returns {string} Formatted log message
30
+ */
31
+ function formatMessage(level, message, meta) {
32
+ const timestamp = new Date().toISOString();
33
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
34
+ return `[${timestamp}] ${level}: ${message}${metaStr}`;
35
+ }
36
+
37
+ /**
38
+ * Log a debug message
39
+ * @param {string} message - Log message
40
+ * @param {Object} [meta] - Optional metadata
41
+ */
42
+ export function debug(message, meta) {
43
+ if (currentLevel <= LOG_LEVELS.DEBUG) {
44
+ console.error(formatMessage('DEBUG', message, meta));
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Log an info message
50
+ * @param {string} message - Log message
51
+ * @param {Object} [meta] - Optional metadata
52
+ */
53
+ export function info(message, meta) {
54
+ if (currentLevel <= LOG_LEVELS.INFO) {
55
+ console.error(formatMessage('INFO', message, meta));
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Log a warning message
61
+ * @param {string} message - Log message
62
+ * @param {Object} [meta] - Optional metadata
63
+ */
64
+ export function warn(message, meta) {
65
+ if (currentLevel <= LOG_LEVELS.WARN) {
66
+ console.warn(formatMessage('WARN', message, meta));
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Log an error message
72
+ * @param {string} message - Log message
73
+ * @param {Object} [meta] - Optional metadata
74
+ */
75
+ export function error(message, meta) {
76
+ if (currentLevel <= LOG_LEVELS.ERROR) {
77
+ console.error(formatMessage('ERROR', message, meta));
78
+ }
79
+ }