@jhizzard/termdeck 0.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,308 @@
1
+ // TermDeck config loader with secrets.env support (F2.2).
2
+ //
3
+ // Secrets live in ~/.termdeck/secrets.env (dotenv format) so that
4
+ // ~/.termdeck/config.yaml can be committed / shared / reviewed without
5
+ // carrying plaintext API keys. Precedence (highest wins):
6
+ //
7
+ // 1. process.env (as it was at launch)
8
+ // 2. ~/.termdeck/secrets.env (loaded here, merged INTO process.env)
9
+ // 3. ${VAR} substitutions inside config.yaml
10
+ // 4. Inline values in config.yaml (legacy, triggers a deprecation warning)
11
+ // 5. Built-in defaults
12
+ //
13
+ // Never print secret values. The deprecation warning must not echo the leaked
14
+ // key — it just names the config.yaml field that still holds an inline secret.
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+
20
+ const CONFIG_DIR = path.join(os.homedir(), '.termdeck');
21
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.yaml');
22
+ const SECRETS_PATH = path.join(CONFIG_DIR, 'secrets.env');
23
+
24
+ // Fields in config.yaml that historically held plaintext secrets. Used to
25
+ // emit the one-time deprecation warning on startup.
26
+ const LEGACY_SECRET_PATHS = [
27
+ ['rag', 'supabaseKey'],
28
+ ['rag', 'openaiApiKey'],
29
+ ['rag', 'anthropicApiKey']
30
+ ];
31
+
32
+ // Very small dotenv parser so we don't take on a dependency for 40 lines of code.
33
+ // Supports: KEY=value, KEY="quoted value", KEY='single', blank lines, #comments.
34
+ // Does NOT support variable expansion inside values (not needed here).
35
+ function parseDotenv(raw) {
36
+ const out = {};
37
+ const lines = raw.split(/\r?\n/);
38
+ for (const line of lines) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed || trimmed.startsWith('#')) continue;
41
+ const eq = trimmed.indexOf('=');
42
+ if (eq === -1) continue;
43
+ const key = trimmed.slice(0, eq).trim();
44
+ if (!key) continue;
45
+ let val = trimmed.slice(eq + 1).trim();
46
+ if ((val.startsWith('"') && val.endsWith('"')) ||
47
+ (val.startsWith("'") && val.endsWith("'"))) {
48
+ val = val.slice(1, -1);
49
+ }
50
+ out[key] = val;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function loadSecretsEnv() {
56
+ if (!fs.existsSync(SECRETS_PATH)) return { loaded: false, keys: [] };
57
+ try {
58
+ const raw = fs.readFileSync(SECRETS_PATH, 'utf-8');
59
+ const parsed = parseDotenv(raw);
60
+ const keys = [];
61
+ for (const [k, v] of Object.entries(parsed)) {
62
+ // Do not clobber pre-set process env; shell wins.
63
+ if (process.env[k] === undefined) {
64
+ process.env[k] = v;
65
+ }
66
+ keys.push(k);
67
+ }
68
+ return { loaded: true, keys };
69
+ } catch (err) {
70
+ console.warn('[config] Failed to read secrets.env:', err.message);
71
+ return { loaded: false, keys: [] };
72
+ }
73
+ }
74
+
75
+ // Walk a parsed YAML tree and replace ${VAR} / ${VAR:-default} tokens in any
76
+ // string leaf using process.env. Unknown vars → empty string (matches dotenv
77
+ // conventions), unless a default is supplied via :-.
78
+ function substituteEnv(value) {
79
+ if (value == null) return value;
80
+ if (typeof value === 'string') {
81
+ return value.replace(/\$\{([A-Z0-9_]+)(?::-([^}]*))?\}/gi, (_m, key, def) => {
82
+ const v = process.env[key];
83
+ if (v !== undefined && v !== '') return v;
84
+ return def !== undefined ? def : '';
85
+ });
86
+ }
87
+ if (Array.isArray(value)) return value.map(substituteEnv);
88
+ if (typeof value === 'object') {
89
+ const out = {};
90
+ for (const [k, v] of Object.entries(value)) out[k] = substituteEnv(v);
91
+ return out;
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function getPath(obj, segs) {
97
+ let cur = obj;
98
+ for (const s of segs) {
99
+ if (cur == null || typeof cur !== 'object') return undefined;
100
+ cur = cur[s];
101
+ }
102
+ return cur;
103
+ }
104
+
105
+ // A "secret-shaped" inline value is a non-empty string that is NOT an ${ENV}
106
+ // reference. We treat `${FOO}` and `${FOO:-bar}` as safe (just a template).
107
+ function looksLikeInlineSecret(v) {
108
+ if (typeof v !== 'string') return false;
109
+ if (!v) return false;
110
+ if (/^\$\{[A-Z0-9_]+(?::-[^}]*)?\}$/i.test(v.trim())) return false;
111
+ return true;
112
+ }
113
+
114
+ function warnIfLegacyInlineSecrets(parsed, secretsLoaded) {
115
+ if (secretsLoaded) return; // secrets.env exists — user has migrated, don't nag.
116
+ const hits = [];
117
+ for (const segs of LEGACY_SECRET_PATHS) {
118
+ const v = getPath(parsed, segs);
119
+ if (looksLikeInlineSecret(v)) hits.push(segs.join('.'));
120
+ }
121
+ if (hits.length > 0) {
122
+ console.warn(
123
+ `[config] WARNING: secrets in config.yaml are deprecated — move them to ~/.termdeck/secrets.env ` +
124
+ `(see config/secrets.env.example). Fields still inline: ${hits.join(', ')}`
125
+ );
126
+ }
127
+ }
128
+
129
+ function defaultConfig() {
130
+ return {
131
+ port: 3000,
132
+ host: '127.0.0.1',
133
+ shell: process.env.SHELL || '/bin/bash',
134
+ defaultTheme: 'tokyo-night',
135
+ projects: {},
136
+ rag: {
137
+ enabled: false,
138
+ supabaseUrl: null,
139
+ supabaseKey: null,
140
+ openaiApiKey: null,
141
+ anthropicApiKey: null,
142
+ developerId: os.userInfo().username,
143
+ syncIntervalMs: 10000,
144
+ engramMode: 'direct',
145
+ engramWebhookUrl: 'http://localhost:37778/engram',
146
+ tables: {
147
+ session: 'engram_session_memory',
148
+ project: 'engram_project_memory',
149
+ developer: 'engram_developer_memory',
150
+ commands: 'engram_commands'
151
+ }
152
+ },
153
+ sessionLogs: {
154
+ enabled: false,
155
+ summaryModel: 'claude-haiku-4-5'
156
+ }
157
+ };
158
+ }
159
+
160
+ function loadConfig() {
161
+ if (!fs.existsSync(CONFIG_DIR)) {
162
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
163
+ }
164
+
165
+ const secrets = loadSecretsEnv();
166
+ if (secrets.loaded) {
167
+ console.log(`[config] Loaded secrets from ${SECRETS_PATH} (${secrets.keys.length} key${secrets.keys.length === 1 ? '' : 's'})`);
168
+ }
169
+
170
+ const defaults = defaultConfig();
171
+
172
+ // Auto-create default config.yaml on first run (unchanged behavior).
173
+ if (!fs.existsSync(CONFIG_PATH)) {
174
+ const defaultYaml = `# TermDeck Configuration
175
+ # Secrets belong in ~/.termdeck/secrets.env, not here.
176
+
177
+ port: 3000
178
+ host: 127.0.0.1
179
+
180
+ shell: ${process.env.SHELL || '/bin/bash'}
181
+ defaultTheme: tokyo-night
182
+
183
+ # projects:
184
+ # my-project:
185
+ # path: ~/code/my-project
186
+ # defaultTheme: catppuccin-mocha
187
+ # defaultCommand: claude
188
+
189
+ rag:
190
+ enabled: false
191
+ # supabaseUrl and secrets come from ~/.termdeck/secrets.env
192
+ supabaseUrl: \${SUPABASE_URL}
193
+ supabaseKey: \${SUPABASE_SERVICE_ROLE_KEY}
194
+ openaiApiKey: \${OPENAI_API_KEY}
195
+ anthropicApiKey: \${ANTHROPIC_API_KEY}
196
+ syncIntervalMs: 10000
197
+ engramMode: direct
198
+ engramWebhookUrl: http://localhost:37778/engram
199
+
200
+ sessionLogs:
201
+ enabled: false
202
+ summaryModel: claude-haiku-4-5
203
+ `;
204
+ fs.writeFileSync(CONFIG_PATH, defaultYaml, 'utf-8');
205
+ console.log('[config] Created default config at', CONFIG_PATH);
206
+ }
207
+
208
+ let parsed = {};
209
+ try {
210
+ const yaml = require('yaml');
211
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
212
+ parsed = yaml.parse(raw) || {};
213
+ console.log('[config] Loaded from', CONFIG_PATH);
214
+ } catch (err) {
215
+ console.warn('[config] Could not load config.yaml, using defaults:', err.message);
216
+ parsed = {};
217
+ }
218
+
219
+ // Warn about inline secrets BEFORE substitution so the diagnostic matches
220
+ // what the user actually has on disk.
221
+ warnIfLegacyInlineSecrets(parsed, secrets.loaded);
222
+
223
+ const substituted = substituteEnv(parsed);
224
+
225
+ return {
226
+ ...defaults,
227
+ ...substituted,
228
+ rag: { ...defaults.rag, ...(substituted?.rag || {}) },
229
+ sessionLogs: { ...defaults.sessionLogs, ...(substituted?.sessionLogs || {}) }
230
+ };
231
+ }
232
+
233
+ // Add a project to ~/.termdeck/config.yaml and return the updated projects map.
234
+ // Writes a timestamped .bak of the existing file before overwriting so the
235
+ // user can always recover manually. Comments in the original yaml WILL be lost
236
+ // on rewrite — yaml.stringify does not round-trip comments. That's acceptable
237
+ // for a v0.2 convenience feature; permanent editing still belongs in a text
238
+ // editor for users who care about file comments.
239
+ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
240
+ if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
241
+ throw new Error('Project name must be non-empty and contain only letters, digits, . _ or -');
242
+ }
243
+ if (!projectPath || typeof projectPath !== 'string') {
244
+ throw new Error('Project path is required');
245
+ }
246
+
247
+ // Expand ~ for validation but keep tilde form in the stored config so it
248
+ // remains portable across machines.
249
+ const expanded = projectPath.replace(/^~/, os.homedir());
250
+ const resolved = path.resolve(expanded);
251
+ if (!fs.existsSync(resolved)) {
252
+ throw new Error(`Project path does not exist: ${resolved}`);
253
+ }
254
+ const stat = fs.statSync(resolved);
255
+ if (!stat.isDirectory()) {
256
+ throw new Error(`Project path is not a directory: ${resolved}`);
257
+ }
258
+
259
+ const yaml = require('yaml');
260
+ let parsed = {};
261
+ if (fs.existsSync(CONFIG_PATH)) {
262
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
263
+ try {
264
+ parsed = yaml.parse(raw) || {};
265
+ } catch (err) {
266
+ throw new Error(`config.yaml is not valid YAML — cannot safely rewrite: ${err.message}`);
267
+ }
268
+ }
269
+
270
+ if (!parsed.projects || typeof parsed.projects !== 'object') {
271
+ parsed.projects = {};
272
+ }
273
+ if (parsed.projects[name]) {
274
+ throw new Error(`Project "${name}" already exists`);
275
+ }
276
+
277
+ parsed.projects[name] = {
278
+ path: projectPath,
279
+ ...(defaultTheme ? { defaultTheme } : {}),
280
+ ...(defaultCommand ? { defaultCommand } : {})
281
+ };
282
+
283
+ // Backup before overwrite.
284
+ if (fs.existsSync(CONFIG_PATH)) {
285
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
286
+ const bak = `${CONFIG_PATH}.${ts}.bak`;
287
+ try {
288
+ fs.copyFileSync(CONFIG_PATH, bak);
289
+ } catch (err) {
290
+ console.warn('[config] Could not write backup before adding project:', err.message);
291
+ }
292
+ }
293
+
294
+ const out = yaml.stringify(parsed);
295
+ fs.writeFileSync(CONFIG_PATH, out, 'utf-8');
296
+ console.log(`[config] Added project "${name}" → ${projectPath}`);
297
+
298
+ return parsed.projects;
299
+ }
300
+
301
+ module.exports = {
302
+ loadConfig,
303
+ addProject,
304
+ // exported for tests / introspection
305
+ _parseDotenv: parseDotenv,
306
+ _substituteEnv: substituteEnv,
307
+ _paths: { CONFIG_DIR, CONFIG_PATH, SECRETS_PATH }
308
+ };
@@ -0,0 +1,130 @@
1
+ // Database layer - SQLite for session persistence and command history
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+
6
+ function initDatabase(Database) {
7
+ const dbPath = path.join(os.homedir(), '.termdeck', 'termdeck.db');
8
+
9
+ // Ensure directory exists
10
+ const fs = require('fs');
11
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
12
+
13
+ const db = new Database(dbPath);
14
+
15
+ // Enable WAL mode for better concurrent read performance
16
+ db.pragma('journal_mode = WAL');
17
+
18
+ db.exec(`
19
+ CREATE TABLE IF NOT EXISTS sessions (
20
+ id TEXT PRIMARY KEY,
21
+ type TEXT NOT NULL DEFAULT 'shell',
22
+ project TEXT,
23
+ label TEXT,
24
+ command TEXT,
25
+ cwd TEXT,
26
+ created_at TEXT NOT NULL,
27
+ exited_at TEXT,
28
+ exit_code INTEGER,
29
+ reason TEXT,
30
+ theme TEXT DEFAULT 'tokyo-night'
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS command_history (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ session_id TEXT NOT NULL,
36
+ command TEXT NOT NULL,
37
+ output_snippet TEXT,
38
+ timestamp TEXT NOT NULL,
39
+ source TEXT NOT NULL DEFAULT 'user',
40
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS rag_events (
44
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ session_id TEXT NOT NULL,
46
+ event_type TEXT NOT NULL,
47
+ payload TEXT NOT NULL,
48
+ project TEXT,
49
+ timestamp TEXT NOT NULL,
50
+ synced INTEGER DEFAULT 0,
51
+ FOREIGN KEY (session_id) REFERENCES sessions(id)
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS projects (
55
+ name TEXT PRIMARY KEY,
56
+ path TEXT NOT NULL,
57
+ default_theme TEXT DEFAULT 'tokyo-night',
58
+ default_command TEXT DEFAULT 'bash',
59
+ rag_namespace TEXT,
60
+ created_at TEXT NOT NULL
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);
64
+ CREATE INDEX IF NOT EXISTS idx_commands_session ON command_history(session_id);
65
+ CREATE INDEX IF NOT EXISTS idx_rag_synced ON rag_events(synced);
66
+ CREATE INDEX IF NOT EXISTS idx_rag_project ON rag_events(project);
67
+ `);
68
+
69
+ // Migration: add command_history.source on existing databases
70
+ try {
71
+ const cols = db.prepare(`PRAGMA table_info(command_history)`).all();
72
+ const hasSource = cols.some((c) => c.name === 'source');
73
+ if (!hasSource) {
74
+ db.exec(`ALTER TABLE command_history ADD COLUMN source TEXT NOT NULL DEFAULT 'user'`);
75
+ db.exec(`UPDATE command_history SET source = 'user' WHERE source IS NULL`);
76
+ console.log("[db] Migrated command_history: added 'source' column");
77
+ }
78
+ } catch (err) {
79
+ console.warn('[db] command_history.source migration failed:', err.message);
80
+ }
81
+
82
+ return db;
83
+ }
84
+
85
+ function logCommand(db, sessionId, command, outputSnippet, source) {
86
+ db.prepare(`
87
+ INSERT INTO command_history (session_id, command, output_snippet, timestamp, source)
88
+ VALUES (?, ?, ?, ?, ?)
89
+ `).run(sessionId, command, outputSnippet || null, new Date().toISOString(), source || 'user');
90
+ }
91
+
92
+ function logRagEvent(db, sessionId, eventType, payload, project) {
93
+ db.prepare(`
94
+ INSERT INTO rag_events (session_id, event_type, payload, project, timestamp)
95
+ VALUES (?, ?, ?, ?, ?)
96
+ `).run(sessionId, eventType, JSON.stringify(payload), project, new Date().toISOString());
97
+ }
98
+
99
+ function getUnsyncedRagEvents(db, limit = 50) {
100
+ return db.prepare(`
101
+ SELECT * FROM rag_events WHERE synced = 0 ORDER BY timestamp ASC LIMIT ?
102
+ `).all(limit);
103
+ }
104
+
105
+ function markRagEventsSynced(db, ids) {
106
+ const placeholders = ids.map(() => '?').join(',');
107
+ db.prepare(`UPDATE rag_events SET synced = 1 WHERE id IN (${placeholders})`).run(...ids);
108
+ }
109
+
110
+ function getSessionHistory(db, sessionId) {
111
+ return db.prepare(`
112
+ SELECT * FROM command_history WHERE session_id = ? ORDER BY timestamp DESC LIMIT 50
113
+ `).all(sessionId);
114
+ }
115
+
116
+ function getProjectSessions(db, project) {
117
+ return db.prepare(`
118
+ SELECT * FROM sessions WHERE project = ? ORDER BY created_at DESC
119
+ `).all(project);
120
+ }
121
+
122
+ module.exports = {
123
+ initDatabase,
124
+ logCommand,
125
+ logRagEvent,
126
+ getUnsyncedRagEvents,
127
+ markRagEventsSynced,
128
+ getSessionHistory,
129
+ getProjectSessions
130
+ };
@@ -0,0 +1,232 @@
1
+ // Engram bridge — routes TermDeck memory queries through one of three backends:
2
+ // - direct: talk to Supabase + OpenAI from the server (pre-bridge behavior)
3
+ // - webhook: POST to Engram's HTTP webhook server (T3.1) at rag.engramWebhookUrl
4
+ // - mcp: spawn the @jhizzard/engram binary and talk JSON-RPC over stdio
5
+ //
6
+ // All three modes return the same shape:
7
+ // { memories: Array<{ content, source_type, project, similarity, created_at }>, total }
8
+ //
9
+ // Errors are thrown as plain Error objects; the caller maps them to HTTP responses.
10
+
11
+ const { spawn } = require('child_process');
12
+
13
+ function createBridge(config) {
14
+ const mode = config.rag?.engramMode || 'direct';
15
+ const state = { mcpChild: null, mcpQueue: [], mcpNextId: 1, mcpBuffer: '' };
16
+
17
+ async function queryDirect({ question, project, searchAll }) {
18
+ const supabaseUrl = config.rag?.supabaseUrl;
19
+ const supabaseKey = config.rag?.supabaseKey;
20
+ const openaiKey = config.rag?.openaiApiKey || process.env.OPENAI_API_KEY;
21
+
22
+ if (!supabaseUrl || !supabaseKey) {
23
+ throw new Error('RAG not configured — add supabaseUrl and supabaseKey to ~/.termdeck/config.yaml');
24
+ }
25
+ if (!openaiKey) {
26
+ throw new Error('OPENAI_API_KEY not configured');
27
+ }
28
+
29
+ const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Authorization': `Bearer ${openaiKey}`,
33
+ 'Content-Type': 'application/json'
34
+ },
35
+ body: JSON.stringify({
36
+ model: 'text-embedding-3-large',
37
+ input: question,
38
+ dimensions: 1536
39
+ })
40
+ });
41
+ if (!embeddingRes.ok) {
42
+ const err = await embeddingRes.text();
43
+ console.error('[engram-bridge:direct] embedding failed:', err);
44
+ throw new Error('Embedding generation failed');
45
+ }
46
+ const embeddingData = await embeddingRes.json();
47
+ const embedding = embeddingData.data[0].embedding;
48
+
49
+ const searchRes = await fetch(`${supabaseUrl}/rest/v1/rpc/memory_hybrid_search`, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'apikey': supabaseKey,
54
+ 'Authorization': `Bearer ${supabaseKey}`
55
+ },
56
+ body: JSON.stringify({
57
+ query_text: question,
58
+ query_embedding: `[${embedding.join(',')}]`,
59
+ match_count: 10,
60
+ full_text_weight: 1.0,
61
+ semantic_weight: 1.0,
62
+ rrf_k: 60,
63
+ filter_project: searchAll ? null : (project || null),
64
+ filter_source_type: null,
65
+ recency_weight: 0.15,
66
+ decay_days: 30.0
67
+ })
68
+ });
69
+ if (!searchRes.ok) {
70
+ const err = await searchRes.text();
71
+ console.error('[engram-bridge:direct] supabase search failed:', err);
72
+ throw new Error('Memory search failed');
73
+ }
74
+ const rows = await searchRes.json();
75
+ return {
76
+ memories: rows.map((m) => ({
77
+ content: m.content,
78
+ source_type: m.source_type,
79
+ project: m.project,
80
+ similarity: m.similarity,
81
+ created_at: m.created_at
82
+ })),
83
+ total: rows.length
84
+ };
85
+ }
86
+
87
+ async function queryWebhook({ question, project, searchAll }) {
88
+ const url = config.rag?.engramWebhookUrl || 'http://localhost:37778/engram';
89
+ const res = await fetch(url, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({
93
+ op: 'recall',
94
+ question,
95
+ project: searchAll ? null : (project || null),
96
+ min_results: 5
97
+ })
98
+ });
99
+ if (!res.ok) {
100
+ const err = await res.text();
101
+ console.error('[engram-bridge:webhook] request failed:', err);
102
+ throw new Error(`Engram webhook returned ${res.status}`);
103
+ }
104
+ const data = await res.json();
105
+ const rows = data.memories || [];
106
+ return {
107
+ memories: rows.map((m) => ({
108
+ content: m.content,
109
+ source_type: m.source_type,
110
+ project: m.project,
111
+ similarity: m.similarity ?? m.score ?? null,
112
+ created_at: m.created_at
113
+ })),
114
+ total: rows.length
115
+ };
116
+ }
117
+
118
+ function ensureMcpChild() {
119
+ if (state.mcpChild && !state.mcpChild.killed) return state.mcpChild;
120
+
121
+ const bin = config.rag?.engramBinary || 'engram';
122
+ const child = spawn(bin, ['serve', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
123
+ state.mcpChild = child;
124
+ state.mcpBuffer = '';
125
+
126
+ child.stdout.on('data', (chunk) => {
127
+ state.mcpBuffer += chunk.toString('utf-8');
128
+ let idx;
129
+ while ((idx = state.mcpBuffer.indexOf('\n')) >= 0) {
130
+ const line = state.mcpBuffer.slice(0, idx).trim();
131
+ state.mcpBuffer = state.mcpBuffer.slice(idx + 1);
132
+ if (!line) continue;
133
+ try {
134
+ const msg = JSON.parse(line);
135
+ const pending = state.mcpQueue.find((p) => p.id === msg.id);
136
+ if (pending) {
137
+ state.mcpQueue = state.mcpQueue.filter((p) => p !== pending);
138
+ if (msg.error) pending.reject(new Error(msg.error.message || 'Engram MCP error'));
139
+ else pending.resolve(msg.result);
140
+ }
141
+ } catch (err) {
142
+ console.error('[engram-bridge:mcp] parse error:', err.message, line);
143
+ }
144
+ }
145
+ });
146
+
147
+ child.stderr.on('data', (chunk) => {
148
+ console.error('[engram-bridge:mcp]', chunk.toString('utf-8').trim());
149
+ });
150
+
151
+ child.on('exit', (code, signal) => {
152
+ console.warn(`[engram-bridge:mcp] child exited (code=${code}, signal=${signal}); will respawn on next call`);
153
+ state.mcpChild = null;
154
+ for (const pending of state.mcpQueue) {
155
+ pending.reject(new Error('Engram MCP child exited'));
156
+ }
157
+ state.mcpQueue = [];
158
+ });
159
+
160
+ return child;
161
+ }
162
+
163
+ function mcpCall(method, params) {
164
+ const child = ensureMcpChild();
165
+ const id = state.mcpNextId++;
166
+ const req = { jsonrpc: '2.0', id, method, params };
167
+ return new Promise((resolve, reject) => {
168
+ state.mcpQueue.push({ id, resolve, reject });
169
+ try {
170
+ child.stdin.write(JSON.stringify(req) + '\n');
171
+ } catch (err) {
172
+ state.mcpQueue = state.mcpQueue.filter((p) => p.id !== id);
173
+ reject(err);
174
+ }
175
+ // Safety timeout
176
+ setTimeout(() => {
177
+ const pending = state.mcpQueue.find((p) => p.id === id);
178
+ if (pending) {
179
+ state.mcpQueue = state.mcpQueue.filter((p) => p !== pending);
180
+ pending.reject(new Error('Engram MCP call timed out'));
181
+ }
182
+ }, 15000);
183
+ });
184
+ }
185
+
186
+ async function queryMcp({ question, project, searchAll }) {
187
+ try {
188
+ const result = await mcpCall('tools/call', {
189
+ name: 'memory_recall',
190
+ arguments: {
191
+ query: question,
192
+ project: searchAll ? null : (project || null),
193
+ match_count: 10
194
+ }
195
+ });
196
+ const rows = (result && (result.memories || result.content || [])) || [];
197
+ return {
198
+ memories: rows.map((m) => ({
199
+ content: m.content,
200
+ source_type: m.source_type,
201
+ project: m.project,
202
+ similarity: m.similarity ?? m.score ?? null,
203
+ created_at: m.created_at
204
+ })),
205
+ total: rows.length
206
+ };
207
+ } catch (err) {
208
+ // Kill child so it respawns next call
209
+ if (state.mcpChild) {
210
+ try { state.mcpChild.kill(); } catch {}
211
+ state.mcpChild = null;
212
+ }
213
+ throw err;
214
+ }
215
+ }
216
+
217
+ async function queryEngram({ question, project, searchAll }) {
218
+ switch (mode) {
219
+ case 'webhook':
220
+ return queryWebhook({ question, project, searchAll });
221
+ case 'mcp':
222
+ return queryMcp({ question, project, searchAll });
223
+ case 'direct':
224
+ default:
225
+ return queryDirect({ question, project, searchAll });
226
+ }
227
+ }
228
+
229
+ return { mode, queryEngram };
230
+ }
231
+
232
+ module.exports = { createBridge };