dual-brain 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.
package/src/replit.mjs ADDED
@@ -0,0 +1,1210 @@
1
+ /**
2
+ * replit.mjs — Replit platform integration for dual-brain.
3
+ *
4
+ * Treats replit-tools as infrastructure and adds intelligence on top.
5
+ * Uses only Node built-ins. Never reads or returns secret values.
6
+ *
7
+ * Sections:
8
+ * 1. Discovery — read-only inspection of environment and replit-tools
9
+ * 2. Planning — compute .replit config changes, no side effects
10
+ * 3. Apply — mutating; low-risk changes only in v1
11
+ * 4. Formatters — pretty-print integration reports
12
+ */
13
+
14
+ import {
15
+ existsSync,
16
+ readFileSync,
17
+ writeFileSync,
18
+ readdirSync,
19
+ statSync,
20
+ mkdirSync,
21
+ renameSync,
22
+ createReadStream,
23
+ } from 'node:fs';
24
+ import { join, resolve } from 'node:path';
25
+ import { execSync, spawnSync } from 'node:child_process';
26
+ import { createInterface } from 'node:readline';
27
+
28
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
29
+
30
+ function safeRead(filePath) {
31
+ try {
32
+ return readFileSync(filePath, 'utf8');
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function safeJson(filePath) {
39
+ const raw = safeRead(filePath);
40
+ if (!raw) return null;
41
+ try {
42
+ return JSON.parse(raw);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function safeReaddir(dirPath) {
49
+ try {
50
+ return readdirSync(dirPath);
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ function safeStat(filePath) {
57
+ try {
58
+ return statSync(filePath);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** Returns the replit-tools root directory for a given workspace cwd, or null. */
65
+ function findReplitToolsDir(cwd) {
66
+ const candidates = [
67
+ join(cwd, '.replit-tools'),
68
+ '/home/runner/workspace/.replit-tools',
69
+ ];
70
+ for (const c of candidates) {
71
+ if (existsSync(c)) return resolve(c);
72
+ }
73
+ return null;
74
+ }
75
+
76
+ // ─── Section 1: Discovery ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Detect the Replit runtime environment from env vars.
80
+ * @param {string} [cwd]
81
+ * @returns {{ isReplit, replId, replSlug, replOwner, replUrl, nixChannel, containerType, uptimeSeconds }}
82
+ */
83
+ export function detectReplitEnvironment(cwd = process.cwd()) {
84
+ const env = process.env;
85
+ const isReplit = Boolean(env.REPL_ID || env.REPL_SLUG);
86
+
87
+ let uptimeSeconds = null;
88
+ try {
89
+ const raw = readFileSync('/proc/uptime', 'utf8');
90
+ uptimeSeconds = Math.floor(parseFloat(raw.split(' ')[0]));
91
+ } catch { /* not available */ }
92
+
93
+ // Container type from env signals
94
+ let containerType = 'local';
95
+ if (isReplit) containerType = 'replit';
96
+ else if (env.CODESPACES) containerType = 'codespace';
97
+ else if (env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI) containerType = 'ci';
98
+
99
+ // nixChannel from .replit file if available
100
+ let nixChannel = env.NIX_CHANNEL || null;
101
+ const replitFile = join(resolve(cwd), '.replit');
102
+ if (!nixChannel && existsSync(replitFile)) {
103
+ const content = safeRead(replitFile) || '';
104
+ const m = content.match(/channel\s*=\s*["']?([^\s"'\n]+)["']?/);
105
+ if (m) nixChannel = m[1];
106
+ }
107
+
108
+ return {
109
+ isReplit,
110
+ replId: env.REPL_ID || null,
111
+ replSlug: env.REPL_SLUG || null,
112
+ replOwner: env.REPL_OWNER || null,
113
+ replUrl: env.REPL_URL || (env.REPL_SLUG ? `https://replit.com/@${env.REPL_OWNER || 'unknown'}/${env.REPL_SLUG}` : null),
114
+ nixChannel,
115
+ containerType,
116
+ uptimeSeconds,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Read and parse the .replit config file.
122
+ * Uses simple line-by-line parsing — no TOML dependency.
123
+ * @param {string} [cwd]
124
+ * @returns {{ raw, run, onBoot, expertMode, hidden, modules, nix, deployment, hasRun, hasOnBoot, hasExpertMode }}
125
+ */
126
+ export function inspectReplitConfig(cwd = process.cwd()) {
127
+ const replitPath = join(resolve(cwd), '.replit');
128
+ const raw = safeRead(replitPath);
129
+
130
+ if (!raw) {
131
+ return {
132
+ raw: null, run: null, onBoot: null, expertMode: null,
133
+ hidden: [], modules: [], nix: {}, deployment: {},
134
+ hasRun: false, hasOnBoot: false, hasExpertMode: false,
135
+ };
136
+ }
137
+
138
+ const lines = raw.split('\n');
139
+ let run = null;
140
+ let onBoot = null;
141
+ let expertMode = null;
142
+ const hidden = [];
143
+ const modules = [];
144
+ const nix = {};
145
+ const deployment = {};
146
+ let currentSection = null;
147
+
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (!trimmed || trimmed.startsWith('#')) continue;
151
+
152
+ // Section headers: [nix], [agent], [deployment]
153
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
154
+ if (sectionMatch) {
155
+ currentSection = sectionMatch[1].trim();
156
+ continue;
157
+ }
158
+
159
+ // Key = value (handle quoted and unquoted)
160
+ const kvMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+)$/);
161
+ if (!kvMatch) continue;
162
+
163
+ const key = kvMatch[1];
164
+ let value = kvMatch[2].trim();
165
+
166
+ // Strip surrounding quotes
167
+ if ((value.startsWith('"') && value.endsWith('"')) ||
168
+ (value.startsWith("'") && value.endsWith("'"))) {
169
+ value = value.slice(1, -1);
170
+ }
171
+
172
+ if (currentSection === 'nix') {
173
+ nix[key] = value;
174
+ } else if (currentSection === 'deployment') {
175
+ deployment[key] = value;
176
+ } else if (currentSection === 'agent') {
177
+ if (key === 'expertMode') {
178
+ expertMode = value === 'true' || value === '1';
179
+ }
180
+ } else if (!currentSection) {
181
+ // Top-level keys
182
+ if (key === 'run') run = value;
183
+ else if (key === 'onBoot') onBoot = value;
184
+ else if (key === 'modules') {
185
+ // modules = ["nodejs-20"] style
186
+ const items = value.replace(/[\[\]"']/g, '').split(',').map(s => s.trim()).filter(Boolean);
187
+ modules.push(...items);
188
+ } else if (key === 'hidden') {
189
+ const items = value.replace(/[\[\]"']/g, '').split(',').map(s => s.trim()).filter(Boolean);
190
+ hidden.push(...items);
191
+ }
192
+ }
193
+ }
194
+
195
+ return {
196
+ raw,
197
+ run,
198
+ onBoot,
199
+ expertMode,
200
+ hidden,
201
+ modules,
202
+ nix,
203
+ deployment,
204
+ hasRun: run !== null,
205
+ hasOnBoot: onBoot !== null,
206
+ hasExpertMode: expertMode !== null,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Inventory what replit-tools provides in the current workspace.
212
+ * @param {string} [cwd]
213
+ * @returns {object} Structured capability report
214
+ */
215
+ export function inspectReplitTools(cwd = process.cwd()) {
216
+ const toolsDir = findReplitToolsDir(resolve(cwd));
217
+
218
+ if (!toolsDir) {
219
+ return {
220
+ installed: false,
221
+ version: null,
222
+ toolsDir: null,
223
+ sessionArchive: { exists: false, sessionCount: 0, latestTimestamp: null },
224
+ persistentHomes: { claude: false, codex: false },
225
+ authRefresh: { available: false },
226
+ config: null,
227
+ codexPlugins: { count: 0 },
228
+ shellSnapshots: { available: false, count: 0 },
229
+ mcpAuthCache: { available: false, entries: 0 },
230
+ };
231
+ }
232
+
233
+ // Version
234
+ let version = null;
235
+ const versionFile = join(toolsDir, '.version');
236
+ if (existsSync(versionFile)) {
237
+ version = (safeRead(versionFile) || '').trim() || null;
238
+ }
239
+ if (!version) {
240
+ const pkg = safeJson(join(toolsDir, 'package.json'));
241
+ if (pkg?.version) version = pkg.version;
242
+ }
243
+
244
+ // Session archive: .replit-tools/.session-archive/claude/
245
+ const archiveBase = join(toolsDir, '.session-archive', 'claude');
246
+ let sessionCount = 0;
247
+ let latestTimestamp = null;
248
+ if (existsSync(archiveBase)) {
249
+ // Recursively count all .jsonl files under the archive tree
250
+ function countJsonl(dir) {
251
+ for (const entry of safeReaddir(dir)) {
252
+ const full = join(dir, entry);
253
+ const st = safeStat(full);
254
+ if (!st) continue;
255
+ if (st.isDirectory()) {
256
+ countJsonl(full);
257
+ } else if (entry.endsWith('.jsonl')) {
258
+ sessionCount++;
259
+ const ts = st.mtimeMs;
260
+ if (!latestTimestamp || ts > latestTimestamp) latestTimestamp = ts;
261
+ }
262
+ }
263
+ }
264
+ countJsonl(archiveBase);
265
+ }
266
+
267
+ // Persistent homes
268
+ const claudePersistent = join(toolsDir, '.claude-persistent');
269
+ const codexPersistent = join(toolsDir, '.codex-persistent');
270
+
271
+ // Auth refresh
272
+ const authRefreshScript = join(toolsDir, 'scripts', 'claude-auth-refresh.sh');
273
+ const authRefreshAvailable = existsSync(authRefreshScript);
274
+
275
+ // Config
276
+ const config = safeJson(join(toolsDir, 'config.json'));
277
+
278
+ // Codex plugins
279
+ const pluginsDir = join(codexPersistent, '.tmp', 'plugins', 'plugins');
280
+ const pluginCount = existsSync(pluginsDir) ? safeReaddir(pluginsDir).length : 0;
281
+
282
+ // Shell snapshots
283
+ const shellSnapshotsDir = join(claudePersistent, 'shell-snapshots');
284
+ const shellSnapshotFiles = existsSync(shellSnapshotsDir)
285
+ ? safeReaddir(shellSnapshotsDir).filter(f => f.endsWith('.sh'))
286
+ : [];
287
+
288
+ // MCP auth cache
289
+ const mcpCacheFile = join(claudePersistent, 'mcp-needs-auth-cache.json');
290
+ const mcpCache = safeJson(mcpCacheFile);
291
+ const mcpEntries = mcpCache ? Object.keys(mcpCache).length : 0;
292
+
293
+ return {
294
+ installed: true,
295
+ version,
296
+ toolsDir,
297
+ sessionArchive: {
298
+ exists: existsSync(archiveBase),
299
+ sessionCount,
300
+ latestTimestamp,
301
+ },
302
+ persistentHomes: {
303
+ claude: existsSync(claudePersistent),
304
+ codex: existsSync(codexPersistent),
305
+ },
306
+ authRefresh: {
307
+ available: authRefreshAvailable,
308
+ scriptPath: authRefreshAvailable ? authRefreshScript : null,
309
+ },
310
+ config,
311
+ codexPlugins: { count: pluginCount },
312
+ shellSnapshots: {
313
+ available: existsSync(shellSnapshotsDir),
314
+ count: shellSnapshotFiles.length,
315
+ },
316
+ mcpAuthCache: {
317
+ available: existsSync(mcpCacheFile),
318
+ entries: mcpEntries,
319
+ },
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Check whether a named environment variable is set (never returns its value).
325
+ * @param {string} name
326
+ * @returns {boolean}
327
+ */
328
+ export function hasSecret(name) {
329
+ return process.env[name] !== undefined && process.env[name] !== '';
330
+ }
331
+
332
+ // System env var patterns to exclude from listSecretNames
333
+ const SYSTEM_PREFIXES = [
334
+ 'npm_', 'NODE_', 'PATH', 'HOME', 'SHELL', 'USER', 'LOGNAME', 'TERM',
335
+ 'LANG', 'LC_', 'PWD', 'OLDPWD', 'SHLVL', 'HOSTNAME', 'MAIL',
336
+ 'XDG_', 'DBUS_', 'DISPLAY', 'COLORTERM', 'LESS', 'PAGER', 'EDITOR',
337
+ 'MANPATH', 'INFOPATH', 'LS_COLORS', 'PS1', 'PS2', 'IFS', '_',
338
+ 'REPL_', 'REPLIT_', 'NIX_', 'NIXPKGS_', 'LOCALE_', 'JAVA_',
339
+ ];
340
+
341
+ const KNOWN_SECRET_NAMES = [
342
+ 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'DATABASE_URL', 'REPLIT_DB_URL',
343
+ 'GITHUB_TOKEN', 'GITHUB_API_TOKEN', 'NPM_TOKEN', 'NPM_AUTH_TOKEN',
344
+ 'STRIPE_SECRET_KEY', 'STRIPE_API_KEY', 'AWS_ACCESS_KEY_ID',
345
+ 'AWS_SECRET_ACCESS_KEY', 'GOOGLE_API_KEY', 'GOOGLE_APPLICATION_CREDENTIALS',
346
+ 'FIREBASE_TOKEN', 'SUPABASE_KEY', 'SUPABASE_URL', 'POSTGRES_URL',
347
+ 'MONGODB_URI', 'REDIS_URL', 'SENDGRID_API_KEY', 'TWILIO_AUTH_TOKEN',
348
+ 'SLACK_BOT_TOKEN', 'DISCORD_TOKEN', 'VERCEL_TOKEN', 'CLOUDFLARE_API_TOKEN',
349
+ ];
350
+
351
+ function looksLikeSystemVar(name) {
352
+ for (const prefix of SYSTEM_PREFIXES) {
353
+ if (name.startsWith(prefix)) return true;
354
+ }
355
+ return false;
356
+ }
357
+
358
+ /**
359
+ * Return names of set secrets/credentials. Never returns values.
360
+ * @returns {string[]}
361
+ */
362
+ export function listSecretNames() {
363
+ const result = new Set();
364
+
365
+ // Check known secrets first
366
+ for (const name of KNOWN_SECRET_NAMES) {
367
+ if (hasSecret(name)) result.add(name);
368
+ }
369
+
370
+ // Find other non-system env vars that look like secrets
371
+ for (const name of Object.keys(process.env)) {
372
+ if (result.has(name)) continue;
373
+ if (looksLikeSystemVar(name)) continue;
374
+ // Heuristic: name contains KEY, TOKEN, SECRET, PASSWORD, PASS, URL, CREDENTIAL
375
+ if (/KEY|TOKEN|SECRET|PASS(WORD)?|CREDENTIAL|SALT|PRIVATE/i.test(name)) {
376
+ if (hasSecret(name)) result.add(name);
377
+ }
378
+ }
379
+
380
+ return [...result].sort();
381
+ }
382
+
383
+ /**
384
+ * Read the session archive from replit-tools directly.
385
+ * @param {string} [cwd]
386
+ * @returns {{ sessions: Array<{id, path, size, lastModified}>, totalSessions, latestTimestamp }}
387
+ */
388
+ export function getSessionArchive(cwd = process.cwd()) {
389
+ const toolsDir = findReplitToolsDir(resolve(cwd));
390
+ if (!toolsDir) {
391
+ return { sessions: [], totalSessions: 0, latestTimestamp: null };
392
+ }
393
+
394
+ const archiveBase = join(toolsDir, '.session-archive', 'claude');
395
+ if (!existsSync(archiveBase)) {
396
+ return { sessions: [], totalSessions: 0, latestTimestamp: null };
397
+ }
398
+
399
+ const sessions = [];
400
+
401
+ function scanDir(dir) {
402
+ for (const entry of safeReaddir(dir)) {
403
+ const full = join(dir, entry);
404
+ const st = safeStat(full);
405
+ if (!st) continue;
406
+ if (st.isDirectory()) {
407
+ scanDir(full);
408
+ } else if (entry.endsWith('.jsonl')) {
409
+ sessions.push({
410
+ id: entry.replace(/\.jsonl$/, ''),
411
+ path: full,
412
+ size: st.size,
413
+ lastModified: new Date(st.mtimeMs).toISOString(),
414
+ });
415
+ }
416
+ }
417
+ }
418
+
419
+ scanDir(archiveBase);
420
+ sessions.sort((a, b) => b.lastModified.localeCompare(a.lastModified));
421
+
422
+ const latestTimestamp = sessions.length > 0 ? sessions[0].lastModified : null;
423
+
424
+ return {
425
+ sessions,
426
+ totalSessions: sessions.length,
427
+ latestTimestamp,
428
+ };
429
+ }
430
+
431
+ /**
432
+ * Get auth status from the claude-auth-refresh.sh script.
433
+ * @param {string} [cwd]
434
+ * @returns {{ available, tokenStatus, expiresAt, needsRefresh }}
435
+ */
436
+ export function getAuthStatus(cwd = process.cwd()) {
437
+ const toolsDir = findReplitToolsDir(resolve(cwd));
438
+ if (!toolsDir) return { available: false };
439
+
440
+ const script = join(toolsDir, 'scripts', 'claude-auth-refresh.sh');
441
+ if (!existsSync(script)) return { available: false };
442
+
443
+ try {
444
+ const result = spawnSync('bash', [script, '--status'], {
445
+ encoding: 'utf8',
446
+ timeout: 10000,
447
+ stdio: ['ignore', 'pipe', 'pipe'],
448
+ });
449
+
450
+ if (result.status !== 0 && !result.stdout) {
451
+ return { available: true, tokenStatus: 'unknown', expiresAt: null, needsRefresh: false };
452
+ }
453
+
454
+ const output = (result.stdout || '') + (result.stderr || '');
455
+
456
+ // Parse common status patterns from the script output
457
+ let tokenStatus = 'unknown';
458
+ let expiresAt = null;
459
+ let needsRefresh = false;
460
+
461
+ if (/valid|ok|authenticated/i.test(output)) tokenStatus = 'valid';
462
+ else if (/expired|invalid|missing/i.test(output)) tokenStatus = 'expired';
463
+ else if (/refresh/i.test(output)) { tokenStatus = 'expiring'; needsRefresh = true; }
464
+
465
+ const expiresMatch = output.match(/expires[:\s]+([^\n]+)/i);
466
+ if (expiresMatch) expiresAt = expiresMatch[1].trim();
467
+
468
+ if (/need.*refresh|should.*refresh/i.test(output)) needsRefresh = true;
469
+
470
+ return { available: true, tokenStatus, expiresAt, needsRefresh };
471
+ } catch {
472
+ return { available: true, tokenStatus: 'unknown', expiresAt: null, needsRefresh: false };
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Read replit-tools config.json settings that dual-brain should respect.
478
+ * @param {string} [cwd]
479
+ * @returns {{ recentWindowHours, persistenceDays, mirror, raw } | null}
480
+ */
481
+ export function getReplitToolsConfig(cwd = process.cwd()) {
482
+ const toolsDir = findReplitToolsDir(resolve(cwd));
483
+ if (!toolsDir) return null;
484
+
485
+ const config = safeJson(join(toolsDir, 'config.json'));
486
+ if (!config) return null;
487
+
488
+ return {
489
+ recentWindowHours: config.recentWindowHours ?? 48,
490
+ persistenceDays: config.persistenceDays ?? 365,
491
+ mirror: config.mirror ?? null,
492
+ raw: config,
493
+ };
494
+ }
495
+
496
+ // ─── Section 2: Planning ──────────────────────────────────────────────────────
497
+
498
+ const DUAL_BRAIN_HIDDEN = ['.dualbrain', '.replit-tools', '.dual-brain', 'node_modules'];
499
+
500
+ /**
501
+ * Plan .replit config changes needed to reach a desired state.
502
+ * No side effects — returns a plan object only.
503
+ * @param {object} [desired]
504
+ * @param {string} [cwd]
505
+ * @returns {{ changes, summary, riskLevel, preserves }}
506
+ */
507
+ export function planReplitConfig(desired = {}, cwd = process.cwd()) {
508
+ const current = inspectReplitConfig(resolve(cwd));
509
+ const changes = [];
510
+ const preserves = [];
511
+
512
+ // Track what we're keeping
513
+ if (Object.keys(current.nix).length) preserves.push('existing nix config');
514
+ if (current.modules.length) preserves.push(`modules: ${current.modules.join(', ')}`);
515
+ if (Object.keys(current.deployment).length) preserves.push('existing deployment config');
516
+
517
+ // 1. Remove expertMode = true if set (suppresses random shell noise)
518
+ const wantExpertMode = desired.expertMode ?? false;
519
+ if (current.expertMode === true && !wantExpertMode) {
520
+ changes.push({
521
+ key: 'expertMode',
522
+ action: 'remove',
523
+ reason: 'prevents random shell spawning in Replit agent',
524
+ risk: 'medium',
525
+ });
526
+ }
527
+
528
+ // 2. hidden array — add dual-brain entries that are missing
529
+ const currentHidden = new Set(current.hidden);
530
+ const desiredHidden = desired.hidden ?? DUAL_BRAIN_HIDDEN;
531
+ const missingHidden = desiredHidden.filter(h => !currentHidden.has(h));
532
+ if (missingHidden.length) {
533
+ const merged = [...new Set([...current.hidden, ...desiredHidden])];
534
+ changes.push({
535
+ key: 'hidden',
536
+ action: 'add',
537
+ value: merged,
538
+ adds: missingHidden,
539
+ risk: 'low',
540
+ });
541
+ }
542
+
543
+ // 3. onBoot — ensure dual-brain is mentioned; if missing entirely, suggest adding
544
+ if (desired.onBoot !== undefined) {
545
+ if (current.onBoot !== desired.onBoot) {
546
+ changes.push({
547
+ key: 'onBoot',
548
+ action: 'set',
549
+ value: desired.onBoot,
550
+ previous: current.onBoot,
551
+ risk: 'low',
552
+ });
553
+ }
554
+ } else if (!current.hasOnBoot) {
555
+ // Suggest a sensible default
556
+ const suggestedOnBoot = 'source .replit-tools/scripts/setup-claude-code.sh 2>/dev/null || true';
557
+ changes.push({
558
+ key: 'onBoot',
559
+ action: 'set',
560
+ value: suggestedOnBoot,
561
+ reason: 'ensure replit-tools auth persistence on container restart',
562
+ risk: 'low',
563
+ });
564
+ }
565
+
566
+ // 4. run — remove if it's a trivial/noop command; preserve if it looks like a dev server
567
+ if (current.hasRun && desired.removeRun !== false) {
568
+ const runVal = current.run || '';
569
+ const isTrivial = /^(echo|true|:|#|dual-brain|npx.*dual-brain)/i.test(runVal.trim());
570
+ if (isTrivial) {
571
+ changes.push({
572
+ key: 'run',
573
+ action: 'remove',
574
+ reason: 'vibe coders use `dual-brain go`, not the Run button — trivial command removed',
575
+ previous: runVal,
576
+ risk: 'medium',
577
+ });
578
+ } else {
579
+ preserves.push(`run command: "${runVal.slice(0, 60)}${runVal.length > 60 ? '…' : ''}"`);
580
+ }
581
+ }
582
+
583
+ // Compute overall risk
584
+ const risks = changes.map(c => c.risk);
585
+ let riskLevel = 'low';
586
+ if (risks.includes('high')) riskLevel = 'high';
587
+ else if (risks.includes('medium')) riskLevel = 'medium';
588
+
589
+ // Summary
590
+ const actionSummary = changes.map(c => {
591
+ if (c.action === 'remove') return `remove ${c.key}`;
592
+ if (c.action === 'add') return `add ${c.adds?.join(', ')} to ${c.key}`;
593
+ if (c.action === 'set') return `set ${c.key}`;
594
+ return `${c.action} ${c.key}`;
595
+ });
596
+
597
+ const summary = changes.length === 0
598
+ ? 'No changes needed — .replit is already optimal for dual-brain.'
599
+ : `${changes.length} change${changes.length > 1 ? 's' : ''}: ${actionSummary.join('; ')}.`;
600
+
601
+ return { changes, summary, riskLevel, preserves };
602
+ }
603
+
604
+ // ─── Section 3: Apply ─────────────────────────────────────────────────────────
605
+
606
+ /**
607
+ * Apply a planned change set to the .replit file.
608
+ * Only applies low-risk changes by default (skipMedium = false applies medium too).
609
+ * Preserves original file structure — patches in-place where possible.
610
+ *
611
+ * @param {Array} changes — from planReplitConfig
612
+ * @param {string} cwd
613
+ * @param {{ skipMedium?: boolean }} options
614
+ * @returns {string[]} list of applied change keys
615
+ */
616
+ function applyReplitChanges(changes, cwd, { skipMedium = false } = {}) {
617
+ if (!changes.length) return [];
618
+
619
+ const replitPath = join(resolve(cwd), '.replit');
620
+ const raw = safeRead(replitPath) || '';
621
+ let lines = raw.split('\n');
622
+ const applied = [];
623
+
624
+ for (const change of changes) {
625
+ if (change.risk === 'high') continue;
626
+ if (change.risk === 'medium' && skipMedium) continue;
627
+
628
+ if (change.key === 'expertMode' && change.action === 'remove') {
629
+ // Remove the [agent] section lines containing expertMode
630
+ const newLines = [];
631
+ let inAgentSection = false;
632
+ let removedExpertMode = false;
633
+
634
+ for (const line of lines) {
635
+ const trimmed = line.trim();
636
+ if (trimmed === '[agent]') {
637
+ inAgentSection = true;
638
+ // Only include if there are other keys besides expertMode
639
+ // We'll add back if needed after scanning
640
+ newLines.push(line);
641
+ continue;
642
+ }
643
+ if (inAgentSection && trimmed.startsWith('[') && trimmed.endsWith(']')) {
644
+ inAgentSection = false;
645
+ }
646
+ if (inAgentSection && /^expertMode\s*=/.test(trimmed)) {
647
+ removedExpertMode = true;
648
+ continue; // skip this line
649
+ }
650
+ newLines.push(line);
651
+ }
652
+
653
+ if (removedExpertMode) {
654
+ // Clean up empty [agent] section
655
+ lines = cleanEmptySection(newLines, 'agent');
656
+ applied.push('expertMode');
657
+ }
658
+ }
659
+
660
+ else if (change.key === 'hidden' && change.action === 'add') {
661
+ const valueStr = formatTomlArray(change.value);
662
+ const replaced = replaceOrInsertTopLevel(lines, 'hidden', valueStr);
663
+ lines = replaced;
664
+ applied.push('hidden');
665
+ }
666
+
667
+ else if (change.key === 'onBoot' && change.action === 'set') {
668
+ const valueStr = `"${change.value}"`;
669
+ const replaced = replaceOrInsertTopLevel(lines, 'onBoot', valueStr);
670
+ lines = replaced;
671
+ applied.push('onBoot');
672
+ }
673
+
674
+ else if (change.key === 'run' && change.action === 'remove') {
675
+ lines = lines.filter(l => !/^run\s*=/.test(l.trim()));
676
+ applied.push('run');
677
+ }
678
+ }
679
+
680
+ if (applied.length) {
681
+ const newContent = lines.join('\n');
682
+ const tmp = replitPath + '.tmp.' + process.pid;
683
+ try {
684
+ writeFileSync(tmp, newContent);
685
+ renameSync(tmp, replitPath);
686
+ } catch (err) {
687
+ try { require('node:fs').unlinkSync(tmp); } catch { /* ignore */ }
688
+ throw err;
689
+ }
690
+ }
691
+
692
+ return applied;
693
+ }
694
+
695
+ function formatTomlArray(items) {
696
+ return '[' + items.map(i => `"${i}"`).join(', ') + ']';
697
+ }
698
+
699
+ function replaceOrInsertTopLevel(lines, key, valueStr) {
700
+ const regex = new RegExp(`^${key}\\s*=`);
701
+ let found = false;
702
+ const result = lines.map(line => {
703
+ if (regex.test(line.trim())) {
704
+ found = true;
705
+ return `${key} = ${valueStr}`;
706
+ }
707
+ return line;
708
+ });
709
+ if (!found) {
710
+ // Insert before first section header or at end
711
+ const firstSection = result.findIndex(l => /^\s*\[/.test(l));
712
+ if (firstSection > 0) {
713
+ result.splice(firstSection, 0, `${key} = ${valueStr}`);
714
+ } else {
715
+ result.push(`${key} = ${valueStr}`);
716
+ }
717
+ }
718
+ return result;
719
+ }
720
+
721
+ function cleanEmptySection(lines, sectionName) {
722
+ const header = `[${sectionName}]`;
723
+ const result = [];
724
+ let i = 0;
725
+ while (i < lines.length) {
726
+ const trimmed = lines[i].trim();
727
+ if (trimmed === header) {
728
+ // Look ahead: if next non-blank line is another section or EOF, skip the header
729
+ let j = i + 1;
730
+ while (j < lines.length && !lines[j].trim()) j++;
731
+ const nextIsSectionOrEnd = j >= lines.length || /^\[/.test(lines[j].trim());
732
+ if (nextIsSectionOrEnd) {
733
+ // Remove blank lines between removed header and next section
734
+ while (result.length && !result[result.length - 1].trim()) result.pop();
735
+ i = j;
736
+ continue;
737
+ }
738
+ }
739
+ result.push(lines[i]);
740
+ i++;
741
+ }
742
+ return result;
743
+ }
744
+
745
+ /**
746
+ * Main integration function: detect, inspect, plan, optionally apply.
747
+ * @param {{ dryRun?: boolean, cwd?: string, skipMedium?: boolean }} options
748
+ * @returns {{ environment, replitTools, config, plan, applied, report }}
749
+ */
750
+ export function initReplitIntegration({ dryRun = false, cwd = process.cwd() } = {}) {
751
+ const resolvedCwd = resolve(cwd);
752
+
753
+ const environment = detectReplitEnvironment(resolvedCwd);
754
+ const config = inspectReplitConfig(resolvedCwd);
755
+ const replitTools = inspectReplitTools(resolvedCwd);
756
+ const toolsConfig = getReplitToolsConfig(resolvedCwd);
757
+
758
+ // Plan optimal config
759
+ const plan = planReplitConfig({}, resolvedCwd);
760
+
761
+ let applied = [];
762
+ if (!dryRun && plan.changes.length) {
763
+ try {
764
+ applied = applyReplitChanges(plan.changes, resolvedCwd);
765
+ } catch (err) {
766
+ applied = [];
767
+ }
768
+ }
769
+
770
+ const report = {
771
+ environment,
772
+ replitTools: {
773
+ ...replitTools,
774
+ toolsConfig,
775
+ },
776
+ config,
777
+ plan,
778
+ applied,
779
+ dryRun,
780
+ };
781
+
782
+ return report;
783
+ }
784
+
785
+ /**
786
+ * Thin escape hatch to run the replit CLI.
787
+ * @param {string[]} args
788
+ * @param {{ timeout?: number }} options
789
+ * @returns {{ ok, stdout, stderr }}
790
+ */
791
+ export function runReplitCli(args, options = {}) {
792
+ const timeout = options.timeout ?? 30000;
793
+ try {
794
+ const whichResult = spawnSync('which', ['replit'], { encoding: 'utf8' });
795
+ if (whichResult.status !== 0) {
796
+ return { ok: false, stdout: '', stderr: 'replit CLI not found in PATH' };
797
+ }
798
+
799
+ const result = spawnSync('replit', args, {
800
+ encoding: 'utf8',
801
+ timeout,
802
+ stdio: ['ignore', 'pipe', 'pipe'],
803
+ });
804
+
805
+ return {
806
+ ok: result.status === 0,
807
+ stdout: result.stdout || '',
808
+ stderr: result.stderr || '',
809
+ };
810
+ } catch (err) {
811
+ return { ok: false, stdout: '', stderr: err.message };
812
+ }
813
+ }
814
+
815
+ // ─── Section 4: Formatters ────────────────────────────────────────────────────
816
+
817
+ /**
818
+ * Pretty-print the integration report for TUI/dashboard display.
819
+ * @param {object} report — from initReplitIntegration
820
+ * @returns {string}
821
+ */
822
+ export function formatReplitReport(report) {
823
+ const { environment, replitTools, config, plan, applied, dryRun } = report;
824
+ const lines = [];
825
+
826
+ // Environment
827
+ const envLabel = environment.isReplit ? 'Replit' : environment.containerType;
828
+ const uptimeLabel = environment.uptimeSeconds != null
829
+ ? ` (up ${Math.floor(environment.uptimeSeconds / 60)}m)`
830
+ : '';
831
+ lines.push(`Environment: ${envLabel}${uptimeLabel}`);
832
+ if (environment.nixChannel) lines.push(` nix: ${environment.nixChannel}`);
833
+
834
+ // replit-tools
835
+ if (replitTools.installed) {
836
+ const ver = replitTools.version ? `v${replitTools.version}` : 'installed';
837
+ lines.push(`replit-tools: ${ver}`);
838
+
839
+ const { sessionArchive, codexPlugins, shellSnapshots, mcpAuthCache } = replitTools;
840
+ if (sessionArchive.exists) {
841
+ const tsLabel = sessionArchive.latestTimestamp
842
+ ? ` latest: ${new Date(sessionArchive.latestTimestamp).toLocaleDateString()}`
843
+ : '';
844
+ lines.push(` sessions: ${sessionArchive.sessionCount}${tsLabel}`);
845
+ }
846
+ if (codexPlugins.count > 0) lines.push(` codex plugins: ${codexPlugins.count}`);
847
+ if (shellSnapshots.count > 0) lines.push(` shell snapshots: ${shellSnapshots.count}`);
848
+ if (mcpAuthCache.entries > 0) lines.push(` mcp cached: ${mcpAuthCache.entries} servers`);
849
+ if (replitTools.toolsConfig) {
850
+ lines.push(` session window: ${replitTools.toolsConfig.recentWindowHours}h`);
851
+ }
852
+ } else {
853
+ lines.push('replit-tools: not found');
854
+ }
855
+
856
+ // Current .replit
857
+ lines.push('.replit:');
858
+ if (config.raw === null) {
859
+ lines.push(' (not found)');
860
+ } else {
861
+ if (config.hasExpertMode) lines.push(` expertMode: ${config.expertMode}`);
862
+ if (config.hidden.length) lines.push(` hidden: ${config.hidden.join(', ')}`);
863
+ if (config.hasOnBoot) lines.push(` onBoot: ${(config.onBoot || '').slice(0, 60)}…`);
864
+ if (config.modules.length) lines.push(` modules: ${config.modules.join(', ')}`);
865
+ }
866
+
867
+ // Plan
868
+ if (plan.changes.length === 0) {
869
+ lines.push('Config: already optimal');
870
+ } else {
871
+ lines.push(`Plan (${dryRun ? 'dry-run' : plan.riskLevel} risk):`);
872
+ for (const c of plan.changes) {
873
+ const prefix = ` [${c.risk}]`;
874
+ if (c.action === 'remove') lines.push(`${prefix} remove ${c.key} — ${c.reason || ''}`);
875
+ else if (c.action === 'add') lines.push(`${prefix} add to ${c.key}: ${c.adds?.join(', ')}`);
876
+ else if (c.action === 'set') lines.push(`${prefix} set ${c.key}`);
877
+ }
878
+ if (plan.preserves.length) lines.push(` preserves: ${plan.preserves.join('; ')}`);
879
+ }
880
+
881
+ // Applied
882
+ if (!dryRun && applied.length > 0) {
883
+ lines.push(`Applied: ${applied.join(', ')}`);
884
+ } else if (!dryRun && plan.changes.length > 0) {
885
+ lines.push('Applied: none (errors or all changes were medium/high risk)');
886
+ }
887
+
888
+ return lines.join('\n');
889
+ }
890
+
891
+ // ─── Section 5: Plugin Inventory ──────────────────────────────────────────────
892
+
893
+ /** In-process cache for plugin inventory (plugins don't change during a session). */
894
+ let _pluginInventoryCache = null;
895
+
896
+ /**
897
+ * Parse YAML-style frontmatter from a SKILL.md string.
898
+ * Returns { name, description, metadata } — all optional.
899
+ * @param {string} content
900
+ * @returns {{ name?: string, description?: string, metadata?: object }}
901
+ */
902
+ function _parseFrontmatter(content) {
903
+ if (!content || !content.startsWith('---')) return {};
904
+ const end = content.indexOf('\n---', 3);
905
+ if (end === -1) return {};
906
+ const fm = content.slice(3, end).trim();
907
+ const result = {};
908
+ for (const line of fm.split('\n')) {
909
+ const colon = line.indexOf(':');
910
+ if (colon === -1) continue;
911
+ const key = line.slice(0, colon).trim();
912
+ const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
913
+ if (key === 'name') result.name = val;
914
+ else if (key === 'description') result.description = val;
915
+ }
916
+ return result;
917
+ }
918
+
919
+ /**
920
+ * Scan the Codex plugin directory and return a structured inventory.
921
+ * Reads each plugin's skills subdirectories for SKILL.md (name, description, capabilities).
922
+ * Result is cached after the first call.
923
+ *
924
+ * @param {string} [cwd]
925
+ * @returns {{ plugins: Array<{ id, name, description, capabilities, skillNames, path }>, count }}
926
+ */
927
+ export function getPluginInventory(cwd = process.cwd()) {
928
+ if (_pluginInventoryCache) return _pluginInventoryCache;
929
+
930
+ const toolsDir = findReplitToolsDir(resolve(cwd));
931
+ if (!toolsDir) {
932
+ _pluginInventoryCache = { plugins: [], count: 0 };
933
+ return _pluginInventoryCache;
934
+ }
935
+
936
+ const pluginsDir = join(toolsDir, '.codex-persistent', '.tmp', 'plugins', 'plugins');
937
+ if (!existsSync(pluginsDir)) {
938
+ _pluginInventoryCache = { plugins: [], count: 0 };
939
+ return _pluginInventoryCache;
940
+ }
941
+
942
+ const plugins = [];
943
+
944
+ for (const pluginId of safeReaddir(pluginsDir)) {
945
+ const pluginPath = join(pluginsDir, pluginId);
946
+ const st = safeStat(pluginPath);
947
+ if (!st || !st.isDirectory()) continue;
948
+
949
+ const skillsDir = join(pluginPath, 'skills');
950
+ const skillDirs = existsSync(skillsDir) ? safeReaddir(skillsDir) : [];
951
+
952
+ let pluginName = pluginId;
953
+ let pluginDescription = '';
954
+ const capabilities = [];
955
+ const skillNames = [];
956
+
957
+ for (const skillDir of skillDirs) {
958
+ const skillPath = join(skillsDir, skillDir);
959
+ const skillSt = safeStat(skillPath);
960
+ if (!skillSt || !skillSt.isDirectory()) continue;
961
+
962
+ const skillMdPath = join(skillPath, 'SKILL.md');
963
+ const skillContent = safeRead(skillMdPath);
964
+ if (!skillContent) continue;
965
+
966
+ const fm = _parseFrontmatter(skillContent);
967
+
968
+ // Use the first skill's name/description as the plugin's primary identity
969
+ if (fm.name && pluginName === pluginId) pluginName = fm.name;
970
+ if (fm.description && !pluginDescription) pluginDescription = fm.description;
971
+
972
+ // Collect all skill names as capabilities
973
+ if (fm.name) {
974
+ skillNames.push(fm.name);
975
+ capabilities.push(fm.name);
976
+ } else {
977
+ skillNames.push(skillDir);
978
+ capabilities.push(skillDir);
979
+ }
980
+
981
+ // Extract additional capabilities from description keywords
982
+ if (fm.description) {
983
+ // Pull out words in "Triggers: X, Y, Z" format if present
984
+ const triggerMatch = fm.description.match(/[Tt]riggers?:\s*([^.]+)/);
985
+ if (triggerMatch) {
986
+ const triggers = triggerMatch[1].split(/[,;]+/).map(s => s.trim()).filter(s => s.length > 1 && s.length < 30);
987
+ capabilities.push(...triggers);
988
+ }
989
+ }
990
+ }
991
+
992
+ plugins.push({
993
+ id: pluginId,
994
+ name: pluginName,
995
+ description: pluginDescription,
996
+ capabilities: [...new Set(capabilities)],
997
+ skillNames,
998
+ path: pluginPath,
999
+ });
1000
+ }
1001
+
1002
+ _pluginInventoryCache = { plugins, count: plugins.length };
1003
+ return _pluginInventoryCache;
1004
+ }
1005
+
1006
+ /**
1007
+ * Match plugins to a task description using keyword matching.
1008
+ * Returns plugins sorted by relevance score (descending).
1009
+ *
1010
+ * @param {string} taskDescription
1011
+ * @param {Array<{ id, name, description, capabilities, skillNames, path }>} [plugins]
1012
+ * @param {string} [cwd]
1013
+ * @returns {Array<{ plugin: object, relevance: number, reason: string }>}
1014
+ */
1015
+ export function matchPluginsForTask(taskDescription, plugins, cwd = process.cwd()) {
1016
+ if (!taskDescription) return [];
1017
+
1018
+ const inventory = plugins ?? getPluginInventory(cwd).plugins;
1019
+ if (!inventory || inventory.length === 0) return [];
1020
+
1021
+ const desc = taskDescription.toLowerCase();
1022
+ const results = [];
1023
+
1024
+ for (const plugin of inventory) {
1025
+ let score = 0;
1026
+ const reasons = [];
1027
+
1028
+ // Check plugin id (e.g. "stripe" in "check stripe webhook") — highest weight
1029
+ const idLower = plugin.id.toLowerCase();
1030
+ if (desc.includes(idLower)) {
1031
+ score += 3;
1032
+ reasons.push(`plugin id "${plugin.id}" mentioned`);
1033
+ }
1034
+
1035
+ // Check plugin name
1036
+ const nameLower = plugin.name.toLowerCase();
1037
+ if (nameLower !== idLower && desc.includes(nameLower)) {
1038
+ score += 2;
1039
+ reasons.push(`plugin name "${plugin.name}" mentioned`);
1040
+ }
1041
+
1042
+ // Check description keywords (≥4 chars to avoid noise)
1043
+ if (plugin.description) {
1044
+ const descWords = plugin.description
1045
+ .toLowerCase()
1046
+ .split(/\W+/)
1047
+ .filter(w => w.length >= 4);
1048
+ for (const word of descWords) {
1049
+ if (desc.includes(word)) {
1050
+ score += 1;
1051
+ reasons.push(`keyword "${word}"`);
1052
+ break; // one hit per description is enough
1053
+ }
1054
+ }
1055
+ }
1056
+
1057
+ // Check skill names
1058
+ for (const skill of plugin.skillNames) {
1059
+ if (desc.includes(skill.toLowerCase())) {
1060
+ score += 2;
1061
+ reasons.push(`skill "${skill}" mentioned`);
1062
+ break;
1063
+ }
1064
+ }
1065
+
1066
+ // Check capabilities
1067
+ for (const cap of plugin.capabilities) {
1068
+ if (cap.length >= 4 && desc.includes(cap.toLowerCase())) {
1069
+ score += 1;
1070
+ reasons.push(`capability "${cap}" matched`);
1071
+ break;
1072
+ }
1073
+ }
1074
+
1075
+ if (score > 0) {
1076
+ results.push({
1077
+ plugin,
1078
+ relevance: score,
1079
+ reason: reasons.slice(0, 3).join('; '),
1080
+ });
1081
+ }
1082
+ }
1083
+
1084
+ return results.sort((a, b) => b.relevance - a.relevance);
1085
+ }
1086
+
1087
+ // ─── Section 6: Session Archive Search ────────────────────────────────────────
1088
+
1089
+ /**
1090
+ * Search the Claude session archive for keyword matches in user messages.
1091
+ * Reads session files line by line to avoid loading full files into memory.
1092
+ * Results are recency-weighted: today ×2, this week ×1.5, older ×1.
1093
+ *
1094
+ * @param {string} query
1095
+ * @param {{ limit?: number, days?: number }} [options]
1096
+ * @param {string} [cwd]
1097
+ * @returns {Promise<Array<{ sessionId, date, matchingMessage, relevance }>>}
1098
+ */
1099
+ export async function searchSessionArchive(query, options = {}, cwd = process.cwd()) {
1100
+ const { limit = 5, days = 30 } = options;
1101
+
1102
+ if (!query) return [];
1103
+
1104
+ const toolsDir = findReplitToolsDir(resolve(cwd));
1105
+ if (!toolsDir) return [];
1106
+
1107
+ const archiveBase = join(toolsDir, '.session-archive', 'claude');
1108
+ if (!existsSync(archiveBase)) return [];
1109
+
1110
+ const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 2);
1111
+ if (queryTerms.length === 0) return [];
1112
+
1113
+ const now = Date.now();
1114
+ const cutoffMs = now - days * 24 * 60 * 60 * 1000;
1115
+ const oneDayMs = 24 * 60 * 60 * 1000;
1116
+ const oneWeekMs = 7 * oneDayMs;
1117
+
1118
+ // Collect JSONL session files (not history.jsonl which has different format)
1119
+ const sessionFiles = [];
1120
+
1121
+ function collectJsonl(dir) {
1122
+ try {
1123
+ for (const entry of safeReaddir(dir)) {
1124
+ if (entry === 'history.jsonl') continue; // skip — different structure
1125
+ const full = join(dir, entry);
1126
+ const st = safeStat(full);
1127
+ if (!st) continue;
1128
+ if (st.isDirectory()) {
1129
+ collectJsonl(full);
1130
+ } else if (entry.endsWith('.jsonl')) {
1131
+ if (st.mtimeMs >= cutoffMs) {
1132
+ sessionFiles.push({ path: full, mtime: st.mtimeMs });
1133
+ }
1134
+ }
1135
+ }
1136
+ } catch { /* ignore unreadable dirs */ }
1137
+ }
1138
+
1139
+ collectJsonl(archiveBase);
1140
+
1141
+ if (sessionFiles.length === 0) return [];
1142
+
1143
+ // Sort newest first so we hit the most relevant sessions early
1144
+ sessionFiles.sort((a, b) => b.mtime - a.mtime);
1145
+
1146
+ const matches = [];
1147
+
1148
+ for (const { path: filePath, mtime } of sessionFiles) {
1149
+ // Age-based recency weight
1150
+ const ageMs = now - mtime;
1151
+ const recency = ageMs < oneDayMs ? 2.0 : ageMs < oneWeekMs ? 1.5 : 1.0;
1152
+
1153
+ // Derive sessionId from filename
1154
+ const sessionId = filePath.split('/').pop().replace(/\.jsonl$/, '');
1155
+ const date = new Date(mtime).toISOString().slice(0, 10);
1156
+
1157
+ let fileMatched = false;
1158
+
1159
+ await new Promise((resolveFn) => {
1160
+ try {
1161
+ const rl = createInterface({
1162
+ input: createReadStream(filePath, { encoding: 'utf8' }),
1163
+ crlfDelay: Infinity,
1164
+ });
1165
+
1166
+ rl.on('line', (line) => {
1167
+ if (!line || fileMatched) return;
1168
+ try {
1169
+ const entry = JSON.parse(line);
1170
+
1171
+ // Only look at user messages
1172
+ if (entry.type !== 'user') return;
1173
+ if (entry.isMeta) return; // skip meta/command-caveat lines
1174
+
1175
+ const content = entry.message?.content;
1176
+ if (!content || typeof content !== 'string') return;
1177
+ if (content.length < 3) return;
1178
+
1179
+ const contentLower = content.toLowerCase();
1180
+ let termScore = 0;
1181
+
1182
+ for (const term of queryTerms) {
1183
+ if (contentLower.includes(term)) termScore++;
1184
+ }
1185
+
1186
+ if (termScore === 0) return;
1187
+
1188
+ const relevance = Math.round(termScore * recency * 10) / 10;
1189
+ const snippet = content.length > 120 ? content.slice(0, 120) + '…' : content;
1190
+
1191
+ matches.push({ sessionId, date, matchingMessage: snippet, relevance });
1192
+ fileMatched = true; // one match per session file is enough for the index
1193
+ } catch { /* skip malformed lines */ }
1194
+ });
1195
+
1196
+ rl.on('close', resolveFn);
1197
+ rl.on('error', resolveFn);
1198
+ } catch {
1199
+ resolveFn();
1200
+ }
1201
+ });
1202
+
1203
+ // Early exit once we have plenty of candidates
1204
+ if (matches.length >= limit * 4) break;
1205
+ }
1206
+
1207
+ // Sort by relevance descending, return top `limit`
1208
+ matches.sort((a, b) => b.relevance - a.relevance);
1209
+ return matches.slice(0, limit);
1210
+ }