@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 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.1",
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 checks = data.checks || [];
2423
+ const allChecks = data.checks || [];
2424
+ const checks = filterChecksByTier(allChecks);
2412
2425
  const total = checks.length;
2413
- const passed = checks.filter(c => c.ok).length;
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 = 'Stack: OK';
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 = `Stack: ${passed}/${total}`;
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.ok ? '✓' : '✗';
2443
- const cls = check.ok ? 'hd-ok' : 'hd-fail';
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.ok ? '' : (check.remediation ? `<div class="hd-remediation">${escapeHtml(check.remediation)}</div>` : '');
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.ok ? 'OK' : 'FAIL'}</span>
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
- padding: 0 16px;
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: 16px;
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: 8px;
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 12px;
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 || _rumenPoolFailed) return _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 results = await transcriptWriter.getRecent(minutes, limit);
508
- res.json(results);
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
- res.json(chunks);
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\/?$/, '/health')
27
- : 'http://localhost:37778/health';
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 || data.memories || data.count);
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}/health`, 3000);
48
+ const body = await httpGet(`${baseUrl}/healthz`, 3000);
49
49
  const data = tryParseJSON(body);
50
- const total = data && (data.total || data.memories || data.count);
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,