@jhizzard/termdeck 0.2.5 → 0.3.1
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/README.md +12 -4
- package/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/cli/src/init-rumen.js +99 -10
- package/packages/client/public/app.js +2786 -0
- package/packages/client/public/index.html +39 -3280
- package/packages/client/public/style.css +1776 -0
- package/packages/server/src/index.js +277 -6
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +40 -0
- package/packages/server/src/session.js +13 -2
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +6 -1
- package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +39 -3
- package/packages/server/src/transcripts.js +290 -0
|
@@ -10,15 +10,45 @@ const fs = require('fs');
|
|
|
10
10
|
const { v4: uuidv4 } = require('uuid');
|
|
11
11
|
|
|
12
12
|
// Conditional imports (graceful fallback if not installed yet)
|
|
13
|
-
let pty, Database;
|
|
13
|
+
let pty, Database, pg;
|
|
14
14
|
try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
|
|
15
15
|
try { Database = require('better-sqlite3'); } catch { Database = null; }
|
|
16
|
+
try { pg = require('pg'); } catch { pg = null; }
|
|
17
|
+
|
|
18
|
+
// Module-level singleton Postgres pool for rumen_insights (petvetbid DB).
|
|
19
|
+
// Lazy-initialized on first rumen endpoint hit so startup stays fast and
|
|
20
|
+
// servers without DATABASE_URL never pay the connection cost.
|
|
21
|
+
let _rumenPool = null;
|
|
22
|
+
let _rumenPoolFailed = false;
|
|
23
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
function getRumenPool() {
|
|
25
|
+
if (_rumenPool || _rumenPoolFailed) return _rumenPool;
|
|
26
|
+
if (!pg || !process.env.DATABASE_URL) return null;
|
|
27
|
+
try {
|
|
28
|
+
_rumenPool = new pg.Pool({
|
|
29
|
+
connectionString: process.env.DATABASE_URL,
|
|
30
|
+
max: 4,
|
|
31
|
+
idleTimeoutMillis: 30000,
|
|
32
|
+
connectionTimeoutMillis: 5000
|
|
33
|
+
});
|
|
34
|
+
_rumenPool.on('error', (err) => {
|
|
35
|
+
console.warn('[rumen] pg pool error:', err.message);
|
|
36
|
+
});
|
|
37
|
+
return _rumenPool;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.warn('[rumen] failed to create pg pool:', err.message);
|
|
40
|
+
_rumenPoolFailed = true;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
16
44
|
|
|
17
45
|
const { SessionManager } = require('./session');
|
|
18
46
|
const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
|
|
19
47
|
const { RAGIntegration } = require('./rag');
|
|
20
48
|
const { createBridge } = require('./mnestra-bridge');
|
|
21
49
|
const { writeSessionLog } = require('./session-logger');
|
|
50
|
+
const { TranscriptWriter } = require('./transcripts');
|
|
51
|
+
const { createHealthHandler } = require('./preflight');
|
|
22
52
|
const { themes, statusColors } = require('./themes');
|
|
23
53
|
const { loadConfig, addProject } = require('./config');
|
|
24
54
|
|
|
@@ -59,12 +89,33 @@ function createServer(config) {
|
|
|
59
89
|
const mnestraBridge = createBridge(config);
|
|
60
90
|
console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
|
|
61
91
|
|
|
92
|
+
// Initialize transcript writer (Session Transcripts — Sprint 6)
|
|
93
|
+
const transcriptConfig = config.transcripts || {};
|
|
94
|
+
const transcriptEnabled = transcriptConfig.enabled !== undefined
|
|
95
|
+
? transcriptConfig.enabled
|
|
96
|
+
: !!process.env.DATABASE_URL;
|
|
97
|
+
let transcriptWriter = null;
|
|
98
|
+
if (transcriptEnabled && process.env.DATABASE_URL) {
|
|
99
|
+
transcriptWriter = new TranscriptWriter(process.env.DATABASE_URL, {
|
|
100
|
+
batchSize: transcriptConfig.batchSize || 50,
|
|
101
|
+
flushIntervalMs: transcriptConfig.flushIntervalMs || 2000,
|
|
102
|
+
enabled: true
|
|
103
|
+
});
|
|
104
|
+
console.log('[transcript] Writer initialized (flush every %dms, batch %d)',
|
|
105
|
+
transcriptConfig.flushIntervalMs || 2000, transcriptConfig.batchSize || 50);
|
|
106
|
+
} else {
|
|
107
|
+
console.log('[transcript] Writer disabled (no DATABASE_URL or transcripts.enabled=false)');
|
|
108
|
+
}
|
|
109
|
+
|
|
62
110
|
// Wire RAG to session events
|
|
63
111
|
sessions.on('session:created', (s) => rag.onSessionCreated(s));
|
|
64
112
|
sessions.on('session:removed', (s) => rag.onSessionEnded(s));
|
|
65
113
|
|
|
66
114
|
// ==================== REST API ====================
|
|
67
115
|
|
|
116
|
+
// GET /api/health - preflight health checks (Sprint 6 T1, wired by T3)
|
|
117
|
+
app.get('/api/health', createHealthHandler(config));
|
|
118
|
+
|
|
68
119
|
// GET /api/sessions - list all active sessions
|
|
69
120
|
app.get('/api/sessions', (req, res) => {
|
|
70
121
|
res.json(sessions.getAll());
|
|
@@ -129,7 +180,7 @@ function createServer(config) {
|
|
|
129
180
|
session.pid = term.pid;
|
|
130
181
|
session.meta.status = 'active';
|
|
131
182
|
|
|
132
|
-
// PTY output → analyze + broadcast to WebSocket
|
|
183
|
+
// PTY output → analyze + broadcast to WebSocket + transcript archive
|
|
133
184
|
term.onData((data) => {
|
|
134
185
|
session.analyzeOutput(data);
|
|
135
186
|
|
|
@@ -137,6 +188,15 @@ function createServer(config) {
|
|
|
137
188
|
if (session.ws && session.ws.readyState === 1) {
|
|
138
189
|
session.ws.send(JSON.stringify({ type: 'output', data }));
|
|
139
190
|
}
|
|
191
|
+
|
|
192
|
+
// Archive to transcript writer (non-blocking, failure-safe)
|
|
193
|
+
if (transcriptWriter) {
|
|
194
|
+
try {
|
|
195
|
+
transcriptWriter.append(session.id, data, Buffer.byteLength(data, 'utf8'));
|
|
196
|
+
} catch (err) {
|
|
197
|
+
// Never let transcript failures disrupt the PTY data path
|
|
198
|
+
}
|
|
199
|
+
}
|
|
140
200
|
});
|
|
141
201
|
|
|
142
202
|
term.onExit(({ exitCode, signal }) => {
|
|
@@ -171,9 +231,13 @@ function createServer(config) {
|
|
|
171
231
|
rag.onStatusChanged(sess, oldStatus, newStatus);
|
|
172
232
|
};
|
|
173
233
|
|
|
174
|
-
// Proactive Mnestra queries on error — fire-and-forget
|
|
234
|
+
// Proactive Mnestra queries on error — fire-and-forget.
|
|
235
|
+
// Independent of rag.enabled — the push loop (rag.js) and the Flashback
|
|
236
|
+
// bridge (mnestra-bridge) are separate systems. rag.enabled gates only
|
|
237
|
+
// the telemetry push loop. Flashback has its own error handling via
|
|
238
|
+
// the catch below and should fire whenever the Mnestra bridge is
|
|
239
|
+
// configured, regardless of the push-loop flag.
|
|
175
240
|
session.onErrorDetected = (sess, ctx) => {
|
|
176
|
-
if (!rag.enabled) return;
|
|
177
241
|
const question = `${sess.meta.type} error ${ctx.lastCommand || ''} ${ctx.tail || ''}`.trim();
|
|
178
242
|
mnestraBridge.queryMnestra({
|
|
179
243
|
question,
|
|
@@ -415,6 +479,193 @@ function createServer(config) {
|
|
|
415
479
|
});
|
|
416
480
|
});
|
|
417
481
|
|
|
482
|
+
// ==================== Transcript endpoints (Sprint 6 T3) ====================
|
|
483
|
+
|
|
484
|
+
// GET /api/transcripts/search - FTS across all sessions
|
|
485
|
+
// (Must be registered before :sessionId to avoid route collision)
|
|
486
|
+
app.get('/api/transcripts/search', async (req, res) => {
|
|
487
|
+
if (!transcriptWriter) return res.json([]);
|
|
488
|
+
const q = req.query.q;
|
|
489
|
+
if (!q) return res.status(400).json({ error: 'Missing q parameter' });
|
|
490
|
+
const since = req.query.since || null;
|
|
491
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
|
492
|
+
try {
|
|
493
|
+
const results = await transcriptWriter.search(q, { since, limit });
|
|
494
|
+
res.json(results);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.error('[transcript] search endpoint error:', err.message);
|
|
497
|
+
res.status(500).json({ error: 'Transcript search failed' });
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// GET /api/transcripts/recent - time-windowed crash recovery
|
|
502
|
+
app.get('/api/transcripts/recent', async (req, res) => {
|
|
503
|
+
if (!transcriptWriter) return res.json([]);
|
|
504
|
+
const minutes = Math.min(Math.max(parseInt(req.query.minutes) || 60, 1), 1440);
|
|
505
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 500, 1), 2000);
|
|
506
|
+
try {
|
|
507
|
+
const results = await transcriptWriter.getRecent(minutes, limit);
|
|
508
|
+
res.json(results);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
console.error('[transcript] recent endpoint error:', err.message);
|
|
511
|
+
res.status(500).json({ error: 'Transcript recent query failed' });
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// GET /api/transcripts/:sessionId - ordered chunks for a session
|
|
516
|
+
app.get('/api/transcripts/:sessionId', async (req, res) => {
|
|
517
|
+
if (!transcriptWriter) return res.json([]);
|
|
518
|
+
const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit), 1), 5000) : undefined;
|
|
519
|
+
const since = req.query.since || undefined;
|
|
520
|
+
try {
|
|
521
|
+
const chunks = await transcriptWriter.getSessionTranscript(req.params.sessionId, { limit, since });
|
|
522
|
+
res.json(chunks);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error('[transcript] session transcript endpoint error:', err.message);
|
|
525
|
+
res.status(500).json({ error: 'Transcript retrieval failed' });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ==================== Rumen insights (Sprint 4 T2) ====================
|
|
530
|
+
// Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
|
|
531
|
+
// instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
|
|
532
|
+
|
|
533
|
+
function rumenUnreachable(res) {
|
|
534
|
+
return res.status(503).json({ error: 'rumen database unreachable' });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// GET /api/rumen/insights
|
|
538
|
+
app.get('/api/rumen/insights', async (req, res) => {
|
|
539
|
+
const pool = getRumenPool();
|
|
540
|
+
if (!pool) {
|
|
541
|
+
return res.json({ insights: [], total: 0, enabled: false });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
let limit = parseInt(req.query.limit, 10);
|
|
545
|
+
if (!Number.isFinite(limit)) limit = 20;
|
|
546
|
+
limit = Math.max(1, Math.min(100, limit));
|
|
547
|
+
|
|
548
|
+
const project = typeof req.query.project === 'string' && req.query.project.trim()
|
|
549
|
+
? req.query.project.trim() : null;
|
|
550
|
+
const since = typeof req.query.since === 'string' && !Number.isNaN(Date.parse(req.query.since))
|
|
551
|
+
? new Date(req.query.since).toISOString() : null;
|
|
552
|
+
const unseen = typeof req.query.unseen === 'string' &&
|
|
553
|
+
/^(1|true|yes)$/i.test(req.query.unseen);
|
|
554
|
+
|
|
555
|
+
const where = [];
|
|
556
|
+
const params = [];
|
|
557
|
+
if (project) { params.push(project); where.push(`$${params.length} = ANY(projects)`); }
|
|
558
|
+
if (since) { params.push(since); where.push(`created_at >= $${params.length}`); }
|
|
559
|
+
if (unseen) { where.push(`acted_upon = FALSE`); }
|
|
560
|
+
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const countSql = `SELECT COUNT(*)::int AS n FROM rumen_insights ${whereSql}`;
|
|
564
|
+
const listParams = params.slice();
|
|
565
|
+
listParams.push(limit);
|
|
566
|
+
const listSql =
|
|
567
|
+
`SELECT id, insight_text, confidence, projects, source_memory_ids, created_at, acted_upon
|
|
568
|
+
FROM rumen_insights
|
|
569
|
+
${whereSql}
|
|
570
|
+
ORDER BY created_at DESC
|
|
571
|
+
LIMIT $${listParams.length}`;
|
|
572
|
+
|
|
573
|
+
const [countRes, listRes] = await Promise.all([
|
|
574
|
+
pool.query(countSql, params),
|
|
575
|
+
pool.query(listSql, listParams)
|
|
576
|
+
]);
|
|
577
|
+
|
|
578
|
+
const insights = listRes.rows.map((r) => ({
|
|
579
|
+
id: r.id,
|
|
580
|
+
insight_text: r.insight_text,
|
|
581
|
+
confidence: r.confidence == null ? 0 : Number(r.confidence),
|
|
582
|
+
projects: r.projects || [],
|
|
583
|
+
source_memory_ids: r.source_memory_ids || [],
|
|
584
|
+
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
|
|
585
|
+
acted_upon: !!r.acted_upon
|
|
586
|
+
}));
|
|
587
|
+
|
|
588
|
+
res.json({ insights, total: countRes.rows[0]?.n || 0 });
|
|
589
|
+
} catch (err) {
|
|
590
|
+
console.warn('[rumen] GET /insights failed:', err.message);
|
|
591
|
+
return rumenUnreachable(res);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// GET /api/rumen/status
|
|
596
|
+
app.get('/api/rumen/status', async (req, res) => {
|
|
597
|
+
const pool = getRumenPool();
|
|
598
|
+
if (!pool) return res.json({ enabled: false });
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
const jobSql =
|
|
602
|
+
`SELECT id, status, completed_at, sessions_processed, insights_generated
|
|
603
|
+
FROM rumen_jobs
|
|
604
|
+
ORDER BY started_at DESC
|
|
605
|
+
LIMIT 1`;
|
|
606
|
+
const insightSql =
|
|
607
|
+
`SELECT
|
|
608
|
+
COUNT(*)::int AS total,
|
|
609
|
+
COUNT(*) FILTER (WHERE acted_upon = FALSE)::int AS unseen,
|
|
610
|
+
MAX(created_at) AS latest
|
|
611
|
+
FROM rumen_insights`;
|
|
612
|
+
|
|
613
|
+
const [jobRes, insightRes] = await Promise.all([
|
|
614
|
+
pool.query(jobSql),
|
|
615
|
+
pool.query(insightSql)
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
const job = jobRes.rows[0] || null;
|
|
619
|
+
const stat = insightRes.rows[0] || { total: 0, unseen: 0, latest: null };
|
|
620
|
+
|
|
621
|
+
res.json({
|
|
622
|
+
enabled: true,
|
|
623
|
+
last_job_id: job ? job.id : null,
|
|
624
|
+
last_job_status: job ? job.status : null,
|
|
625
|
+
last_job_completed_at: job && job.completed_at
|
|
626
|
+
? (job.completed_at instanceof Date ? job.completed_at.toISOString() : job.completed_at)
|
|
627
|
+
: null,
|
|
628
|
+
last_job_sessions_processed: job ? (job.sessions_processed || 0) : 0,
|
|
629
|
+
last_job_insights_generated: job ? (job.insights_generated || 0) : 0,
|
|
630
|
+
total_insights: stat.total || 0,
|
|
631
|
+
unseen_insights: stat.unseen || 0,
|
|
632
|
+
latest_insight_at: stat.latest
|
|
633
|
+
? (stat.latest instanceof Date ? stat.latest.toISOString() : stat.latest)
|
|
634
|
+
: null
|
|
635
|
+
});
|
|
636
|
+
} catch (err) {
|
|
637
|
+
console.warn('[rumen] GET /status failed:', err.message);
|
|
638
|
+
return rumenUnreachable(res);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// POST /api/rumen/insights/:id/seen
|
|
643
|
+
app.post('/api/rumen/insights/:id/seen', async (req, res) => {
|
|
644
|
+
const pool = getRumenPool();
|
|
645
|
+
if (!pool) return res.status(503).json({ error: 'rumen not configured' });
|
|
646
|
+
|
|
647
|
+
const id = req.params.id;
|
|
648
|
+
if (!UUID_RE.test(id)) {
|
|
649
|
+
return res.status(400).json({ error: 'invalid insight id' });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
const result = await pool.query(
|
|
654
|
+
`UPDATE rumen_insights SET acted_upon = TRUE WHERE id = $1
|
|
655
|
+
RETURNING id, acted_upon`,
|
|
656
|
+
[id]
|
|
657
|
+
);
|
|
658
|
+
if (result.rowCount === 0) {
|
|
659
|
+
return res.status(404).json({ error: 'insight not found' });
|
|
660
|
+
}
|
|
661
|
+
const row = result.rows[0];
|
|
662
|
+
res.json({ id: row.id, acted_upon: !!row.acted_upon });
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.warn('[rumen] POST /insights/:id/seen failed:', err.message);
|
|
665
|
+
return rumenUnreachable(res);
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
418
669
|
// POST /api/ai/query - query Mnestra memory via the bridge (direct|webhook|mcp)
|
|
419
670
|
app.post('/api/ai/query', async (req, res) => {
|
|
420
671
|
let { question, sessionId, project } = req.body;
|
|
@@ -545,7 +796,7 @@ function createServer(config) {
|
|
|
545
796
|
res.sendFile(path.join(clientDir, 'index.html'));
|
|
546
797
|
});
|
|
547
798
|
|
|
548
|
-
return { app, server, wss, sessions, rag, db };
|
|
799
|
+
return { app, server, wss, sessions, rag, db, transcriptWriter };
|
|
549
800
|
}
|
|
550
801
|
|
|
551
802
|
// Start server
|
|
@@ -561,10 +812,29 @@ if (require.main === module) {
|
|
|
561
812
|
config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
|
|
562
813
|
}
|
|
563
814
|
|
|
564
|
-
const { server } = createServer(config);
|
|
815
|
+
const { server, transcriptWriter } = createServer(config);
|
|
565
816
|
const port = config.port || 3000;
|
|
566
817
|
const host = config.host || '127.0.0.1';
|
|
567
818
|
|
|
819
|
+
// Graceful shutdown — flush transcript buffer before exit
|
|
820
|
+
let shutdownInProgress = false;
|
|
821
|
+
async function handleShutdown(signal) {
|
|
822
|
+
if (shutdownInProgress) return;
|
|
823
|
+
shutdownInProgress = true;
|
|
824
|
+
console.log(`\n[server] ${signal} received, shutting down...`);
|
|
825
|
+
if (transcriptWriter) {
|
|
826
|
+
console.log('[transcript] Flushing buffer before exit...');
|
|
827
|
+
try { await transcriptWriter.close(); } catch (err) {
|
|
828
|
+
console.error('[transcript] Shutdown flush failed:', err.message);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
server.close(() => process.exit(0));
|
|
832
|
+
// Force exit after 5s if server.close hangs
|
|
833
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
834
|
+
}
|
|
835
|
+
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
836
|
+
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
837
|
+
|
|
568
838
|
server.listen(port, host, () => {
|
|
569
839
|
console.log(`\n TermDeck running at http://${host}:${port}\n`);
|
|
570
840
|
console.log(` Terminals: 0 active`);
|
|
@@ -572,6 +842,7 @@ if (require.main === module) {
|
|
|
572
842
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
573
843
|
console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
|
|
574
844
|
console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
|
|
845
|
+
console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
|
|
575
846
|
console.log(`\n WARNING: TermDeck binds to ${host} only.`);
|
|
576
847
|
console.log(` Do NOT expose this to the network without authentication.`);
|
|
577
848
|
console.log(` Terminal sessions have full shell access.\n`);
|
|
@@ -207,7 +207,7 @@ function createBridge(config) {
|
|
|
207
207
|
} catch (err) {
|
|
208
208
|
// Kill child so it respawns next call
|
|
209
209
|
if (state.mcpChild) {
|
|
210
|
-
try { state.mcpChild.kill(); } catch {}
|
|
210
|
+
try { state.mcpChild.kill(); } catch (err) { /* process may already be dead */ }
|
|
211
211
|
state.mcpChild = null;
|
|
212
212
|
}
|
|
213
213
|
throw err;
|