@jhizzard/termdeck 0.7.1 → 0.7.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/config/transcript-migration.sql +34 -0
- package/package.json +2 -1
- package/packages/cli/src/doctor.js +300 -20
- package/packages/cli/src/index.js +88 -2
- package/packages/cli/src/init-mnestra.js +31 -7
- package/packages/cli/src/update-check.js +5 -5
- package/packages/server/src/index.js +115 -1
- package/packages/server/src/mnestra-bridge/index.js +8 -0
- package/packages/server/src/rag.js +39 -12
- package/packages/server/src/setup/migration-runner.js +12 -5
- package/packages/server/src/setup/mnestra-migrations/008_legacy_rag_tables.sql +122 -0
- package/packages/server/src/setup/preconditions.js +24 -4
- package/packages/server/src/theme-resolver.js +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- termdeck_transcripts: append-only log of all PTY output
|
|
2
|
+
-- Run with: psql -f config/transcript-migration.sql "$DATABASE_URL"
|
|
3
|
+
-- Idempotent — safe to re-run.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS termdeck_transcripts (
|
|
6
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7
|
+
session_id TEXT NOT NULL, -- TermDeck session UUID
|
|
8
|
+
chunk_index BIGINT NOT NULL, -- monotonic per session, for ordering
|
|
9
|
+
content TEXT NOT NULL, -- raw PTY output (ANSI stripped)
|
|
10
|
+
raw_bytes BIGINT NOT NULL DEFAULT 0, -- byte count before stripping
|
|
11
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Index for session replay (ordered chunks)
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_session_order
|
|
16
|
+
ON termdeck_transcripts (session_id, chunk_index);
|
|
17
|
+
|
|
18
|
+
-- Index for time-range queries (crash recovery: "what happened in the last hour?")
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_created
|
|
20
|
+
ON termdeck_transcripts (created_at DESC);
|
|
21
|
+
|
|
22
|
+
-- Full-text search for finding specific output across all sessions
|
|
23
|
+
ALTER TABLE termdeck_transcripts
|
|
24
|
+
ADD COLUMN IF NOT EXISTS fts tsvector
|
|
25
|
+
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_fts
|
|
28
|
+
ON termdeck_transcripts USING GIN (fts);
|
|
29
|
+
|
|
30
|
+
-- RLS: service-role only (no anon access to raw terminal output)
|
|
31
|
+
ALTER TABLE termdeck_transcripts ENABLE ROW LEVEL SECURITY;
|
|
32
|
+
|
|
33
|
+
-- Cleanup policy: transcripts older than 30 days get purged
|
|
34
|
+
-- (implement as a pg_cron job or scheduled function, not in this migration)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"packages/client/public/**",
|
|
13
13
|
"config/config.example.yaml",
|
|
14
14
|
"config/secrets.env.example",
|
|
15
|
+
"config/transcript-migration.sql",
|
|
15
16
|
"LICENSE",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
|
@@ -1,22 +1,33 @@
|
|
|
1
|
-
// `termdeck doctor` — Sprint 28 T2.
|
|
1
|
+
// `termdeck doctor` — Sprint 28 T2 + Sprint 35 T3.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Two-section diagnostic:
|
|
4
|
+
// Section 1 (Sprint 28) — npm version-check across the four stack packages,
|
|
5
|
+
// comparing installed (`npm ls -g`) to the registry's `dist-tags.latest`.
|
|
6
|
+
// Section 2 (Sprint 35) — Supabase schema state. Connects via DATABASE_URL
|
|
7
|
+
// from ~/.termdeck/secrets.env and verifies the tables / columns / RPCs /
|
|
8
|
+
// extensions that TermDeck + Mnestra + Rumen depend on.
|
|
6
9
|
//
|
|
7
|
-
//
|
|
10
|
+
// Read-only — no auto-fix. Each fail prints a remediation hint.
|
|
11
|
+
//
|
|
12
|
+
// Module contract:
|
|
8
13
|
// module.exports = function doctor(argv): Promise<exitCode>
|
|
9
|
-
// 0 = all current
|
|
10
|
-
// 1 = at least one update available
|
|
11
|
-
// 2 = network/registry failure or
|
|
14
|
+
// 0 = all current and schema clean
|
|
15
|
+
// 1 = at least one update available OR at least one schema gap
|
|
16
|
+
// 2 = network/registry failure or DB-unreachable when --schema requested
|
|
17
|
+
//
|
|
18
|
+
// Flags:
|
|
19
|
+
// --json Emit a parseable JSON document (shape extended for Sprint 35:
|
|
20
|
+
// `{ exitCode, rows, schema? }` — `rows` retained for back-compat)
|
|
21
|
+
// --no-color Strip ANSI codes
|
|
22
|
+
// --no-schema Skip the Supabase schema section (used by tests + offline runs)
|
|
12
23
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// `module.exports.<name>` so monkey-patching takes effect at call time.
|
|
24
|
+
// Test seams (monkey-patchable):
|
|
25
|
+
// _detectInstalled / _fetchLatest — npm probes (Sprint 28)
|
|
26
|
+
// _runSchemaCheck — Supabase probe (Sprint 35) — tests stub to `{ skipped: true }`
|
|
17
27
|
|
|
18
28
|
const https = require('https');
|
|
19
29
|
const { spawn } = require('child_process');
|
|
30
|
+
const path = require('path');
|
|
20
31
|
|
|
21
32
|
const STACK_PACKAGES = [
|
|
22
33
|
'@jhizzard/termdeck',
|
|
@@ -58,7 +69,7 @@ async function _detectInstalled(pkg) {
|
|
|
58
69
|
child = spawn('npm', ['ls', '-g', pkg, '--depth=0', '--json'], {
|
|
59
70
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
60
71
|
});
|
|
61
|
-
} catch {
|
|
72
|
+
} catch (_err) {
|
|
62
73
|
return resolve(null);
|
|
63
74
|
}
|
|
64
75
|
|
|
@@ -80,7 +91,7 @@ async function _detectInstalled(pkg) {
|
|
|
80
91
|
const dep = parsed && parsed.dependencies && parsed.dependencies[pkg];
|
|
81
92
|
if (dep && typeof dep.version === 'string') return resolve(dep.version);
|
|
82
93
|
return resolve(null);
|
|
83
|
-
} catch {
|
|
94
|
+
} catch (_err) {
|
|
84
95
|
return resolve(null);
|
|
85
96
|
}
|
|
86
97
|
});
|
|
@@ -118,13 +129,13 @@ async function _fetchLatest(pkg) {
|
|
|
118
129
|
const parsed = JSON.parse(body);
|
|
119
130
|
if (parsed && typeof parsed.latest === 'string') return done(parsed.latest);
|
|
120
131
|
return done(null);
|
|
121
|
-
} catch {
|
|
132
|
+
} catch (_err) {
|
|
122
133
|
return done(null);
|
|
123
134
|
}
|
|
124
135
|
});
|
|
125
136
|
res.on('error', () => done(null));
|
|
126
137
|
});
|
|
127
|
-
} catch {
|
|
138
|
+
} catch (_err) {
|
|
128
139
|
return done(null);
|
|
129
140
|
}
|
|
130
141
|
req.on('timeout', () => {
|
|
@@ -200,9 +211,255 @@ function parseArgv(argv) {
|
|
|
200
211
|
return {
|
|
201
212
|
json: args.includes('--json'),
|
|
202
213
|
noColor: args.includes('--no-color'),
|
|
214
|
+
noSchema: args.includes('--no-schema'),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Sprint 35 T3: Supabase schema-check ────────────────────────────────────
|
|
219
|
+
//
|
|
220
|
+
// Connects via DATABASE_URL from ~/.termdeck/secrets.env and runs the schema
|
|
221
|
+
// invariants TermDeck + Mnestra + Rumen depend on. Read-only — no DDL.
|
|
222
|
+
//
|
|
223
|
+
// Returns `{ skipped, sections, passed, total, hasGaps, error? }` where
|
|
224
|
+
// `sections` is an ordered list of `{ name, checks: [{ label, status, hint? }] }`.
|
|
225
|
+
// `status` is one of 'pass' | 'fail'. A `skipped: true` result short-circuits
|
|
226
|
+
// rendering with an informational note.
|
|
227
|
+
|
|
228
|
+
const SCHEMA_QUERIES = {
|
|
229
|
+
table: (name) =>
|
|
230
|
+
`SELECT EXISTS(SELECT 1 FROM information_schema.tables ` +
|
|
231
|
+
`WHERE table_schema = 'public' AND table_name = '${name}') AS ok`,
|
|
232
|
+
column: (table, column) =>
|
|
233
|
+
`SELECT EXISTS(SELECT 1 FROM information_schema.columns ` +
|
|
234
|
+
`WHERE table_schema = 'public' AND table_name = '${table}' ` +
|
|
235
|
+
`AND column_name = '${column}') AS ok`,
|
|
236
|
+
rpc: (name) =>
|
|
237
|
+
`SELECT EXISTS(SELECT 1 FROM pg_proc WHERE proname = '${name}') AS ok`,
|
|
238
|
+
extension: (name) =>
|
|
239
|
+
`SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = '${name}') AS ok`,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// pgvector ships under extname 'vector' on Supabase; some older installs
|
|
243
|
+
// or self-hosted boxes use 'pgvector' directly. Accept either.
|
|
244
|
+
async function checkPgVector(client) {
|
|
245
|
+
try {
|
|
246
|
+
const r = await client.query(
|
|
247
|
+
"SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname IN ('vector', 'pgvector')) AS ok"
|
|
248
|
+
);
|
|
249
|
+
return r.rows && r.rows[0] && r.rows[0].ok === true;
|
|
250
|
+
} catch (_e) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function probeSchema(client, sql) {
|
|
256
|
+
try {
|
|
257
|
+
const r = await client.query(sql);
|
|
258
|
+
return r.rows && r.rows[0] && r.rows[0].ok === true;
|
|
259
|
+
} catch (_e) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function _runSchemaCheck(opts = {}) {
|
|
265
|
+
const optsObj = opts || {};
|
|
266
|
+
// Lazy-require so users running version-check-only never load pg / fs.
|
|
267
|
+
const fs = require('fs');
|
|
268
|
+
const os = require('os');
|
|
269
|
+
const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
|
|
270
|
+
let pgRunner;
|
|
271
|
+
let dotenv;
|
|
272
|
+
try {
|
|
273
|
+
pgRunner = require(path.join(SETUP_DIR, 'pg-runner'));
|
|
274
|
+
dotenv = require(path.join(SETUP_DIR, 'dotenv-io'));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return {
|
|
277
|
+
skipped: true,
|
|
278
|
+
reason: `setup helpers unavailable: ${err.message}`,
|
|
279
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const secretsPath = optsObj.secretsPath ||
|
|
284
|
+
path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
285
|
+
if (!fs.existsSync(secretsPath)) {
|
|
286
|
+
return {
|
|
287
|
+
skipped: true,
|
|
288
|
+
reason: `~/.termdeck/secrets.env not found — run \`termdeck init --mnestra\` first`,
|
|
289
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const secrets = optsObj.secrets || dotenv.readSecrets(secretsPath);
|
|
293
|
+
if (!secrets.DATABASE_URL) {
|
|
294
|
+
return {
|
|
295
|
+
skipped: true,
|
|
296
|
+
reason: `DATABASE_URL not set in ${secretsPath}`,
|
|
297
|
+
sections: [], passed: 0, total: 0, hasGaps: false,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let client = optsObj._pgClient || null;
|
|
302
|
+
let ownsClient = false;
|
|
303
|
+
if (!client) {
|
|
304
|
+
try {
|
|
305
|
+
client = await pgRunner.connect(secrets.DATABASE_URL);
|
|
306
|
+
ownsClient = true;
|
|
307
|
+
} catch (err) {
|
|
308
|
+
return {
|
|
309
|
+
skipped: false,
|
|
310
|
+
connectError: err.message,
|
|
311
|
+
sections: [], passed: 0, total: 0, hasGaps: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const sections = [
|
|
317
|
+
{ name: 'Mnestra modern schema', checks: [] },
|
|
318
|
+
{ name: 'Mnestra legacy schema', checks: [] },
|
|
319
|
+
{ name: 'Transcript backup', checks: [] },
|
|
320
|
+
{ name: 'Rumen schema', checks: [] },
|
|
321
|
+
{ name: 'Postgres extensions', checks: [] },
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Mnestra modern
|
|
326
|
+
const modern = sections[0].checks;
|
|
327
|
+
for (const t of ['memory_items', 'memory_sessions', 'memory_relationships']) {
|
|
328
|
+
modern.push({
|
|
329
|
+
label: `${t} table`,
|
|
330
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
|
|
331
|
+
hint: `run: termdeck init --mnestra (applies migrations 001–007)`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
modern.push({
|
|
335
|
+
label: `memory_items.source_session_id column (v0.6.5+)`,
|
|
336
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.column('memory_items', 'source_session_id'))) ? 'pass' : 'fail',
|
|
337
|
+
hint: `migration 007 adds it — run: npm cache clean --force && npm i -g @jhizzard/termdeck@latest && termdeck init --mnestra --yes`,
|
|
338
|
+
});
|
|
339
|
+
for (const fn of ['match_memories', 'search_memories', 'memory_status_aggregation']) {
|
|
340
|
+
modern.push({
|
|
341
|
+
label: `${fn}() RPC`,
|
|
342
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.rpc(fn))) ? 'pass' : 'fail',
|
|
343
|
+
hint: `migration 005/006 creates it — re-run: termdeck init --mnestra --yes`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Mnestra legacy (Sprint 35 T2 ships these via 008_legacy_rag_tables.sql)
|
|
348
|
+
const legacy = sections[1].checks;
|
|
349
|
+
for (const t of ['mnestra_session_memory', 'mnestra_project_memory', 'mnestra_developer_memory', 'mnestra_commands']) {
|
|
350
|
+
legacy.push({
|
|
351
|
+
label: `${t} table`,
|
|
352
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table(t))) ? 'pass' : 'fail',
|
|
353
|
+
hint: `run: termdeck init --mnestra --yes (applies migration 008 — Sprint 35)`,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Transcript
|
|
358
|
+
sections[2].checks.push({
|
|
359
|
+
label: `termdeck_transcripts table`,
|
|
360
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.table('termdeck_transcripts'))) ? 'pass' : 'fail',
|
|
361
|
+
hint: `run: psql "$DATABASE_URL" -f config/transcript-migration.sql`,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Rumen — table existence and the created_at column drift Brad hit
|
|
365
|
+
const rumen = sections[3].checks;
|
|
366
|
+
for (const t of ['rumen_jobs', 'rumen_insights', 'rumen_questions']) {
|
|
367
|
+
const tableOk = await probeSchema(client, SCHEMA_QUERIES.table(t));
|
|
368
|
+
rumen.push({
|
|
369
|
+
label: `${t} table`,
|
|
370
|
+
status: tableOk ? 'pass' : 'fail',
|
|
371
|
+
hint: `run: termdeck init --rumen (applies rumen migration 001)`,
|
|
372
|
+
});
|
|
373
|
+
// Only check the column when the table exists — otherwise the column
|
|
374
|
+
// line is redundant noise.
|
|
375
|
+
if (tableOk) {
|
|
376
|
+
rumen.push({
|
|
377
|
+
label: `${t}.created_at column`,
|
|
378
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.column(t, 'created_at'))) ? 'pass' : 'fail',
|
|
379
|
+
hint: `column drift detected — re-run: termdeck init --rumen`,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Extensions — pg_cron / pg_net / pgvector / pg_trgm / pgcrypto
|
|
385
|
+
const exts = sections[4].checks;
|
|
386
|
+
const dashboardHint = (() => {
|
|
387
|
+
if (!secrets.SUPABASE_URL) return `enable in dashboard: Database → Extensions`;
|
|
388
|
+
const m = String(secrets.SUPABASE_URL).match(/https:\/\/([a-z0-9-]+)\.supabase\.(co|in)/i);
|
|
389
|
+
if (!m) return `enable in dashboard: Database → Extensions`;
|
|
390
|
+
return `enable: https://supabase.com/dashboard/project/${m[1]}/database/extensions`;
|
|
391
|
+
})();
|
|
392
|
+
for (const ext of ['pg_cron', 'pg_net', 'pg_trgm', 'pgcrypto']) {
|
|
393
|
+
exts.push({
|
|
394
|
+
label: `${ext}`,
|
|
395
|
+
status: (await probeSchema(client, SCHEMA_QUERIES.extension(ext))) ? 'pass' : 'fail',
|
|
396
|
+
hint: dashboardHint,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
exts.push({
|
|
400
|
+
label: `pgvector (extname: vector)`,
|
|
401
|
+
status: (await checkPgVector(client)) ? 'pass' : 'fail',
|
|
402
|
+
hint: dashboardHint,
|
|
403
|
+
});
|
|
404
|
+
} finally {
|
|
405
|
+
if (ownsClient) {
|
|
406
|
+
try { await client.end(); } catch (_e) { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
let passed = 0;
|
|
411
|
+
let total = 0;
|
|
412
|
+
for (const s of sections) {
|
|
413
|
+
for (const c of s.checks) {
|
|
414
|
+
total += 1;
|
|
415
|
+
if (c.status === 'pass') passed += 1;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
skipped: false,
|
|
420
|
+
sections,
|
|
421
|
+
passed,
|
|
422
|
+
total,
|
|
423
|
+
hasGaps: passed < total,
|
|
203
424
|
};
|
|
204
425
|
}
|
|
205
426
|
|
|
427
|
+
function renderSchemaResult(result, c) {
|
|
428
|
+
const out = [];
|
|
429
|
+
out.push('');
|
|
430
|
+
out.push(c.bold('TermDeck stack — Supabase schema check'));
|
|
431
|
+
out.push('');
|
|
432
|
+
if (result.skipped) {
|
|
433
|
+
out.push(` ${c.dim(`(skipped) ${result.reason}`)}`);
|
|
434
|
+
return out.join('\n');
|
|
435
|
+
}
|
|
436
|
+
if (result.connectError) {
|
|
437
|
+
out.push(` ${c.yellow('✗')} could not connect: ${result.connectError}`);
|
|
438
|
+
out.push(` ${c.dim('Check DATABASE_URL in ~/.termdeck/secrets.env, then re-run.')}`);
|
|
439
|
+
return out.join('\n');
|
|
440
|
+
}
|
|
441
|
+
for (const section of result.sections) {
|
|
442
|
+
out.push(` ${c.bold(section.name)}`);
|
|
443
|
+
if (section.checks.length === 0) {
|
|
444
|
+
out.push(` ${c.dim('(no checks ran)')}`);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
for (const check of section.checks) {
|
|
448
|
+
if (check.status === 'pass') {
|
|
449
|
+
out.push(` ${c.green('✓')} ${check.label}`);
|
|
450
|
+
} else {
|
|
451
|
+
out.push(` ${c.yellow('✗')} ${check.label}`);
|
|
452
|
+
if (check.hint) {
|
|
453
|
+
out.push(` ${c.dim(check.hint)}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
out.push('');
|
|
458
|
+
}
|
|
459
|
+
out.push(` ${result.passed}/${result.total} schema checks passed`);
|
|
460
|
+
return out.join('\n');
|
|
461
|
+
}
|
|
462
|
+
|
|
206
463
|
async function doctor(argv) {
|
|
207
464
|
const opts = parseArgv(argv);
|
|
208
465
|
|
|
@@ -223,9 +480,24 @@ async function doctor(argv) {
|
|
|
223
480
|
})
|
|
224
481
|
);
|
|
225
482
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
483
|
+
// Sprint 35 T3: schema check (skippable for tests / offline runs).
|
|
484
|
+
let schema = null;
|
|
485
|
+
if (!opts.noSchema) {
|
|
486
|
+
try {
|
|
487
|
+
schema = await module.exports._runSchemaCheck();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
schema = {
|
|
490
|
+
skipped: false,
|
|
491
|
+
connectError: `unexpected error: ${err && err.message || err}`,
|
|
492
|
+
sections: [], passed: 0, total: 0, hasGaps: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Exit-code priority: any network failure → 2; any update available OR
|
|
498
|
+
// schema gap → 1; else 0. Computed after all rows resolve so a single
|
|
499
|
+
// transient failure doesn't mask real updates in stdout. A schema connect
|
|
500
|
+
// error counts as 2 (same class as a registry fetch failure).
|
|
229
501
|
let exitCode = 0;
|
|
230
502
|
for (const r of rows) {
|
|
231
503
|
if (r.status === STATUS.NETWORK_ERROR) {
|
|
@@ -234,9 +506,13 @@ async function doctor(argv) {
|
|
|
234
506
|
}
|
|
235
507
|
if (r.status === STATUS.UPDATE && exitCode < 1) exitCode = 1;
|
|
236
508
|
}
|
|
509
|
+
if (schema && schema.connectError && exitCode < 2) exitCode = 2;
|
|
510
|
+
if (schema && !schema.skipped && schema.hasGaps && exitCode < 1) exitCode = 1;
|
|
237
511
|
|
|
238
512
|
if (opts.json) {
|
|
239
|
-
|
|
513
|
+
const payload = { exitCode, rows };
|
|
514
|
+
if (schema) payload.schema = schema;
|
|
515
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
240
516
|
return exitCode;
|
|
241
517
|
}
|
|
242
518
|
|
|
@@ -244,6 +520,9 @@ async function doctor(argv) {
|
|
|
244
520
|
const c = makeColors(colorEnabled);
|
|
245
521
|
process.stdout.write(renderTable(rows, c) + '\n');
|
|
246
522
|
process.stdout.write(renderFooter(rows, exitCode) + '\n');
|
|
523
|
+
if (schema) {
|
|
524
|
+
process.stdout.write(renderSchemaResult(schema, c) + '\n');
|
|
525
|
+
}
|
|
247
526
|
return exitCode;
|
|
248
527
|
}
|
|
249
528
|
|
|
@@ -251,5 +530,6 @@ module.exports = doctor;
|
|
|
251
530
|
module.exports._detectInstalled = _detectInstalled;
|
|
252
531
|
module.exports._fetchLatest = _fetchLatest;
|
|
253
532
|
module.exports._compareSemver = _compareSemver;
|
|
533
|
+
module.exports._runSchemaCheck = _runSchemaCheck;
|
|
254
534
|
module.exports.STACK_PACKAGES = STACK_PACKAGES;
|
|
255
535
|
module.exports.STATUS = STATUS;
|
|
@@ -14,7 +14,75 @@
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const os = require('os');
|
|
17
|
-
const { execSync } = require('child_process');
|
|
17
|
+
const { exec, execSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Sprint 35 T4: stale-port reclaim. If the target port is held by a previous
|
|
20
|
+
// TermDeck instance (crash, runaway, prior `termdeck` left orphaned), kill it
|
|
21
|
+
// and continue. If it's held by something else, print a clear error and exit
|
|
22
|
+
// instead of letting `server.listen()` throw a generic EADDRINUSE.
|
|
23
|
+
// Lifted from scripts/start.sh:127–154 so npm-installed users (who never see
|
|
24
|
+
// start.sh) get the same recovery behavior.
|
|
25
|
+
function reclaimStalePort(port) {
|
|
26
|
+
let pids = [];
|
|
27
|
+
try {
|
|
28
|
+
const out = execSync(`lsof -ti TCP:${port} -sTCP:LISTEN 2>/dev/null`, { encoding: 'utf8' });
|
|
29
|
+
pids = out.split(/\s+/).filter(Boolean);
|
|
30
|
+
} catch (_e) {
|
|
31
|
+
// lsof exits 1 when no PIDs match — empty case, not an error.
|
|
32
|
+
pids = [];
|
|
33
|
+
}
|
|
34
|
+
if (pids.length === 0) {
|
|
35
|
+
// Linux fallback for systems without lsof
|
|
36
|
+
try {
|
|
37
|
+
const out = execSync(`fuser -n tcp ${port} 2>/dev/null`, { encoding: 'utf8' });
|
|
38
|
+
pids = out.split(/\s+/).filter((s) => /^\d+$/.test(s));
|
|
39
|
+
} catch (_e) { pids = []; }
|
|
40
|
+
}
|
|
41
|
+
if (pids.length === 0) return;
|
|
42
|
+
|
|
43
|
+
let isTermDeck = false;
|
|
44
|
+
for (const pid of pids) {
|
|
45
|
+
try {
|
|
46
|
+
const cmd = execSync(`ps -o command= -p ${pid}`, { encoding: 'utf8' });
|
|
47
|
+
if (/packages\/cli\/src\/index\.js/.test(cmd) || /termdeck/i.test(cmd)) {
|
|
48
|
+
isTermDeck = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
} catch (_e) { /* PID gone between lsof and ps — ignore */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (isTermDeck) {
|
|
55
|
+
console.log(` \x1b[2m[port] Reclaiming :${port} from stale TermDeck (PIDs: ${pids.join(' ')})\x1b[0m`);
|
|
56
|
+
for (const pid of pids) {
|
|
57
|
+
try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch (_e) {}
|
|
58
|
+
}
|
|
59
|
+
try { execSync('sleep 1'); } catch (_e) {}
|
|
60
|
+
for (const pid of pids) {
|
|
61
|
+
try { process.kill(parseInt(pid, 10), 'SIGKILL'); } catch (_e) {}
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
console.error(`\n \x1b[31m✗ Port ${port} is in use by a non-TermDeck process (PIDs: ${pids.join(' ')})\x1b[0m`);
|
|
65
|
+
console.error(` \x1b[2mTry a different port: termdeck --port ${port + 1}\x1b[0m\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Sprint 35 T4: transcript-table-missing hint. If DATABASE_URL is set and
|
|
71
|
+
// psql is on PATH, probe for termdeck_transcripts. Fire-and-forget so a slow
|
|
72
|
+
// network round-trip to Supabase never blocks boot. Lifted from
|
|
73
|
+
// scripts/start.sh:309–313.
|
|
74
|
+
function checkTranscriptTableHint(databaseUrl) {
|
|
75
|
+
if (!databaseUrl) return;
|
|
76
|
+
try { execSync('command -v psql', { stdio: 'ignore' }); } catch (_e) { return; }
|
|
77
|
+
exec('psql "$DATABASE_URL" -c "SELECT 1 FROM termdeck_transcripts LIMIT 0"', {
|
|
78
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
79
|
+
timeout: 5000,
|
|
80
|
+
}, (err) => {
|
|
81
|
+
if (err) {
|
|
82
|
+
console.log(` \x1b[33m[hint]\x1b[0m Transcript backup table missing. Run: \x1b[1mtermdeck doctor\x1b[0m (or psql $DATABASE_URL -f config/transcript-migration.sql)`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
18
86
|
|
|
19
87
|
// Parse CLI args
|
|
20
88
|
const args = process.argv.slice(2);
|
|
@@ -130,7 +198,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
130
198
|
termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
|
|
131
199
|
termdeck init --rumen Deploy Tier 3 async learning (Rumen)
|
|
132
200
|
termdeck forge Generate Claude skills from memories (experimental)
|
|
133
|
-
termdeck doctor
|
|
201
|
+
termdeck doctor Diagnose stack — npm versions + Supabase schema (use --no-schema to skip the DB probe)
|
|
134
202
|
|
|
135
203
|
Keyboard shortcuts (in browser):
|
|
136
204
|
Ctrl+Shift+N Focus prompt bar
|
|
@@ -174,6 +242,11 @@ const port = config.port || 3000;
|
|
|
174
242
|
const host = config.host || '127.0.0.1';
|
|
175
243
|
const url = `http://${host}:${port}`;
|
|
176
244
|
|
|
245
|
+
// Sprint 35 T4: reclaim the port if a previous TermDeck is squatting on it,
|
|
246
|
+
// or hard-stop with a useful hint if a non-TermDeck process holds it. Runs
|
|
247
|
+
// before server.listen() so EADDRINUSE never bubbles up.
|
|
248
|
+
reclaimStalePort(port);
|
|
249
|
+
|
|
177
250
|
// Bind guardrail: refuse non-loopback without auth token
|
|
178
251
|
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
179
252
|
if (!LOOPBACK.has(host)) {
|
|
@@ -229,10 +302,23 @@ server.listen(port, host, async () => {
|
|
|
229
302
|
╚══════════════════════════════════════╝
|
|
230
303
|
`);
|
|
231
304
|
|
|
305
|
+
// Sprint 35 T4: RAG state line. Always-visible indicator of what mode the
|
|
306
|
+
// user is in — MCP-only (the new default after Sprint 35 T1) or full RAG
|
|
307
|
+
// writing to mnestra_*_memory tables. Dim line, single sentence.
|
|
308
|
+
if (config.rag && config.rag.enabled === true) {
|
|
309
|
+
console.log(` \x1b[2mRAG: on — events syncing to mnestra_session_memory / mnestra_project_memory / mnestra_developer_memory\x1b[0m\n`);
|
|
310
|
+
} else {
|
|
311
|
+
console.log(` \x1b[2mRAG: off (MCP-only mode) — toggle in dashboard at ${url}/#config to enable session/project/developer memory tables\x1b[0m\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
232
314
|
if (firstRun) {
|
|
233
315
|
console.log(" First run detected. Open http://localhost:3000 and click 'config' to set up.\n");
|
|
234
316
|
}
|
|
235
317
|
|
|
318
|
+
// Sprint 35 T4: probe Supabase for the transcript backup table; print a
|
|
319
|
+
// hint if it's missing. Non-blocking — the result lands after the banner.
|
|
320
|
+
checkTranscriptTableHint(process.env.DATABASE_URL || (config.rag && config.rag.databaseUrl));
|
|
321
|
+
|
|
236
322
|
// Run preflight health checks (non-blocking — warn but don't prevent startup)
|
|
237
323
|
runPreflight(config).then((result) => {
|
|
238
324
|
printHealthBanner(result);
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// or migration failure doesn't lose the user's typed-in keys.
|
|
12
12
|
// 3. Connect via `pg` using the direct URL
|
|
13
13
|
// 4. Apply the six bundled Mnestra migrations in order
|
|
14
|
-
// 5. Update ~/.termdeck/config.yaml
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// 5. Update ~/.termdeck/config.yaml — set rag.enabled: false (MCP-only
|
|
15
|
+
// default; opt into TermDeck-side RAG via dashboard toggle) and point
|
|
16
|
+
// at ${VAR} refs (only after migrations apply cleanly — otherwise the
|
|
17
|
+
// server would try to use an incomplete schema on next startup)
|
|
17
18
|
// 6. Verify with a memory_status_aggregation() call
|
|
18
19
|
//
|
|
19
20
|
// Flags:
|
|
@@ -74,7 +75,8 @@ const HELP = [
|
|
|
74
75
|
' 2. Writes ~/.termdeck/secrets.env IMMEDIATELY (merge-aware) so a later',
|
|
75
76
|
' pg connect or migration failure does not lose what you typed in.',
|
|
76
77
|
' 3. Connects to Postgres and applies the six Mnestra schema + RPC migrations.',
|
|
77
|
-
' 4. Updates ~/.termdeck/config.yaml
|
|
78
|
+
' 4. Updates ~/.termdeck/config.yaml — sets rag.enabled: false (MCP-only',
|
|
79
|
+
' default) and references ${VAR} keys for credentials.',
|
|
78
80
|
' 5. Verifies the Mnestra store is reachable via memory_status_aggregation().',
|
|
79
81
|
'',
|
|
80
82
|
'Every secret stays on your machine. Nothing is ever printed once entered.',
|
|
@@ -180,7 +182,8 @@ This wizard configures TermDeck's Tier 2 memory layer (Mnestra) by:
|
|
|
180
182
|
5. Writing ~/.termdeck/secrets.env (before any database work, so a
|
|
181
183
|
pg failure cannot lose what you typed in)
|
|
182
184
|
6. Connecting to Postgres + applying six SQL migrations
|
|
183
|
-
7. Updating ~/.termdeck/config.yaml
|
|
185
|
+
7. Updating ~/.termdeck/config.yaml — rag.enabled: false (MCP-only
|
|
186
|
+
default; toggle in dashboard later) with \${VAR} refs (only after
|
|
184
187
|
migrations apply cleanly)
|
|
185
188
|
8. Verifying the connection with a memory_status call
|
|
186
189
|
|
|
@@ -410,11 +413,27 @@ function writeSecretsFile(inputs, dryRun) {
|
|
|
410
413
|
ok();
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
// MCP-only is the default starting v0.7.3. Mnestra's MCP server populates
|
|
417
|
+
// `memory_items` whenever an AI worker calls memory_remember / memory_recall,
|
|
418
|
+
// so the dashboard's Flashback queries work out of the box. The TermDeck-side
|
|
419
|
+
// RAG event tables (mnestra_session_memory / mnestra_project_memory /
|
|
420
|
+
// mnestra_developer_memory / mnestra_commands) stay off until the user opts
|
|
421
|
+
// in via the dashboard or by editing config.yaml. This matches Joshua's
|
|
422
|
+
// daily-driver setup and avoids the v0.7.2-and-earlier asymmetry that hit
|
|
423
|
+
// Brad's box on 2026-04-27 (default `enabled: true` against tables no init
|
|
424
|
+
// path created → 404 cascade → silent RAG drop).
|
|
413
425
|
function writeYamlConfig(dryRun) {
|
|
414
|
-
|
|
426
|
+
process.stdout.write(
|
|
427
|
+
'\nSetup mode: MCP-only (default)\n' +
|
|
428
|
+
' Mnestra MCP tools fill memory_items via memory_remember / memory_recall.\n' +
|
|
429
|
+
' TermDeck event tables (session / project / developer) stay OFF by default.\n' +
|
|
430
|
+
' Enable later: toggle in dashboard at http://localhost:3000/#config\n' +
|
|
431
|
+
' or set rag.enabled: true in ~/.termdeck/config.yaml.\n\n'
|
|
432
|
+
);
|
|
433
|
+
step('Updating ~/.termdeck/config.yaml (rag.enabled: false, MCP-only default)...');
|
|
415
434
|
if (dryRun) { ok('(dry-run)'); return; }
|
|
416
435
|
const r = yaml.updateRagConfig({
|
|
417
|
-
enabled:
|
|
436
|
+
enabled: false,
|
|
418
437
|
supabaseUrl: '${SUPABASE_URL}',
|
|
419
438
|
supabaseKey: '${SUPABASE_SERVICE_ROLE_KEY}',
|
|
420
439
|
openaiApiKey: '${OPENAI_API_KEY}',
|
|
@@ -428,6 +447,11 @@ function printNextSteps() {
|
|
|
428
447
|
process.stdout.write(`
|
|
429
448
|
Mnestra is configured.
|
|
430
449
|
|
|
450
|
+
Setup mode: MCP-only (default) — TermDeck-side RAG event tables are off.
|
|
451
|
+
To enable session / project / developer memory tables, toggle in the dashboard
|
|
452
|
+
at http://localhost:3000/#config or set rag.enabled: true in
|
|
453
|
+
~/.termdeck/config.yaml and restart TermDeck.
|
|
454
|
+
|
|
431
455
|
Next steps:
|
|
432
456
|
1. Restart TermDeck: termdeck
|
|
433
457
|
2. Flashback will fire automatically on panel errors
|
|
@@ -38,7 +38,7 @@ function defaultPackageVersion() {
|
|
|
38
38
|
try {
|
|
39
39
|
const pkg = require(path.join(__dirname, '..', '..', '..', 'package.json'));
|
|
40
40
|
return pkg && pkg.version ? String(pkg.version) : null;
|
|
41
|
-
} catch {
|
|
41
|
+
} catch (_err) {
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -68,7 +68,7 @@ function readCache(cachePath) {
|
|
|
68
68
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
69
69
|
if (parsed.version !== CACHE_VERSION) return null;
|
|
70
70
|
return parsed;
|
|
71
|
-
} catch {
|
|
71
|
+
} catch (_err) {
|
|
72
72
|
return null;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -77,7 +77,7 @@ function writeCache(cachePath, data) {
|
|
|
77
77
|
try {
|
|
78
78
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
79
79
|
fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf8');
|
|
80
|
-
} catch {
|
|
80
|
+
} catch (_err) {
|
|
81
81
|
// Read-only home, ENOSPC, race with another process — all benign here.
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -91,7 +91,7 @@ async function fetchLatest(registryUrl) {
|
|
|
91
91
|
const json = await res.json();
|
|
92
92
|
const latest = json && json.latest;
|
|
93
93
|
return isValidSemver(latest) ? latest : null;
|
|
94
|
-
} catch {
|
|
94
|
+
} catch (_err) {
|
|
95
95
|
return null;
|
|
96
96
|
} finally {
|
|
97
97
|
clearTimeout(timeout);
|
|
@@ -144,7 +144,7 @@ async function checkAndPrintHint(_config, opts) {
|
|
|
144
144
|
' Or run `termdeck doctor` for the whole stack. ' +
|
|
145
145
|
'Suppress with TERMDECK_NO_UPDATE_CHECK=1.'
|
|
146
146
|
);
|
|
147
|
-
} catch {
|
|
147
|
+
} catch (_err) {
|
|
148
148
|
// Never throw from a fire-and-forget hook.
|
|
149
149
|
}
|
|
150
150
|
}
|
|
@@ -915,6 +915,120 @@ function createServer(config) {
|
|
|
915
915
|
res.json({ ok: true, bytes: normalized.length, replyCount: session.meta.replyCount });
|
|
916
916
|
});
|
|
917
917
|
|
|
918
|
+
// POST /api/sessions/:id/poke - PTY-flush recovery endpoint
|
|
919
|
+
// Body: { methods?: ('sigcont' | 'bracketed-paste' | 'cr-flood' | 'all')[] } default ['all']
|
|
920
|
+
// Used to recover from the post-stop PTY delivery gap where injected input via /input
|
|
921
|
+
// returns 200 OK but never reaches the running TUI process. Tries multiple flush
|
|
922
|
+
// mechanisms in sequence and reports per-attempt status plus session state before/after.
|
|
923
|
+
// Discovered 2026-04-26 / 2026-04-27 during ClaimGuard Sprints 4-6 (TMR 4+1 orchestration);
|
|
924
|
+
// see ~/.claude/plans/skill-tmr-orchestrate/known-issues/2026-04-27-pty-delivery-gap.md
|
|
925
|
+
app.post('/api/sessions/:id/poke', async (req, res) => {
|
|
926
|
+
const session = sessions.get(req.params.id);
|
|
927
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
928
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
929
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const { methods } = req.body || {};
|
|
933
|
+
const requested = Array.isArray(methods) && methods.length > 0
|
|
934
|
+
? methods
|
|
935
|
+
: ['all'];
|
|
936
|
+
const runAll = requested.includes('all');
|
|
937
|
+
const wants = (m) => runAll || requested.includes(m);
|
|
938
|
+
|
|
939
|
+
const before = {
|
|
940
|
+
status: session.meta.status,
|
|
941
|
+
statusDetail: session.meta.statusDetail || '',
|
|
942
|
+
lastActivity: session.meta.lastActivity,
|
|
943
|
+
pid: session.pty.pid,
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const attempts = [];
|
|
947
|
+
|
|
948
|
+
// Attempt 1: SIGCONT — wakes the child process if it's somehow stopped (job-control state).
|
|
949
|
+
// Harmless when the process is already running.
|
|
950
|
+
if (wants('sigcont')) {
|
|
951
|
+
try {
|
|
952
|
+
process.kill(session.pty.pid, 'SIGCONT');
|
|
953
|
+
attempts.push({ method: 'sigcont', ok: true });
|
|
954
|
+
} catch (err) {
|
|
955
|
+
attempts.push({ method: 'sigcont', ok: false, error: err.message });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Attempt 2: bracketed-paste sequence wrapping a single CR.
|
|
960
|
+
// Some TUIs treat bracketed-paste differently from raw input; this is a documented
|
|
961
|
+
// (and previously untested) workaround mentioned in the TermDeck API reference.
|
|
962
|
+
if (wants('bracketed-paste')) {
|
|
963
|
+
try {
|
|
964
|
+
session.pty.write('\x1b[200~\r\x1b[201~');
|
|
965
|
+
attempts.push({ method: 'bracketed-paste', ok: true });
|
|
966
|
+
} catch (err) {
|
|
967
|
+
attempts.push({ method: 'bracketed-paste', ok: false, error: err.message });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Wait briefly between attempts so each one has a chance to take effect
|
|
972
|
+
// before the next floods the buffer.
|
|
973
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
974
|
+
|
|
975
|
+
// Attempt 3: triple CR — multiple Enter keypresses in case the TUI needs more
|
|
976
|
+
// than one to register. Each \r is a literal Enter (zsh/readline submit).
|
|
977
|
+
if (wants('cr-flood')) {
|
|
978
|
+
try {
|
|
979
|
+
session.pty.write('\r\r\r');
|
|
980
|
+
attempts.push({ method: 'cr-flood', ok: true });
|
|
981
|
+
} catch (err) {
|
|
982
|
+
attempts.push({ method: 'cr-flood', ok: false, error: err.message });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Final settle delay so `after` reflects the result of all attempts.
|
|
987
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
988
|
+
|
|
989
|
+
const after = {
|
|
990
|
+
status: session.meta.status,
|
|
991
|
+
statusDetail: session.meta.statusDetail || '',
|
|
992
|
+
lastActivity: session.meta.lastActivity,
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
// Heuristic recovery signal: if lastActivity advanced between before and after,
|
|
996
|
+
// at least one attempt got the TUI to consume input. Not definitive (the TUI
|
|
997
|
+
// might have advanced for other reasons) but a useful hint to the caller.
|
|
998
|
+
const advanced = before.lastActivity !== after.lastActivity;
|
|
999
|
+
|
|
1000
|
+
res.json({
|
|
1001
|
+
ok: true,
|
|
1002
|
+
pid: session.pty.pid,
|
|
1003
|
+
before,
|
|
1004
|
+
after,
|
|
1005
|
+
advanced,
|
|
1006
|
+
attempts,
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
// GET /api/sessions/:id/buffer - lightweight introspection of recent input writes
|
|
1011
|
+
// Returns the session's recent _inputBuffer state (what the orchestrator has
|
|
1012
|
+
// written via /input that may or may not have been consumed by the TUI yet).
|
|
1013
|
+
// Useful for diagnosing whether bytes are queued vs consumed.
|
|
1014
|
+
app.get('/api/sessions/:id/buffer', (req, res) => {
|
|
1015
|
+
const session = sessions.get(req.params.id);
|
|
1016
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
1017
|
+
if (session.meta.status === 'exited' || !session.pty) {
|
|
1018
|
+
return res.status(404).json({ error: 'Session is exited' });
|
|
1019
|
+
}
|
|
1020
|
+
res.json({
|
|
1021
|
+
ok: true,
|
|
1022
|
+
pid: session.pty.pid,
|
|
1023
|
+
inputBufferLength: (session._inputBuffer || '').length,
|
|
1024
|
+
inputBufferPreview: (session._inputBuffer || '').slice(-200),
|
|
1025
|
+
lastActivity: session.meta.lastActivity,
|
|
1026
|
+
status: session.meta.status,
|
|
1027
|
+
statusDetail: session.meta.statusDetail || '',
|
|
1028
|
+
replyCount: session.meta.replyCount || 0,
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
918
1032
|
// POST /api/sessions/:id/resize - resize terminal
|
|
919
1033
|
app.post('/api/sessions/:id/resize', (req, res) => {
|
|
920
1034
|
const session = sessions.get(req.params.id);
|
|
@@ -1619,7 +1733,7 @@ if (require.main === module) {
|
|
|
1619
1733
|
console.log(` Terminals: 0 active`);
|
|
1620
1734
|
console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
|
|
1621
1735
|
console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
|
|
1622
|
-
console.log(` RAG: ${config.rag?.
|
|
1736
|
+
console.log(` RAG: ${config.rag?.enabled === true ? 'on (writing to mnestra_*_memory tables)' : 'off (MCP-only mode)'}`);
|
|
1623
1737
|
console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
|
|
1624
1738
|
console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
|
|
1625
1739
|
console.log(`\n WARNING: TermDeck binds to ${host} only.`);
|
|
@@ -231,13 +231,21 @@ function createBridge(config) {
|
|
|
231
231
|
// back to resolving the session's cwd against config.projects so queries
|
|
232
232
|
// don't leak into unrelated repos via basename collisions.
|
|
233
233
|
let effectiveProject = project;
|
|
234
|
+
let projectSource = project ? 'explicit' : 'none';
|
|
234
235
|
if (!effectiveProject) {
|
|
235
236
|
const ctxCwd = cwd || (sessionContext && sessionContext.cwd);
|
|
236
237
|
if (ctxCwd) {
|
|
237
238
|
effectiveProject = resolveProjectName(ctxCwd, config);
|
|
239
|
+
projectSource = effectiveProject ? 'cwd' : 'none';
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
|
|
243
|
+
// Sprint 34 observability: every Flashback query announces its project tag
|
|
244
|
+
// and how it was resolved. If the writer chain is ever mis-emitting a tag
|
|
245
|
+
// (as happened pre-v0.7.2 with the `chopin-nashville` regression from the
|
|
246
|
+
// out-of-repo session-end hook), the mismatch surfaces here at query time.
|
|
247
|
+
console.log(`[mnestra-bridge] query project=${effectiveProject ?? 'ALL'} source=${searchAll ? 'searchAll' : projectSource} mode=${mode}`);
|
|
248
|
+
|
|
241
249
|
switch (mode) {
|
|
242
250
|
case 'webhook':
|
|
243
251
|
return queryWebhook({ question, project: effectiveProject, searchAll });
|
|
@@ -97,53 +97,80 @@ class RAGIntegration {
|
|
|
97
97
|
|
|
98
98
|
// Canonical project tag for a session. Prefers the explicit config.yaml name
|
|
99
99
|
// (set at session creation), falls back to cwd → config.projects resolution.
|
|
100
|
+
// Returns { tag, source } so callers can audit which resolution path fired —
|
|
101
|
+
// explicit (session.meta.project), cwd (cwd matched a config.projects entry),
|
|
102
|
+
// fallback (cwd basename), or null (no cwd, no config). Sprint 34: the
|
|
103
|
+
// chopin-nashville mis-tag came from an out-of-repo writer, but source
|
|
104
|
+
// attribution here makes any future TermDeck-side regression visible in logs.
|
|
105
|
+
_resolveProjectAttribution(session) {
|
|
106
|
+
if (session.meta.project) return { tag: session.meta.project, source: 'explicit' };
|
|
107
|
+
const tag = resolveProjectName(session.meta.cwd, this.config);
|
|
108
|
+
if (!tag) return { tag: null, source: 'none' };
|
|
109
|
+
const cwdResolved = session.meta.cwd && path.resolve(String(session.meta.cwd).replace(/^~/, os.homedir()));
|
|
110
|
+
const matchedConfig = !!cwdResolved && Object.values((this.config && this.config.projects) || {}).some((def) => {
|
|
111
|
+
if (!def || typeof def.path !== 'string') return false;
|
|
112
|
+
const p = path.resolve(def.path.replace(/^~/, os.homedir()));
|
|
113
|
+
return cwdResolved === p || cwdResolved.startsWith(p + path.sep);
|
|
114
|
+
});
|
|
115
|
+
return { tag, source: matchedConfig ? 'cwd' : 'fallback' };
|
|
116
|
+
}
|
|
117
|
+
|
|
100
118
|
_projectFor(session) {
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
return this._resolveProjectAttribution(session).tag;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Single attribution + observability point for session events. Logs once per
|
|
123
|
+
// record() so future drift in the project-resolution chain (e.g. a writer
|
|
124
|
+
// that bypasses _projectFor and stamps a raw path segment) is visible in
|
|
125
|
+
// stdout. Cheap: ~one log line per RAG event, off the hot path.
|
|
126
|
+
_recordForSession(session, eventType, payload) {
|
|
127
|
+
const { tag, source } = this._resolveProjectAttribution(session);
|
|
128
|
+
console.log(`[rag] write project=${tag ?? 'null'} source=${source} session=${session.id} event=${eventType}`);
|
|
129
|
+
this.record(session.id, eventType, payload, tag);
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
// Event types to record
|
|
106
133
|
onSessionCreated(session) {
|
|
107
|
-
this.
|
|
134
|
+
this._recordForSession(session, 'session_created', {
|
|
108
135
|
type: session.meta.type,
|
|
109
136
|
command: session.meta.command,
|
|
110
137
|
cwd: session.meta.cwd,
|
|
111
138
|
reason: session.meta.reason
|
|
112
|
-
}
|
|
139
|
+
});
|
|
113
140
|
}
|
|
114
141
|
|
|
115
142
|
onCommandExecuted(session, command, outputSnippet) {
|
|
116
|
-
this.
|
|
143
|
+
this._recordForSession(session, 'command_executed', {
|
|
117
144
|
command,
|
|
118
145
|
output_snippet: outputSnippet?.slice(0, 500), // Truncate for storage
|
|
119
146
|
type: session.meta.type
|
|
120
|
-
}
|
|
147
|
+
});
|
|
121
148
|
}
|
|
122
149
|
|
|
123
150
|
onStatusChanged(session, oldStatus, newStatus) {
|
|
124
|
-
this.
|
|
151
|
+
this._recordForSession(session, 'status_changed', {
|
|
125
152
|
from: oldStatus,
|
|
126
153
|
to: newStatus,
|
|
127
154
|
detail: session.meta.statusDetail,
|
|
128
155
|
type: session.meta.type
|
|
129
|
-
}
|
|
156
|
+
});
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
onSessionEnded(session) {
|
|
133
|
-
this.
|
|
160
|
+
this._recordForSession(session, 'session_ended', {
|
|
134
161
|
type: session.meta.type,
|
|
135
162
|
duration_ms: Date.now() - new Date(session.meta.createdAt).getTime(),
|
|
136
163
|
command_count: session.meta.lastCommands.length,
|
|
137
164
|
exit_code: session.meta.exitCode
|
|
138
|
-
}
|
|
165
|
+
});
|
|
139
166
|
}
|
|
140
167
|
|
|
141
168
|
onFileEdited(session, filepath, editType) {
|
|
142
|
-
this.
|
|
169
|
+
this._recordForSession(session, 'file_edited', {
|
|
143
170
|
filepath,
|
|
144
171
|
edit_type: editType,
|
|
145
172
|
type: session.meta.type
|
|
146
|
-
}
|
|
173
|
+
});
|
|
147
174
|
}
|
|
148
175
|
|
|
149
176
|
// Circuit breaker check — returns true if pushes to this table are disabled.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// Unified migration runner for the setup wizard and `termdeck init --mnestra`.
|
|
2
2
|
//
|
|
3
|
-
// Applies the full
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Applies the full bootstrap sequence in order:
|
|
4
|
+
// - Every *.sql file bundled under ./mnestra-migrations, sorted alphabetically
|
|
5
|
+
// by filename (currently 001…008 — Mnestra schema + RPCs + the legacy RAG
|
|
6
|
+
// tables that rag.js writes to when rag.enabled is on).
|
|
7
|
+
// - Then config/transcript-migration.sql (the termdeck_transcripts table).
|
|
6
8
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
+
// Migrations are discovered via migrations.listMnestraMigrations(), so adding
|
|
10
|
+
// a new file under ./mnestra-migrations/ is the only step needed to ship it —
|
|
11
|
+
// no edits here required as long as the filename sorts after the previous one.
|
|
12
|
+
//
|
|
13
|
+
// Every migration file is authored with IF NOT EXISTS / CREATE OR REPLACE
|
|
14
|
+
// (and DROP POLICY IF EXISTS where applicable) so re-running the sequence is
|
|
15
|
+
// a no-op on an already-configured database.
|
|
9
16
|
|
|
10
17
|
const fs = require('fs');
|
|
11
18
|
const path = require('path');
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
-- 008_legacy_rag_tables.sql
|
|
2
|
+
-- Mirror of config/supabase-migration.sql (kept in repo root for reference / manual application).
|
|
3
|
+
-- Auto-applied by packages/server/src/setup/migration-runner.js as the 8th Mnestra migration.
|
|
4
|
+
-- Safe to re-run: all CREATE statements use IF NOT EXISTS guards (and DROP IF EXISTS for policies).
|
|
5
|
+
|
|
6
|
+
-- Mnestra RAG Tables
|
|
7
|
+
-- Multi-layer memory: session → project → developer (cross-project)
|
|
8
|
+
|
|
9
|
+
-- pg_trgm enables gin_trgm_ops used by the commands FTS index below.
|
|
10
|
+
-- Must be created before any object that depends on it.
|
|
11
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
12
|
+
|
|
13
|
+
-- Session-level memory (per terminal session)
|
|
14
|
+
CREATE TABLE IF NOT EXISTS mnestra_session_memory (
|
|
15
|
+
id BIGSERIAL PRIMARY KEY,
|
|
16
|
+
session_id TEXT NOT NULL,
|
|
17
|
+
event_type TEXT NOT NULL,
|
|
18
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
19
|
+
project TEXT,
|
|
20
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
21
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
22
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_session ON mnestra_session_memory(session_id);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_developer ON mnestra_session_memory(developer_id);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_session_memory_ts ON mnestra_session_memory(timestamp DESC);
|
|
28
|
+
|
|
29
|
+
-- Project-level memory (shared across sessions within a project)
|
|
30
|
+
CREATE TABLE IF NOT EXISTS mnestra_project_memory (
|
|
31
|
+
id BIGSERIAL PRIMARY KEY,
|
|
32
|
+
session_id TEXT NOT NULL,
|
|
33
|
+
event_type TEXT NOT NULL,
|
|
34
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
35
|
+
project TEXT NOT NULL,
|
|
36
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
37
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
38
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_project ON mnestra_project_memory(project);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_developer ON mnestra_project_memory(developer_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_project_memory_ts ON mnestra_project_memory(timestamp DESC);
|
|
44
|
+
|
|
45
|
+
-- Developer-level memory (cross-project patterns and context)
|
|
46
|
+
CREATE TABLE IF NOT EXISTS mnestra_developer_memory (
|
|
47
|
+
id BIGSERIAL PRIMARY KEY,
|
|
48
|
+
session_id TEXT NOT NULL,
|
|
49
|
+
event_type TEXT NOT NULL,
|
|
50
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
51
|
+
project TEXT,
|
|
52
|
+
developer_id TEXT NOT NULL,
|
|
53
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
54
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_developer_memory_developer ON mnestra_developer_memory(developer_id);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_developer_memory_ts ON mnestra_developer_memory(timestamp DESC);
|
|
59
|
+
|
|
60
|
+
-- Command log (full-text searchable command history)
|
|
61
|
+
CREATE TABLE IF NOT EXISTS mnestra_commands (
|
|
62
|
+
id BIGSERIAL PRIMARY KEY,
|
|
63
|
+
session_id TEXT NOT NULL,
|
|
64
|
+
event_type TEXT NOT NULL DEFAULT 'command_executed',
|
|
65
|
+
payload JSONB NOT NULL DEFAULT '{}',
|
|
66
|
+
project TEXT,
|
|
67
|
+
developer_id TEXT NOT NULL DEFAULT 'default',
|
|
68
|
+
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
69
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_commands_developer ON mnestra_commands(developer_id);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_commands_project ON mnestra_commands(project);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_commands_ts ON mnestra_commands(timestamp DESC);
|
|
75
|
+
|
|
76
|
+
-- Enable full-text search on command payloads
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_commands_fts ON mnestra_commands
|
|
78
|
+
USING GIN ((payload->>'command') gin_trgm_ops);
|
|
79
|
+
|
|
80
|
+
-- RLS policies (enable row-level security for multi-tenant)
|
|
81
|
+
ALTER TABLE mnestra_session_memory ENABLE ROW LEVEL SECURITY;
|
|
82
|
+
ALTER TABLE mnestra_project_memory ENABLE ROW LEVEL SECURITY;
|
|
83
|
+
ALTER TABLE mnestra_developer_memory ENABLE ROW LEVEL SECURITY;
|
|
84
|
+
ALTER TABLE mnestra_commands ENABLE ROW LEVEL SECURITY;
|
|
85
|
+
|
|
86
|
+
-- Allow insert from anon/authenticated for the sync process.
|
|
87
|
+
-- DROP-then-CREATE pattern keeps the migration re-run safe on Postgres < 15
|
|
88
|
+
-- (which has no CREATE POLICY IF NOT EXISTS).
|
|
89
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_session_memory;
|
|
90
|
+
CREATE POLICY "Allow insert for all" ON mnestra_session_memory FOR INSERT WITH CHECK (true);
|
|
91
|
+
|
|
92
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_project_memory;
|
|
93
|
+
CREATE POLICY "Allow insert for all" ON mnestra_project_memory FOR INSERT WITH CHECK (true);
|
|
94
|
+
|
|
95
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_developer_memory;
|
|
96
|
+
CREATE POLICY "Allow insert for all" ON mnestra_developer_memory FOR INSERT WITH CHECK (true);
|
|
97
|
+
|
|
98
|
+
DROP POLICY IF EXISTS "Allow insert for all" ON mnestra_commands;
|
|
99
|
+
CREATE POLICY "Allow insert for all" ON mnestra_commands FOR INSERT WITH CHECK (true);
|
|
100
|
+
|
|
101
|
+
-- Read access scoped to developer_id
|
|
102
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_session_memory;
|
|
103
|
+
CREATE POLICY "Read own data" ON mnestra_session_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
104
|
+
|
|
105
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_project_memory;
|
|
106
|
+
CREATE POLICY "Read own data" ON mnestra_project_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
107
|
+
|
|
108
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_developer_memory;
|
|
109
|
+
CREATE POLICY "Read own data" ON mnestra_developer_memory FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
110
|
+
|
|
111
|
+
DROP POLICY IF EXISTS "Read own data" ON mnestra_commands;
|
|
112
|
+
CREATE POLICY "Read own data" ON mnestra_commands FOR SELECT USING (developer_id = current_setting('request.jwt.claims', true)::json->>'sub' OR developer_id = 'default');
|
|
113
|
+
|
|
114
|
+
-- Useful view: recent activity across all layers
|
|
115
|
+
CREATE OR REPLACE VIEW mnestra_recent_activity AS
|
|
116
|
+
SELECT 'session' as layer, * FROM mnestra_session_memory
|
|
117
|
+
UNION ALL
|
|
118
|
+
SELECT 'project' as layer, * FROM mnestra_project_memory
|
|
119
|
+
UNION ALL
|
|
120
|
+
SELECT 'developer' as layer, * FROM mnestra_developer_memory
|
|
121
|
+
ORDER BY timestamp DESC
|
|
122
|
+
LIMIT 100;
|
|
@@ -39,6 +39,18 @@
|
|
|
39
39
|
|
|
40
40
|
const { spawnSync } = require('child_process');
|
|
41
41
|
const pgRunner = require('./pg-runner');
|
|
42
|
+
const supabaseUrlHelper = require('./supabase-url');
|
|
43
|
+
|
|
44
|
+
// Build the project-specific Supabase dashboard URL for the Database →
|
|
45
|
+
// Extensions page when SUPABASE_URL is derivable, else null. Sprint 35 T3:
|
|
46
|
+
// previous hints said "Database → Extensions" with no clickable target —
|
|
47
|
+
// this gives the user a one-click landing page.
|
|
48
|
+
function extensionsDashboardUrl(secrets) {
|
|
49
|
+
if (!secrets || !secrets.SUPABASE_URL) return null;
|
|
50
|
+
const parsed = supabaseUrlHelper.parseProjectUrl(secrets.SUPABASE_URL);
|
|
51
|
+
if (!parsed.ok) return null;
|
|
52
|
+
return `https://supabase.com/dashboard/project/${parsed.projectRef}/database/extensions`;
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
// Render a single gap into 2-3 lines of CLI output (one indented hint per
|
|
44
56
|
// non-empty `hint` line). Format aligned with the rest of the wizard's
|
|
@@ -144,6 +156,13 @@ async function auditRumenPreconditions({ secrets, env, _pgClient } = {}) {
|
|
|
144
156
|
return { ok: gaps.length === 0, gaps };
|
|
145
157
|
}
|
|
146
158
|
|
|
159
|
+
// Project-specific dashboard URL for missing-extension hints. Falls back
|
|
160
|
+
// to generic copy when SUPABASE_URL isn't derivable.
|
|
161
|
+
const dashboardUrl = extensionsDashboardUrl(secrets);
|
|
162
|
+
const dashboardLine = dashboardUrl
|
|
163
|
+
? ` Open: ${dashboardUrl}\n Search for the extension and toggle it ON.`
|
|
164
|
+
: ' Database → Extensions → toggle ON';
|
|
165
|
+
|
|
147
166
|
try {
|
|
148
167
|
// pg_cron extension
|
|
149
168
|
const cron = await safeQuery(client,
|
|
@@ -153,8 +172,8 @@ async function auditRumenPreconditions({ secrets, env, _pgClient } = {}) {
|
|
|
153
172
|
key: 'pg_cron',
|
|
154
173
|
message: 'The pg_cron extension is not enabled on this Supabase project',
|
|
155
174
|
hint:
|
|
156
|
-
'Enable
|
|
157
|
-
|
|
175
|
+
'Enable pg_cron in the Supabase dashboard:\n' +
|
|
176
|
+
dashboardLine + '\n' +
|
|
158
177
|
'(Without pg_cron, the rumen-tick schedule cannot run.)'
|
|
159
178
|
});
|
|
160
179
|
}
|
|
@@ -167,8 +186,8 @@ async function auditRumenPreconditions({ secrets, env, _pgClient } = {}) {
|
|
|
167
186
|
key: 'pg_net',
|
|
168
187
|
message: 'The pg_net extension is not enabled on this Supabase project',
|
|
169
188
|
hint:
|
|
170
|
-
'Enable
|
|
171
|
-
|
|
189
|
+
'Enable pg_net in the Supabase dashboard:\n' +
|
|
190
|
+
dashboardLine + '\n' +
|
|
172
191
|
'(pg_net is what the cron schedule uses to call the Edge Function.)'
|
|
173
192
|
});
|
|
174
193
|
}
|
|
@@ -364,6 +383,7 @@ module.exports = {
|
|
|
364
383
|
verifyMnestraOutcomes,
|
|
365
384
|
printAuditReport,
|
|
366
385
|
printVerifyReport,
|
|
386
|
+
extensionsDashboardUrl,
|
|
367
387
|
// Test surface
|
|
368
388
|
_probeSupabaseAuth: probeSupabaseAuth,
|
|
369
389
|
_safeQuery: safeQuery
|
|
@@ -40,7 +40,7 @@ function getCurrentConfig() {
|
|
|
40
40
|
const { loadConfig } = require('./config');
|
|
41
41
|
_configCache = { mtimeMs: stat.mtimeMs, value: loadConfig(), frozen: false };
|
|
42
42
|
return _configCache.value;
|
|
43
|
-
} catch {
|
|
43
|
+
} catch (_err) {
|
|
44
44
|
return _configCache.value || {};
|
|
45
45
|
}
|
|
46
46
|
}
|