@jhizzard/termdeck 0.3.0 → 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.
@@ -0,0 +1,373 @@
1
+ // TermDeck Preflight Health Check
2
+ // Runs at startup to verify the entire memory stack is operational.
3
+ // Each check is independent — one failure does not block others.
4
+ //
5
+ // Exports:
6
+ // runPreflight(config) — run all checks, return result object
7
+ // createHealthHandler(config) — Express route handler for GET /api/health
8
+
9
+ const http = require('http');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ // Cache preflight results for 60s
15
+ let _cachedResult = null;
16
+ let _cachedAt = 0;
17
+ const CACHE_TTL_MS = 60_000;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Individual checks
21
+ // ---------------------------------------------------------------------------
22
+
23
+ async function checkMnestra(config) {
24
+ const rag = config.rag || {};
25
+ const url = rag.mnestraWebhookUrl
26
+ ? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '/health')
27
+ : 'http://localhost:37778/health';
28
+
29
+ const body = await httpGet(url, 3000);
30
+ const data = tryParseJSON(body);
31
+ const total = data && (data.total || data.memories || data.count);
32
+ if (total != null) {
33
+ return { name: 'mnestra_reachable', passed: true, detail: `${Number(total).toLocaleString()} memories` };
34
+ }
35
+ // Got 200 but no count — still reachable
36
+ return { name: 'mnestra_reachable', passed: true, detail: 'reachable (no memory count)' };
37
+ }
38
+
39
+ async function checkMnestraMemories(config) {
40
+ // If Mnestra responded with a count in the reachable check we can skip
41
+ // a second request — but since checks run independently we check the
42
+ // memory_status endpoint separately.
43
+ const rag = config.rag || {};
44
+ const baseUrl = rag.mnestraWebhookUrl
45
+ ? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '')
46
+ : 'http://localhost:37778';
47
+
48
+ const body = await httpGet(`${baseUrl}/health`, 3000);
49
+ const data = tryParseJSON(body);
50
+ const total = data && (data.total || data.memories || data.count);
51
+ if (total != null && Number(total) > 0) {
52
+ return { name: 'mnestra_has_memories', passed: true, detail: `${Number(total).toLocaleString()} memories loaded` };
53
+ }
54
+ if (total != null && Number(total) === 0) {
55
+ return { name: 'mnestra_has_memories', passed: false, detail: 'Mnestra running but 0 memories — run `mnestra ingest`' };
56
+ }
57
+ return { name: 'mnestra_has_memories', passed: false, detail: 'could not determine memory count' };
58
+ }
59
+
60
+ async function checkRumen(config) {
61
+ // Try to query rumen_jobs table via DATABASE_URL for last successful job
62
+ const dbUrl = process.env.DATABASE_URL;
63
+ if (!dbUrl) {
64
+ return { name: 'rumen_recent', passed: false, detail: 'DATABASE_URL not set — cannot check Rumen jobs' };
65
+ }
66
+
67
+ let pg;
68
+ try { pg = require('pg'); } catch (err) { pg = null; }
69
+ if (!pg) {
70
+ return { name: 'rumen_recent', passed: false, detail: 'pg module not installed' };
71
+ }
72
+
73
+ const pool = new pg.Pool({
74
+ connectionString: dbUrl,
75
+ max: 1,
76
+ connectionTimeoutMillis: 5000,
77
+ });
78
+
79
+ try {
80
+ const res = await pool.query(
81
+ `SELECT status, completed_at, insights_generated
82
+ FROM rumen_jobs
83
+ WHERE status = 'done'
84
+ ORDER BY completed_at DESC
85
+ LIMIT 1`
86
+ );
87
+ if (res.rows.length === 0) {
88
+ return { name: 'rumen_recent', passed: false, detail: 'no completed Rumen jobs found' };
89
+ }
90
+ const row = res.rows[0];
91
+ const completedAt = new Date(row.completed_at);
92
+ const agoMs = Date.now() - completedAt.getTime();
93
+ const agoMin = Math.round(agoMs / 60_000);
94
+ const insights = row.insights_generated || 0;
95
+ const recent = agoMs < 30 * 60_000; // within 30 minutes
96
+ return {
97
+ name: 'rumen_recent',
98
+ passed: recent,
99
+ detail: recent
100
+ ? `last job ${agoMin}m ago, ${insights} insights`
101
+ : `last job ${agoMin}m ago (stale — expected within 30m), ${insights} insights`,
102
+ };
103
+ } finally {
104
+ await pool.end().catch(() => {});
105
+ }
106
+ }
107
+
108
+ async function checkDatabase() {
109
+ const dbUrl = process.env.DATABASE_URL;
110
+ if (!dbUrl) {
111
+ return { name: 'database_url', passed: false, detail: 'DATABASE_URL not set' };
112
+ }
113
+
114
+ let pg;
115
+ try { pg = require('pg'); } catch (err) { pg = null; }
116
+ if (!pg) {
117
+ return { name: 'database_url', passed: false, detail: 'pg module not installed' };
118
+ }
119
+
120
+ const pool = new pg.Pool({
121
+ connectionString: dbUrl,
122
+ max: 1,
123
+ connectionTimeoutMillis: 5000,
124
+ });
125
+
126
+ const t0 = Date.now();
127
+ try {
128
+ const res = await pool.query('SELECT 1 AS ok');
129
+ const ms = Date.now() - t0;
130
+ if (res.rows[0] && res.rows[0].ok === 1) {
131
+ return { name: 'database_url', passed: true, detail: `connected in ${ms}ms` };
132
+ }
133
+ return { name: 'database_url', passed: false, detail: 'SELECT 1 returned unexpected result' };
134
+ } finally {
135
+ await pool.end().catch(() => {});
136
+ }
137
+ }
138
+
139
+ async function checkProjectPaths(config) {
140
+ const projects = config.projects || {};
141
+ const names = Object.keys(projects);
142
+ if (names.length === 0) {
143
+ return { name: 'project_paths', passed: true, detail: 'no projects configured' };
144
+ }
145
+
146
+ let ok = 0;
147
+ const missing = [];
148
+ for (const name of names) {
149
+ const p = projects[name];
150
+ const resolved = (p.path || '').replace(/^~/, os.homedir());
151
+ if (fs.existsSync(resolved)) {
152
+ ok++;
153
+ } else {
154
+ missing.push(name);
155
+ }
156
+ }
157
+
158
+ const total = names.length;
159
+ if (missing.length === 0) {
160
+ return { name: 'project_paths', passed: true, detail: `${ok}/${total} paths exist` };
161
+ }
162
+ return {
163
+ name: 'project_paths',
164
+ passed: false,
165
+ detail: `${ok}/${total} paths exist — missing: ${missing.join(', ')}`,
166
+ };
167
+ }
168
+
169
+ async function checkShellSanity() {
170
+ const shell = process.env.SHELL || '/bin/bash';
171
+ const shellName = path.basename(shell);
172
+
173
+ return new Promise((resolve) => {
174
+ let ptyMod;
175
+ try { ptyMod = require('@homebridge/node-pty-prebuilt-multiarch'); } catch (err) { ptyMod = null; }
176
+ if (!ptyMod) {
177
+ try { ptyMod = require('node-pty'); } catch (err) { ptyMod = null; }
178
+ }
179
+ if (!ptyMod) {
180
+ resolve({ name: 'shell_sanity', passed: false, detail: 'node-pty not available' });
181
+ return;
182
+ }
183
+
184
+ const t0 = Date.now();
185
+ let output = '';
186
+ let resolved = false;
187
+
188
+ const proc = ptyMod.spawn(shell, ['-l', '-c', 'echo TERMDECK_OK'], {
189
+ name: 'xterm-256color',
190
+ cols: 80,
191
+ rows: 24,
192
+ cwd: os.homedir(),
193
+ env: process.env,
194
+ });
195
+
196
+ proc.onData((data) => {
197
+ output += data;
198
+ if (output.includes('TERMDECK_OK') && !resolved) {
199
+ resolved = true;
200
+ const ms = ((Date.now() - t0) / 1000).toFixed(1);
201
+ proc.kill();
202
+ resolve({ name: 'shell_sanity', passed: true, detail: `${shellName} OK in ${ms}s` });
203
+ }
204
+ });
205
+
206
+ proc.onExit(({ exitCode }) => {
207
+ if (!resolved) {
208
+ resolved = true;
209
+ const ms = ((Date.now() - t0) / 1000).toFixed(1);
210
+ resolve({
211
+ name: 'shell_sanity',
212
+ passed: false,
213
+ detail: `${shellName} exited ${exitCode} after ${ms}s without OK`,
214
+ });
215
+ }
216
+ });
217
+
218
+ // 3s timeout
219
+ setTimeout(() => {
220
+ if (!resolved) {
221
+ resolved = true;
222
+ try { proc.kill(); } catch (err) { /* cleanup — process may already be dead */ }
223
+ resolve({ name: 'shell_sanity', passed: false, detail: `${shellName} timed out after 3s` });
224
+ }
225
+ }, 3000);
226
+ });
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // HTTP helper (no external dependencies — uses built-in http)
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function httpGet(url, timeoutMs) {
234
+ return new Promise((resolve, reject) => {
235
+ const req = http.get(url, { timeout: timeoutMs }, (res) => {
236
+ if (res.statusCode !== 200) {
237
+ reject(new Error(`HTTP ${res.statusCode}`));
238
+ res.resume();
239
+ return;
240
+ }
241
+ let body = '';
242
+ res.setEncoding('utf8');
243
+ res.on('data', (chunk) => { body += chunk; });
244
+ res.on('end', () => resolve(body));
245
+ });
246
+ req.on('error', reject);
247
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
248
+ });
249
+ }
250
+
251
+ function tryParseJSON(str) {
252
+ try { return JSON.parse(str); } catch (err) { return null; }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Main preflight runner
257
+ // ---------------------------------------------------------------------------
258
+
259
+ async function runPreflight(config) {
260
+ const checks = await Promise.all([
261
+ checkMnestra(config).catch((err) => ({
262
+ name: 'mnestra_reachable', passed: false,
263
+ detail: `unreachable — ${err.message}. Start with \`mnestra serve\``,
264
+ })),
265
+ checkMnestraMemories(config).catch((err) => ({
266
+ name: 'mnestra_has_memories', passed: false,
267
+ detail: `check failed — ${err.message}`,
268
+ })),
269
+ checkRumen(config).catch((err) => ({
270
+ name: 'rumen_recent', passed: false,
271
+ detail: `check failed — ${err.message}`,
272
+ })),
273
+ checkDatabase().catch((err) => ({
274
+ name: 'database_url', passed: false,
275
+ detail: `connection failed — ${err.message}`,
276
+ })),
277
+ checkProjectPaths(config).catch((err) => ({
278
+ name: 'project_paths', passed: false,
279
+ detail: `check failed — ${err.message}`,
280
+ })),
281
+ checkShellSanity().catch((err) => ({
282
+ name: 'shell_sanity', passed: false,
283
+ detail: `check failed — ${err.message}`,
284
+ })),
285
+ ]);
286
+
287
+ const result = {
288
+ passed: checks.every((c) => c.passed),
289
+ checks,
290
+ timestamp: new Date().toISOString(),
291
+ };
292
+
293
+ _cachedResult = result;
294
+ _cachedAt = Date.now();
295
+
296
+ return result;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Express route handler factory (GET /api/health)
301
+ //
302
+ // T3 or index.js wires this into the app:
303
+ // const { createHealthHandler } = require('./preflight');
304
+ // app.get('/api/health', createHealthHandler(config));
305
+ // ---------------------------------------------------------------------------
306
+
307
+ function createHealthHandler(config) {
308
+ return async (_req, res) => {
309
+ // Return cached result if fresh
310
+ if (_cachedResult && (Date.now() - _cachedAt) < CACHE_TTL_MS) {
311
+ return res.json(_cachedResult);
312
+ }
313
+ try {
314
+ const result = await runPreflight(config);
315
+ res.json(result);
316
+ } catch (err) {
317
+ res.status(500).json({ error: err.message });
318
+ }
319
+ };
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // CLI banner printer
324
+ // ---------------------------------------------------------------------------
325
+
326
+ const REMEDIATION = {
327
+ mnestra_reachable: 'Start Mnestra with `mnestra serve`',
328
+ mnestra_has_memories: 'Run `mnestra ingest` to populate the memory store',
329
+ rumen_recent: 'Check Rumen Edge Function deployment or run `termdeck init --rumen`',
330
+ database_url: 'Set DATABASE_URL in ~/.termdeck/secrets.env',
331
+ project_paths: 'Fix paths in ~/.termdeck/config.yaml → projects',
332
+ shell_sanity: 'Check $SHELL and your login profile (~/.zshrc or ~/.bashrc)',
333
+ };
334
+
335
+ const CHECK_LABELS = {
336
+ mnestra_reachable: 'Mnestra',
337
+ mnestra_has_memories: 'Mnestra data',
338
+ rumen_recent: 'Rumen',
339
+ database_url: 'Database',
340
+ project_paths: 'Project paths',
341
+ shell_sanity: 'Shell',
342
+ };
343
+
344
+ function printHealthBanner(result) {
345
+ const green = '\x1b[32m';
346
+ const red = '\x1b[31m';
347
+ const dim = '\x1b[2m';
348
+ const reset = '\x1b[0m';
349
+ const bold = '\x1b[1m';
350
+
351
+ for (const check of result.checks) {
352
+ const label = (CHECK_LABELS[check.name] || check.name).padEnd(18, ' ');
353
+ const dots = '.'.repeat(Math.max(1, 20 - label.length));
354
+ if (check.passed) {
355
+ console.log(` ${green}✓${reset} ${dim}[health]${reset} ${label}${dim}${dots}${reset} ${green}OK${reset} ${dim}(${check.detail})${reset}`);
356
+ } else {
357
+ console.log(` ${red}✗${reset} ${dim}[health]${reset} ${label}${dim}${dots}${reset} ${red}FAIL${reset} ${dim}(${check.detail})${reset}`);
358
+ const hint = REMEDIATION[check.name];
359
+ if (hint) {
360
+ console.log(` ${dim}→ ${hint}${reset}`);
361
+ }
362
+ }
363
+ }
364
+
365
+ const failCount = result.checks.filter((c) => !c.passed).length;
366
+ if (failCount === 0) {
367
+ console.log(`\n ${green}${bold}All ${result.checks.length} health checks passed.${reset}\n`);
368
+ } else {
369
+ console.log(`\n ${red}${bold}${failCount}/${result.checks.length} health checks failed.${reset} TermDeck will still run, but memory features may be degraded.\n`);
370
+ }
371
+ }
372
+
373
+ module.exports = { runPreflight, createHealthHandler, printHealthBanner };
@@ -22,6 +22,10 @@ class RAGIntegration {
22
22
  commandLog: config.rag?.tables?.commands || 'mnestra_commands'
23
23
  };
24
24
 
25
+ // Circuit breaker: track consecutive 404s per table name.
26
+ // After 3 consecutive 404s, disable pushes to that table until restart.
27
+ this._circuitBreaker = new Map(); // table -> { count: number, open: boolean }
28
+
25
29
  if (this.enabled) {
26
30
  this._startSync();
27
31
  }
@@ -95,6 +99,33 @@ class RAGIntegration {
95
99
  }, session.meta.project);
96
100
  }
97
101
 
102
+ // Circuit breaker check — returns true if pushes to this table are disabled
103
+ _isCircuitOpen(table) {
104
+ const state = this._circuitBreaker.get(table);
105
+ return !!(state && state.open);
106
+ }
107
+
108
+ // Record a 404 for a table; opens the breaker after 3 consecutive hits
109
+ _record404(table) {
110
+ let state = this._circuitBreaker.get(table);
111
+ if (!state) {
112
+ state = { count: 0, open: false };
113
+ this._circuitBreaker.set(table, state);
114
+ }
115
+ state.count += 1;
116
+ if (state.count >= 3 && !state.open) {
117
+ state.open = true;
118
+ console.error(`[rag] circuit breaker open for ${table} — 3 consecutive 404s, disabling pushes until server restart`);
119
+ }
120
+ }
121
+
122
+ // Reset the breaker for a table on successful push
123
+ _resetCircuit(table) {
124
+ if (this._circuitBreaker.has(table)) {
125
+ this._circuitBreaker.delete(table);
126
+ }
127
+ }
128
+
98
129
  // Push a single event to Supabase
99
130
  async _pushEvent(event) {
100
131
  if (!this.enabled) return;
@@ -102,6 +133,9 @@ class RAGIntegration {
102
133
  const layer = this._determineLayer(event);
103
134
  const table = this.tables[layer];
104
135
 
136
+ // Skip if circuit breaker is open for this table
137
+ if (this._isCircuitOpen(table)) return;
138
+
105
139
  try {
106
140
  const response = await fetch(`${this.supabaseUrl}/rest/v1/${table}`, {
107
141
  method: 'POST',
@@ -122,8 +156,14 @@ class RAGIntegration {
122
156
  });
123
157
 
124
158
  if (!response.ok) {
159
+ if (response.status === 404) {
160
+ this._record404(table);
161
+ }
125
162
  throw new Error(`Supabase responded ${response.status}`);
126
163
  }
164
+
165
+ // Success — reset any accumulated 404 count for this table
166
+ this._resetCircuit(table);
127
167
  } catch (err) {
128
168
  // Will be retried by sync loop
129
169
  console.error('[mnestra] Push failed:', err.message);
@@ -93,6 +93,9 @@ class Session {
93
93
  ragEvents: [] // buffer before flush
94
94
  };
95
95
 
96
+ // Transcript chunk counter — monotonic per session for deterministic replay
97
+ this.transcriptChunkIndex = 0;
98
+
96
99
  // Output analysis state
97
100
  this._outputBuffer = '';
98
101
  this._outputFlushTimer = null;
@@ -283,6 +286,10 @@ class Session {
283
286
  }
284
287
  }
285
288
 
289
+ getNextChunkIndex() {
290
+ return this.transcriptChunkIndex++;
291
+ }
292
+
286
293
  _detectErrors(clean) {
287
294
  if (!PATTERNS.error.test(clean)) return;
288
295
 
@@ -293,7 +300,7 @@ class Session {
293
300
  // Mirror status-change callback so T1 sees 'errored' in status_broadcast without
294
301
  // waiting for the 3s debounce.
295
302
  if (oldStatus !== 'errored' && this.onStatusChange) {
296
- try { this.onStatusChange(this, oldStatus, 'errored'); } catch {}
303
+ try { this.onStatusChange(this, oldStatus, 'errored'); } catch (err) { console.error('[pty] onStatusChange error:', err.message); }
297
304
  }
298
305
 
299
306
  // Server-side rate limit: at most one error_detected event every 30s per session