@hbarefoot/engram 1.1.0 → 1.2.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,132 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ const SSH_CONFIG_PATH = path.join(os.homedir(), '.ssh/config');
6
+
7
+ /**
8
+ * Detect if SSH config exists
9
+ */
10
+ export function detect() {
11
+ return {
12
+ found: fs.existsSync(SSH_CONFIG_PATH),
13
+ path: fs.existsSync(SSH_CONFIG_PATH) ? SSH_CONFIG_PATH : null
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Parse ~/.ssh/config for host names only.
19
+ * NEVER extracts keys, IdentityFile contents, passwords, or passphrases.
20
+ */
21
+ export async function parse(options = {}) {
22
+ const result = { source: 'ssh', memories: [], skipped: [], warnings: [] };
23
+
24
+ const filePath = options.filePath || SSH_CONFIG_PATH;
25
+
26
+ if (!fs.existsSync(filePath)) {
27
+ result.warnings.push('No ~/.ssh/config found');
28
+ return result;
29
+ }
30
+
31
+ const content = fs.readFileSync(filePath, 'utf-8');
32
+ const hosts = parseSSHConfig(content);
33
+
34
+ if (hosts.length === 0) {
35
+ result.warnings.push('No SSH hosts found in config');
36
+ return result;
37
+ }
38
+
39
+ // Filter out wildcard-only hosts
40
+ const namedHosts = hosts.filter(h => h.name !== '*' && !h.name.includes('*'));
41
+
42
+ if (namedHosts.length === 0) {
43
+ result.warnings.push('Only wildcard SSH hosts found');
44
+ return result;
45
+ }
46
+
47
+ // Create a summary memory with all host names
48
+ const hostNames = namedHosts.map(h => h.name);
49
+ result.memories.push({
50
+ content: `SSH configured hosts: ${hostNames.join(', ')}`,
51
+ category: 'fact',
52
+ entity: 'ssh',
53
+ confidence: 0.9,
54
+ tags: ['ssh-config', 'servers'],
55
+ source: 'import:ssh'
56
+ });
57
+
58
+ // Individual host entries with hostname (not key info)
59
+ for (const host of namedHosts) {
60
+ if (host.hostname) {
61
+ result.memories.push({
62
+ content: `SSH host "${host.name}" connects to ${host.hostname}${host.user ? ` as ${host.user}` : ''}${host.port ? ` on port ${host.port}` : ''}`,
63
+ category: 'fact',
64
+ entity: 'ssh',
65
+ confidence: 0.9,
66
+ tags: ['ssh-config', 'servers'],
67
+ source: 'import:ssh'
68
+ });
69
+ }
70
+ }
71
+
72
+ // Warn about skipped sensitive fields
73
+ const skippedCount = hosts.reduce((sum, h) => sum + (h.identityFile ? 1 : 0), 0);
74
+ if (skippedCount > 0) {
75
+ result.warnings.push(`Skipped ${skippedCount} IdentityFile entries (security)`);
76
+ }
77
+
78
+ return result;
79
+ }
80
+
81
+ /**
82
+ * Parse SSH config file into host objects
83
+ * Only extracts safe fields: Host, HostName, User, Port
84
+ */
85
+ function parseSSHConfig(content) {
86
+ const hosts = [];
87
+ let current = null;
88
+
89
+ for (const rawLine of content.split('\n')) {
90
+ const line = rawLine.trim();
91
+
92
+ if (!line || line.startsWith('#')) continue;
93
+
94
+ const [key, ...valueParts] = line.split(/\s+/);
95
+ const value = valueParts.join(' ');
96
+ const keyLower = key.toLowerCase();
97
+
98
+ if (keyLower === 'host') {
99
+ if (current) hosts.push(current);
100
+ current = { name: value };
101
+ } else if (current) {
102
+ // ONLY extract safe, non-secret fields
103
+ switch (keyLower) {
104
+ case 'hostname':
105
+ current.hostname = value;
106
+ break;
107
+ case 'user':
108
+ current.user = value;
109
+ break;
110
+ case 'port':
111
+ current.port = value;
112
+ break;
113
+ case 'identityfile':
114
+ // Note existence but NEVER store the path or contents
115
+ current.identityFile = true;
116
+ break;
117
+ // All other fields are intentionally ignored for security
118
+ }
119
+ }
120
+ }
121
+
122
+ if (current) hosts.push(current);
123
+ return hosts;
124
+ }
125
+
126
+ export const meta = {
127
+ name: 'ssh',
128
+ label: '~/.ssh/config',
129
+ description: 'Server names and hosts (NEVER extracts keys)',
130
+ category: 'fact',
131
+ locations: ['~/.ssh/config']
132
+ };
@@ -0,0 +1,280 @@
1
+ import { createInterface } from 'readline';
2
+ import { detectSources, scanSources, commitMemories } from './index.js';
3
+ import { initDatabase } from '../memory/store.js';
4
+ import { loadConfig, getDatabasePath } from '../config/index.js';
5
+ import {
6
+ printHeader, printSection, warning, info,
7
+ categoryBadge, confidenceColor, truncate,
8
+ createTable, spinner
9
+ } from '../utils/format.js';
10
+ import chalk from 'chalk';
11
+
12
+ /**
13
+ * Interactive CLI import wizard
14
+ * @param {Object} [options] - Wizard options
15
+ * @param {string} [options.source] - Single source for non-interactive mode
16
+ * @param {boolean} [options.dryRun] - Preview without committing
17
+ * @param {string} [options.namespace] - Override namespace
18
+ * @param {string} [options.config] - Config file path
19
+ */
20
+ export async function runWizard(options = {}) {
21
+ const config = loadConfig(options.config);
22
+
23
+ // Non-interactive single-source mode
24
+ if (options.source) {
25
+ return runNonInteractive(config, options);
26
+ }
27
+
28
+ const rl = createInterface({
29
+ input: process.stdin,
30
+ output: process.stdout
31
+ });
32
+
33
+ const ask = (question) => new Promise(resolve => rl.question(question, resolve));
34
+
35
+ try {
36
+ printHeader('1.1.0');
37
+ printSection('Smart Import Wizard');
38
+ console.log('');
39
+ info('Scanning your system for developer artifacts to import as memories.');
40
+ console.log('');
41
+
42
+ // Step 1: Detect available sources
43
+ const spin = spinner('Scanning for sources...');
44
+ spin.start();
45
+ const sources = await detectSources({ cwd: process.cwd() });
46
+ const foundSources = sources.filter(s => s.detected.found);
47
+ const notFoundSources = sources.filter(s => !s.detected.found);
48
+
49
+ if (foundSources.length === 0) {
50
+ spin.fail('No importable sources detected');
51
+ info('Try running this from a project directory with package.json, .cursorrules, etc.');
52
+ console.log('');
53
+ rl.close();
54
+ return;
55
+ }
56
+
57
+ spin.succeed(`Found ${foundSources.length} source${foundSources.length === 1 ? '' : 's'}`);
58
+ console.log('');
59
+
60
+ const srcTable = createTable({ head: ['#', 'Source', 'Description', 'Path'] });
61
+ foundSources.forEach((s, i) => {
62
+ srcTable.push([
63
+ String(i + 1),
64
+ chalk.bold(s.label),
65
+ s.description,
66
+ chalk.dim(s.detected.path || '-')
67
+ ]);
68
+ });
69
+ console.log(srcTable.toString());
70
+
71
+ if (notFoundSources.length > 0) {
72
+ console.log('');
73
+ info(`Not found: ${chalk.dim(notFoundSources.map(s => s.label).join(', '))}`);
74
+ }
75
+
76
+ // Step 2: Select sources
77
+ console.log('');
78
+ const selection = await ask(`${chalk.bold('Select sources')} (comma-separated numbers, or "all"): `);
79
+
80
+ let selectedIds;
81
+ if (selection.trim().toLowerCase() === 'all') {
82
+ selectedIds = foundSources.map(s => s.id);
83
+ } else {
84
+ const indices = selection.split(',').map(s => parseInt(s.trim()) - 1);
85
+ selectedIds = indices
86
+ .filter(i => i >= 0 && i < foundSources.length)
87
+ .map(i => foundSources[i].id);
88
+ }
89
+
90
+ if (selectedIds.length === 0) {
91
+ info('No sources selected. Exiting.');
92
+ rl.close();
93
+ return;
94
+ }
95
+
96
+ info(`Selected: ${chalk.bold(selectedIds.join(', '))}`);
97
+
98
+ // Step 3: Scan
99
+ console.log('');
100
+ const scanSpin = spinner('Scanning sources...');
101
+ scanSpin.start();
102
+ const scanResult = await scanSources(selectedIds, { cwd: process.cwd() });
103
+ scanSpin.succeed(`Found ${scanResult.memories.length} memories from ${selectedIds.length} source${selectedIds.length === 1 ? '' : 's'}`);
104
+
105
+ if (scanResult.skipped.length > 0) {
106
+ warning(`Skipped: ${scanResult.skipped.length} items (security or parse errors)`);
107
+ }
108
+ if (scanResult.warnings.length > 0) {
109
+ for (const w of scanResult.warnings) {
110
+ warning(w);
111
+ }
112
+ }
113
+
114
+ if (scanResult.memories.length === 0) {
115
+ console.log('');
116
+ info('No memories to import. Exiting.');
117
+ rl.close();
118
+ return;
119
+ }
120
+
121
+ // Step 4: Preview
122
+ printSection('Preview');
123
+
124
+ const previewCount = Math.min(scanResult.memories.length, 20);
125
+ const previewTable = createTable({ head: ['#', 'Category', 'Content', 'Confidence', 'Source'] });
126
+ for (let i = 0; i < previewCount; i++) {
127
+ const m = scanResult.memories[i];
128
+ previewTable.push([
129
+ String(i + 1),
130
+ categoryBadge(m.category),
131
+ truncate(m.content, 50),
132
+ confidenceColor(m.confidence),
133
+ chalk.dim(m.source)
134
+ ]);
135
+ }
136
+ console.log(previewTable.toString());
137
+
138
+ if (scanResult.memories.length > 20) {
139
+ info(`... and ${scanResult.memories.length - 20} more`);
140
+ }
141
+
142
+ // Step 5: Confirm
143
+ if (options.dryRun) {
144
+ console.log('');
145
+ info('Dry run mode — no memories were stored.');
146
+ console.log('');
147
+ printSummary(scanResult);
148
+ rl.close();
149
+ return;
150
+ }
151
+
152
+ console.log('');
153
+ const confirm = await ask(`${chalk.bold(`Commit ${scanResult.memories.length} memories?`)} (y/N) `);
154
+ if (confirm.trim().toLowerCase() !== 'y') {
155
+ info('Import cancelled.');
156
+ rl.close();
157
+ return;
158
+ }
159
+
160
+ // Step 6: Commit
161
+ const commitSpin = spinner('Committing memories...');
162
+ commitSpin.start();
163
+ const db = initDatabase(getDatabasePath(config));
164
+
165
+ const commitResult = await commitMemories(db, scanResult.memories, {
166
+ namespace: options.namespace
167
+ });
168
+
169
+ db.close();
170
+ commitSpin.succeed('Import complete');
171
+
172
+ // Step 7: Summary
173
+ console.log('');
174
+ const summaryTable = createTable({ head: ['Metric', 'Count'] });
175
+ summaryTable.push(
176
+ ['Created', String(commitResult.created)],
177
+ ['Duplicates', `${commitResult.duplicates} (skipped)`],
178
+ ['Merged', String(commitResult.merged)],
179
+ ['Rejected', `${commitResult.rejected} (security)`]
180
+ );
181
+ if (commitResult.errors.length > 0) {
182
+ summaryTable.push(['Errors', String(commitResult.errors.length)]);
183
+ }
184
+ summaryTable.push(['Duration', `${commitResult.duration}ms`]);
185
+ console.log(summaryTable.toString());
186
+
187
+ console.log('');
188
+ info(`View your memories at ${chalk.cyan('http://localhost:3838')}`);
189
+ console.log('');
190
+
191
+ rl.close();
192
+ return commitResult;
193
+ } catch (err) {
194
+ rl.close();
195
+ throw err;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Non-interactive single-source import
201
+ */
202
+ async function runNonInteractive(config, options) {
203
+ printSection(`Import: ${options.source}`);
204
+ console.log('');
205
+
206
+ const scanSpin = spinner(`Scanning ${options.source}...`);
207
+ scanSpin.start();
208
+ const scanResult = await scanSources([options.source], { cwd: process.cwd() });
209
+ scanSpin.succeed(`Found ${scanResult.memories.length} memories`);
210
+
211
+ if (scanResult.warnings.length > 0) {
212
+ for (const w of scanResult.warnings) {
213
+ warning(w);
214
+ }
215
+ }
216
+
217
+ if (scanResult.memories.length === 0) {
218
+ info('No memories to import.');
219
+ console.log('');
220
+ return;
221
+ }
222
+
223
+ if (options.dryRun) {
224
+ printSection('Preview');
225
+ const previewTable = createTable({ head: ['Category', 'Content'] });
226
+ for (const m of scanResult.memories) {
227
+ previewTable.push([
228
+ categoryBadge(m.category),
229
+ truncate(m.content, 60)
230
+ ]);
231
+ }
232
+ console.log(previewTable.toString());
233
+ console.log('');
234
+ info('Dry run — no memories stored.');
235
+ console.log('');
236
+ return;
237
+ }
238
+
239
+ const commitSpin = spinner('Committing memories...');
240
+ commitSpin.start();
241
+ const db = initDatabase(getDatabasePath(config));
242
+ const commitResult = await commitMemories(db, scanResult.memories, {
243
+ namespace: options.namespace
244
+ });
245
+ db.close();
246
+ commitSpin.succeed(`Imported ${commitResult.created} memories (${commitResult.duplicates} duplicates skipped)`);
247
+ console.log('');
248
+ return commitResult;
249
+ }
250
+
251
+ /**
252
+ * Print scan summary
253
+ */
254
+ function printSummary(scanResult) {
255
+ const byCategory = {};
256
+ const bySource = {};
257
+
258
+ for (const m of scanResult.memories) {
259
+ byCategory[m.category] = (byCategory[m.category] || 0) + 1;
260
+ bySource[m.source] = (bySource[m.source] || 0) + 1;
261
+ }
262
+
263
+ if (Object.keys(byCategory).length > 0) {
264
+ const catTable = createTable({ head: ['Category', 'Count'] });
265
+ for (const [cat, count] of Object.entries(byCategory)) {
266
+ catTable.push([categoryBadge(cat), String(count)]);
267
+ }
268
+ console.log(catTable.toString());
269
+ }
270
+
271
+ if (Object.keys(bySource).length > 0) {
272
+ console.log('');
273
+ const srcTable = createTable({ head: ['Source', 'Count'] });
274
+ for (const [src, count] of Object.entries(bySource)) {
275
+ srcTable.push([src, String(count)]);
276
+ }
277
+ console.log(srcTable.toString());
278
+ }
279
+ console.log('');
280
+ }
package/src/server/mcp.js CHANGED
@@ -575,6 +575,10 @@ export class EngramMCPServer {
575
575
  modelInfo = {
576
576
  name: 'unknown',
577
577
  available: false,
578
+ cached: false,
579
+ loading: false,
580
+ sizeMB: 0,
581
+ path: '',
578
582
  error: error.message
579
583
  };
580
584
  }
@@ -590,7 +594,7 @@ export class EngramMCPServer {
590
594
 
591
595
  🤖 Embedding Model:
592
596
  - Name: ${modelInfo.name}
593
- - Available: ${modelInfo.available ? 'Yes' : 'No'}
597
+ - Status: ${modelInfo.cached ? 'Ready' : modelInfo.loading ? 'Loading...' : modelInfo.available ? 'Available (not loaded)' : 'Not available'}
594
598
  - Cached: ${modelInfo.cached ? 'Yes' : 'No'}
595
599
  - Size: ${modelInfo.sizeMB} MB
596
600
  - Path: ${modelInfo.path}
@@ -1,5 +1,6 @@
1
1
  import Fastify from 'fastify';
2
2
  import fastifyStatic from '@fastify/static';
3
+ import fs from 'fs';
3
4
  import path from 'path';
4
5
  import { fileURLToPath } from 'url';
5
6
  import { loadConfig, getDatabasePath, getModelsPath } from '../config/index.js';
@@ -64,6 +65,10 @@ export function createRESTServer(config) {
64
65
  modelInfo = {
65
66
  name: 'unknown',
66
67
  available: false,
68
+ cached: false,
69
+ loading: false,
70
+ sizeMB: 0,
71
+ path: '',
67
72
  error: error.message
68
73
  };
69
74
  }
@@ -79,6 +84,7 @@ export function createRESTServer(config) {
79
84
  model: {
80
85
  name: modelInfo.name,
81
86
  available: modelInfo.available,
87
+ loading: modelInfo.loading || false,
82
88
  cached: modelInfo.cached,
83
89
  size: modelInfo.sizeMB,
84
90
  path: modelInfo.path
@@ -193,6 +199,13 @@ export function createRESTServer(config) {
193
199
  namespace
194
200
  });
195
201
 
202
+ // Get total count for pagination
203
+ let countQuery = 'SELECT COUNT(*) as count FROM memories WHERE 1=1';
204
+ const countParams = [];
205
+ if (namespace) { countQuery += ' AND namespace = ?'; countParams.push(namespace); }
206
+ if (category) { countQuery += ' AND category = ?'; countParams.push(category); }
207
+ const totalCount = db.prepare(countQuery).get(...countParams).count;
208
+
196
209
  return {
197
210
  success: true,
198
211
  memories: memories.map(m => ({
@@ -210,7 +223,7 @@ export function createRESTServer(config) {
210
223
  pagination: {
211
224
  limit: parseInt(limit),
212
225
  offset: parseInt(offset),
213
- total: memories.length
226
+ total: totalCount
214
227
  }
215
228
  };
216
229
  } catch (error) {
@@ -472,18 +485,118 @@ export function createRESTServer(config) {
472
485
  }
473
486
  });
474
487
 
475
- // Serve dashboard static files
476
- const dashboardPath = path.resolve(__dirname, '../../dashboard/dist');
488
+ // === Import Wizard Endpoints ===
489
+
490
+ // Get available import sources with detection status
491
+ fastify.get('/api/import/sources', async (request, reply) => {
492
+ try {
493
+ const { detectSources } = await import('../import/index.js');
494
+ const cwd = request.query.cwd || process.cwd();
495
+ const sources = await detectSources({ cwd });
496
+
497
+ return {
498
+ success: true,
499
+ sources: sources.map(s => ({
500
+ id: s.id,
501
+ name: s.name,
502
+ label: s.label,
503
+ description: s.description,
504
+ category: s.category,
505
+ detected: s.detected
506
+ }))
507
+ };
508
+ } catch (error) {
509
+ logger.error('Import sources error', { error: error.message });
510
+ reply.code(500);
511
+ return { error: error.message };
512
+ }
513
+ });
514
+
515
+ // Scan selected sources for memory candidates
516
+ fastify.post('/api/import/scan', async (request, reply) => {
517
+ try {
518
+ const { sources } = request.body;
519
+
520
+ if (!sources || !Array.isArray(sources) || sources.length === 0) {
521
+ reply.code(400);
522
+ return { error: 'sources array is required' };
523
+ }
524
+
525
+ const { scanSources } = await import('../import/index.js');
526
+ const result = await scanSources(sources, { cwd: process.cwd() });
527
+
528
+ return {
529
+ success: true,
530
+ memories: result.memories.map((m, i) => ({
531
+ _index: i,
532
+ content: m.content,
533
+ category: m.category,
534
+ entity: m.entity,
535
+ confidence: m.confidence,
536
+ tags: m.tags,
537
+ source: m.source
538
+ })),
539
+ skipped: result.skipped,
540
+ warnings: result.warnings,
541
+ sources: result.sources,
542
+ duration: result.duration
543
+ };
544
+ } catch (error) {
545
+ logger.error('Import scan error', { error: error.message });
546
+ reply.code(500);
547
+ return { error: error.message };
548
+ }
549
+ });
477
550
 
478
- fastify.register(fastifyStatic, {
479
- root: dashboardPath,
480
- prefix: '/'
551
+ // Commit imported memories
552
+ fastify.post('/api/import/commit', async (request, reply) => {
553
+ try {
554
+ const { memories, namespace } = request.body;
555
+
556
+ if (!memories || !Array.isArray(memories) || memories.length === 0) {
557
+ reply.code(400);
558
+ return { error: 'memories array is required' };
559
+ }
560
+
561
+ const { commitMemories } = await import('../import/index.js');
562
+ const result = await commitMemories(db, memories, { namespace });
563
+
564
+ return {
565
+ success: true,
566
+ results: {
567
+ total: result.total,
568
+ created: result.created,
569
+ duplicates: result.duplicates,
570
+ merged: result.merged,
571
+ rejected: result.rejected,
572
+ errors: result.errors,
573
+ duration: result.duration
574
+ }
575
+ };
576
+ } catch (error) {
577
+ logger.error('Import commit error', { error: error.message });
578
+ reply.code(500);
579
+ return { error: error.message };
580
+ }
481
581
  });
482
582
 
483
- // Fallback to index.html for SPA routing
583
+ // Serve dashboard static files (skip if dist dir doesn't exist, e.g. in sidecar bundle)
584
+ const dashboardPath = path.resolve(__dirname, '../../dashboard/dist');
585
+ const hasDashboard = fs.existsSync(dashboardPath);
586
+
587
+ if (hasDashboard) {
588
+ fastify.register(fastifyStatic, {
589
+ root: dashboardPath,
590
+ prefix: '/'
591
+ });
592
+ } else {
593
+ logger.warn('Dashboard dist not found, skipping static file serving', { path: dashboardPath });
594
+ }
595
+
596
+ // Fallback to index.html for SPA routing (only when dashboard is available)
484
597
  fastify.setNotFoundHandler((request, reply) => {
485
- if (!request.url.startsWith('/api') && !request.url.startsWith('/health')) {
486
- reply.sendFile('index.html');
598
+ if (!request.url.startsWith('/api') && !request.url.startsWith('/health') && hasDashboard) {
599
+ reply.type('text/html').sendFile('index.html');
487
600
  } else {
488
601
  reply.code(404).send({ error: 'Not found' });
489
602
  }
@@ -514,6 +627,21 @@ export async function startRESTServer(config, port = 3838) {
514
627
 
515
628
  logger.info('REST API server started', { port, url: `http://localhost:${port}` });
516
629
 
630
+ // Set TRANSFORMERS_CACHE early so isModelAvailable() can find cached models
631
+ const modelsPath = getModelsPath(config);
632
+ process.env.TRANSFORMERS_CACHE = modelsPath;
633
+
634
+ // Pre-warm embedding pipeline in background so status reports correctly
635
+ import('../embed/index.js').then(({ initializePipeline }) => {
636
+ initializePipeline(modelsPath).then(() => {
637
+ logger.info('Embedding pipeline pre-warmed');
638
+ }).catch(err => {
639
+ logger.warn('Embedding pipeline pre-warm failed (will retry on first use)', { error: err.message });
640
+ });
641
+ }).catch(err => {
642
+ logger.warn('Failed to import embedding module for pre-warm', { error: err?.message ?? String(err) });
643
+ });
644
+
517
645
  return fastify;
518
646
  } catch (error) {
519
647
  logger.error('Failed to start REST server', { error: error.message });