@jhizzard/termdeck 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/README.md +191 -169
  2. package/package.json +7 -6
  3. package/packages/cli/src/index.js +39 -1
  4. package/packages/cli/src/init-engram.js +344 -0
  5. package/packages/cli/src/init-rumen.js +425 -0
  6. package/packages/client/public/index.html +2 -2
  7. package/packages/server/src/setup/dotenv-io.js +116 -0
  8. package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
  9. package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
  10. package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
  11. package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
  12. package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
  13. package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
  14. package/packages/server/src/setup/index.js +14 -0
  15. package/packages/server/src/setup/migrations.js +80 -0
  16. package/packages/server/src/setup/pg-runner.js +113 -0
  17. package/packages/server/src/setup/prompts.js +177 -0
  18. package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
  19. package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
  20. package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
  21. package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
  22. package/packages/server/src/setup/supabase-url.js +114 -0
  23. package/packages/server/src/setup/yaml-io.js +99 -0
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+
3
+ // `termdeck init --rumen` — interactive wizard for deploying Rumen as a
4
+ // Supabase Edge Function + pg_cron schedule against the same Supabase
5
+ // project that holds the Engram store.
6
+ //
7
+ // Requirements checked at runtime:
8
+ // - `supabase` CLI on PATH
9
+ // - `deno` on PATH
10
+ // - `~/.termdeck/secrets.env` with SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY +
11
+ // DATABASE_URL + ANTHROPIC_API_KEY populated (run `termdeck init --engram` first)
12
+ //
13
+ // Steps:
14
+ // 1. Preflight: which supabase, which deno, read secrets.env
15
+ // 2. Derive project ref from SUPABASE_URL; confirm with user
16
+ // 3. supabase link --project-ref <ref>
17
+ // 4. Apply rumen migration 001 via pg
18
+ // 5. supabase functions deploy rumen-tick --no-verify-jwt
19
+ // 6. supabase secrets set DATABASE_URL=... ANTHROPIC_API_KEY=...
20
+ // 7. Test the function with a manual POST (fetch)
21
+ // 8. Apply pg_cron schedule migration (002) with project ref substituted
22
+
23
+ const path = require('path');
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const { execSync, spawnSync } = require('child_process');
27
+
28
+ const SETUP_DIR = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
29
+ const {
30
+ prompts,
31
+ dotenv,
32
+ supabaseUrl: urlHelper,
33
+ migrations,
34
+ pgRunner
35
+ } = require(SETUP_DIR);
36
+
37
+ const HELP = [
38
+ '',
39
+ 'TermDeck Rumen Setup',
40
+ '',
41
+ 'Usage: termdeck init --rumen [flags]',
42
+ '',
43
+ 'Flags:',
44
+ ' --help Print this message and exit',
45
+ ' --yes Skip the project-ref confirmation',
46
+ ' --dry-run Print what would run; touch nothing',
47
+ ' --skip-schedule Deploy the function but do not install the pg_cron schedule',
48
+ '',
49
+ 'Requires: Supabase CLI and Deno already installed.',
50
+ 'Requires: `termdeck init --engram` has already run (needs secrets.env).',
51
+ ''
52
+ ].join('\n');
53
+
54
+ function parseFlags(argv) {
55
+ const out = { help: false, yes: false, dryRun: false, skipSchedule: false };
56
+ for (const a of argv) {
57
+ if (a === '--help' || a === '-h') out.help = true;
58
+ else if (a === '--yes' || a === '-y') out.yes = true;
59
+ else if (a === '--dry-run') out.dryRun = true;
60
+ else if (a === '--skip-schedule') out.skipSchedule = true;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function printBanner() {
66
+ process.stdout.write(`
67
+ TermDeck Rumen Setup
68
+ ────────────────────
69
+
70
+ This wizard deploys Rumen as a Supabase Edge Function with a pg_cron schedule.
71
+ Requires: Supabase CLI + Deno already installed.
72
+
73
+ Press Ctrl+C at any time to cancel.
74
+
75
+ `);
76
+ }
77
+
78
+ function step(msg) { process.stdout.write(`→ ${msg}`); }
79
+ function ok(suffix = '') { process.stdout.write(` ✓${suffix ? ' ' + suffix : ''}\n`); }
80
+ function fail(err) { process.stdout.write(` ✗\n ${err}\n`); }
81
+
82
+ function which(bin) {
83
+ const r = spawnSync('which', [bin], { encoding: 'utf-8' });
84
+ if (r.status !== 0) return null;
85
+ return (r.stdout || '').trim() || null;
86
+ }
87
+
88
+ function preflight() {
89
+ step('Checking for supabase CLI...');
90
+ const sb = which('supabase');
91
+ if (!sb) {
92
+ fail('not found on PATH');
93
+ process.stderr.write(
94
+ '\nInstall it first:\n' +
95
+ ' macOS: brew install supabase/tap/supabase\n' +
96
+ ' other: https://supabase.com/docs/guides/local-development/cli/getting-started\n'
97
+ );
98
+ return null;
99
+ }
100
+ ok();
101
+
102
+ step('Checking for deno...');
103
+ const deno = which('deno');
104
+ if (!deno) {
105
+ fail('not found on PATH');
106
+ process.stderr.write(
107
+ '\nInstall it first:\n' +
108
+ ' macOS: brew install deno\n' +
109
+ ' other: https://deno.com/#installation\n'
110
+ );
111
+ return null;
112
+ }
113
+ ok();
114
+
115
+ step('Reading Engram config from ~/.termdeck/secrets.env...');
116
+ const secrets = dotenv.readSecrets();
117
+ const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'DATABASE_URL', 'ANTHROPIC_API_KEY'];
118
+ const missing = required.filter((k) => !secrets[k]);
119
+ if (missing.length > 0) {
120
+ fail(`missing keys: ${missing.join(', ')}`);
121
+ process.stderr.write(
122
+ '\nRun `termdeck init --engram` first — it writes the keys this wizard needs.\n'
123
+ );
124
+ return null;
125
+ }
126
+ ok();
127
+
128
+ return { supabaseBin: sb, denoBin: deno, secrets };
129
+ }
130
+
131
+ function deriveProjectRef(secrets) {
132
+ const parsed = urlHelper.parseProjectUrl(secrets.SUPABASE_URL);
133
+ if (!parsed.ok) {
134
+ throw new Error(`SUPABASE_URL is not a valid Supabase project URL: ${parsed.error}`);
135
+ }
136
+ return parsed.projectRef;
137
+ }
138
+
139
+ // Shell out with inherited stdio so the user sees real-time supabase CLI
140
+ // output. Returns `{ ok, code }`.
141
+ function runShell(command, args, opts = {}) {
142
+ const r = spawnSync(command, args, {
143
+ stdio: 'inherit',
144
+ encoding: 'utf-8',
145
+ ...opts
146
+ });
147
+ return { ok: r.status === 0, code: r.status };
148
+ }
149
+
150
+ // Same but capture stdout for things we want to inspect programmatically.
151
+ function runShellCaptured(command, args, opts = {}) {
152
+ const r = spawnSync(command, args, {
153
+ stdio: ['ignore', 'pipe', 'pipe'],
154
+ encoding: 'utf-8',
155
+ ...opts
156
+ });
157
+ return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
158
+ }
159
+
160
+ async function link(projectRef, dryRun) {
161
+ step(`Running: supabase link --project-ref ${projectRef}...`);
162
+ if (dryRun) { ok('(dry-run)'); return true; }
163
+ const r = runShellCaptured('supabase', ['link', '--project-ref', projectRef]);
164
+ if (!r.ok) {
165
+ fail(`supabase link failed (exit ${r.code})`);
166
+ if (r.stderr) process.stderr.write(r.stderr + '\n');
167
+ return false;
168
+ }
169
+ ok();
170
+ return true;
171
+ }
172
+
173
+ async function applyRumenTables(secrets, dryRun) {
174
+ step('Applying rumen tables migration...');
175
+ if (dryRun) { ok('(dry-run)'); return true; }
176
+ const files = migrations.listRumenMigrations();
177
+ const tableFile = files.find((f) => /001.*rumen_tables/.test(path.basename(f)));
178
+ if (!tableFile) {
179
+ fail('bundled 001_rumen_tables.sql is missing');
180
+ return false;
181
+ }
182
+ let client;
183
+ try {
184
+ client = await pgRunner.connect(secrets.DATABASE_URL);
185
+ } catch (err) {
186
+ fail(err.message);
187
+ return false;
188
+ }
189
+ try {
190
+ const result = await pgRunner.applyFile(client, tableFile);
191
+ if (!result.ok) { fail(result.error); return false; }
192
+ ok(`(${result.elapsedMs}ms)`);
193
+ return true;
194
+ } finally {
195
+ try { await client.end(); } catch (_err) { /* ignore */ }
196
+ }
197
+ }
198
+
199
+ function deployFunction(dryRun) {
200
+ step('Running: supabase functions deploy rumen-tick --no-verify-jwt...');
201
+ if (dryRun) { ok('(dry-run)'); return true; }
202
+
203
+ // We need the supabase command to run against a repo layout with
204
+ // `supabase/functions/rumen-tick/`. The TermDeck install does NOT include
205
+ // a `supabase/` directory at the project root, so we stage a tiny working
206
+ // directory under `os.tmpdir()` that mirrors what the CLI expects.
207
+ const stage = stageRumenFunction();
208
+ if (!stage) {
209
+ fail('could not stage rumen-tick function source');
210
+ return false;
211
+ }
212
+
213
+ const r = runShell('supabase', ['functions', 'deploy', 'rumen-tick', '--no-verify-jwt'], {
214
+ cwd: stage
215
+ });
216
+ if (!r.ok) { fail(`deploy failed (exit ${r.code})`); return false; }
217
+ ok();
218
+ return true;
219
+ }
220
+
221
+ // Create a staging directory containing:
222
+ // <stage>/supabase/functions/rumen-tick/{index.ts, tsconfig.json}
223
+ // Also write a minimal `supabase/config.toml` so `supabase functions deploy`
224
+ // doesn't complain about a missing project root.
225
+ function stageRumenFunction() {
226
+ const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'termdeck-rumen-stage-'));
227
+ const functionSrc = migrations.rumenFunctionDir();
228
+ if (!fs.existsSync(functionSrc)) return null;
229
+
230
+ const dest = path.join(stage, 'supabase', 'functions', 'rumen-tick');
231
+ fs.mkdirSync(dest, { recursive: true });
232
+ for (const f of fs.readdirSync(functionSrc)) {
233
+ fs.copyFileSync(path.join(functionSrc, f), path.join(dest, f));
234
+ }
235
+
236
+ const configToml = `# staged by termdeck init --rumen
237
+ project_id = "termdeck-rumen-stage"
238
+
239
+ [functions.rumen-tick]
240
+ verify_jwt = false
241
+ `;
242
+ fs.writeFileSync(path.join(stage, 'supabase', 'config.toml'), configToml);
243
+
244
+ return stage;
245
+ }
246
+
247
+ function setFunctionSecrets(secrets, dryRun) {
248
+ step('Setting function secrets (DATABASE_URL, ANTHROPIC_API_KEY)...');
249
+ if (dryRun) { ok('(dry-run)'); return true; }
250
+ const r = runShellCaptured('supabase', [
251
+ 'secrets', 'set',
252
+ `DATABASE_URL=${secrets.DATABASE_URL}`,
253
+ `ANTHROPIC_API_KEY=${secrets.ANTHROPIC_API_KEY}`
254
+ ]);
255
+ if (!r.ok) {
256
+ fail(`secrets set failed (exit ${r.code})`);
257
+ if (r.stderr) process.stderr.write(r.stderr + '\n');
258
+ return false;
259
+ }
260
+ ok();
261
+ return true;
262
+ }
263
+
264
+ async function testFunction(projectRef, secrets, dryRun) {
265
+ step('Testing function with a manual POST...');
266
+ if (dryRun) { ok('(dry-run)'); return true; }
267
+
268
+ const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
269
+ let response;
270
+ try {
271
+ response = await fetch(functionUrl, {
272
+ method: 'POST',
273
+ headers: {
274
+ 'Authorization': `Bearer ${secrets.SUPABASE_SERVICE_ROLE_KEY}`,
275
+ 'Content-Type': 'application/json'
276
+ },
277
+ body: '{}'
278
+ });
279
+ } catch (err) {
280
+ fail(`network error: ${err.message}`);
281
+ return false;
282
+ }
283
+
284
+ let body;
285
+ try { body = await response.json(); } catch (_err) { body = {}; }
286
+
287
+ if (response.status !== 200 || !body || body.ok !== true) {
288
+ fail(`function returned ${response.status} — ${JSON.stringify(body).slice(0, 200)}`);
289
+ process.stderr.write(
290
+ '\nCheck the function logs in the Supabase dashboard: ' +
291
+ `https://supabase.com/dashboard/project/${projectRef}/functions/rumen-tick\n`
292
+ );
293
+ return false;
294
+ }
295
+
296
+ const summary = body.summary || {};
297
+ ok(`(job_id: ${summary.job_id || '?'}, extracted: ${summary.extracted ?? '?'}, surfaced: ${summary.insights_generated ?? summary.surfaced ?? '?'})`);
298
+ return true;
299
+ }
300
+
301
+ async function applySchedule(projectRef, secrets, dryRun) {
302
+ step('Applying pg_cron schedule (every 15 minutes)...');
303
+ if (dryRun) { ok('(dry-run)'); return true; }
304
+
305
+ const files = migrations.listRumenMigrations();
306
+ const scheduleFile = files.find((f) => /002.*pg_cron/.test(path.basename(f)));
307
+ if (!scheduleFile) { fail('bundled 002_pg_cron_schedule.sql is missing'); return false; }
308
+
309
+ const raw = migrations.readFile(scheduleFile);
310
+ // Substitute the project ref into the schedule body. The bundled migration
311
+ // ships with the placeholder `<project-ref>` per Rumen's deploy docs; we
312
+ // also accept `{{PROJECT_REF}}` for robustness.
313
+ const substituted = raw
314
+ .replace(/<project-ref>/g, projectRef)
315
+ .replace(/\{\{PROJECT_REF\}\}/g, projectRef);
316
+
317
+ // The shipped migration uses Supabase Vault (`vault.decrypted_secrets`) to
318
+ // pull the service-role key. If the user hasn't stored the key in Vault the
319
+ // cron call will fail. We leave that as a post-install step and print a
320
+ // reminder below.
321
+
322
+ let client;
323
+ try {
324
+ client = await pgRunner.connect(secrets.DATABASE_URL);
325
+ } catch (err) {
326
+ fail(err.message);
327
+ return false;
328
+ }
329
+ try {
330
+ // Run the substituted SQL directly rather than applying the original file.
331
+ try {
332
+ await pgRunner.run(client, substituted);
333
+ ok();
334
+ return true;
335
+ } catch (err) {
336
+ fail(err.message);
337
+ process.stderr.write(
338
+ '\nThe schedule SQL failed — the most common cause is that pg_cron or pg_net\n' +
339
+ 'is not enabled in the Supabase project. Enable them in Dashboard → Database\n' +
340
+ '→ Extensions, then re-run `termdeck init --rumen --skip-schedule=false`.\n'
341
+ );
342
+ return false;
343
+ }
344
+ } finally {
345
+ try { await client.end(); } catch (_err) { /* ignore */ }
346
+ }
347
+ }
348
+
349
+ function printNextSteps(projectRef) {
350
+ const functionUrl = `https://${projectRef}.supabase.co/functions/v1/rumen-tick`;
351
+ const now = new Date();
352
+ // Round up to the next 15-minute mark so the hint is accurate.
353
+ const next = new Date(now.getTime());
354
+ next.setUTCMinutes(Math.ceil((now.getUTCMinutes() + 1) / 15) * 15, 0, 0);
355
+ process.stdout.write(`
356
+ Rumen is deployed.
357
+
358
+ Schedule: every 15 minutes via pg_cron
359
+ First scheduled run: ${next.toISOString().replace(/\.\d+Z$/, 'Z')}
360
+ Edge Function URL: ${functionUrl}
361
+
362
+ Next steps:
363
+ 1. Monitor: psql "$DATABASE_URL" -c "SELECT * FROM rumen_jobs ORDER BY started_at DESC LIMIT 5"
364
+ 2. Store the service_role key in Supabase Vault as \`rumen_service_role_key\`
365
+ so the cron call in migrations/002_pg_cron_schedule.sql can authenticate.
366
+ 3. Rumen insights flow back into Engram's memory_items via rumen_insights.
367
+ 4. TermDeck's Flashback will surface cross-project patterns automatically.
368
+ `);
369
+ }
370
+
371
+ async function main(argv) {
372
+ const flags = parseFlags(argv || []);
373
+ if (flags.help) {
374
+ process.stdout.write(HELP);
375
+ return 0;
376
+ }
377
+
378
+ printBanner();
379
+
380
+ const pf = preflight();
381
+ if (!pf) return 2;
382
+ const { secrets } = pf;
383
+
384
+ let projectRef;
385
+ try {
386
+ projectRef = deriveProjectRef(secrets);
387
+ process.stdout.write(`→ Deriving project ref from SUPABASE_URL... ✓ ${projectRef}\n`);
388
+ } catch (err) {
389
+ process.stderr.write(`\n[init --rumen] ${err.message}\n`);
390
+ return 3;
391
+ }
392
+
393
+ if (!flags.yes) {
394
+ const go = await prompts.confirm(`? Proceed with deploy to project ${projectRef}?`);
395
+ if (!go) {
396
+ process.stdout.write('Cancelled.\n');
397
+ return 0;
398
+ }
399
+ }
400
+
401
+ if (!(await link(projectRef, flags.dryRun))) return 4;
402
+ if (!(await applyRumenTables(secrets, flags.dryRun))) return 5;
403
+ if (!deployFunction(flags.dryRun)) return 6;
404
+ if (!setFunctionSecrets(secrets, flags.dryRun)) return 7;
405
+ if (!(await testFunction(projectRef, secrets, flags.dryRun))) return 8;
406
+ if (!flags.skipSchedule) {
407
+ if (!(await applySchedule(projectRef, secrets, flags.dryRun))) return 9;
408
+ } else {
409
+ process.stdout.write('→ Skipping pg_cron schedule (per --skip-schedule) ✓\n');
410
+ }
411
+
412
+ printNextSteps(projectRef);
413
+ return 0;
414
+ }
415
+
416
+ if (require.main === module) {
417
+ main(process.argv.slice(2))
418
+ .then((code) => process.exit(code || 0))
419
+ .catch((err) => {
420
+ process.stderr.write(`\n[init --rumen] unexpected error: ${err && err.stack || err}\n`);
421
+ process.exit(1);
422
+ });
423
+ }
424
+
425
+ module.exports = main;
@@ -1300,7 +1300,7 @@
1300
1300
  <button id="btn-status">status</button>
1301
1301
  <button id="btn-config">config</button>
1302
1302
  <button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
1303
- <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://github.com/jhizzard/termdeck#readme','_blank','noopener')">help</button>
1303
+ <button id="btn-help" title="Open the TermDeck documentation" onclick="window.open('https://termdeck-docs.vercel.app','_blank','noopener')">help</button>
1304
1304
  </div>
1305
1305
  </div>
1306
1306
 
@@ -3048,7 +3048,7 @@
3048
3048
  {
3049
3049
  targets: ['#btn-how', '#btn-help'],
3050
3050
  title: 'How this works and help',
3051
- body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation on GitHub in a new tab.`,
3051
+ body: `Click <strong>how this works</strong> any time to replay this tour. <strong>help</strong> opens the full TermDeck documentation in a new tab.`,
3052
3052
  },
3053
3053
  {
3054
3054
  target: '.panel-header',
@@ -0,0 +1,116 @@
1
+ // Merge-aware reader/writer for ~/.termdeck/secrets.env.
2
+ //
3
+ // The existing config.js module already parses the file at load time, but for
4
+ // the `init` wizards we need to UPDATE the file without clobbering values the
5
+ // user has already set. This helper preserves unknown keys, preserves order
6
+ // of existing keys, and appends new keys at the bottom.
7
+ //
8
+ // File format (same subset as config.js parseDotenv):
9
+ // KEY=value
10
+ // KEY="quoted"
11
+ // KEY='single'
12
+ // # comments are preserved
13
+ // blank lines are preserved
14
+
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ const SECRETS_PATH = path.join(os.homedir(), '.termdeck', 'secrets.env');
20
+
21
+ function readSecretsRaw(filepath = SECRETS_PATH) {
22
+ if (!fs.existsSync(filepath)) return { exists: false, lines: [], keys: {} };
23
+ const raw = fs.readFileSync(filepath, 'utf-8');
24
+ const lines = raw.split(/\r?\n/);
25
+ const keys = {};
26
+ lines.forEach((line, idx) => {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith('#')) return;
29
+ const eq = trimmed.indexOf('=');
30
+ if (eq === -1) return;
31
+ const key = trimmed.slice(0, eq).trim();
32
+ let val = trimmed.slice(eq + 1).trim();
33
+ if ((val.startsWith('"') && val.endsWith('"')) ||
34
+ (val.startsWith("'") && val.endsWith("'"))) {
35
+ val = val.slice(1, -1);
36
+ }
37
+ keys[key] = { value: val, lineIndex: idx };
38
+ });
39
+ return { exists: true, lines, keys };
40
+ }
41
+
42
+ // Escape a value for safe re-serialization. Wraps in double quotes if the
43
+ // value contains whitespace, `#`, or `"`. Always safe to wrap — we wrap when
44
+ // in doubt to avoid ambiguity with the dotenv parser.
45
+ function formatValue(value) {
46
+ if (value == null) return '';
47
+ const str = String(value);
48
+ if (str === '') return '';
49
+ const needsQuoting = /[\s#"'=]/.test(str);
50
+ if (!needsQuoting) return str;
51
+ const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
52
+ return `"${escaped}"`;
53
+ }
54
+
55
+ // Write a merged secrets.env. `updates` is an object of key→value pairs. Pass
56
+ // null/undefined value to delete a key. Existing lines are preserved for keys
57
+ // not listed in `updates`. New keys get appended to the bottom with a header.
58
+ function writeSecrets(updates, filepath = SECRETS_PATH) {
59
+ const dir = path.dirname(filepath);
60
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
61
+
62
+ const { exists, lines, keys } = readSecretsRaw(filepath);
63
+ const originalLines = exists ? lines.slice() : [
64
+ '# TermDeck secrets — loaded on server startup.',
65
+ '# Never commit this file.',
66
+ ''
67
+ ];
68
+ const workingLines = originalLines.slice();
69
+ const toAppend = [];
70
+
71
+ for (const [key, rawVal] of Object.entries(updates)) {
72
+ if (rawVal == null || rawVal === '') {
73
+ // Delete existing line (by overwriting with blank). Safer to mark for
74
+ // deletion than splice (splice shifts indices of subsequent keys).
75
+ if (keys[key]) workingLines[keys[key].lineIndex] = '';
76
+ continue;
77
+ }
78
+ const formatted = `${key}=${formatValue(rawVal)}`;
79
+ if (keys[key]) {
80
+ workingLines[keys[key].lineIndex] = formatted;
81
+ } else {
82
+ toAppend.push(formatted);
83
+ }
84
+ }
85
+
86
+ let out = workingLines.join('\n');
87
+ if (toAppend.length > 0) {
88
+ if (out.length > 0 && !out.endsWith('\n')) out += '\n';
89
+ // Add a header for the first append-pass on an empty file.
90
+ if (!exists) out = originalLines.join('\n') + toAppend.join('\n') + '\n';
91
+ else out = out.replace(/\n+$/, '') + '\n' + toAppend.join('\n') + '\n';
92
+ } else if (!out.endsWith('\n')) {
93
+ out += '\n';
94
+ }
95
+
96
+ // chmod 600 for secrets — owner read/write only.
97
+ fs.writeFileSync(filepath, out, { encoding: 'utf-8', mode: 0o600 });
98
+ try { fs.chmodSync(filepath, 0o600); } catch (_err) { /* best-effort */ }
99
+
100
+ return { path: filepath, wrote: Object.keys(updates).length, appended: toAppend.length };
101
+ }
102
+
103
+ // Convenience read — returns `{ SUPABASE_URL, ... }` object, no metadata.
104
+ function readSecrets(filepath = SECRETS_PATH) {
105
+ const { keys } = readSecretsRaw(filepath);
106
+ const out = {};
107
+ for (const [k, v] of Object.entries(keys)) out[k] = v.value;
108
+ return out;
109
+ }
110
+
111
+ module.exports = {
112
+ SECRETS_PATH,
113
+ readSecrets,
114
+ writeSecrets,
115
+ _formatValue: formatValue
116
+ };
@@ -0,0 +1,116 @@
1
+ -- Engram v0.1 — core tables
2
+ --
3
+ -- Run against a Postgres 15+ database that has pgvector installed
4
+ -- (Supabase already ships with it). Apply in order:
5
+ -- 001_engram_tables.sql
6
+ -- 002_engram_search_function.sql
7
+ -- 003_engram_event_webhook.sql
8
+
9
+ create extension if not exists "vector";
10
+ create extension if not exists "pg_trgm";
11
+ create extension if not exists "pgcrypto";
12
+
13
+ -- ── memory_items ──────────────────────────────────────────────────────────
14
+
15
+ create table if not exists memory_items (
16
+ id uuid primary key default gen_random_uuid(),
17
+ content text not null,
18
+ embedding vector(1536),
19
+ source_type text not null default 'fact',
20
+ category text,
21
+ project text not null default 'global',
22
+ metadata jsonb not null default '{}'::jsonb,
23
+ is_active boolean not null default true,
24
+ archived boolean not null default false,
25
+ superseded_by uuid references memory_items(id) on delete set null,
26
+ created_at timestamptz not null default now(),
27
+ updated_at timestamptz not null default now()
28
+ );
29
+
30
+ create index if not exists memory_items_project_idx
31
+ on memory_items(project) where is_active = true and archived = false;
32
+
33
+ create index if not exists memory_items_source_type_idx
34
+ on memory_items(source_type) where is_active = true and archived = false;
35
+
36
+ create index if not exists memory_items_created_at_idx
37
+ on memory_items(created_at desc);
38
+
39
+ create index if not exists memory_items_content_trgm_idx
40
+ on memory_items using gin (content gin_trgm_ops);
41
+
42
+ -- HNSW vector index. If your Postgres/pgvector is too old for HNSW,
43
+ -- swap to ivfflat:
44
+ -- create index memory_items_embedding_idx on memory_items
45
+ -- using ivfflat (embedding vector_cosine_ops) with (lists = 100);
46
+ create index if not exists memory_items_embedding_hnsw_idx
47
+ on memory_items using hnsw (embedding vector_cosine_ops)
48
+ with (m = 16, ef_construction = 64);
49
+
50
+ -- ── memory_sessions ───────────────────────────────────────────────────────
51
+
52
+ create table if not exists memory_sessions (
53
+ id uuid primary key default gen_random_uuid(),
54
+ project text not null default 'global',
55
+ summary text,
56
+ metadata jsonb not null default '{}'::jsonb,
57
+ created_at timestamptz not null default now()
58
+ );
59
+
60
+ create index if not exists memory_sessions_project_idx on memory_sessions(project);
61
+ create index if not exists memory_sessions_created_at_idx on memory_sessions(created_at desc);
62
+
63
+ -- ── memory_relationships ──────────────────────────────────────────────────
64
+
65
+ create table if not exists memory_relationships (
66
+ id uuid primary key default gen_random_uuid(),
67
+ source_id uuid not null references memory_items(id) on delete cascade,
68
+ target_id uuid not null references memory_items(id) on delete cascade,
69
+ relationship_type text not null,
70
+ created_at timestamptz not null default now(),
71
+ unique (source_id, target_id, relationship_type),
72
+ check (source_id <> target_id),
73
+ check (relationship_type in ('supersedes','relates_to','contradicts','elaborates','caused_by'))
74
+ );
75
+
76
+ create index if not exists memory_relationships_source_idx on memory_relationships(source_id);
77
+ create index if not exists memory_relationships_target_idx on memory_relationships(target_id);
78
+
79
+ -- ── match_memories helper RPC ─────────────────────────────────────────────
80
+ -- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
81
+
82
+ create or replace function match_memories (
83
+ query_embedding vector(1536),
84
+ match_threshold float,
85
+ match_count int,
86
+ filter_project text default null
87
+ )
88
+ returns table (
89
+ id uuid,
90
+ content text,
91
+ source_type text,
92
+ category text,
93
+ project text,
94
+ metadata jsonb,
95
+ similarity float
96
+ )
97
+ language sql stable
98
+ as $$
99
+ select
100
+ m.id,
101
+ m.content,
102
+ m.source_type,
103
+ m.category,
104
+ m.project,
105
+ m.metadata,
106
+ 1 - (m.embedding <=> query_embedding) as similarity
107
+ from memory_items m
108
+ where m.is_active = true
109
+ and m.archived = false
110
+ and m.superseded_by is null
111
+ and m.embedding is not null
112
+ and (filter_project is null or m.project = filter_project)
113
+ and 1 - (m.embedding <=> query_embedding) >= match_threshold
114
+ order by m.embedding <=> query_embedding asc
115
+ limit match_count;
116
+ $$;