@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,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Engram Dashboard</title>
8
+ <script type="module" crossorigin src="/assets/index-D9QR_Cnu.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BHkLa5w_.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "engram-dashboard",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1"
13
+ },
14
+ "devDependencies": {
15
+ "@vitejs/plugin-react": "^4.3.4",
16
+ "autoprefixer": "^10.4.20",
17
+ "postcss": "^8.4.49",
18
+ "tailwindcss": "^3.4.17",
19
+ "vite": "^6.0.5"
20
+ }
21
+ }
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@hbarefoot/engram",
3
+ "version": "1.0.0",
4
+ "description": "Persistent memory for AI agents. SQLite for agent state.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "engram": "bin/engram.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "dashboard/dist",
14
+ "dashboard/package.json",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "start": "node bin/engram.js start",
20
+ "mcp": "node bin/engram.js start --mcp-only",
21
+ "dev": "concurrently \"node bin/engram.js start\" \"cd dashboard && npm run dev\"",
22
+ "build": "cd dashboard && npm run build",
23
+ "prepublishOnly": "npm run build",
24
+ "test": "vitest",
25
+ "test:run": "vitest run",
26
+ "lint": "eslint src/",
27
+ "pm2:start": "pm2 start ecosystem.config.cjs",
28
+ "pm2:stop": "pm2 stop engram",
29
+ "pm2:restart": "pm2 restart engram",
30
+ "pm2:delete": "pm2 delete engram",
31
+ "pm2:logs": "pm2 logs engram",
32
+ "pm2:status": "pm2 status engram",
33
+ "pm2:monit": "pm2 monit"
34
+ },
35
+ "keywords": [
36
+ "ai",
37
+ "agent",
38
+ "memory",
39
+ "mcp",
40
+ "claude",
41
+ "llm",
42
+ "persistent",
43
+ "knowledge",
44
+ "sqlite",
45
+ "embedding"
46
+ ],
47
+ "license": "MIT",
48
+ "author": "Engram Contributors",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/HBarefoot/engram.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/HBarefoot/engram/issues"
55
+ },
56
+ "homepage": "https://github.com/HBarefoot/engram#readme",
57
+ "engines": {
58
+ "node": ">=20.0.0"
59
+ },
60
+ "dependencies": {
61
+ "@modelcontextprotocol/sdk": "^1.0.0",
62
+ "better-sqlite3": "^11.0.0",
63
+ "fastify": "^5.0.0",
64
+ "@fastify/static": "^8.0.0",
65
+ "@fastify/multipart": "^9.0.0",
66
+ "@fastify/cors": "^10.0.0",
67
+ "commander": "^12.0.0",
68
+ "@xenova/transformers": "^2.17.0",
69
+ "yaml": "^2.4.0"
70
+ },
71
+ "devDependencies": {
72
+ "vitest": "^2.0.0",
73
+ "eslint": "^9.0.0",
74
+ "concurrently": "^9.0.0"
75
+ }
76
+ }
@@ -0,0 +1,150 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ /**
6
+ * Default configuration for Engram
7
+ */
8
+ const DEFAULT_CONFIG = {
9
+ port: 3838,
10
+ dataDir: path.join(os.homedir(), '.engram'),
11
+ defaults: {
12
+ namespace: 'default',
13
+ recallLimit: 5,
14
+ confidenceThreshold: 0.3,
15
+ tokenBudget: 500,
16
+ maxRecallResults: 20
17
+ },
18
+ embedding: {
19
+ provider: 'local',
20
+ model: 'Xenova/all-MiniLM-L6-v2',
21
+ endpoint: null
22
+ },
23
+ llm: {
24
+ provider: null,
25
+ endpoint: null,
26
+ model: null,
27
+ apiKey: null
28
+ },
29
+ consolidation: {
30
+ enabled: true,
31
+ intervalHours: 24,
32
+ duplicateThreshold: 0.92,
33
+ decayEnabled: true
34
+ },
35
+ security: {
36
+ secretDetection: true,
37
+ auditLog: false
38
+ }
39
+ };
40
+
41
+ /**
42
+ * Deep merge two objects
43
+ * @param {Object} target - Target object
44
+ * @param {Object} source - Source object
45
+ * @returns {Object} Merged object
46
+ */
47
+ function deepMerge(target, source) {
48
+ const result = { ...target };
49
+
50
+ for (const key in source) {
51
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
52
+ result[key] = deepMerge(target[key] || {}, source[key]);
53
+ } else {
54
+ result[key] = source[key];
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * Ensure the data directory exists
63
+ * @param {string} dataDir - Path to data directory
64
+ */
65
+ function ensureDataDir(dataDir) {
66
+ if (!fs.existsSync(dataDir)) {
67
+ fs.mkdirSync(dataDir, { recursive: true });
68
+ }
69
+
70
+ // Create subdirectories
71
+ const modelsDir = path.join(dataDir, 'models');
72
+ if (!fs.existsSync(modelsDir)) {
73
+ fs.mkdirSync(modelsDir, { recursive: true });
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Load configuration from file or create default
79
+ * @param {string} [configPath] - Optional custom config path
80
+ * @returns {Object} Configuration object
81
+ */
82
+ export function loadConfig(configPath) {
83
+ const config = { ...DEFAULT_CONFIG };
84
+
85
+ // Determine config file path
86
+ const actualConfigPath = configPath || path.join(config.dataDir, 'config.json');
87
+
88
+ // Ensure data directory exists
89
+ ensureDataDir(config.dataDir);
90
+
91
+ // Load config from file if it exists
92
+ if (fs.existsSync(actualConfigPath)) {
93
+ try {
94
+ const fileConfig = JSON.parse(fs.readFileSync(actualConfigPath, 'utf-8'));
95
+ const merged = deepMerge(config, fileConfig);
96
+
97
+ // Ensure data directory from loaded config exists
98
+ if (merged.dataDir !== config.dataDir) {
99
+ ensureDataDir(merged.dataDir);
100
+ }
101
+
102
+ return merged;
103
+ } catch (error) {
104
+ console.warn(`Failed to load config from ${actualConfigPath}:`, error.message);
105
+ console.warn('Using default configuration');
106
+ }
107
+ }
108
+
109
+ // Save default config to file if it doesn't exist
110
+ try {
111
+ fs.writeFileSync(actualConfigPath, JSON.stringify(config, null, 2), 'utf-8');
112
+ } catch (error) {
113
+ console.warn(`Failed to save default config to ${actualConfigPath}:`, error.message);
114
+ }
115
+
116
+ return config;
117
+ }
118
+
119
+ /**
120
+ * Save configuration to file
121
+ * @param {Object} config - Configuration object
122
+ * @param {string} [configPath] - Optional custom config path
123
+ */
124
+ export function saveConfig(config, configPath) {
125
+ const actualConfigPath = configPath || path.join(config.dataDir, 'config.json');
126
+
127
+ try {
128
+ fs.writeFileSync(actualConfigPath, JSON.stringify(config, null, 2), 'utf-8');
129
+ } catch (error) {
130
+ throw new Error(`Failed to save config to ${actualConfigPath}: ${error.message}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get the database path from config
136
+ * @param {Object} config - Configuration object
137
+ * @returns {string} Path to SQLite database
138
+ */
139
+ export function getDatabasePath(config) {
140
+ return path.join(config.dataDir, 'memory.db');
141
+ }
142
+
143
+ /**
144
+ * Get the models directory from config
145
+ * @param {Object} config - Configuration object
146
+ * @returns {string} Path to models directory
147
+ */
148
+ export function getModelsPath(config) {
149
+ return path.join(config.dataDir, 'models');
150
+ }
@@ -0,0 +1,249 @@
1
+ import { pipeline } from '@xenova/transformers';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import * as logger from '../utils/logger.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ /**
11
+ * Cached pipeline instance
12
+ */
13
+ let cachedPipeline = null;
14
+
15
+ /**
16
+ * Model configuration
17
+ */
18
+ const MODEL_CONFIG = {
19
+ name: 'Xenova/all-MiniLM-L6-v2',
20
+ task: 'feature-extraction'
21
+ };
22
+
23
+ /**
24
+ * Initialize the embedding pipeline
25
+ * Downloads model on first use and caches it
26
+ * @param {string} modelsPath - Path to models directory
27
+ * @returns {Promise<Object>} Pipeline instance
28
+ */
29
+ export async function initializePipeline(modelsPath) {
30
+ if (cachedPipeline) {
31
+ logger.debug('Using cached embedding pipeline');
32
+ return cachedPipeline;
33
+ }
34
+
35
+ try {
36
+ logger.info('Initializing embedding model', { model: MODEL_CONFIG.name });
37
+
38
+ // Ensure models directory exists
39
+ if (!fs.existsSync(modelsPath)) {
40
+ fs.mkdirSync(modelsPath, { recursive: true });
41
+ }
42
+
43
+ // Set cache directory for transformers
44
+ process.env.TRANSFORMERS_CACHE = modelsPath;
45
+
46
+ logger.info('Loading embedding model (this may take a moment on first run)...');
47
+
48
+ // Create pipeline
49
+ cachedPipeline = await pipeline(
50
+ MODEL_CONFIG.task,
51
+ MODEL_CONFIG.name,
52
+ {
53
+ quantized: true,
54
+ progress_callback: (progress) => {
55
+ if (progress.status === 'downloading') {
56
+ const percent = progress.progress ? Math.round(progress.progress) : 0;
57
+ logger.debug(`Downloading model: ${percent}%`);
58
+ }
59
+ }
60
+ }
61
+ );
62
+
63
+ logger.info('Embedding model loaded successfully');
64
+
65
+ return cachedPipeline;
66
+ } catch (error) {
67
+ logger.error('Failed to initialize embedding pipeline', { error: error.message });
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Generate embedding for text
74
+ * @param {string} text - Text to embed
75
+ * @param {string} modelsPath - Path to models directory
76
+ * @returns {Promise<Float32Array>} Embedding vector
77
+ */
78
+ export async function generateEmbedding(text, modelsPath) {
79
+ try {
80
+ const pipe = await initializePipeline(modelsPath);
81
+
82
+ logger.debug('Generating embedding', { textLength: text.length });
83
+
84
+ // Generate embedding
85
+ const result = await pipe(text, {
86
+ pooling: 'mean',
87
+ normalize: true
88
+ });
89
+
90
+ // Extract the embedding array
91
+ // The result is a tensor, we need to convert it to Float32Array
92
+ const embedding = new Float32Array(result.data);
93
+
94
+ logger.debug('Embedding generated', { dimensions: embedding.length });
95
+
96
+ return embedding;
97
+ } catch (error) {
98
+ logger.error('Failed to generate embedding', { error: error.message });
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Generate embeddings for multiple texts in batch
105
+ * @param {string[]} texts - Array of texts to embed
106
+ * @param {string} modelsPath - Path to models directory
107
+ * @returns {Promise<Float32Array[]>} Array of embedding vectors
108
+ */
109
+ export async function generateEmbeddings(texts, modelsPath) {
110
+ try {
111
+ const pipe = await initializePipeline(modelsPath);
112
+
113
+ logger.debug('Generating batch embeddings', { count: texts.length });
114
+
115
+ const embeddings = [];
116
+
117
+ for (const text of texts) {
118
+ const result = await pipe(text, {
119
+ pooling: 'mean',
120
+ normalize: true
121
+ });
122
+
123
+ embeddings.push(new Float32Array(result.data));
124
+ }
125
+
126
+ logger.debug('Batch embeddings generated', { count: embeddings.length });
127
+
128
+ return embeddings;
129
+ } catch (error) {
130
+ logger.error('Failed to generate batch embeddings', { error: error.message });
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Calculate cosine similarity between two embeddings
137
+ * @param {Float32Array} a - First embedding
138
+ * @param {Float32Array} b - Second embedding
139
+ * @returns {number} Similarity score (0-1)
140
+ */
141
+ export function cosineSimilarity(a, b) {
142
+ if (a.length !== b.length) {
143
+ throw new Error('Embeddings must have the same dimensions');
144
+ }
145
+
146
+ let dotProduct = 0;
147
+ let normA = 0;
148
+ let normB = 0;
149
+
150
+ for (let i = 0; i < a.length; i++) {
151
+ dotProduct += a[i] * b[i];
152
+ normA += a[i] * a[i];
153
+ normB += b[i] * b[i];
154
+ }
155
+
156
+ const similarity = dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
157
+
158
+ // Clamp to [0, 1] range (should already be in this range for normalized embeddings)
159
+ return Math.max(0, Math.min(1, similarity));
160
+ }
161
+
162
+ /**
163
+ * Check if embedding model is available (cached)
164
+ * @param {string} modelsPath - Path to models directory (may be overridden)
165
+ * @returns {Object} Object with available flag and actual path
166
+ */
167
+ export function isModelAvailable(modelsPath) {
168
+ // First check the provided modelsPath
169
+ if (fs.existsSync(modelsPath) && fs.readdirSync(modelsPath).length > 0) {
170
+ return { available: true, path: modelsPath };
171
+ }
172
+
173
+ // Check Xenova transformers cache in node_modules (most common for local dev)
174
+ const possiblePaths = [
175
+ path.resolve(__dirname, '../../node_modules/@xenova/transformers/.cache/Xenova/all-MiniLM-L6-v2'),
176
+ ];
177
+
178
+ // Add home directory cache paths
179
+ const homeDir = process.env.HOME || process.env.USERPROFILE;
180
+ if (homeDir) {
181
+ possiblePaths.push(path.join(homeDir, '.cache', 'huggingface', 'hub', 'models--Xenova--all-MiniLM-L6-v2'));
182
+ }
183
+
184
+ for (const cachePath of possiblePaths) {
185
+ try {
186
+ if (fs.existsSync(cachePath) && fs.readdirSync(cachePath).length > 0) {
187
+ return { available: true, path: cachePath };
188
+ }
189
+ } catch (e) {
190
+ // Continue checking other paths
191
+ }
192
+ }
193
+
194
+ return { available: false, path: modelsPath };
195
+ }
196
+
197
+ /**
198
+ * Calculate directory size recursively
199
+ * @param {string} dirPath - Directory path
200
+ * @returns {number} Size in bytes
201
+ */
202
+ function getDirectorySize(dirPath) {
203
+ let size = 0;
204
+ try {
205
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
206
+ for (const entry of entries) {
207
+ const fullPath = path.join(dirPath, entry.name);
208
+ if (entry.isFile()) {
209
+ try {
210
+ const stats = fs.statSync(fullPath);
211
+ size += stats.size;
212
+ } catch (e) {
213
+ // Skip unreadable files
214
+ }
215
+ } else if (entry.isDirectory()) {
216
+ size += getDirectorySize(fullPath);
217
+ }
218
+ }
219
+ } catch (e) {
220
+ // Return 0 if directory can't be read
221
+ }
222
+ return size;
223
+ }
224
+
225
+ /**
226
+ * Get model information
227
+ * @param {string} modelsPath - Path to models directory
228
+ * @returns {Object} Model information
229
+ */
230
+ export function getModelInfo(modelsPath) {
231
+ const modelCheck = isModelAvailable(modelsPath);
232
+ const available = modelCheck.available;
233
+ const actualPath = modelCheck.path;
234
+
235
+ let size = 0;
236
+ if (available) {
237
+ size = getDirectorySize(actualPath);
238
+ }
239
+
240
+ return {
241
+ name: MODEL_CONFIG.name,
242
+ task: MODEL_CONFIG.task,
243
+ available,
244
+ cached: cachedPipeline !== null,
245
+ sizeBytes: size,
246
+ sizeMB: Math.round(size / (1024 * 1024)),
247
+ path: actualPath
248
+ };
249
+ }