@jhizzard/termdeck 0.3.1 → 0.3.3
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 +10 -0
- package/package.json +1 -1
- package/packages/client/public/app.js +24 -10
- package/packages/client/public/style.css +16 -4
- package/packages/server/src/index.js +31 -8
- package/packages/server/src/preflight.js +5 -5
- package/packages/server/src/rag.js +1 -1
- package/packages/server/src/transcripts.js +6 -0
package/README.md
CHANGED
|
@@ -22,6 +22,16 @@ Enabling Flashback takes **one additional 15-minute setup step** — see Tier 2
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
## Documentation hierarchy
|
|
26
|
+
|
|
27
|
+
- **This README** — quickstart, pitch, and links
|
|
28
|
+
- **[docs/GETTING-STARTED.md](docs/GETTING-STARTED.md)** — full 4-tier installation guide
|
|
29
|
+
- **[termdeck-docs.vercel.app](https://termdeck-docs.vercel.app)** — reference docs (Astro/Starlight)
|
|
30
|
+
- **docs/launch/** — launch collateral (Show HN, Twitter, etc.)
|
|
31
|
+
- **docs/sprint-N-*/** — historical sprint logs (append-only, not maintained post-sprint)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
25
35
|
## How Flashback works
|
|
26
36
|
|
|
27
37
|
When a panel's status transitions to `errored`, the server's output analyzer fires an event. The mnestra bridge takes the session context (type, project, last command, error tail) and queries your Mnestra memory store for the top similar match. If it finds one above the relevance threshold, the result is pushed to the panel's WebSocket as a `proactive_memory` message. The client renders it as a toast anchored to the panel, showing the match's project tag, source type, similarity score, and content snippet. You click the toast to expand into the Memory tab of that panel's drawer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -2403,29 +2403,43 @@
|
|
|
2403
2403
|
document.getElementById('healthBadgeLabel').textContent = 'Health: offline';
|
|
2404
2404
|
}
|
|
2405
2405
|
|
|
2406
|
+
// Tier 2/3 checks only shown when the user has configured those tiers.
|
|
2407
|
+
// Without DATABASE_URL, mnestra/rumen/database checks are irrelevant noise.
|
|
2408
|
+
const TIER1_CHECKS = new Set(['project_paths', 'shell_sanity']);
|
|
2409
|
+
const TIER23_CHECKS = new Set(['mnestra_reachable', 'mnestra_has_memories', 'rumen_recent', 'database_url']);
|
|
2410
|
+
|
|
2411
|
+
function filterChecksByTier(checks) {
|
|
2412
|
+
const hasDb = checks.some(c => c.name === 'database_url' && c.passed);
|
|
2413
|
+
if (hasDb) return checks; // full stack configured — show everything
|
|
2414
|
+
// No DATABASE_URL: only show Tier 1 checks
|
|
2415
|
+
return checks.filter(c => TIER1_CHECKS.has(c.name));
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2406
2418
|
function renderHealthBadge(data) {
|
|
2407
2419
|
const badge = document.getElementById('healthBadge');
|
|
2408
2420
|
if (!badge) return;
|
|
2409
2421
|
badge.style.display = '';
|
|
2410
2422
|
|
|
2411
|
-
const
|
|
2423
|
+
const allChecks = data.checks || [];
|
|
2424
|
+
const checks = filterChecksByTier(allChecks);
|
|
2412
2425
|
const total = checks.length;
|
|
2413
|
-
const passed = checks.filter(c => c.
|
|
2426
|
+
const passed = checks.filter(c => c.passed).length;
|
|
2414
2427
|
const allOk = passed === total && total > 0;
|
|
2428
|
+
const tierLabel = total < allChecks.length ? 'Tier 1' : 'Stack';
|
|
2415
2429
|
|
|
2416
2430
|
if (allOk) {
|
|
2417
2431
|
badge.className = 'health-badge hb-green';
|
|
2418
|
-
document.getElementById('healthBadgeLabel').textContent =
|
|
2432
|
+
document.getElementById('healthBadgeLabel').textContent = `${tierLabel}: OK`;
|
|
2419
2433
|
} else if (total === 0) {
|
|
2420
2434
|
badge.className = 'health-badge hb-amber';
|
|
2421
2435
|
document.getElementById('healthBadgeLabel').textContent = 'Stack: ?';
|
|
2422
2436
|
} else {
|
|
2423
2437
|
badge.className = 'health-badge hb-red';
|
|
2424
|
-
document.getElementById('healthBadgeLabel').textContent =
|
|
2438
|
+
document.getElementById('healthBadgeLabel').textContent = `${tierLabel}: ${passed}/${total}`;
|
|
2425
2439
|
}
|
|
2426
2440
|
|
|
2427
|
-
// Update dropdown content
|
|
2428
|
-
renderHealthDropdown(data);
|
|
2441
|
+
// Update dropdown content — pass filtered checks
|
|
2442
|
+
renderHealthDropdown({ ...data, checks });
|
|
2429
2443
|
}
|
|
2430
2444
|
|
|
2431
2445
|
function renderHealthDropdown(data) {
|
|
@@ -2439,16 +2453,16 @@
|
|
|
2439
2453
|
|
|
2440
2454
|
let html = '';
|
|
2441
2455
|
for (const check of checks) {
|
|
2442
|
-
const icon = check.
|
|
2443
|
-
const cls = check.
|
|
2456
|
+
const icon = check.passed ? '✓' : '✗';
|
|
2457
|
+
const cls = check.passed ? 'hd-ok' : 'hd-fail';
|
|
2444
2458
|
const name = check.name || 'Unknown';
|
|
2445
2459
|
const detail = check.detail || '';
|
|
2446
|
-
const remediation = check.
|
|
2460
|
+
const remediation = check.passed ? '' : (check.remediation ? `<div class="hd-remediation">${escapeHtml(check.remediation)}</div>` : '');
|
|
2447
2461
|
html += `<div class="hd-check ${cls}">
|
|
2448
2462
|
<span class="hd-icon">${icon}</span>
|
|
2449
2463
|
<span class="hd-name">${escapeHtml(name)}</span>
|
|
2450
2464
|
<span class="hd-dots"></span>
|
|
2451
|
-
<span class="hd-status">${check.
|
|
2465
|
+
<span class="hd-status">${check.passed ? 'OK' : 'FAIL'}</span>
|
|
2452
2466
|
<span class="hd-detail">${escapeHtml(detail)}</span>
|
|
2453
2467
|
${remediation}
|
|
2454
2468
|
</div>`;
|
|
@@ -39,17 +39,27 @@
|
|
|
39
39
|
display: flex;
|
|
40
40
|
align-items: center;
|
|
41
41
|
justify-content: space-between;
|
|
42
|
-
|
|
42
|
+
gap: 10px;
|
|
43
|
+
padding: 0 12px;
|
|
43
44
|
height: 42px;
|
|
44
45
|
background: var(--tg-surface);
|
|
45
46
|
border-bottom: 1px solid var(--tg-border);
|
|
46
47
|
flex-shrink: 0;
|
|
48
|
+
overflow-x: auto;
|
|
49
|
+
overflow-y: hidden;
|
|
50
|
+
min-width: 0;
|
|
51
|
+
scrollbar-width: thin;
|
|
52
|
+
flex-wrap: nowrap;
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
.topbar::-webkit-scrollbar { height: 4px; }
|
|
56
|
+
.topbar::-webkit-scrollbar-thumb { background: var(--tg-border); border-radius: 2px; }
|
|
57
|
+
|
|
49
58
|
.topbar-left {
|
|
50
59
|
display: flex;
|
|
51
60
|
align-items: center;
|
|
52
61
|
gap: 12px;
|
|
62
|
+
flex-shrink: 0;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
.topbar-logo {
|
|
@@ -66,7 +76,7 @@
|
|
|
66
76
|
|
|
67
77
|
.topbar-stats {
|
|
68
78
|
display: flex;
|
|
69
|
-
gap:
|
|
79
|
+
gap: 10px;
|
|
70
80
|
font-size: 11px;
|
|
71
81
|
color: var(--tg-text-dim);
|
|
72
82
|
}
|
|
@@ -84,6 +94,7 @@
|
|
|
84
94
|
background: var(--tg-bg);
|
|
85
95
|
padding: 3px;
|
|
86
96
|
border-radius: var(--tg-radius-sm);
|
|
97
|
+
flex-shrink: 0;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
.layout-btn {
|
|
@@ -104,7 +115,8 @@
|
|
|
104
115
|
.topbar-right {
|
|
105
116
|
display: flex;
|
|
106
117
|
align-items: center;
|
|
107
|
-
gap:
|
|
118
|
+
gap: 4px;
|
|
119
|
+
flex-shrink: 0;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
.topbar-right button {
|
|
@@ -112,7 +124,7 @@
|
|
|
112
124
|
border: 1px solid var(--tg-border);
|
|
113
125
|
color: var(--tg-text-dim);
|
|
114
126
|
font-size: 11px;
|
|
115
|
-
padding: 4px
|
|
127
|
+
padding: 4px 8px;
|
|
116
128
|
border-radius: var(--tg-radius-sm);
|
|
117
129
|
cursor: pointer;
|
|
118
130
|
font-family: var(--tg-sans);
|
|
@@ -20,9 +20,17 @@ try { pg = require('pg'); } catch { pg = null; }
|
|
|
20
20
|
// servers without DATABASE_URL never pay the connection cost.
|
|
21
21
|
let _rumenPool = null;
|
|
22
22
|
let _rumenPoolFailed = false;
|
|
23
|
+
let _rumenPoolFailedAt = 0;
|
|
24
|
+
const RUMEN_POOL_RETRY_MS = 30_000;
|
|
23
25
|
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
26
|
function getRumenPool() {
|
|
25
|
-
if (_rumenPool
|
|
27
|
+
if (_rumenPool) return _rumenPool;
|
|
28
|
+
if (_rumenPoolFailed) {
|
|
29
|
+
if (Date.now() - _rumenPoolFailedAt < RUMEN_POOL_RETRY_MS) return null;
|
|
30
|
+
console.warn('[rumen] retrying pool creation after 30s cooldown');
|
|
31
|
+
_rumenPoolFailed = false;
|
|
32
|
+
_rumenPoolFailedAt = 0;
|
|
33
|
+
}
|
|
26
34
|
if (!pg || !process.env.DATABASE_URL) return null;
|
|
27
35
|
try {
|
|
28
36
|
_rumenPool = new pg.Pool({
|
|
@@ -38,6 +46,7 @@ function getRumenPool() {
|
|
|
38
46
|
} catch (err) {
|
|
39
47
|
console.warn('[rumen] failed to create pg pool:', err.message);
|
|
40
48
|
_rumenPoolFailed = true;
|
|
49
|
+
_rumenPoolFailedAt = Date.now();
|
|
41
50
|
return null;
|
|
42
51
|
}
|
|
43
52
|
}
|
|
@@ -484,14 +493,14 @@ function createServer(config) {
|
|
|
484
493
|
// GET /api/transcripts/search - FTS across all sessions
|
|
485
494
|
// (Must be registered before :sessionId to avoid route collision)
|
|
486
495
|
app.get('/api/transcripts/search', async (req, res) => {
|
|
487
|
-
if (!transcriptWriter) return res.json([]);
|
|
496
|
+
if (!transcriptWriter) return res.json({ results: [] });
|
|
488
497
|
const q = req.query.q;
|
|
489
498
|
if (!q) return res.status(400).json({ error: 'Missing q parameter' });
|
|
490
499
|
const since = req.query.since || null;
|
|
491
500
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
|
|
492
501
|
try {
|
|
493
502
|
const results = await transcriptWriter.search(q, { since, limit });
|
|
494
|
-
res.json(results);
|
|
503
|
+
res.json({ results });
|
|
495
504
|
} catch (err) {
|
|
496
505
|
console.error('[transcript] search endpoint error:', err.message);
|
|
497
506
|
res.status(500).json({ error: 'Transcript search failed' });
|
|
@@ -499,13 +508,24 @@ function createServer(config) {
|
|
|
499
508
|
});
|
|
500
509
|
|
|
501
510
|
// GET /api/transcripts/recent - time-windowed crash recovery
|
|
511
|
+
// Returns { sessions: [ { session_id, chunks: [...] }, ... ] }
|
|
502
512
|
app.get('/api/transcripts/recent', async (req, res) => {
|
|
503
|
-
if (!transcriptWriter) return res.json([]);
|
|
513
|
+
if (!transcriptWriter) return res.json({ sessions: [] });
|
|
504
514
|
const minutes = Math.min(Math.max(parseInt(req.query.minutes) || 60, 1), 1440);
|
|
505
515
|
const limit = Math.min(Math.max(parseInt(req.query.limit) || 500, 1), 2000);
|
|
506
516
|
try {
|
|
507
|
-
const
|
|
508
|
-
|
|
517
|
+
const rows = await transcriptWriter.getRecent(minutes, limit);
|
|
518
|
+
// Group by session_id for client consumption
|
|
519
|
+
const grouped = new Map();
|
|
520
|
+
for (const row of rows) {
|
|
521
|
+
if (!grouped.has(row.session_id)) grouped.set(row.session_id, []);
|
|
522
|
+
grouped.get(row.session_id).push(row);
|
|
523
|
+
}
|
|
524
|
+
const sessions = [];
|
|
525
|
+
for (const [session_id, chunks] of grouped) {
|
|
526
|
+
sessions.push({ session_id, chunks });
|
|
527
|
+
}
|
|
528
|
+
res.json({ sessions });
|
|
509
529
|
} catch (err) {
|
|
510
530
|
console.error('[transcript] recent endpoint error:', err.message);
|
|
511
531
|
res.status(500).json({ error: 'Transcript recent query failed' });
|
|
@@ -513,13 +533,16 @@ function createServer(config) {
|
|
|
513
533
|
});
|
|
514
534
|
|
|
515
535
|
// GET /api/transcripts/:sessionId - ordered chunks for a session
|
|
536
|
+
// Returns { content: string } (joined transcript text)
|
|
516
537
|
app.get('/api/transcripts/:sessionId', async (req, res) => {
|
|
517
|
-
if (!transcriptWriter) return res.json([]);
|
|
538
|
+
if (!transcriptWriter) return res.json({ content: '', lines: [] });
|
|
518
539
|
const limit = req.query.limit ? Math.min(Math.max(parseInt(req.query.limit), 1), 5000) : undefined;
|
|
519
540
|
const since = req.query.since || undefined;
|
|
520
541
|
try {
|
|
521
542
|
const chunks = await transcriptWriter.getSessionTranscript(req.params.sessionId, { limit, since });
|
|
522
|
-
|
|
543
|
+
const lines = chunks.map(c => c.content);
|
|
544
|
+
const content = lines.join('');
|
|
545
|
+
res.json({ content, lines, chunks });
|
|
523
546
|
} catch (err) {
|
|
524
547
|
console.error('[transcript] session transcript endpoint error:', err.message);
|
|
525
548
|
res.status(500).json({ error: 'Transcript retrieval failed' });
|
|
@@ -23,12 +23,12 @@ const CACHE_TTL_MS = 60_000;
|
|
|
23
23
|
async function checkMnestra(config) {
|
|
24
24
|
const rag = config.rag || {};
|
|
25
25
|
const url = rag.mnestraWebhookUrl
|
|
26
|
-
? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '/
|
|
27
|
-
: 'http://localhost:37778/
|
|
26
|
+
? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '/healthz')
|
|
27
|
+
: 'http://localhost:37778/healthz';
|
|
28
28
|
|
|
29
29
|
const body = await httpGet(url, 3000);
|
|
30
30
|
const data = tryParseJSON(body);
|
|
31
|
-
const total = data && (data.total
|
|
31
|
+
const total = data && (data.store?.rows ?? data.total ?? data.memories ?? data.count ?? null);
|
|
32
32
|
if (total != null) {
|
|
33
33
|
return { name: 'mnestra_reachable', passed: true, detail: `${Number(total).toLocaleString()} memories` };
|
|
34
34
|
}
|
|
@@ -45,9 +45,9 @@ async function checkMnestraMemories(config) {
|
|
|
45
45
|
? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '')
|
|
46
46
|
: 'http://localhost:37778';
|
|
47
47
|
|
|
48
|
-
const body = await httpGet(`${baseUrl}/
|
|
48
|
+
const body = await httpGet(`${baseUrl}/healthz`, 3000);
|
|
49
49
|
const data = tryParseJSON(body);
|
|
50
|
-
const total = data && (data.total
|
|
50
|
+
const total = data && (data.store?.rows ?? data.total ?? data.memories ?? data.count ?? null);
|
|
51
51
|
if (total != null && Number(total) > 0) {
|
|
52
52
|
return { name: 'mnestra_has_memories', passed: true, detail: `${Number(total).toLocaleString()} memories loaded` };
|
|
53
53
|
}
|
|
@@ -165,8 +165,8 @@ class RAGIntegration {
|
|
|
165
165
|
// Success — reset any accumulated 404 count for this table
|
|
166
166
|
this._resetCircuit(table);
|
|
167
167
|
} catch (err) {
|
|
168
|
-
// Will be retried by sync loop
|
|
169
168
|
console.error('[mnestra] Push failed:', err.message);
|
|
169
|
+
throw err; // Propagate to caller so sync loop knows this event failed
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
@@ -28,6 +28,7 @@ class TranscriptWriter {
|
|
|
28
28
|
this._databaseUrl = databaseUrl;
|
|
29
29
|
this._batchSize = options.batchSize || 50;
|
|
30
30
|
this._flushIntervalMs = options.flushIntervalMs || 2000;
|
|
31
|
+
this._maxBufferSize = options.maxBufferSize || 10000;
|
|
31
32
|
this._enabled = options.enabled !== false;
|
|
32
33
|
|
|
33
34
|
// Per-session monotonic chunk counters
|
|
@@ -91,6 +92,11 @@ class TranscriptWriter {
|
|
|
91
92
|
const idx = this._counters.get(sessionId) || 0;
|
|
92
93
|
this._counters.set(sessionId, idx + 1);
|
|
93
94
|
|
|
95
|
+
// Cap buffer to prevent unbounded growth during sustained DB failures
|
|
96
|
+
if (this._buffer.length >= this._maxBufferSize) {
|
|
97
|
+
this._buffer.splice(0, this._buffer.length - this._maxBufferSize + 1);
|
|
98
|
+
}
|
|
99
|
+
|
|
94
100
|
this._buffer.push({
|
|
95
101
|
sessionId,
|
|
96
102
|
content: stripped,
|