@jhizzard/termdeck 0.2.0 → 0.2.2

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 (28) hide show
  1. package/README.md +191 -169
  2. package/config/config.example.yaml +10 -10
  3. package/package.json +7 -6
  4. package/packages/cli/src/index.js +44 -1
  5. package/packages/cli/src/init-engram.js +344 -0
  6. package/packages/cli/src/init-rumen.js +425 -0
  7. package/packages/client/public/index.html +10 -10
  8. package/packages/server/src/config.js +8 -8
  9. package/packages/server/src/index.js +10 -10
  10. package/packages/server/src/{engram-bridge → mnestra-bridge}/index.js +18 -18
  11. package/packages/server/src/rag.js +6 -6
  12. package/packages/server/src/setup/dotenv-io.js +116 -0
  13. package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
  14. package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
  15. package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
  16. package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
  17. package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
  18. package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
  19. package/packages/server/src/setup/index.js +14 -0
  20. package/packages/server/src/setup/migrations.js +80 -0
  21. package/packages/server/src/setup/pg-runner.js +113 -0
  22. package/packages/server/src/setup/prompts.js +177 -0
  23. package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
  24. package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
  25. package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
  26. package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
  27. package/packages/server/src/setup/supabase-url.js +114 -0
  28. package/packages/server/src/setup/yaml-io.js +99 -0
@@ -0,0 +1,176 @@
1
+ -- Engram v0.2 — match_count cap + EXPLAIN variant
2
+ --
3
+ -- Two changes to the search surface:
4
+ --
5
+ -- 1. memory_hybrid_search gains a configurable cap on `match_count`.
6
+ -- Default cap: 200. The original function was unbounded, which risks
7
+ -- runaway queries at scale (10k+ rows pulled per call).
8
+ --
9
+ -- Override per-database: ALTER DATABASE your_db SET engram.max_match_count = 500;
10
+ -- Override per-session: SET engram.max_match_count = 500;
11
+ -- Leave unset: cap defaults to 200.
12
+ --
13
+ -- 2. A new function `memory_hybrid_search_explain` that returns
14
+ -- EXPLAIN (ANALYZE, BUFFERS) output for an equivalent call. Used by
15
+ -- `engram diagnose` to troubleshoot slow recall queries.
16
+ --
17
+ -- Rerun-safe: CREATE OR REPLACE on both.
18
+
19
+ -- ── memory_hybrid_search ─────────────────────────────────────────────────
20
+
21
+ create or replace function memory_hybrid_search (
22
+ query_text text,
23
+ query_embedding vector(1536),
24
+ match_count int default 20,
25
+ full_text_weight float default 1.0,
26
+ semantic_weight float default 1.0,
27
+ rrf_k int default 60,
28
+ filter_project text default null,
29
+ filter_source_type text default null
30
+ )
31
+ returns table (
32
+ id uuid,
33
+ content text,
34
+ source_type text,
35
+ category text,
36
+ project text,
37
+ metadata jsonb,
38
+ score float,
39
+ created_at timestamptz
40
+ )
41
+ language sql stable
42
+ as $$
43
+ with candidates as (
44
+ select
45
+ m.id,
46
+ m.content,
47
+ m.source_type,
48
+ m.category,
49
+ m.project,
50
+ m.metadata,
51
+ m.created_at,
52
+ m.embedding,
53
+ ts_rank_cd(to_tsvector('english', m.content), plainto_tsquery('english', query_text))
54
+ as ft_rank,
55
+ 1 - (m.embedding <=> query_embedding) as sem_rank,
56
+ extract(epoch from (now() - m.created_at))::float as age_seconds
57
+ from memory_items m
58
+ where m.is_active = true
59
+ and m.archived = false
60
+ and m.superseded_by is null
61
+ and m.embedding is not null
62
+ and (filter_project is null or m.project = filter_project)
63
+ and (filter_source_type is null or m.source_type = filter_source_type)
64
+ ),
65
+ ft_ranked as (
66
+ select id, row_number() over (order by ft_rank desc nulls last) as rank
67
+ from candidates where ft_rank > 0
68
+ ),
69
+ sem_ranked as (
70
+ select id, row_number() over (order by sem_rank desc nulls last) as rank
71
+ from candidates
72
+ ),
73
+ fused as (
74
+ select
75
+ c.id,
76
+ c.content,
77
+ c.source_type,
78
+ c.category,
79
+ c.project,
80
+ c.metadata,
81
+ c.created_at,
82
+ c.age_seconds,
83
+ coalesce(full_text_weight / (rrf_k + ft.rank), 0.0) +
84
+ coalesce(semantic_weight / (rrf_k + sr.rank), 0.0) as base_score
85
+ from candidates c
86
+ left join ft_ranked ft on ft.id = c.id
87
+ left join sem_ranked sr on sr.id = c.id
88
+ ),
89
+ scored as (
90
+ select
91
+ f.id,
92
+ f.content,
93
+ f.source_type,
94
+ f.category,
95
+ f.project,
96
+ f.metadata,
97
+ f.created_at,
98
+ f.base_score
99
+ * case f.source_type
100
+ when 'decision' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
101
+ when 'architecture' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
102
+ when 'preference' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
103
+ when 'fact' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
104
+ when 'convention' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
105
+ when 'bug_fix' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
106
+ when 'debugging' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
107
+ when 'session_summary' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
108
+ when 'document_chunk' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
109
+ when 'code_context' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
110
+ else 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
111
+ end
112
+ * case f.source_type
113
+ when 'decision' then 1.5
114
+ when 'architecture' then 1.4
115
+ when 'bug_fix' then 1.3
116
+ when 'preference' then 1.2
117
+ when 'fact' then 1.0
118
+ when 'document_chunk' then 0.6
119
+ else 1.0
120
+ end
121
+ * case
122
+ when filter_project is null then 1.0
123
+ when f.project = filter_project then 1.5
124
+ when f.project = 'global' then 1.0
125
+ else 0.7
126
+ end
127
+ as score
128
+ from fused f
129
+ )
130
+ select
131
+ s.id,
132
+ s.content,
133
+ s.source_type,
134
+ s.category,
135
+ s.project,
136
+ s.metadata,
137
+ s.score,
138
+ s.created_at
139
+ from scored s
140
+ order by s.score desc
141
+ limit least(
142
+ greatest(match_count, 1),
143
+ coalesce(nullif(current_setting('engram.max_match_count', true), '')::int, 200)
144
+ );
145
+ $$;
146
+
147
+ -- ── memory_hybrid_search_explain ─────────────────────────────────────────
148
+
149
+ create or replace function memory_hybrid_search_explain (
150
+ query_text text,
151
+ query_embedding vector(1536),
152
+ match_count int default 20,
153
+ full_text_weight float default 1.0,
154
+ semantic_weight float default 1.0,
155
+ rrf_k int default 60,
156
+ filter_project text default null,
157
+ filter_source_type text default null
158
+ )
159
+ returns setof text
160
+ language plpgsql
161
+ as $$
162
+ begin
163
+ return query execute
164
+ 'explain (analyze, buffers, format text) '
165
+ || 'select * from memory_hybrid_search($1, $2, $3, $4, $5, $6, $7, $8)'
166
+ using
167
+ query_text,
168
+ query_embedding,
169
+ match_count,
170
+ full_text_weight,
171
+ semantic_weight,
172
+ rrf_k,
173
+ filter_project,
174
+ filter_source_type;
175
+ end;
176
+ $$;
@@ -0,0 +1,23 @@
1
+ -- Engram v0.2 — minimal additive delta for stores provisioned against the
2
+ -- original rag-system schema.
3
+ --
4
+ -- This migration is idempotent and non-destructive:
5
+ -- • adds only the single column (`archived`) that Engram v0.2 reads/writes
6
+ -- but which is absent from the original rag-system `memory_items` table;
7
+ -- • re-creates the two partial indexes under v2 names so they cannot
8
+ -- collide with any pre-existing same-named index that uses a different
9
+ -- `where` predicate;
10
+ -- • does NOT replace `memory_hybrid_search`, `match_memories`, or any
11
+ -- other SQL function — those remain on their production versions.
12
+ --
13
+ -- Apply once, in the Supabase SQL editor, against any existing store that
14
+ -- pre-dates Engram v0.2's `archived` soft-delete column.
15
+
16
+ alter table memory_items
17
+ add column if not exists archived boolean not null default false;
18
+
19
+ create index if not exists memory_items_project_idx_v2
20
+ on memory_items(project) where is_active = true and archived = false;
21
+
22
+ create index if not exists memory_items_source_type_idx_v2
23
+ on memory_items(source_type) where is_active = true and archived = false;
@@ -0,0 +1,58 @@
1
+ -- Engram migration 006 — memory_status_aggregation RPC
2
+ --
3
+ -- Why: `memoryStatus()` previously did a plain
4
+ -- supabase.from('memory_items').select('project, source_type, category')
5
+ -- which hits PostgREST's default 1000-row cap. On a store with 3,397 active
6
+ -- rows the `by_project` / `by_source_type` / `by_category` histograms only
7
+ -- summed to ~1000 even though `total_active` was correct. Pushing the GROUP
8
+ -- BY server-side eliminates the cap and saves the round-trip of streaming
9
+ -- every row to the client just to count them.
10
+ --
11
+ -- Safe to re-run — CREATE OR REPLACE.
12
+
13
+ create or replace function memory_status_aggregation()
14
+ returns table (
15
+ total_active bigint,
16
+ sessions bigint,
17
+ by_project jsonb,
18
+ by_source_type jsonb,
19
+ by_category jsonb
20
+ )
21
+ language sql
22
+ stable
23
+ as $$
24
+ select
25
+ (select count(*)::bigint from memory_items
26
+ where is_active = true and archived = false) as total_active,
27
+ (select count(*)::bigint from memory_sessions) as sessions,
28
+ coalesce(
29
+ (select jsonb_object_agg(project, c) from (
30
+ select project, count(*)::bigint as c
31
+ from memory_items
32
+ where is_active = true and archived = false
33
+ group by project
34
+ ) p),
35
+ '{}'::jsonb
36
+ ) as by_project,
37
+ coalesce(
38
+ (select jsonb_object_agg(source_type, c) from (
39
+ select source_type, count(*)::bigint as c
40
+ from memory_items
41
+ where is_active = true and archived = false
42
+ group by source_type
43
+ ) s),
44
+ '{}'::jsonb
45
+ ) as by_source_type,
46
+ coalesce(
47
+ (select jsonb_object_agg(coalesce(category, 'uncategorized'), c) from (
48
+ select category, count(*)::bigint as c
49
+ from memory_items
50
+ where is_active = true and archived = false
51
+ group by category
52
+ ) cat),
53
+ '{}'::jsonb
54
+ ) as by_category;
55
+ $$;
56
+
57
+ -- Ensure the service role (and anon, if you allow it) can call the RPC.
58
+ grant execute on function memory_status_aggregation() to anon, authenticated, service_role;
@@ -0,0 +1,14 @@
1
+ // Aggregate export for the `termdeck init` setup helpers.
2
+ //
3
+ // The init wizards live in packages/cli/src/ but all the heavy lifting
4
+ // (prompting, reading/writing config, applying migrations) lives here so
5
+ // the CLI files can stay short, linear, and easy to audit.
6
+
7
+ module.exports = {
8
+ prompts: require('./prompts'),
9
+ dotenv: require('./dotenv-io'),
10
+ yaml: require('./yaml-io'),
11
+ supabaseUrl: require('./supabase-url'),
12
+ migrations: require('./migrations'),
13
+ pgRunner: require('./pg-runner')
14
+ };
@@ -0,0 +1,80 @@
1
+ // Discover the SQL migration files that ship bundled inside the TermDeck
2
+ // package. Both init wizards call this — init-engram for the six Engram
3
+ // migrations, init-rumen for the two Rumen migrations.
4
+ //
5
+ // The wizards intentionally do NOT fall back to a sibling `../../engram`
6
+ // working copy. Resolution order:
7
+ //
8
+ // 1. Files bundled at `packages/server/src/setup/engram-migrations/*.sql`
9
+ // (this directory is covered by the root package.json `files` glob).
10
+ // 2. Files at `node_modules/@jhizzard/engram/migrations/*.sql` if that
11
+ // package is installed alongside TermDeck (future-proof path — shipping
12
+ // `@jhizzard/engram` as an optional peer would let us drop the bundled
13
+ // copy).
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const SETUP_DIR = __dirname;
19
+
20
+ function listBundled(subdir) {
21
+ const dir = path.join(SETUP_DIR, subdir);
22
+ if (!fs.existsSync(dir)) return [];
23
+ return fs.readdirSync(dir)
24
+ .filter((f) => f.toLowerCase().endsWith('.sql'))
25
+ .sort()
26
+ .map((f) => path.join(dir, f));
27
+ }
28
+
29
+ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
30
+ try {
31
+ // Resolve the package's main file, then look for a migrations sibling dir.
32
+ const pkgJsonPath = require.resolve(`${packageName}/package.json`, {
33
+ paths: [process.cwd(), SETUP_DIR]
34
+ });
35
+ const pkgDir = path.dirname(pkgJsonPath);
36
+ const migrationDir = path.join(pkgDir, migrationSubdir);
37
+ if (!fs.existsSync(migrationDir)) return [];
38
+ return fs.readdirSync(migrationDir)
39
+ .filter((f) => f.toLowerCase().endsWith('.sql'))
40
+ .sort()
41
+ .map((f) => path.join(migrationDir, f));
42
+ } catch (_err) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function listEngramMigrations() {
48
+ const fromNm = tryNodeModules('@jhizzard/engram');
49
+ if (fromNm.length > 0) return fromNm;
50
+ return listBundled('engram-migrations');
51
+ }
52
+
53
+ function listRumenMigrations() {
54
+ const fromNm = tryNodeModules('@jhizzard/rumen');
55
+ if (fromNm.length > 0) return fromNm;
56
+ return listBundled(path.join('rumen', 'migrations'));
57
+ }
58
+
59
+ function rumenFunctionDir() {
60
+ // Same resolution order.
61
+ try {
62
+ const pkgJsonPath = require.resolve('@jhizzard/rumen/package.json', {
63
+ paths: [process.cwd(), SETUP_DIR]
64
+ });
65
+ const candidate = path.join(path.dirname(pkgJsonPath), 'supabase', 'functions', 'rumen-tick');
66
+ if (fs.existsSync(candidate)) return candidate;
67
+ } catch (_err) { /* fallthrough */ }
68
+ return path.join(SETUP_DIR, 'rumen', 'functions', 'rumen-tick');
69
+ }
70
+
71
+ function readFile(filepath) {
72
+ return fs.readFileSync(filepath, 'utf-8');
73
+ }
74
+
75
+ module.exports = {
76
+ listEngramMigrations,
77
+ listRumenMigrations,
78
+ rumenFunctionDir,
79
+ readFile
80
+ };
@@ -0,0 +1,113 @@
1
+ // Thin wrapper around node-postgres for applying SQL migration files.
2
+ //
3
+ // `pg` is a runtime dep of the top-level package and must be present when the
4
+ // init wizards run. If `require('pg')` fails we throw an actionable error
5
+ // pointing the user at `npm install` — TermDeck's normal install path pulls
6
+ // `pg` in automatically, so this only fires on partial / broken installs.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ function loadPg() {
12
+ try {
13
+ return require('pg');
14
+ } catch (err) {
15
+ const e = new Error(
16
+ "Could not load node-postgres ('pg'). TermDeck's init wizards need it to " +
17
+ 'apply migrations. Run `npm install` (or reinstall TermDeck with `npm install -g @jhizzard/termdeck`) and try again.'
18
+ );
19
+ e.cause = err;
20
+ throw e;
21
+ }
22
+ }
23
+
24
+ // Connect to the given postgres URL and return a live Client. The caller is
25
+ // responsible for `await client.end()`.
26
+ async function connect(databaseUrl) {
27
+ const { Client } = loadPg();
28
+ const client = new Client({
29
+ connectionString: databaseUrl,
30
+ // Supabase pooler endpoints present a TLS cert that Node's default CA
31
+ // store accepts, but some users hit verification issues behind corporate
32
+ // proxies. Enable TLS with rejectUnauthorized=false so the wizard works
33
+ // in those environments — we're talking to a Supabase host we already
34
+ // trust by project ref, and the connection is still encrypted.
35
+ ssl: { rejectUnauthorized: false },
36
+ // Keep connection attempts bounded so a misconfigured URL fails fast.
37
+ connectionTimeoutMillis: 15000
38
+ });
39
+ try {
40
+ await client.connect();
41
+ } catch (err) {
42
+ const friendly = mapConnectError(err);
43
+ const e = new Error(`Could not connect to Postgres: ${friendly}`);
44
+ e.cause = err;
45
+ throw e;
46
+ }
47
+ return client;
48
+ }
49
+
50
+ function mapConnectError(err) {
51
+ const msg = err && err.message ? err.message : String(err);
52
+ if (/ENOTFOUND/.test(msg)) return 'host not found (check the project URL)';
53
+ if (/ECONNREFUSED/.test(msg)) return 'connection refused (wrong port?)';
54
+ if (/password authentication failed/i.test(msg)) return 'password authentication failed (check the service_role / db password)';
55
+ if (/SASL/i.test(msg)) return 'authentication failed (check username, it should be `postgres` or `postgres.<project-ref>`)';
56
+ if (/timeout/i.test(msg)) return 'connect timed out after 15s (network issue or wrong host)';
57
+ return msg;
58
+ }
59
+
60
+ // Read a migration file and execute it as a single batched query. Returns
61
+ // `{ ok, file, elapsedMs, rowCount, skipped?, error? }`.
62
+ //
63
+ // A migration "skipped" means the file body included a marker the runner
64
+ // detected as already-applied (reserved for future use — currently not wired).
65
+ async function applyFile(client, filepath) {
66
+ const started = Date.now();
67
+ const sql = fs.readFileSync(filepath, 'utf-8');
68
+ const base = path.basename(filepath);
69
+ try {
70
+ const result = await client.query(sql);
71
+ const rowCount = Array.isArray(result)
72
+ ? result.reduce((sum, r) => sum + (r && r.rowCount ? r.rowCount : 0), 0)
73
+ : (result && result.rowCount) || 0;
74
+ return {
75
+ ok: true,
76
+ file: base,
77
+ elapsedMs: Date.now() - started,
78
+ rowCount
79
+ };
80
+ } catch (err) {
81
+ return {
82
+ ok: false,
83
+ file: base,
84
+ elapsedMs: Date.now() - started,
85
+ error: err && err.message ? err.message : String(err)
86
+ };
87
+ }
88
+ }
89
+
90
+ // Apply a list of migration files in order. Returns an array of per-file
91
+ // results. Stops on first failure unless `continueOnError: true` is passed.
92
+ async function applyAll(client, files, { continueOnError = false } = {}) {
93
+ const results = [];
94
+ for (const file of files) {
95
+ const result = await applyFile(client, file);
96
+ results.push(result);
97
+ if (!result.ok && !continueOnError) break;
98
+ }
99
+ return results;
100
+ }
101
+
102
+ // Run a single SQL string (not a file). Useful for parameterized checks like
103
+ // `SELECT COUNT(*) FROM memory_items`.
104
+ async function run(client, sql, params = []) {
105
+ return client.query(sql, params);
106
+ }
107
+
108
+ module.exports = {
109
+ connect,
110
+ applyFile,
111
+ applyAll,
112
+ run
113
+ };
@@ -0,0 +1,177 @@
1
+ // Readline-based interactive prompts for the `termdeck init` wizards.
2
+ //
3
+ // No external deps. All prompts in a single wizard run share ONE readline
4
+ // interface (via the module-scoped `rl` below). We consume lines via the
5
+ // `line` event and an internal FIFO of pending resolvers instead of
6
+ // `rl.question()` — the latter has broken behavior when stdin is a piped
7
+ // stream in non-terminal mode, where the second `.question()` call silently
8
+ // hangs. The event-driven path works for both TTY and piped input, which lets
9
+ // us drive the wizard non-interactively in tests:
10
+ //
11
+ // printf 'a\nb\nc\n' | termdeck init --engram --dry-run
12
+ //
13
+ // Secret prompts still mute stdout echo when TTY is attached; on non-TTY
14
+ // stdin they fall back to visible input so piped test runs work.
15
+
16
+ const readline = require('readline');
17
+
18
+ let rl = null;
19
+ const waiting = []; // pending resolver functions
20
+ const buffered = []; // lines that arrived before anyone was waiting
21
+ let sawEnd = false;
22
+
23
+ function getRl() {
24
+ if (rl) return rl;
25
+ rl = readline.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout,
28
+ terminal: false
29
+ });
30
+ rl.on('line', (line) => {
31
+ if (waiting.length > 0) {
32
+ const resolver = waiting.shift();
33
+ resolver(line);
34
+ } else {
35
+ buffered.push(line);
36
+ }
37
+ });
38
+ rl.on('close', () => {
39
+ sawEnd = true;
40
+ while (waiting.length > 0) {
41
+ const resolver = waiting.shift();
42
+ resolver('');
43
+ }
44
+ });
45
+ return rl;
46
+ }
47
+
48
+ function readLine() {
49
+ return new Promise((resolve) => {
50
+ getRl();
51
+ if (buffered.length > 0) {
52
+ resolve(buffered.shift());
53
+ return;
54
+ }
55
+ if (sawEnd) { resolve(''); return; }
56
+ waiting.push(resolve);
57
+ });
58
+ }
59
+
60
+ // Basic non-secret prompt. Returns empty string if the user just hits enter.
61
+ async function ask(question, { defaultValue } = {}) {
62
+ const suffix = defaultValue ? ` [${defaultValue}]` : '';
63
+ if (question) process.stdout.write(`${question}${suffix}: `);
64
+ const line = await readLine();
65
+ const trimmed = (line || '').trim();
66
+ return trimmed || defaultValue || '';
67
+ }
68
+
69
+ // Required prompt — re-asks up to `maxAttempts` times if the answer is empty
70
+ // or fails `validate(value) → null | string`. A validator returning a string
71
+ // message means "invalid, re-ask with this error". Throws if all attempts fail.
72
+ async function askRequired(question, { validate, maxAttempts = 3 } = {}) {
73
+ for (let i = 0; i < maxAttempts; i++) {
74
+ const answer = await ask(question);
75
+ if (!answer) {
76
+ process.stdout.write(' (required)\n');
77
+ continue;
78
+ }
79
+ if (validate) {
80
+ const err = validate(answer);
81
+ if (err) {
82
+ process.stdout.write(` ${err}\n`);
83
+ continue;
84
+ }
85
+ }
86
+ return answer;
87
+ }
88
+ throw new Error(`No valid answer after ${maxAttempts} attempts: ${question}`);
89
+ }
90
+
91
+ // Optional prompt. Empty → null. Still validates non-empty answers.
92
+ async function askOptional(question, { validate } = {}) {
93
+ const answer = await ask(question);
94
+ if (!answer) return null;
95
+ if (validate) {
96
+ const err = validate(answer);
97
+ if (err) {
98
+ process.stdout.write(` ${err} — ignoring.\n`);
99
+ return null;
100
+ }
101
+ }
102
+ return answer;
103
+ }
104
+
105
+ // Secret prompt. On TTY we mute echo; on non-TTY we fall back to a visible
106
+ // line read. Callers typically pass an empty string for `question` and write
107
+ // their own label first.
108
+ async function askSecret(question) {
109
+ if (!process.stdin.isTTY) {
110
+ return ask(question);
111
+ }
112
+ if (question) process.stdout.write(`${question}: `);
113
+ // Raw-mode reader. Detach the shared readline for the duration so both
114
+ // consumers aren't racing on stdin 'data' events.
115
+ return new Promise((resolve) => {
116
+ if (rl) rl.pause();
117
+ const stdin = process.stdin;
118
+ stdin.setRawMode(true);
119
+ stdin.resume();
120
+ stdin.setEncoding('utf-8');
121
+
122
+ let buffer = '';
123
+ const onData = (chunk) => {
124
+ for (const ch of chunk) {
125
+ if (ch === '\n' || ch === '\r' || ch === '\u0004') {
126
+ stdin.setRawMode(false);
127
+ stdin.removeListener('data', onData);
128
+ process.stdout.write('\n');
129
+ if (rl) rl.resume();
130
+ resolve(buffer);
131
+ return;
132
+ }
133
+ if (ch === '\u0003') {
134
+ stdin.setRawMode(false);
135
+ stdin.removeListener('data', onData);
136
+ process.stdout.write('\n');
137
+ process.kill(process.pid, 'SIGINT');
138
+ return;
139
+ }
140
+ if (ch === '\u007f' || ch === '\b') {
141
+ if (buffer.length > 0) {
142
+ buffer = buffer.slice(0, -1);
143
+ process.stdout.write('\b \b');
144
+ }
145
+ continue;
146
+ }
147
+ buffer += ch;
148
+ process.stdout.write('*');
149
+ }
150
+ };
151
+ stdin.on('data', onData);
152
+ });
153
+ }
154
+
155
+ // Yes/no confirm. Returns boolean.
156
+ async function confirm(question, { defaultYes = true } = {}) {
157
+ const suffix = defaultYes ? '[Y/n]' : '[y/N]';
158
+ const answer = (await ask(`${question} ${suffix}`)).toLowerCase();
159
+ if (!answer) return defaultYes;
160
+ return answer === 'y' || answer === 'yes';
161
+ }
162
+
163
+ function closeRl() {
164
+ if (rl) {
165
+ rl.close();
166
+ rl = null;
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ ask,
172
+ askRequired,
173
+ askOptional,
174
+ askSecret,
175
+ confirm,
176
+ closeRl
177
+ };