@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.
- package/README.md +12 -4
- package/package.json +1 -1
- package/packages/cli/src/index.js +8 -0
- package/packages/cli/src/init-rumen.js +99 -10
- package/packages/client/public/app.js +2786 -0
- package/packages/client/public/index.html +39 -3280
- package/packages/client/public/style.css +1776 -0
- package/packages/server/src/index.js +277 -6
- package/packages/server/src/mnestra-bridge/index.js +1 -1
- package/packages/server/src/preflight.js +373 -0
- package/packages/server/src/rag.js +40 -0
- package/packages/server/src/session.js +13 -2
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +6 -1
- package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +39 -3
- package/packages/server/src/transcripts.js +290 -0
|
@@ -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);
|
|
@@ -51,7 +51,11 @@ const PATTERNS = {
|
|
|
51
51
|
command: /^[\$#%❯>]\s+(.+)$/m
|
|
52
52
|
},
|
|
53
53
|
// Broad error markers across shells, compilers, scripts, and HTTP servers.
|
|
54
|
-
|
|
54
|
+
// Includes the literal "No such file or directory" phrase because many Unix
|
|
55
|
+
// tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
|
|
56
|
+
// without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
|
|
57
|
+
// first production kickstart insight on 2026-04-15.
|
|
58
|
+
error: /\b(error|Error|ERROR|exception|Exception|Traceback|fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied|\b5\d\d\b)\b/
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
class Session {
|
|
@@ -89,6 +93,9 @@ class Session {
|
|
|
89
93
|
ragEvents: [] // buffer before flush
|
|
90
94
|
};
|
|
91
95
|
|
|
96
|
+
// Transcript chunk counter — monotonic per session for deterministic replay
|
|
97
|
+
this.transcriptChunkIndex = 0;
|
|
98
|
+
|
|
92
99
|
// Output analysis state
|
|
93
100
|
this._outputBuffer = '';
|
|
94
101
|
this._outputFlushTimer = null;
|
|
@@ -279,6 +286,10 @@ class Session {
|
|
|
279
286
|
}
|
|
280
287
|
}
|
|
281
288
|
|
|
289
|
+
getNextChunkIndex() {
|
|
290
|
+
return this.transcriptChunkIndex++;
|
|
291
|
+
}
|
|
292
|
+
|
|
282
293
|
_detectErrors(clean) {
|
|
283
294
|
if (!PATTERNS.error.test(clean)) return;
|
|
284
295
|
|
|
@@ -289,7 +300,7 @@ class Session {
|
|
|
289
300
|
// Mirror status-change callback so T1 sees 'errored' in status_broadcast without
|
|
290
301
|
// waiting for the 3s debounce.
|
|
291
302
|
if (oldStatus !== 'errored' && this.onStatusChange) {
|
|
292
|
-
try { this.onStatusChange(this, oldStatus, 'errored'); } catch {}
|
|
303
|
+
try { this.onStatusChange(this, oldStatus, 'errored'); } catch (err) { console.error('[pty] onStatusChange error:', err.message); }
|
|
293
304
|
}
|
|
294
305
|
|
|
295
306
|
// Server-side rate limit: at most one error_detected event every 30s per session
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
// @ts-ignore Deno std import resolved at runtime.
|
|
21
21
|
import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
|
|
22
22
|
// @ts-ignore npm specifier resolved at runtime.
|
|
23
|
-
|
|
23
|
+
// NOTE: `__RUMEN_VERSION__` is a placeholder. `termdeck init --rumen` reads the
|
|
24
|
+
// current published version from the npm registry at deploy time and rewrites
|
|
25
|
+
// this line in a staged copy of the file before running `supabase functions
|
|
26
|
+
// deploy`. This source file on disk MUST keep the placeholder — do not commit
|
|
27
|
+
// a real version number here. See packages/cli/src/init-rumen.js.
|
|
28
|
+
import { runRumenJob, createPoolFromUrl } from 'npm:@jhizzard/rumen@__RUMEN_VERSION__';
|
|
24
29
|
|
|
25
30
|
// @ts-ignore Deno global available at runtime.
|
|
26
31
|
declare const Deno: { env: { get: (k: string) => string | undefined } };
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
-- Rumen
|
|
2
|
-
-- Non-destructive: creates three new tables under the rumen_ namespace
|
|
1
|
+
-- Rumen schema — self-healing migration.
|
|
2
|
+
-- Non-destructive: creates three new tables under the rumen_ namespace and
|
|
3
|
+
-- brings any pre-existing tables (from partial prior installs) up to the
|
|
4
|
+
-- current shape via ALTER TABLE ADD COLUMN IF NOT EXISTS.
|
|
3
5
|
-- Does NOT modify or reference Mnestra's existing memory_items / memory_sessions tables.
|
|
4
6
|
--
|
|
5
7
|
-- Apply with:
|
|
6
|
-
-- psql "$
|
|
8
|
+
-- psql "$DATABASE_URL" -f migrations/001_rumen_tables.sql
|
|
7
9
|
|
|
8
10
|
BEGIN;
|
|
9
11
|
|
|
@@ -23,6 +25,21 @@ CREATE TABLE IF NOT EXISTS rumen_jobs (
|
|
|
23
25
|
completed_at TIMESTAMPTZ
|
|
24
26
|
);
|
|
25
27
|
|
|
28
|
+
-- Backfill columns for schema drift from earlier install attempts.
|
|
29
|
+
-- CREATE TABLE IF NOT EXISTS is a no-op on existing tables, so without this
|
|
30
|
+
-- block the subsequent CREATE INDEX statements would fail on columns that
|
|
31
|
+
-- never got added.
|
|
32
|
+
ALTER TABLE rumen_jobs
|
|
33
|
+
ADD COLUMN IF NOT EXISTS triggered_by TEXT,
|
|
34
|
+
ADD COLUMN IF NOT EXISTS status TEXT,
|
|
35
|
+
ADD COLUMN IF NOT EXISTS sessions_processed INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
ADD COLUMN IF NOT EXISTS insights_generated INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
ADD COLUMN IF NOT EXISTS questions_generated INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
ADD COLUMN IF NOT EXISTS error_message TEXT,
|
|
39
|
+
ADD COLUMN IF NOT EXISTS source_session_ids UUID[] NOT NULL DEFAULT '{}',
|
|
40
|
+
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
41
|
+
ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
|
|
42
|
+
|
|
26
43
|
CREATE INDEX IF NOT EXISTS idx_rumen_jobs_status
|
|
27
44
|
ON rumen_jobs (status);
|
|
28
45
|
|
|
@@ -49,6 +66,15 @@ CREATE TABLE IF NOT EXISTS rumen_insights (
|
|
|
49
66
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
50
67
|
);
|
|
51
68
|
|
|
69
|
+
ALTER TABLE rumen_insights
|
|
70
|
+
ADD COLUMN IF NOT EXISTS job_id UUID,
|
|
71
|
+
ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] NOT NULL DEFAULT '{}',
|
|
72
|
+
ADD COLUMN IF NOT EXISTS projects TEXT[] NOT NULL DEFAULT '{}',
|
|
73
|
+
ADD COLUMN IF NOT EXISTS insight_text TEXT,
|
|
74
|
+
ADD COLUMN IF NOT EXISTS confidence NUMERIC(4, 3) NOT NULL DEFAULT 0.000,
|
|
75
|
+
ADD COLUMN IF NOT EXISTS acted_upon BOOLEAN NOT NULL DEFAULT FALSE,
|
|
76
|
+
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
|
77
|
+
|
|
52
78
|
CREATE INDEX IF NOT EXISTS idx_rumen_insights_job_id
|
|
53
79
|
ON rumen_insights (job_id);
|
|
54
80
|
|
|
@@ -78,6 +104,16 @@ CREATE TABLE IF NOT EXISTS rumen_questions (
|
|
|
78
104
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
79
105
|
);
|
|
80
106
|
|
|
107
|
+
ALTER TABLE rumen_questions
|
|
108
|
+
ADD COLUMN IF NOT EXISTS job_id UUID,
|
|
109
|
+
ADD COLUMN IF NOT EXISTS session_id UUID,
|
|
110
|
+
ADD COLUMN IF NOT EXISTS question TEXT,
|
|
111
|
+
ADD COLUMN IF NOT EXISTS context TEXT,
|
|
112
|
+
ADD COLUMN IF NOT EXISTS asked_at TIMESTAMPTZ,
|
|
113
|
+
ADD COLUMN IF NOT EXISTS answered_at TIMESTAMPTZ,
|
|
114
|
+
ADD COLUMN IF NOT EXISTS answer TEXT,
|
|
115
|
+
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
|
116
|
+
|
|
81
117
|
CREATE INDEX IF NOT EXISTS idx_rumen_questions_job_id
|
|
82
118
|
ON rumen_questions (job_id);
|
|
83
119
|
|