@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.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/config/config.example.yaml +58 -0
- package/config/secrets.env.example +17 -0
- package/package.json +65 -0
- package/packages/cli/src/index.js +101 -0
- package/packages/client/public/index.html +3444 -0
- package/packages/server/src/config.js +308 -0
- package/packages/server/src/database.js +130 -0
- package/packages/server/src/engram-bridge/index.js +232 -0
- package/packages/server/src/index.js +581 -0
- package/packages/server/src/rag.js +216 -0
- package/packages/server/src/session-logger.js +166 -0
- package/packages/server/src/session.js +421 -0
- package/packages/server/src/themes.js +250 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
// TermDeck Server - main entry point
|
|
2
|
+
// Express REST API + WebSocket hub + PTY management
|
|
3
|
+
|
|
4
|
+
const express = require('express');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const { WebSocketServer } = require('ws');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const { v4: uuidv4 } = require('uuid');
|
|
11
|
+
|
|
12
|
+
// Conditional imports (graceful fallback if not installed yet)
|
|
13
|
+
let pty, Database;
|
|
14
|
+
try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
|
|
15
|
+
try { Database = require('better-sqlite3'); } catch { Database = null; }
|
|
16
|
+
|
|
17
|
+
const { SessionManager } = require('./session');
|
|
18
|
+
const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
|
|
19
|
+
const { RAGIntegration } = require('./rag');
|
|
20
|
+
const { createBridge } = require('./engram-bridge');
|
|
21
|
+
const { writeSessionLog } = require('./session-logger');
|
|
22
|
+
const { themes, statusColors } = require('./themes');
|
|
23
|
+
const { loadConfig, addProject } = require('./config');
|
|
24
|
+
|
|
25
|
+
function createServer(config) {
|
|
26
|
+
const app = express();
|
|
27
|
+
const server = http.createServer(app);
|
|
28
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
29
|
+
|
|
30
|
+
app.use(express.json());
|
|
31
|
+
|
|
32
|
+
// Serve client files
|
|
33
|
+
const clientDir = path.join(__dirname, '..', '..', 'client', 'public');
|
|
34
|
+
app.use(express.static(clientDir));
|
|
35
|
+
|
|
36
|
+
// Initialize database
|
|
37
|
+
let db = null;
|
|
38
|
+
if (Database) {
|
|
39
|
+
try {
|
|
40
|
+
db = initDatabase(Database);
|
|
41
|
+
// Mark orphaned sessions as exited (PTYs lost on server restart)
|
|
42
|
+
const orphaned = db.prepare(
|
|
43
|
+
`UPDATE sessions SET exited_at = ?, exit_code = -1 WHERE exited_at IS NULL`
|
|
44
|
+
).run(new Date().toISOString());
|
|
45
|
+
if (orphaned.changes > 0) {
|
|
46
|
+
console.log(`[db] Marked ${orphaned.changes} orphaned session(s) as exited`);
|
|
47
|
+
}
|
|
48
|
+
console.log('[db] SQLite initialized');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn('[db] SQLite init failed:', err.message);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Initialize session manager
|
|
55
|
+
const sessions = new SessionManager(db);
|
|
56
|
+
|
|
57
|
+
// Initialize RAG + Engram bridge
|
|
58
|
+
const rag = new RAGIntegration(config, db);
|
|
59
|
+
const engramBridge = createBridge(config);
|
|
60
|
+
console.log(`[engram-bridge] mode=${engramBridge.mode}`);
|
|
61
|
+
|
|
62
|
+
// Wire RAG to session events
|
|
63
|
+
sessions.on('session:created', (s) => rag.onSessionCreated(s));
|
|
64
|
+
sessions.on('session:removed', (s) => rag.onSessionEnded(s));
|
|
65
|
+
|
|
66
|
+
// ==================== REST API ====================
|
|
67
|
+
|
|
68
|
+
// GET /api/sessions - list all active sessions
|
|
69
|
+
app.get('/api/sessions', (req, res) => {
|
|
70
|
+
res.json(sessions.getAll());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// POST /api/sessions - create a new terminal session
|
|
74
|
+
app.post('/api/sessions', (req, res) => {
|
|
75
|
+
const { command, cwd, project, label, type, theme, reason } = req.body;
|
|
76
|
+
|
|
77
|
+
const rawCwd = cwd || config.projects?.[project]?.path || os.homedir();
|
|
78
|
+
const resolvedCwd = path.resolve(rawCwd.replace(/^~/, os.homedir()));
|
|
79
|
+
|
|
80
|
+
const session = sessions.create({
|
|
81
|
+
type: type || 'shell',
|
|
82
|
+
project: project || null,
|
|
83
|
+
label: label || command || 'Terminal',
|
|
84
|
+
command: command || config.shell,
|
|
85
|
+
cwd: resolvedCwd,
|
|
86
|
+
theme: theme || config.projects?.[project]?.defaultTheme || config.defaultTheme,
|
|
87
|
+
reason: reason || 'launched via API'
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Spawn PTY
|
|
91
|
+
if (pty) {
|
|
92
|
+
// Three launch shapes:
|
|
93
|
+
// (1) no command → spawn the default shell interactively
|
|
94
|
+
// (2) command is a plain shell name (zsh, bash, fish, ...)
|
|
95
|
+
// → spawn THAT shell interactively, no -c wrapper
|
|
96
|
+
// (otherwise `zsh -c zsh` exits immediately)
|
|
97
|
+
// (3) command is a real command string
|
|
98
|
+
// → spawn default shell with -c <command>
|
|
99
|
+
const cmdTrim = (command || '').trim();
|
|
100
|
+
const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
|
|
101
|
+
const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
|
|
102
|
+
|
|
103
|
+
const spawnShell = isPlainShell ? cmdTrim : (config.shell || '/bin/zsh');
|
|
104
|
+
const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const term = pty.spawn(spawnShell, args, {
|
|
108
|
+
name: 'xterm-256color',
|
|
109
|
+
cols: 120,
|
|
110
|
+
rows: 30,
|
|
111
|
+
cwd: resolvedCwd,
|
|
112
|
+
env: {
|
|
113
|
+
...process.env,
|
|
114
|
+
TERMDECK_SESSION: session.id,
|
|
115
|
+
TERMDECK_PROJECT: project || '',
|
|
116
|
+
TERM: 'xterm-256color',
|
|
117
|
+
COLORTERM: 'truecolor',
|
|
118
|
+
// Kill macOS Terminal.app's zsh session save on teardown.
|
|
119
|
+
// We do NOT override TERM_SESSION_ID or SHELL_SESSION_DID_INIT —
|
|
120
|
+
// touching those caused interactive shells to stop accepting
|
|
121
|
+
// input in at least one confirmed reproducer. If ~/.zsh_sessions/
|
|
122
|
+
// files get corrupted externally and produce a one-line startup
|
|
123
|
+
// warning, that is cosmetic and safe to ignore.
|
|
124
|
+
SHELL_SESSION_HISTORY: '0'
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
session.pty = term;
|
|
129
|
+
session.pid = term.pid;
|
|
130
|
+
session.meta.status = 'active';
|
|
131
|
+
|
|
132
|
+
// PTY output → analyze + broadcast to WebSocket
|
|
133
|
+
term.onData((data) => {
|
|
134
|
+
session.analyzeOutput(data);
|
|
135
|
+
|
|
136
|
+
// Send to connected WebSocket
|
|
137
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
138
|
+
session.ws.send(JSON.stringify({ type: 'output', data }));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
term.onExit(({ exitCode, signal }) => {
|
|
143
|
+
session.meta.status = 'exited';
|
|
144
|
+
session.meta.exitCode = exitCode;
|
|
145
|
+
session.meta.statusDetail = `Exited (${exitCode})${signal ? `, signal ${signal}` : ''}`;
|
|
146
|
+
|
|
147
|
+
if (session.ws && session.ws.readyState === 1) {
|
|
148
|
+
session.ws.send(JSON.stringify({
|
|
149
|
+
type: 'exit',
|
|
150
|
+
exitCode,
|
|
151
|
+
signal
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
rag.onSessionEnded(session);
|
|
156
|
+
|
|
157
|
+
// Fire-and-forget session log (T2.5)
|
|
158
|
+
writeSessionLog({ session, config, db, getSessionHistory });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Wire command logging to SQLite + RAG
|
|
162
|
+
session.onCommand = (sessionId, command) => {
|
|
163
|
+
if (db) {
|
|
164
|
+
try { logCommand(db, sessionId, command); } catch (err) { console.error('[db] logCommand failed:', err); }
|
|
165
|
+
}
|
|
166
|
+
rag.onCommandExecuted(session, command);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Wire status change tracking to RAG
|
|
170
|
+
session.onStatusChange = (sess, oldStatus, newStatus) => {
|
|
171
|
+
rag.onStatusChanged(sess, oldStatus, newStatus);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Proactive Engram queries on error — fire-and-forget, respects rag.enabled
|
|
175
|
+
session.onErrorDetected = (sess, ctx) => {
|
|
176
|
+
if (!rag.enabled) return;
|
|
177
|
+
const question = `${sess.meta.type} error ${ctx.lastCommand || ''} ${ctx.tail || ''}`.trim();
|
|
178
|
+
engramBridge.queryEngram({
|
|
179
|
+
question,
|
|
180
|
+
project: sess.meta.project,
|
|
181
|
+
searchAll: false,
|
|
182
|
+
sessionContext: {
|
|
183
|
+
type: sess.meta.type,
|
|
184
|
+
project: sess.meta.project,
|
|
185
|
+
lastCommands: sess.meta.lastCommands.slice(-5),
|
|
186
|
+
status: 'errored'
|
|
187
|
+
}
|
|
188
|
+
}).then((result) => {
|
|
189
|
+
const hit = (result.memories || [])[0];
|
|
190
|
+
if (!hit) return;
|
|
191
|
+
if (sess.ws && sess.ws.readyState === 1) {
|
|
192
|
+
try {
|
|
193
|
+
sess.ws.send(JSON.stringify({ type: 'proactive_memory', hit }));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error('[ws] proactive_memory send failed:', err);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}).catch((err) => {
|
|
199
|
+
console.warn('[engram-bridge] proactive query failed:', err.message);
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
console.log(`[pty] Spawned session ${session.id} (PID ${term.pid}): ${spawnShell} ${args.join(' ')}`);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
session.meta.status = 'errored';
|
|
206
|
+
session.meta.statusDetail = err.message;
|
|
207
|
+
console.error(`[pty] Spawn failed:`, err);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
session.meta.status = 'errored';
|
|
211
|
+
session.meta.statusDetail = 'node-pty not available';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
res.status(201).json(session.toJSON());
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// GET /api/sessions/:id - get session details
|
|
218
|
+
app.get('/api/sessions/:id', (req, res) => {
|
|
219
|
+
const session = sessions.get(req.params.id);
|
|
220
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
221
|
+
res.json(session.toJSON());
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// PATCH /api/sessions/:id - update session metadata
|
|
225
|
+
app.patch('/api/sessions/:id', (req, res) => {
|
|
226
|
+
const session = sessions.updateMeta(req.params.id, req.body);
|
|
227
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
228
|
+
res.json(session.toJSON());
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// DELETE /api/sessions/:id - kill terminal and remove session
|
|
232
|
+
app.delete('/api/sessions/:id', (req, res) => {
|
|
233
|
+
const session = sessions.get(req.params.id);
|
|
234
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
235
|
+
|
|
236
|
+
// Kill PTY process
|
|
237
|
+
if (session.pty) {
|
|
238
|
+
try { session.pty.kill(); } catch (err) { console.error('[pty] kill failed for session', req.params.id + ':', err); }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
sessions.remove(req.params.id);
|
|
242
|
+
res.json({ ok: true });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// POST /api/sessions/:id/input - write text into a PTY from an external sender
|
|
246
|
+
// Body: { text: string, source?: 'user' | 'reply' | 'ai', fromSessionId?: string }
|
|
247
|
+
// Used by T1.3 reply button and any agent-to-agent routing.
|
|
248
|
+
const inputRateLimit = new Map(); // sessionId -> { windowStart, count }
|
|
249
|
+
app.post('/api/sessions/:id/input', (req, res) => {
|
|
250
|
+
const session = sessions.get(req.params.id);
|
|
251
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
252
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
253
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { text, source, fromSessionId } = req.body || {};
|
|
257
|
+
if (typeof text !== 'string') {
|
|
258
|
+
return res.status(400).json({ error: 'Missing text' });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Rate limit: max 10 writes/sec per target session
|
|
262
|
+
const now = Date.now();
|
|
263
|
+
const bucket = inputRateLimit.get(session.id) || { windowStart: now, count: 0 };
|
|
264
|
+
if (now - bucket.windowStart >= 1000) {
|
|
265
|
+
bucket.windowStart = now;
|
|
266
|
+
bucket.count = 0;
|
|
267
|
+
}
|
|
268
|
+
bucket.count += 1;
|
|
269
|
+
inputRateLimit.set(session.id, bucket);
|
|
270
|
+
if (bucket.count > 10) {
|
|
271
|
+
return res.status(429).json({ error: 'Rate limit exceeded (10/sec)' });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// CRLF normalize: zsh/readline want \r for Enter
|
|
275
|
+
const normalized = text.replace(/\r\n?/g, '\r').replace(/\n/g, '\r');
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
session.pty.write(normalized);
|
|
279
|
+
session.trackInput(normalized);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return res.status(500).json({ error: err.message });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
session.meta.replyCount = (session.meta.replyCount || 0) + 1;
|
|
285
|
+
|
|
286
|
+
// Log the injection to command_history with its source. Commands typed by the
|
|
287
|
+
// user get auto-logged via session.onCommand — here we log the raw write so
|
|
288
|
+
// non-newline-terminated injections and agent-to-agent traffic are visible.
|
|
289
|
+
const effectiveSource = source || 'user';
|
|
290
|
+
if (db) {
|
|
291
|
+
try {
|
|
292
|
+
const snippet = fromSessionId ? `from:${fromSessionId}` : null;
|
|
293
|
+
logCommand(db, session.id, text.slice(0, 500), snippet, effectiveSource);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error('[db] logCommand (input endpoint) failed:', err);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// POST /api/sessions/:id/resize - resize terminal
|
|
303
|
+
app.post('/api/sessions/:id/resize', (req, res) => {
|
|
304
|
+
const session = sessions.get(req.params.id);
|
|
305
|
+
if (!session?.pty) return res.status(404).json({ error: 'Session not found' });
|
|
306
|
+
|
|
307
|
+
const { cols, rows } = req.body;
|
|
308
|
+
try {
|
|
309
|
+
session.pty.resize(cols || 120, rows || 30);
|
|
310
|
+
res.json({ ok: true, cols, rows });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
res.status(500).json({ error: err.message });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// GET /api/sessions/:id/history - command history for session
|
|
317
|
+
app.get('/api/sessions/:id/history', (req, res) => {
|
|
318
|
+
if (!db) return res.json([]);
|
|
319
|
+
res.json(getSessionHistory(db, req.params.id));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// GET /api/themes - available terminal themes
|
|
323
|
+
app.get('/api/themes', (req, res) => {
|
|
324
|
+
const list = Object.entries(themes).map(([id, t]) => ({
|
|
325
|
+
id,
|
|
326
|
+
label: t.label,
|
|
327
|
+
category: t.category,
|
|
328
|
+
background: t.theme.background,
|
|
329
|
+
foreground: t.theme.foreground,
|
|
330
|
+
theme: t.theme
|
|
331
|
+
}));
|
|
332
|
+
res.json(list);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// GET /api/themes/:id - full theme data
|
|
336
|
+
app.get('/api/themes/:id', (req, res) => {
|
|
337
|
+
const t = themes[req.params.id];
|
|
338
|
+
if (!t) return res.status(404).json({ error: 'Theme not found' });
|
|
339
|
+
res.json(t);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// GET /api/config - current config (sanitized)
|
|
343
|
+
app.get('/api/config', (req, res) => {
|
|
344
|
+
res.json({
|
|
345
|
+
projects: config.projects || {},
|
|
346
|
+
defaultTheme: config.defaultTheme,
|
|
347
|
+
ragEnabled: rag.enabled,
|
|
348
|
+
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
349
|
+
statusColors
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// POST /api/projects - add a new project on the fly, persist to config.yaml
|
|
354
|
+
// Body: { name, path, defaultTheme?, defaultCommand? }
|
|
355
|
+
// Updates both the on-disk config.yaml and the in-memory config so new
|
|
356
|
+
// sessions can select the project immediately without a server restart.
|
|
357
|
+
app.post('/api/projects', (req, res) => {
|
|
358
|
+
const { name, path: projectPath, defaultTheme, defaultCommand } = req.body || {};
|
|
359
|
+
try {
|
|
360
|
+
const updatedProjects = addProject({ name, path: projectPath, defaultTheme, defaultCommand });
|
|
361
|
+
config.projects = updatedProjects;
|
|
362
|
+
res.json({ ok: true, projects: updatedProjects });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
console.error('[config] addProject failed:', err.message);
|
|
365
|
+
res.status(400).json({ error: err.message });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// GET /api/status - global status (control room data)
|
|
370
|
+
app.get('/api/status', (req, res) => {
|
|
371
|
+
const allSessions = sessions.getAll();
|
|
372
|
+
const byProject = {};
|
|
373
|
+
const byStatus = {};
|
|
374
|
+
const byType = {};
|
|
375
|
+
|
|
376
|
+
for (const s of allSessions) {
|
|
377
|
+
const proj = s.meta.project || 'untagged';
|
|
378
|
+
byProject[proj] = (byProject[proj] || 0) + 1;
|
|
379
|
+
byStatus[s.meta.status] = (byStatus[s.meta.status] || 0) + 1;
|
|
380
|
+
byType[s.meta.type] = (byType[s.meta.type] || 0) + 1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
res.json({
|
|
384
|
+
totalSessions: allSessions.length,
|
|
385
|
+
byProject,
|
|
386
|
+
byStatus,
|
|
387
|
+
byType,
|
|
388
|
+
uptime: process.uptime(),
|
|
389
|
+
memory: process.memoryUsage(),
|
|
390
|
+
ragEnabled: rag.enabled
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// GET /api/rag/events - recent RAG events from local buffer
|
|
395
|
+
app.get('/api/rag/events', (req, res) => {
|
|
396
|
+
if (!db) return res.json([]);
|
|
397
|
+
const limit = parseInt(req.query.limit) || 50;
|
|
398
|
+
const rows = db.prepare(
|
|
399
|
+
'SELECT * FROM rag_events ORDER BY timestamp DESC LIMIT ?'
|
|
400
|
+
).all(limit);
|
|
401
|
+
res.json(rows.map(r => ({ ...r, payload: JSON.parse(r.payload) })));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// GET /api/rag/status - RAG system status
|
|
405
|
+
app.get('/api/rag/status', (req, res) => {
|
|
406
|
+
if (!db) return res.json({ enabled: false, localEvents: 0, unsynced: 0 });
|
|
407
|
+
const total = db.prepare('SELECT COUNT(*) as n FROM rag_events').get().n;
|
|
408
|
+
const unsynced = db.prepare('SELECT COUNT(*) as n FROM rag_events WHERE synced = 0').get().n;
|
|
409
|
+
res.json({
|
|
410
|
+
enabled: rag.enabled,
|
|
411
|
+
supabaseConfigured: !!(rag.supabaseUrl),
|
|
412
|
+
localEvents: total,
|
|
413
|
+
unsynced,
|
|
414
|
+
tables: rag.tables
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// POST /api/ai/query - query Engram memory via the bridge (direct|webhook|mcp)
|
|
419
|
+
app.post('/api/ai/query', async (req, res) => {
|
|
420
|
+
let { question, sessionId, project } = req.body;
|
|
421
|
+
if (!question) return res.status(400).json({ error: 'Missing question' });
|
|
422
|
+
|
|
423
|
+
let searchAll = false;
|
|
424
|
+
if (question.toLowerCase().startsWith('all:')) {
|
|
425
|
+
question = question.substring(4).trim();
|
|
426
|
+
searchAll = true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const session = sessionId ? sessions.get(sessionId) : null;
|
|
430
|
+
const sessionContext = session ? {
|
|
431
|
+
type: session.meta.type,
|
|
432
|
+
project: session.meta.project,
|
|
433
|
+
lastCommands: session.meta.lastCommands.slice(-5),
|
|
434
|
+
status: session.meta.status
|
|
435
|
+
} : null;
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const { memories, total } = await engramBridge.queryEngram({
|
|
439
|
+
question,
|
|
440
|
+
project,
|
|
441
|
+
searchAll,
|
|
442
|
+
sessionContext
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
res.json({
|
|
446
|
+
question,
|
|
447
|
+
memories: memories.slice(0, 5).map((m) => ({
|
|
448
|
+
content: m.content?.substring(0, 500),
|
|
449
|
+
source_type: m.source_type,
|
|
450
|
+
project: m.project,
|
|
451
|
+
similarity: m.similarity,
|
|
452
|
+
created_at: m.created_at
|
|
453
|
+
})),
|
|
454
|
+
sessionContext,
|
|
455
|
+
total
|
|
456
|
+
});
|
|
457
|
+
} catch (err) {
|
|
458
|
+
console.error('[engram-bridge] query failed:', err.message);
|
|
459
|
+
// Config-shaped errors are 503, everything else 502
|
|
460
|
+
const msg = err.message || 'Query failed';
|
|
461
|
+
const status = /not configured|OPENAI_API_KEY/i.test(msg) ? 503 : 502;
|
|
462
|
+
res.status(status).json({ error: msg });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// ==================== WebSocket ====================
|
|
467
|
+
|
|
468
|
+
wss.on('connection', (ws, req) => {
|
|
469
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
470
|
+
const sessionId = url.searchParams.get('session');
|
|
471
|
+
|
|
472
|
+
if (!sessionId) {
|
|
473
|
+
ws.close(4000, 'Missing session parameter');
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const session = sessions.get(sessionId);
|
|
478
|
+
if (!session) {
|
|
479
|
+
ws.close(4001, 'Session not found');
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Bind WebSocket to session
|
|
484
|
+
session.ws = ws;
|
|
485
|
+
console.log(`[ws] Client connected to session ${sessionId}`);
|
|
486
|
+
|
|
487
|
+
// Send initial metadata
|
|
488
|
+
ws.send(JSON.stringify({
|
|
489
|
+
type: 'meta',
|
|
490
|
+
session: session.toJSON()
|
|
491
|
+
}));
|
|
492
|
+
|
|
493
|
+
// Client → PTY
|
|
494
|
+
ws.on('message', (msg) => {
|
|
495
|
+
try {
|
|
496
|
+
const parsed = JSON.parse(msg);
|
|
497
|
+
|
|
498
|
+
switch (parsed.type) {
|
|
499
|
+
case 'input':
|
|
500
|
+
if (session.pty) {
|
|
501
|
+
session.pty.write(parsed.data);
|
|
502
|
+
session.trackInput(parsed.data);
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
|
|
506
|
+
case 'resize':
|
|
507
|
+
if (session.pty) {
|
|
508
|
+
session.pty.resize(parsed.cols || 120, parsed.rows || 30);
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
|
|
512
|
+
case 'meta':
|
|
513
|
+
// Client requesting metadata refresh
|
|
514
|
+
ws.send(JSON.stringify({
|
|
515
|
+
type: 'meta',
|
|
516
|
+
session: session.toJSON()
|
|
517
|
+
}));
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
} catch (err) { console.error('[ws] message handler error:', err); }
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
ws.on('close', () => {
|
|
524
|
+
console.log(`[ws] Client disconnected from session ${sessionId}`);
|
|
525
|
+
if (session.ws === ws) {
|
|
526
|
+
session.ws = null;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Periodic metadata broadcast (control room live updates)
|
|
532
|
+
setInterval(() => {
|
|
533
|
+
const allMeta = sessions.getAll();
|
|
534
|
+
const payload = JSON.stringify({ type: 'status_broadcast', sessions: allMeta });
|
|
535
|
+
|
|
536
|
+
wss.clients.forEach((client) => {
|
|
537
|
+
if (client.readyState === 1) {
|
|
538
|
+
try { client.send(payload); } catch (err) { console.error('[ws] broadcast send failed:', err); }
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}, 2000);
|
|
542
|
+
|
|
543
|
+
// Fallback route → serve index.html
|
|
544
|
+
app.get('*', (req, res) => {
|
|
545
|
+
res.sendFile(path.join(clientDir, 'index.html'));
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return { app, server, wss, sessions, rag, db };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Start server
|
|
552
|
+
if (require.main === module) {
|
|
553
|
+
// Minimal flag parsing for direct-invocation users (the CLI wrapper has its own).
|
|
554
|
+
const argv = process.argv.slice(2);
|
|
555
|
+
if (argv.includes('--session-logs')) {
|
|
556
|
+
process.env.TERMDECK_SESSION_LOGS = '1';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const config = loadConfig();
|
|
560
|
+
if (process.env.TERMDECK_SESSION_LOGS === '1') {
|
|
561
|
+
config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const { server } = createServer(config);
|
|
565
|
+
const port = config.port || 3000;
|
|
566
|
+
const host = config.host || '127.0.0.1';
|
|
567
|
+
|
|
568
|
+
server.listen(port, host, () => {
|
|
569
|
+
console.log(`\n TermDeck running at http://${host}:${port}\n`);
|
|
570
|
+
console.log(` Terminals: 0 active`);
|
|
571
|
+
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
572
|
+
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
573
|
+
console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
|
|
574
|
+
console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
|
|
575
|
+
console.log(`\n WARNING: TermDeck binds to ${host} only.`);
|
|
576
|
+
console.log(` Do NOT expose this to the network without authentication.`);
|
|
577
|
+
console.log(` Terminal sessions have full shell access.\n`);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
module.exports = { createServer, loadConfig };
|