@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.
@@ -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, respects rag.enabled
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;