@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.
@@ -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.1",
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
- // Compares installed versions of the four TermDeck-stack packages against
4
- // the npm registry's `dist-tags.latest` and prints a status table. Zero new
5
- // deps uses only node:https, node:child_process, and process.stdout.
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
- // Module contract (per docs/sprint-28-update-signal/STATUS.md):
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 unrecoverable error
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
- // `_detectInstalled` and `_fetchLatest` are exposed as properties on the
14
- // exported function so tests can monkey-patch the network/process surface
15
- // without spinning up a real registry. The doctor body calls them via
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
- // Exit-code priority: any network failure 2; any update available → 1;
227
- // else 0. Computed after all rows resolve so a single transient failure
228
- // doesn't mask real updates in stdout.
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
- process.stdout.write(JSON.stringify({ exitCode, rows }, null, 2) + '\n');
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 Check whether the stack packages are up to date
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 to enable RAG + point at ${VAR} refs
15
- // (only after migrations apply cleanly otherwise the server would
16
- // try to use an incomplete schema on next startup)
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 to enable RAG and reference ${VAR} keys.',
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 to enable RAG (only after
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
- step('Updating ~/.termdeck/config.yaml (rag.enabled: true)...');
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: true,
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?.supabaseUrl ? 'configured' : 'not configured'}`);
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
- if (session.meta.project) return session.meta.project;
102
- return resolveProjectName(session.meta.cwd, this.config);
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.record(session.id, 'session_created', {
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
- }, this._projectFor(session));
139
+ });
113
140
  }
114
141
 
115
142
  onCommandExecuted(session, command, outputSnippet) {
116
- this.record(session.id, 'command_executed', {
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
- }, this._projectFor(session));
147
+ });
121
148
  }
122
149
 
123
150
  onStatusChanged(session, oldStatus, newStatus) {
124
- this.record(session.id, 'status_changed', {
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
- }, this._projectFor(session));
156
+ });
130
157
  }
131
158
 
132
159
  onSessionEnded(session) {
133
- this.record(session.id, 'session_ended', {
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
- }, this._projectFor(session));
165
+ });
139
166
  }
140
167
 
141
168
  onFileEdited(session, filepath, editType) {
142
- this.record(session.id, 'file_edited', {
169
+ this._recordForSession(session, 'file_edited', {
143
170
  filepath,
144
171
  edit_type: editType,
145
172
  type: session.meta.type
146
- }, this._projectFor(session));
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 7-migration bootstrap sequence in order:
4
- // 1-6. Mnestra schema + RPCs (bundled under ./mnestra-migrations)
5
- // 7. termdeck_transcripts table (repo root: config/transcript-migration.sql)
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
- // Every migration file is authored with IF NOT EXISTS / CREATE OR REPLACE so
8
- // re-running the sequence is a no-op on an already-configured database.
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 it in the Supabase dashboard:\n' +
157
- ' Database Extensions → pg_cron → toggle ON\n' +
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 it in the Supabase dashboard:\n' +
171
- ' Database Extensions → pg_net → toggle ON\n' +
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
  }