@jhizzard/termdeck 0.3.0 → 0.3.2
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/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/client/public/app.js +485 -0
- package/packages/client/public/style.css +337 -0
- package/packages/server/src/index.js +116 -3
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +41 -1
- package/packages/server/src/session.js +8 -1
- package/packages/server/src/transcripts.js +296 -0
|
@@ -1432,6 +1432,343 @@
|
|
|
1432
1432
|
border-color: var(--tg-accent);
|
|
1433
1433
|
}
|
|
1434
1434
|
|
|
1435
|
+
/* ===== HEALTH BADGE (Sprint 6 T4) ===== */
|
|
1436
|
+
.health-badge {
|
|
1437
|
+
display: inline-flex;
|
|
1438
|
+
align-items: center;
|
|
1439
|
+
gap: 5px;
|
|
1440
|
+
font-family: var(--tg-mono);
|
|
1441
|
+
font-size: 11px;
|
|
1442
|
+
padding: 2px 8px;
|
|
1443
|
+
border-radius: 10px;
|
|
1444
|
+
border: 1px solid var(--tg-border);
|
|
1445
|
+
background: var(--tg-surface);
|
|
1446
|
+
color: var(--tg-text-dim);
|
|
1447
|
+
cursor: pointer;
|
|
1448
|
+
transition: all 0.15s;
|
|
1449
|
+
}
|
|
1450
|
+
.health-badge:hover {
|
|
1451
|
+
color: var(--tg-text);
|
|
1452
|
+
border-color: var(--tg-border-active);
|
|
1453
|
+
}
|
|
1454
|
+
.health-badge .hb-icon { font-size: 12px; line-height: 1; }
|
|
1455
|
+
.health-badge.hb-green {
|
|
1456
|
+
color: var(--tg-green);
|
|
1457
|
+
border-color: rgba(158, 206, 106, 0.3);
|
|
1458
|
+
}
|
|
1459
|
+
.health-badge.hb-amber {
|
|
1460
|
+
color: var(--tg-amber);
|
|
1461
|
+
border-color: var(--tg-amber);
|
|
1462
|
+
background: rgba(224, 175, 104, 0.08);
|
|
1463
|
+
animation: health-pulse 2s ease-in-out infinite;
|
|
1464
|
+
}
|
|
1465
|
+
.health-badge.hb-red {
|
|
1466
|
+
color: var(--tg-red);
|
|
1467
|
+
border-color: var(--tg-red);
|
|
1468
|
+
background: rgba(247, 118, 142, 0.08);
|
|
1469
|
+
animation: health-pulse 2s ease-in-out infinite;
|
|
1470
|
+
}
|
|
1471
|
+
@keyframes health-pulse {
|
|
1472
|
+
0%, 100% { opacity: 1; }
|
|
1473
|
+
50% { opacity: 0.6; }
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/* Health dropdown */
|
|
1477
|
+
.health-dropdown {
|
|
1478
|
+
display: none;
|
|
1479
|
+
position: fixed;
|
|
1480
|
+
z-index: 3100;
|
|
1481
|
+
background: var(--tg-surface);
|
|
1482
|
+
border: 1px solid var(--tg-border);
|
|
1483
|
+
border-radius: var(--tg-radius);
|
|
1484
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
|
1485
|
+
padding: 10px 14px;
|
|
1486
|
+
min-width: 300px;
|
|
1487
|
+
max-width: 420px;
|
|
1488
|
+
font-family: var(--tg-mono);
|
|
1489
|
+
font-size: 11px;
|
|
1490
|
+
}
|
|
1491
|
+
.health-dropdown.open { display: block; }
|
|
1492
|
+
.hd-check {
|
|
1493
|
+
display: grid;
|
|
1494
|
+
grid-template-columns: 16px 1fr auto auto;
|
|
1495
|
+
gap: 6px;
|
|
1496
|
+
align-items: baseline;
|
|
1497
|
+
padding: 5px 0;
|
|
1498
|
+
border-bottom: 1px solid var(--tg-border);
|
|
1499
|
+
}
|
|
1500
|
+
.hd-check:last-child { border-bottom: none; }
|
|
1501
|
+
.hd-icon { text-align: center; font-weight: 700; }
|
|
1502
|
+
.hd-ok .hd-icon { color: var(--tg-green); }
|
|
1503
|
+
.hd-fail .hd-icon { color: var(--tg-red); }
|
|
1504
|
+
.hd-name { color: var(--tg-text); }
|
|
1505
|
+
.hd-dots {
|
|
1506
|
+
border-bottom: 1px dotted var(--tg-border);
|
|
1507
|
+
min-width: 20px;
|
|
1508
|
+
align-self: end;
|
|
1509
|
+
margin-bottom: 3px;
|
|
1510
|
+
}
|
|
1511
|
+
.hd-status { font-weight: 600; }
|
|
1512
|
+
.hd-ok .hd-status { color: var(--tg-green); }
|
|
1513
|
+
.hd-fail .hd-status { color: var(--tg-red); }
|
|
1514
|
+
.hd-detail {
|
|
1515
|
+
grid-column: 2 / -1;
|
|
1516
|
+
color: var(--tg-text-dim);
|
|
1517
|
+
font-size: 10px;
|
|
1518
|
+
}
|
|
1519
|
+
.hd-detail:empty { display: none; }
|
|
1520
|
+
.hd-remediation {
|
|
1521
|
+
grid-column: 2 / -1;
|
|
1522
|
+
color: var(--tg-amber);
|
|
1523
|
+
font-size: 10px;
|
|
1524
|
+
padding: 2px 6px;
|
|
1525
|
+
background: rgba(224, 175, 104, 0.08);
|
|
1526
|
+
border-radius: 3px;
|
|
1527
|
+
margin-top: 2px;
|
|
1528
|
+
}
|
|
1529
|
+
.hd-loading, .hd-empty {
|
|
1530
|
+
color: var(--tg-text-dim);
|
|
1531
|
+
padding: 8px 0;
|
|
1532
|
+
text-align: center;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/* ===== TRANSCRIPT MODAL (Sprint 6 T4) ===== */
|
|
1536
|
+
.transcript-modal {
|
|
1537
|
+
display: none;
|
|
1538
|
+
position: fixed;
|
|
1539
|
+
inset: 0;
|
|
1540
|
+
z-index: 3000;
|
|
1541
|
+
align-items: center;
|
|
1542
|
+
justify-content: center;
|
|
1543
|
+
}
|
|
1544
|
+
.transcript-modal.open { display: flex; }
|
|
1545
|
+
.transcript-backdrop {
|
|
1546
|
+
position: absolute;
|
|
1547
|
+
inset: 0;
|
|
1548
|
+
background: rgba(0, 0, 0, 0.72);
|
|
1549
|
+
}
|
|
1550
|
+
.transcript-card {
|
|
1551
|
+
position: relative;
|
|
1552
|
+
background: var(--tg-surface);
|
|
1553
|
+
border: 1px solid var(--tg-accent-dim);
|
|
1554
|
+
border-radius: 10px;
|
|
1555
|
+
width: 800px;
|
|
1556
|
+
max-width: calc(100vw - 40px);
|
|
1557
|
+
max-height: calc(100vh - 80px);
|
|
1558
|
+
display: flex;
|
|
1559
|
+
flex-direction: column;
|
|
1560
|
+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
|
|
1561
|
+
font-family: var(--tg-sans);
|
|
1562
|
+
color: var(--tg-text);
|
|
1563
|
+
}
|
|
1564
|
+
.transcript-card header {
|
|
1565
|
+
padding: 18px 22px 10px;
|
|
1566
|
+
border-bottom: 1px solid var(--tg-border);
|
|
1567
|
+
display: flex;
|
|
1568
|
+
align-items: center;
|
|
1569
|
+
justify-content: space-between;
|
|
1570
|
+
}
|
|
1571
|
+
.transcript-card header h3 {
|
|
1572
|
+
margin: 0;
|
|
1573
|
+
font-size: 15px;
|
|
1574
|
+
color: var(--tg-accent);
|
|
1575
|
+
}
|
|
1576
|
+
.transcript-tabs {
|
|
1577
|
+
display: flex;
|
|
1578
|
+
gap: 4px;
|
|
1579
|
+
}
|
|
1580
|
+
.transcript-tab {
|
|
1581
|
+
background: none;
|
|
1582
|
+
border: 1px solid var(--tg-border);
|
|
1583
|
+
color: var(--tg-text-dim);
|
|
1584
|
+
font-size: 11px;
|
|
1585
|
+
padding: 4px 12px;
|
|
1586
|
+
border-radius: 3px;
|
|
1587
|
+
cursor: pointer;
|
|
1588
|
+
font-family: var(--tg-sans);
|
|
1589
|
+
transition: all 0.1s;
|
|
1590
|
+
}
|
|
1591
|
+
.transcript-tab:hover { color: var(--tg-text); border-color: var(--tg-border-active); }
|
|
1592
|
+
.transcript-tab.active {
|
|
1593
|
+
color: var(--tg-accent);
|
|
1594
|
+
border-color: var(--tg-accent-dim);
|
|
1595
|
+
background: var(--tg-bg);
|
|
1596
|
+
}
|
|
1597
|
+
.transcript-search-bar {
|
|
1598
|
+
padding: 10px 22px;
|
|
1599
|
+
border-bottom: 1px solid var(--tg-border);
|
|
1600
|
+
}
|
|
1601
|
+
.transcript-search-bar .ctrl-input {
|
|
1602
|
+
width: 100%;
|
|
1603
|
+
}
|
|
1604
|
+
.transcript-body {
|
|
1605
|
+
flex: 1;
|
|
1606
|
+
overflow-y: auto;
|
|
1607
|
+
padding: 12px 22px;
|
|
1608
|
+
min-height: 200px;
|
|
1609
|
+
max-height: 60vh;
|
|
1610
|
+
}
|
|
1611
|
+
.transcript-loading, .transcript-empty {
|
|
1612
|
+
color: var(--tg-text-dim);
|
|
1613
|
+
font-size: 12px;
|
|
1614
|
+
text-align: center;
|
|
1615
|
+
padding: 40px 0;
|
|
1616
|
+
font-family: var(--tg-mono);
|
|
1617
|
+
}
|
|
1618
|
+
.transcript-card footer {
|
|
1619
|
+
padding: 10px 22px 14px;
|
|
1620
|
+
border-top: 1px solid var(--tg-border);
|
|
1621
|
+
display: flex;
|
|
1622
|
+
justify-content: space-between;
|
|
1623
|
+
align-items: center;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/* Transcript session cards (recent view) */
|
|
1627
|
+
.transcript-session {
|
|
1628
|
+
padding: 10px 12px;
|
|
1629
|
+
border: 1px solid var(--tg-border);
|
|
1630
|
+
border-radius: var(--tg-radius-sm);
|
|
1631
|
+
margin-bottom: 8px;
|
|
1632
|
+
cursor: pointer;
|
|
1633
|
+
transition: border-color 0.15s;
|
|
1634
|
+
}
|
|
1635
|
+
.transcript-session:hover { border-color: var(--tg-accent-dim); }
|
|
1636
|
+
.ts-header {
|
|
1637
|
+
display: flex;
|
|
1638
|
+
align-items: center;
|
|
1639
|
+
gap: 8px;
|
|
1640
|
+
margin-bottom: 6px;
|
|
1641
|
+
font-size: 11px;
|
|
1642
|
+
}
|
|
1643
|
+
.ts-id {
|
|
1644
|
+
font-family: var(--tg-mono);
|
|
1645
|
+
color: var(--tg-accent);
|
|
1646
|
+
font-weight: 600;
|
|
1647
|
+
}
|
|
1648
|
+
.ts-type {
|
|
1649
|
+
color: var(--tg-text-dim);
|
|
1650
|
+
font-family: var(--tg-mono);
|
|
1651
|
+
}
|
|
1652
|
+
.ts-project {
|
|
1653
|
+
padding: 1px 6px;
|
|
1654
|
+
border-radius: 3px;
|
|
1655
|
+
background: var(--tg-bg);
|
|
1656
|
+
border: 1px solid var(--tg-accent-dim);
|
|
1657
|
+
color: var(--tg-accent);
|
|
1658
|
+
font-size: 10px;
|
|
1659
|
+
}
|
|
1660
|
+
.ts-lines {
|
|
1661
|
+
color: var(--tg-text-dim);
|
|
1662
|
+
font-size: 10px;
|
|
1663
|
+
margin-left: auto;
|
|
1664
|
+
}
|
|
1665
|
+
.ts-preview {
|
|
1666
|
+
font-family: var(--tg-mono);
|
|
1667
|
+
font-size: 11px;
|
|
1668
|
+
color: var(--tg-text-dim);
|
|
1669
|
+
background: var(--tg-bg);
|
|
1670
|
+
padding: 6px 8px;
|
|
1671
|
+
border-radius: 3px;
|
|
1672
|
+
margin: 0;
|
|
1673
|
+
max-height: 80px;
|
|
1674
|
+
overflow: hidden;
|
|
1675
|
+
white-space: pre-wrap;
|
|
1676
|
+
word-break: break-word;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/* Transcript search results */
|
|
1680
|
+
.transcript-result {
|
|
1681
|
+
padding: 8px 10px;
|
|
1682
|
+
border: 1px solid var(--tg-border);
|
|
1683
|
+
border-radius: var(--tg-radius-sm);
|
|
1684
|
+
margin-bottom: 6px;
|
|
1685
|
+
cursor: pointer;
|
|
1686
|
+
transition: border-color 0.15s;
|
|
1687
|
+
}
|
|
1688
|
+
.transcript-result:hover { border-color: var(--tg-accent-dim); }
|
|
1689
|
+
.tr-meta {
|
|
1690
|
+
display: flex;
|
|
1691
|
+
gap: 8px;
|
|
1692
|
+
font-size: 10px;
|
|
1693
|
+
color: var(--tg-text-dim);
|
|
1694
|
+
margin-bottom: 4px;
|
|
1695
|
+
}
|
|
1696
|
+
.tr-session { font-family: var(--tg-mono); color: var(--tg-accent); }
|
|
1697
|
+
.tr-time { font-family: var(--tg-mono); }
|
|
1698
|
+
.tr-line {
|
|
1699
|
+
font-family: var(--tg-mono);
|
|
1700
|
+
font-size: 11px;
|
|
1701
|
+
color: var(--tg-text);
|
|
1702
|
+
margin: 0;
|
|
1703
|
+
white-space: pre-wrap;
|
|
1704
|
+
word-break: break-word;
|
|
1705
|
+
}
|
|
1706
|
+
mark.tr-highlight {
|
|
1707
|
+
background: rgba(224, 175, 104, 0.25);
|
|
1708
|
+
color: var(--tg-amber);
|
|
1709
|
+
border-radius: 2px;
|
|
1710
|
+
padding: 0 1px;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/* Transcript replay view */
|
|
1714
|
+
.transcript-replay-header {
|
|
1715
|
+
display: flex;
|
|
1716
|
+
align-items: center;
|
|
1717
|
+
justify-content: space-between;
|
|
1718
|
+
margin-bottom: 10px;
|
|
1719
|
+
}
|
|
1720
|
+
.tr-replay-id {
|
|
1721
|
+
font-family: var(--tg-mono);
|
|
1722
|
+
font-size: 12px;
|
|
1723
|
+
color: var(--tg-accent);
|
|
1724
|
+
}
|
|
1725
|
+
.transcript-copy {
|
|
1726
|
+
background: none;
|
|
1727
|
+
border: 1px solid var(--tg-border);
|
|
1728
|
+
color: var(--tg-text-dim);
|
|
1729
|
+
font-family: var(--tg-mono);
|
|
1730
|
+
font-size: 11px;
|
|
1731
|
+
padding: 4px 10px;
|
|
1732
|
+
border-radius: 3px;
|
|
1733
|
+
cursor: pointer;
|
|
1734
|
+
transition: all 0.15s;
|
|
1735
|
+
}
|
|
1736
|
+
.transcript-copy:hover {
|
|
1737
|
+
color: var(--tg-text);
|
|
1738
|
+
border-color: var(--tg-border-active);
|
|
1739
|
+
}
|
|
1740
|
+
.transcript-copy.copied {
|
|
1741
|
+
color: var(--tg-green);
|
|
1742
|
+
border-color: var(--tg-green);
|
|
1743
|
+
}
|
|
1744
|
+
.transcript-replay-content {
|
|
1745
|
+
font-family: var(--tg-mono);
|
|
1746
|
+
font-size: 11px;
|
|
1747
|
+
color: var(--tg-text);
|
|
1748
|
+
background: var(--tg-bg);
|
|
1749
|
+
padding: 12px 14px;
|
|
1750
|
+
border-radius: var(--tg-radius-sm);
|
|
1751
|
+
margin: 0;
|
|
1752
|
+
white-space: pre-wrap;
|
|
1753
|
+
word-break: break-word;
|
|
1754
|
+
max-height: 50vh;
|
|
1755
|
+
overflow-y: auto;
|
|
1756
|
+
}
|
|
1757
|
+
.transcript-back {
|
|
1758
|
+
background: none;
|
|
1759
|
+
border: 1px solid var(--tg-border);
|
|
1760
|
+
color: var(--tg-text-dim);
|
|
1761
|
+
font-family: var(--tg-sans);
|
|
1762
|
+
font-size: 11px;
|
|
1763
|
+
padding: 5px 14px;
|
|
1764
|
+
border-radius: 3px;
|
|
1765
|
+
cursor: pointer;
|
|
1766
|
+
}
|
|
1767
|
+
.transcript-back:hover {
|
|
1768
|
+
color: var(--tg-text);
|
|
1769
|
+
border-color: var(--tg-border-active);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1435
1772
|
/* ===== SCROLLBAR ===== */
|
|
1436
1773
|
::-webkit-scrollbar { width: 6px; }
|
|
1437
1774
|
::-webkit-scrollbar-track { background: transparent; }
|
|
@@ -47,6 +47,8 @@ const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = requ
|
|
|
47
47
|
const { RAGIntegration } = require('./rag');
|
|
48
48
|
const { createBridge } = require('./mnestra-bridge');
|
|
49
49
|
const { writeSessionLog } = require('./session-logger');
|
|
50
|
+
const { TranscriptWriter } = require('./transcripts');
|
|
51
|
+
const { createHealthHandler } = require('./preflight');
|
|
50
52
|
const { themes, statusColors } = require('./themes');
|
|
51
53
|
const { loadConfig, addProject } = require('./config');
|
|
52
54
|
|
|
@@ -87,12 +89,33 @@ function createServer(config) {
|
|
|
87
89
|
const mnestraBridge = createBridge(config);
|
|
88
90
|
console.log(`[mnestra-bridge] mode=${mnestraBridge.mode}`);
|
|
89
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
|
+
|
|
90
110
|
// Wire RAG to session events
|
|
91
111
|
sessions.on('session:created', (s) => rag.onSessionCreated(s));
|
|
92
112
|
sessions.on('session:removed', (s) => rag.onSessionEnded(s));
|
|
93
113
|
|
|
94
114
|
// ==================== REST API ====================
|
|
95
115
|
|
|
116
|
+
// GET /api/health - preflight health checks (Sprint 6 T1, wired by T3)
|
|
117
|
+
app.get('/api/health', createHealthHandler(config));
|
|
118
|
+
|
|
96
119
|
// GET /api/sessions - list all active sessions
|
|
97
120
|
app.get('/api/sessions', (req, res) => {
|
|
98
121
|
res.json(sessions.getAll());
|
|
@@ -157,7 +180,7 @@ function createServer(config) {
|
|
|
157
180
|
session.pid = term.pid;
|
|
158
181
|
session.meta.status = 'active';
|
|
159
182
|
|
|
160
|
-
// PTY output → analyze + broadcast to WebSocket
|
|
183
|
+
// PTY output → analyze + broadcast to WebSocket + transcript archive
|
|
161
184
|
term.onData((data) => {
|
|
162
185
|
session.analyzeOutput(data);
|
|
163
186
|
|
|
@@ -165,6 +188,15 @@ function createServer(config) {
|
|
|
165
188
|
if (session.ws && session.ws.readyState === 1) {
|
|
166
189
|
session.ws.send(JSON.stringify({ type: 'output', data }));
|
|
167
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
|
+
}
|
|
168
200
|
});
|
|
169
201
|
|
|
170
202
|
term.onExit(({ exitCode, signal }) => {
|
|
@@ -447,6 +479,67 @@ function createServer(config) {
|
|
|
447
479
|
});
|
|
448
480
|
});
|
|
449
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({ results: [] });
|
|
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
|
+
// Returns { sessions: [ { session_id, chunks: [...] }, ... ] }
|
|
503
|
+
app.get('/api/transcripts/recent', async (req, res) => {
|
|
504
|
+
if (!transcriptWriter) return res.json({ sessions: [] });
|
|
505
|
+
const minutes = Math.min(Math.max(parseInt(req.query.minutes) || 60, 1), 1440);
|
|
506
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit) || 500, 1), 2000);
|
|
507
|
+
try {
|
|
508
|
+
const rows = await transcriptWriter.getRecent(minutes, limit);
|
|
509
|
+
// Group by session_id for client consumption
|
|
510
|
+
const grouped = new Map();
|
|
511
|
+
for (const row of rows) {
|
|
512
|
+
if (!grouped.has(row.session_id)) grouped.set(row.session_id, []);
|
|
513
|
+
grouped.get(row.session_id).push(row);
|
|
514
|
+
}
|
|
515
|
+
const sessions = [];
|
|
516
|
+
for (const [session_id, chunks] of grouped) {
|
|
517
|
+
sessions.push({ session_id, chunks });
|
|
518
|
+
}
|
|
519
|
+
res.json({ sessions });
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error('[transcript] recent endpoint error:', err.message);
|
|
522
|
+
res.status(500).json({ error: 'Transcript recent query failed' });
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// GET /api/transcripts/:sessionId - ordered chunks for a session
|
|
527
|
+
// Returns { content: string } (joined transcript text)
|
|
528
|
+
app.get('/api/transcripts/:sessionId', async (req, res) => {
|
|
529
|
+
if (!transcriptWriter) return res.json({ content: '', lines: [] });
|
|
530
|
+
const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit), 1), 5000) : undefined;
|
|
531
|
+
const since = req.query.since || undefined;
|
|
532
|
+
try {
|
|
533
|
+
const chunks = await transcriptWriter.getSessionTranscript(req.params.sessionId, { limit, since });
|
|
534
|
+
const lines = chunks.map(c => c.content);
|
|
535
|
+
const content = lines.join('');
|
|
536
|
+
res.json({ content, lines, chunks });
|
|
537
|
+
} catch (err) {
|
|
538
|
+
console.error('[transcript] session transcript endpoint error:', err.message);
|
|
539
|
+
res.status(500).json({ error: 'Transcript retrieval failed' });
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
450
543
|
// ==================== Rumen insights (Sprint 4 T2) ====================
|
|
451
544
|
// Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
|
|
452
545
|
// instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
|
|
@@ -717,7 +810,7 @@ function createServer(config) {
|
|
|
717
810
|
res.sendFile(path.join(clientDir, 'index.html'));
|
|
718
811
|
});
|
|
719
812
|
|
|
720
|
-
return { app, server, wss, sessions, rag, db };
|
|
813
|
+
return { app, server, wss, sessions, rag, db, transcriptWriter };
|
|
721
814
|
}
|
|
722
815
|
|
|
723
816
|
// Start server
|
|
@@ -733,10 +826,29 @@ if (require.main === module) {
|
|
|
733
826
|
config.sessionLogs = { ...(config.sessionLogs || {}), enabled: true };
|
|
734
827
|
}
|
|
735
828
|
|
|
736
|
-
const { server } = createServer(config);
|
|
829
|
+
const { server, transcriptWriter } = createServer(config);
|
|
737
830
|
const port = config.port || 3000;
|
|
738
831
|
const host = config.host || '127.0.0.1';
|
|
739
832
|
|
|
833
|
+
// Graceful shutdown — flush transcript buffer before exit
|
|
834
|
+
let shutdownInProgress = false;
|
|
835
|
+
async function handleShutdown(signal) {
|
|
836
|
+
if (shutdownInProgress) return;
|
|
837
|
+
shutdownInProgress = true;
|
|
838
|
+
console.log(`\n[server] ${signal} received, shutting down...`);
|
|
839
|
+
if (transcriptWriter) {
|
|
840
|
+
console.log('[transcript] Flushing buffer before exit...');
|
|
841
|
+
try { await transcriptWriter.close(); } catch (err) {
|
|
842
|
+
console.error('[transcript] Shutdown flush failed:', err.message);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
server.close(() => process.exit(0));
|
|
846
|
+
// Force exit after 5s if server.close hangs
|
|
847
|
+
setTimeout(() => process.exit(1), 5000).unref();
|
|
848
|
+
}
|
|
849
|
+
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
850
|
+
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
851
|
+
|
|
740
852
|
server.listen(port, host, () => {
|
|
741
853
|
console.log(`\n TermDeck running at http://${host}:${port}\n`);
|
|
742
854
|
console.log(` Terminals: 0 active`);
|
|
@@ -744,6 +856,7 @@ if (require.main === module) {
|
|
|
744
856
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
745
857
|
console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
|
|
746
858
|
console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
|
|
859
|
+
console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
|
|
747
860
|
console.log(`\n WARNING: TermDeck binds to ${host} only.`);
|
|
748
861
|
console.log(` Do NOT expose this to the network without authentication.`);
|
|
749
862
|
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;
|