@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.
- package/bin/engram.js +275 -90
- package/dashboard/dist/assets/index-D0xT6oKC.css +1 -0
- package/dashboard/dist/assets/index-D3bysGhj.js +45 -0
- package/dashboard/dist/engram-logo.png +0 -0
- package/dashboard/dist/favicon.png +0 -0
- package/dashboard/dist/index.html +3 -3
- package/package.json +9 -2
- package/src/embed/index.js +116 -16
- package/src/import/index.js +259 -0
- package/src/import/parsers/claude.js +208 -0
- package/src/import/parsers/cursorrules.js +153 -0
- package/src/import/parsers/env.js +129 -0
- package/src/import/parsers/git.js +194 -0
- package/src/import/parsers/obsidian.js +219 -0
- package/src/import/parsers/package.js +177 -0
- package/src/import/parsers/shell.js +256 -0
- package/src/import/parsers/ssh.js +132 -0
- package/src/import/wizard.js +280 -0
- package/src/server/mcp.js +5 -1
- package/src/server/rest.js +137 -9
- package/src/utils/format.js +224 -0
- package/dashboard/dist/assets/index-BHkLa5w_.css +0 -1
- package/dashboard/dist/assets/index-D9QR_Cnu.js +0 -45
|
@@ -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
|
-
-
|
|
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}
|
package/src/server/rest.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
//
|
|
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 });
|