@jhizzard/termdeck 0.7.2 → 0.8.0

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.
@@ -2002,6 +2002,127 @@
2002
2002
  justify-content: center;
2003
2003
  }
2004
2004
  .setup-modal.open { display: flex; }
2005
+
2006
+ /* Settings panel inside the setup modal (Sprint 36 T3 Deliverable A) */
2007
+ .setup-settings {
2008
+ padding: 0 0 16px;
2009
+ margin-bottom: 14px;
2010
+ border-bottom: 1px solid var(--tg-border);
2011
+ }
2012
+ .settings-section {
2013
+ display: flex;
2014
+ flex-direction: column;
2015
+ gap: 6px;
2016
+ }
2017
+ .settings-heading {
2018
+ margin: 0;
2019
+ font-size: 12px;
2020
+ letter-spacing: 0.06em;
2021
+ text-transform: uppercase;
2022
+ color: var(--tg-text-dim);
2023
+ font-family: var(--tg-sans);
2024
+ }
2025
+ .settings-row {
2026
+ display: grid;
2027
+ grid-template-columns: 120px 1fr;
2028
+ gap: 14px;
2029
+ align-items: start;
2030
+ }
2031
+ .settings-copy {
2032
+ margin: 0;
2033
+ font-size: 12px;
2034
+ line-height: 1.45;
2035
+ color: var(--tg-text);
2036
+ }
2037
+ .settings-copy code {
2038
+ font-family: var(--tg-mono);
2039
+ font-size: 11px;
2040
+ background: var(--tg-bg);
2041
+ padding: 1px 5px;
2042
+ border-radius: 3px;
2043
+ }
2044
+ .settings-warn {
2045
+ margin-top: 4px;
2046
+ padding: 8px 10px;
2047
+ font-size: 12px;
2048
+ line-height: 1.4;
2049
+ color: var(--tg-text);
2050
+ background: rgba(224, 175, 104, 0.10);
2051
+ border: 1px solid var(--tg-amber);
2052
+ border-radius: 4px;
2053
+ }
2054
+ .settings-warn code {
2055
+ font-family: var(--tg-mono);
2056
+ font-size: 11px;
2057
+ background: var(--tg-bg);
2058
+ padding: 1px 5px;
2059
+ border-radius: 3px;
2060
+ }
2061
+ /* iOS-style toggle. The native checkbox is visually hidden but stays in
2062
+ the tab order so keyboard + screen reader navigation still work. */
2063
+ .toggle {
2064
+ display: inline-flex;
2065
+ align-items: center;
2066
+ gap: 8px;
2067
+ cursor: pointer;
2068
+ user-select: none;
2069
+ }
2070
+ .toggle input[type="checkbox"] {
2071
+ position: absolute;
2072
+ opacity: 0;
2073
+ width: 1px;
2074
+ height: 1px;
2075
+ pointer-events: none;
2076
+ }
2077
+ .toggle-track {
2078
+ position: relative;
2079
+ display: inline-block;
2080
+ width: 36px;
2081
+ height: 20px;
2082
+ background: var(--tg-border);
2083
+ border-radius: 999px;
2084
+ transition: background 0.15s ease;
2085
+ }
2086
+ .toggle-thumb {
2087
+ position: absolute;
2088
+ top: 2px;
2089
+ left: 2px;
2090
+ width: 16px;
2091
+ height: 16px;
2092
+ background: var(--tg-text-bright);
2093
+ border-radius: 50%;
2094
+ transition: transform 0.15s ease, background 0.15s ease;
2095
+ }
2096
+ .toggle input[type="checkbox"]:checked + .toggle-track {
2097
+ background: var(--tg-green);
2098
+ }
2099
+ .toggle input[type="checkbox"]:checked + .toggle-track .toggle-thumb {
2100
+ transform: translateX(16px);
2101
+ background: var(--tg-bg);
2102
+ }
2103
+ .toggle input[type="checkbox"]:focus-visible + .toggle-track {
2104
+ outline: 2px solid var(--tg-accent);
2105
+ outline-offset: 2px;
2106
+ }
2107
+ .toggle input[type="checkbox"]:disabled + .toggle-track {
2108
+ opacity: 0.5;
2109
+ }
2110
+ .toggle-label {
2111
+ font-size: 12px;
2112
+ color: var(--tg-text);
2113
+ font-family: var(--tg-mono);
2114
+ }
2115
+ /* Topbar #stat-rag — re-purposed as a live RAG state line */
2116
+ .topbar-stat.rag-on {
2117
+ color: var(--tg-green);
2118
+ font-weight: 500;
2119
+ }
2120
+ .topbar-stat.rag-pending {
2121
+ color: var(--tg-amber);
2122
+ }
2123
+ .topbar-stat.rag-off {
2124
+ color: var(--tg-text-dim);
2125
+ }
2005
2126
  .setup-backdrop {
2006
2127
  position: absolute;
2007
2128
  inset: 0;
@@ -298,11 +298,107 @@ function addProject({ name, path: projectPath, defaultTheme, defaultCommand }) {
298
298
  return parsed.projects;
299
299
  }
300
300
 
301
+ // Apply a structural patch to ~/.termdeck/config.yaml. Sprint 36 introduces
302
+ // this for the dashboard RAG toggle (PATCH /api/config) but the helper is
303
+ // generic — pass a deep partial of the config tree, every leaf in `patch` that
304
+ // matches the whitelist gets written through. Returns the parsed-from-disk
305
+ // post-write tree (NOT post-substitution; we only persist user-authored values
306
+ // here, never substituted secrets).
307
+ //
308
+ // Whitelist deliberately tight. Only fields a UI can safely flip live belong
309
+ // here. Adding a new field is an explicit one-line edit (vs. a freeform writer
310
+ // that would let a buggy/malicious client change `port`, `shell`, or projects).
311
+ //
312
+ // Comments and formatting in config.yaml are NOT preserved — same trade-off
313
+ // as `addProject`. The yaml package's parseDocument API can preserve comments
314
+ // but we'd need to migrate addProject too for consistency; that's a follow-up.
315
+ const UPDATABLE_PATHS = new Set([
316
+ 'rag.enabled'
317
+ ]);
318
+
319
+ function flattenPatch(obj, prefix = '') {
320
+ const out = [];
321
+ if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return out;
322
+ for (const [k, v] of Object.entries(obj)) {
323
+ const key = prefix ? `${prefix}.${k}` : k;
324
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
325
+ out.push(...flattenPatch(v, key));
326
+ } else {
327
+ out.push([key, v]);
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ function setPath(obj, segs, value) {
334
+ let cur = obj;
335
+ for (let i = 0; i < segs.length - 1; i++) {
336
+ const s = segs[i];
337
+ if (cur[s] == null || typeof cur[s] !== 'object') cur[s] = {};
338
+ cur = cur[s];
339
+ }
340
+ cur[segs[segs.length - 1]] = value;
341
+ }
342
+
343
+ function updateConfig(patch, configPath = CONFIG_PATH) {
344
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
345
+ throw new Error('updateConfig: patch must be a plain object');
346
+ }
347
+
348
+ const flat = flattenPatch(patch);
349
+ if (flat.length === 0) {
350
+ throw new Error('updateConfig: patch is empty');
351
+ }
352
+
353
+ for (const [key, val] of flat) {
354
+ if (!UPDATABLE_PATHS.has(key)) {
355
+ throw new Error(`updateConfig: ${key} is not in the updatable whitelist`);
356
+ }
357
+ if (key === 'rag.enabled' && typeof val !== 'boolean') {
358
+ throw new Error('updateConfig: rag.enabled must be a boolean');
359
+ }
360
+ }
361
+
362
+ const yaml = require('yaml');
363
+ let parsed = {};
364
+ if (fs.existsSync(configPath)) {
365
+ const raw = fs.readFileSync(configPath, 'utf-8');
366
+ try {
367
+ parsed = yaml.parse(raw) || {};
368
+ } catch (err) {
369
+ throw new Error(`config.yaml is not valid YAML — refusing to overwrite: ${err.message}`);
370
+ }
371
+ }
372
+
373
+ for (const [key, val] of flat) {
374
+ setPath(parsed, key.split('.'), val);
375
+ }
376
+
377
+ if (fs.existsSync(configPath)) {
378
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
379
+ const bak = `${configPath}.${ts}.bak`;
380
+ try {
381
+ fs.copyFileSync(configPath, bak);
382
+ } catch (err) {
383
+ console.warn('[config] Could not write backup before updateConfig:', err.message);
384
+ }
385
+ }
386
+
387
+ const out = yaml.stringify(parsed);
388
+ fs.writeFileSync(configPath, out, 'utf-8');
389
+ console.log(`[config] updateConfig wrote ${flat.map(([k]) => k).join(', ')}`);
390
+
391
+ return parsed;
392
+ }
393
+
301
394
  module.exports = {
302
395
  loadConfig,
303
396
  addProject,
397
+ updateConfig,
304
398
  // exported for tests / introspection
305
399
  _parseDotenv: parseDotenv,
306
400
  _substituteEnv: substituteEnv,
401
+ _flattenPatch: flattenPatch,
402
+ _UPDATABLE_PATHS: UPDATABLE_PATHS,
307
403
  _paths: { CONFIG_DIR, CONFIG_PATH, SECRETS_PATH }
308
404
  };
@@ -61,7 +61,7 @@ const { TranscriptWriter } = require('./transcripts');
61
61
  const { createHealthHandler, runPreflight } = require('./preflight');
62
62
  const { getFullHealth } = require('./health');
63
63
  const { themes, statusColors } = require('./themes');
64
- const { loadConfig, addProject } = require('./config');
64
+ const { loadConfig, addProject, updateConfig } = require('./config');
65
65
  const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
66
66
 
67
67
  function createServer(config) {
@@ -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);
@@ -955,16 +1069,64 @@ function createServer(config) {
955
1069
  res.json(t);
956
1070
  });
957
1071
 
958
- // GET /api/config - current config (sanitized)
959
- app.get('/api/config', (req, res) => {
960
- res.json({
1072
+ // Public-shape helper so GET and PATCH return the same envelope.
1073
+ function publicConfigPayload() {
1074
+ return {
961
1075
  projects: config.projects || {},
962
1076
  defaultTheme: config.defaultTheme,
1077
+ // ragEnabled is the EFFECTIVE state (after credential eligibility).
1078
+ // ragConfigEnabled is the user's intent from config.yaml. The dashboard
1079
+ // toggle reads ragConfigEnabled (intent) but renders a warning when it
1080
+ // diverges from ragEnabled (e.g. enabled in config but Supabase creds
1081
+ // missing → effective state stays off).
963
1082
  ragEnabled: rag.enabled,
1083
+ ragConfigEnabled: !!(config.rag && config.rag.enabled),
1084
+ ragSupabaseConfigured: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey),
964
1085
  aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
965
1086
  statusColors,
966
1087
  firstRun
967
- });
1088
+ };
1089
+ }
1090
+
1091
+ // GET /api/config - current config (sanitized)
1092
+ app.get('/api/config', (req, res) => {
1093
+ res.json(publicConfigPayload());
1094
+ });
1095
+
1096
+ // PATCH /api/config - update writable config fields. Sprint 36 T3 Deliverable A.
1097
+ // Body: { rag: { enabled: boolean } } — the only currently writable path.
1098
+ // Persists to ~/.termdeck/config.yaml, live-updates the in-memory integration,
1099
+ // and broadcasts a `config_changed` WS event so all open dashboards re-render
1100
+ // their RAG indicator without a refresh.
1101
+ app.patch('/api/config', (req, res) => {
1102
+ const body = req.body;
1103
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
1104
+ return res.status(400).json({ error: 'body must be a JSON object' });
1105
+ }
1106
+ try {
1107
+ updateConfig(body);
1108
+ } catch (err) {
1109
+ return res.status(400).json({ error: err.message });
1110
+ }
1111
+
1112
+ if (body.rag && typeof body.rag.enabled === 'boolean') {
1113
+ rag.setEnabled(body.rag.enabled);
1114
+ }
1115
+
1116
+ const payload = publicConfigPayload();
1117
+
1118
+ try {
1119
+ const wsPayload = JSON.stringify({ type: 'config_changed', config: payload });
1120
+ wss.clients.forEach((client) => {
1121
+ if (client.readyState === 1) {
1122
+ try { client.send(wsPayload); } catch (err) { console.error('[ws] config_changed send failed:', err); }
1123
+ }
1124
+ });
1125
+ } catch (err) {
1126
+ console.error('[ws] config_changed broadcast failed:', err);
1127
+ }
1128
+
1129
+ res.json(payload);
968
1130
  });
969
1131
 
970
1132
  // POST /api/projects - add a new project on the fly, persist to config.yaml
@@ -1352,6 +1514,14 @@ function createServer(config) {
1352
1514
 
1353
1515
  ws.on('close', () => {
1354
1516
  console.log(`[ws] Client disconnected from session ${sessionId}`);
1517
+ // Intentional: PTYs survive WS close. The session stays in the manager,
1518
+ // the PTY keeps running, and reconnecting (?session=<id>) re-binds.
1519
+ // PTY teardown happens only via DELETE /api/sessions/:id (user-initiated)
1520
+ // or the PTY's own exit event. Hard-refresh is therefore non-destructive.
1521
+ // Sprint 36 T3 Deliverable C audit (2026-04-27): the briefing predicted
1522
+ // this handler would call pty.kill() — it does not. Joshua's original
1523
+ // hard-refresh-loses-PTYs symptom was the reclaimStalePort SIGKILL chain
1524
+ // (orchestrator hotfix #2, 15:25 ET), not a WS-close cascade.
1355
1525
  if (session.ws === ws) {
1356
1526
  session.ws = null;
1357
1527
  }
@@ -1619,7 +1789,7 @@ if (require.main === module) {
1619
1789
  console.log(` Terminals: 0 active`);
1620
1790
  console.log(` Database: ${Database ? 'SQLite OK' : 'unavailable'}`);
1621
1791
  console.log(` PTY: ${pty ? 'node-pty OK' : 'unavailable (install node-pty)'}`);
1622
- console.log(` RAG: ${config.rag?.supabaseUrl ? 'configured' : 'not configured'}`);
1792
+ console.log(` RAG: ${config.rag?.enabled === true ? 'on (writing to mnestra_*_memory tables)' : 'off (MCP-only mode)'}`);
1623
1793
  console.log(` Session logs: ${config.sessionLogs?.enabled ? '~/.termdeck/sessions/ (on exit)' : 'off'}`);
1624
1794
  console.log(` Transcripts: ${transcriptWriter ? 'streaming to Supabase' : 'off (no DATABASE_URL)'}`);
1625
1795
  console.log(`\n WARNING: TermDeck binds to ${host} only.`);
@@ -66,6 +66,14 @@ class RAGIntegration {
66
66
  this._circuitBreaker = new Map(); // table -> { count, open, openedAt, halfOpen }
67
67
  this._halfOpenDelayMs = 5 * 60 * 1000;
68
68
 
69
+ // Status-change debounce: rapid `active ↔ thinking` cycling from busy
70
+ // Claude Code workers (4+1 sprint lanes) produces dozens of status_changed
71
+ // events per second. Untreated, this floods stdout and the outbox; on
72
+ // 2026-04-27 it contributed to two server-kill incidents during Sprint 36.
73
+ // Drop intra-second status edges; let error transitions always pass.
74
+ this._statusWriteAt = new Map(); // sessionId -> last write timestamp (ms)
75
+ this._statusDebounceMs = 1000;
76
+
69
77
  if (this.enabled) {
70
78
  this._startSync();
71
79
  }
@@ -148,6 +156,16 @@ class RAGIntegration {
148
156
  }
149
157
 
150
158
  onStatusChanged(session, oldStatus, newStatus) {
159
+ // Always pass through error transitions — those carry signal worth ingesting
160
+ // every time. Debounce only the active ↔ thinking churn that floods the log
161
+ // when a worker cycles tool calls rapidly.
162
+ const isError = newStatus === 'errored' || oldStatus === 'errored';
163
+ if (!isError) {
164
+ const now = Date.now();
165
+ const last = this._statusWriteAt.get(session.id) || 0;
166
+ if (now - last < this._statusDebounceMs) return;
167
+ this._statusWriteAt.set(session.id, now);
168
+ }
151
169
  this._recordForSession(session, 'status_changed', {
152
170
  from: oldStatus,
153
171
  to: newStatus,
@@ -351,7 +369,32 @@ class RAGIntegration {
351
369
  stop() {
352
370
  if (this._syncTimer) {
353
371
  clearInterval(this._syncTimer);
372
+ this._syncTimer = null;
373
+ }
374
+ this._statusWriteAt.clear();
375
+ }
376
+
377
+ // Live-toggle for the dashboard RAG settings panel (Sprint 36 T3 Deliverable A).
378
+ // Re-evaluates eligibility — flipping `enabled: true` without configured
379
+ // Supabase creds is a no-op so the live integration never claims to be on
380
+ // when it can't actually push. Returns the resolved effective flag.
381
+ setEnabled(value) {
382
+ const desired = !!value;
383
+ if (this.config && this.config.rag) {
384
+ this.config.rag.enabled = desired;
385
+ }
386
+ const effective = !!(desired && this.supabaseUrl && this.supabaseKey);
387
+ if (effective === this.enabled) return effective;
388
+ this.enabled = effective;
389
+ if (effective) {
390
+ if (!this._syncTimer) this._startSync();
391
+ } else {
392
+ if (this._syncTimer) {
393
+ clearInterval(this._syncTimer);
394
+ this._syncTimer = null;
395
+ }
354
396
  }
397
+ return effective;
355
398
  }
356
399
  }
357
400
 
@@ -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;