total-recall-brain 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/bin/antigravity.mjs +212 -0
  4. package/bin/init.mjs +36 -0
  5. package/bin/oci-sniper.sh +69 -0
  6. package/bin/total-recall.mjs +229 -0
  7. package/docs/ARCHITECTURE.md +251 -0
  8. package/docs/IDE_INTEGRATION.md +112 -0
  9. package/package.json +71 -0
  10. package/scaffold/.agent/skills/total-recall/SKILL.md +195 -0
  11. package/scaffold/.agent/skills/total-recall/evals/evals.json +20 -0
  12. package/scaffold/.agent/skills/total-recall/memory-vault/concepts/cli-help-reference.md +145 -0
  13. package/scaffold/.agent/skills/total-recall/memory-vault/invariants/operating-instructions.md +75 -0
  14. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/always-websearch-gap.md +16 -0
  15. package/scaffold/.agent/skills/total-recall/memory-vault/preferences/topic-research-sop.md +17 -0
  16. package/scaffold/.agent/skills/total-recall/references/api-reference.md +187 -0
  17. package/scaffold/.agent/skills/total-recall/references/architecture-reference.md +81 -0
  18. package/scaffold/.agent/skills/total-recall/references/cli-reference.md +103 -0
  19. package/scaffold/.agent/skills/total-recall/references/ssss-reference.md +88 -0
  20. package/scaffold/.agent/skills/total-recall/references/troubleshooting.md +68 -0
  21. package/scaffold/.agent/skills/total-recall/scripts/sync-repo.mjs +83 -0
  22. package/scaffold/.agent/skills/total-recall/skills/cli-agents/SKILL.md +122 -0
  23. package/scaffold/.agent/skills/total-recall/skills/cli-agents/agents.yml +27 -0
  24. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/antigravity.txt +20 -0
  25. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/claude.txt +58 -0
  26. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/codex.txt +81 -0
  27. package/scaffold/.agent/skills/total-recall/skills/cli-agents/references/gemini.txt +44 -0
  28. package/scaffold/.agent/skills/total-recall/skills/research/SKILL.md +337 -0
  29. package/scaffold/.agent/skills/total-recall/skills/skill/SKILL.md +281 -0
  30. package/scaffold/.agent/skills/total-recall/skills/skill/assets/skill-template/SKILL.md +20 -0
  31. package/scaffold/.agent/skills/total-recall/skills/skill/evals/evals.json +20 -0
  32. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-add.sh +14 -0
  33. package/scaffold/.agent/skills/total-recall/skills/skill/hooks/on-skill-delete.sh +14 -0
  34. package/scaffold/.agent/skills/total-recall/skills/skill/learnings/.gitkeep +0 -0
  35. package/scaffold/.agent/skills/total-recall/skills/skill/references/.gitkeep +0 -0
  36. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/.gitkeep +0 -0
  37. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/create-skill.sh +65 -0
  38. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/enforce-skill-optimization.mjs +135 -0
  39. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/find-skills.mjs +124 -0
  40. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/scan-skill.mjs +207 -0
  41. package/scaffold/.agent/skills/total-recall/skills/skill/scripts/skill-watcher.mjs +64 -0
  42. package/scaffold/.agent/skills/total-recall/skills/skill/subagents/audit-all-skills.md +62 -0
  43. package/scaffold/.agent/skills/total-recall/skills/ssss/SKILL.md +27 -0
  44. package/scaffold/.agent/skills/total-recall/skills/ssss/evals/evals.json +17 -0
  45. package/scaffold/.agent/skills/total-recall/skills/ssss/references/ssss-spec.md +117 -0
  46. package/scaffold/.agent/skills/total-recall/skills/ssss/scripts/validate-schema.mjs +54 -0
  47. package/scaffold/.agent/skills/total-recall/skills/ssss/subagents/audit-ssss-node.md +9 -0
  48. package/scaffold/.agent/skills/total-recall/subagents/total-recall-diagnostician.md +13 -0
  49. package/src/cli/agent-dir.mjs +105 -0
  50. package/src/cli/backup.mjs +313 -0
  51. package/src/cli/brain.mjs +224 -0
  52. package/src/cli/chat.mjs +74 -0
  53. package/src/cli/config.mjs +247 -0
  54. package/src/cli/connect.mjs +872 -0
  55. package/src/cli/daemon.mjs +228 -0
  56. package/src/cli/deploy-ui.mjs +1793 -0
  57. package/src/cli/deploy.mjs +1726 -0
  58. package/src/cli/doctor.mjs +254 -0
  59. package/src/cli/dream.mjs +80 -0
  60. package/src/cli/forget.mjs +92 -0
  61. package/src/cli/friction.mjs +21 -0
  62. package/src/cli/generate-pat.mjs +65 -0
  63. package/src/cli/hash-password.mjs +22 -0
  64. package/src/cli/help.mjs +153 -0
  65. package/src/cli/import-rules.mjs +102 -0
  66. package/src/cli/ingest/index.mjs +318 -0
  67. package/src/cli/ingest/parsers/chrome-bookmarks.mjs +238 -0
  68. package/src/cli/ingest/parsers/google-keep.mjs +147 -0
  69. package/src/cli/ingest/parsers/search-history.mjs +142 -0
  70. package/src/cli/ingest/parsers/youtube-history.mjs +181 -0
  71. package/src/cli/ingest/utils/dedup.mjs +69 -0
  72. package/src/cli/ingest/utils/takeout-walker.mjs +101 -0
  73. package/src/cli/ingest.mjs +102 -0
  74. package/src/cli/init.mjs +736 -0
  75. package/src/cli/integration-dispatcher.mjs +128 -0
  76. package/src/cli/lint.mjs +166 -0
  77. package/src/cli/map.mjs +194 -0
  78. package/src/cli/migrate.mjs +87 -0
  79. package/src/cli/rebuild.mjs +88 -0
  80. package/src/cli/recall.mjs +219 -0
  81. package/src/cli/relay.mjs +518 -0
  82. package/src/cli/remember.mjs +306 -0
  83. package/src/cli/research.mjs +381 -0
  84. package/src/cli/reset-password.mjs +75 -0
  85. package/src/cli/restore.mjs +135 -0
  86. package/src/cli/setup.mjs +616 -0
  87. package/src/cli/share.mjs +180 -0
  88. package/src/cli/skill.mjs +313 -0
  89. package/src/cli/snapshot.mjs +108 -0
  90. package/src/cli/start.mjs +63 -0
  91. package/src/cli/status.mjs +296 -0
  92. package/src/cli/sync.mjs +383 -0
  93. package/src/cli/uninstall.mjs +613 -0
  94. package/src/cli/upgrade.mjs +141 -0
  95. package/src/cli/wizard.html +3682 -0
  96. package/src/core/blackboard.mjs +54 -0
  97. package/src/core/clarity-rewriter.mjs +770 -0
  98. package/src/core/conclusion-writer.mjs +259 -0
  99. package/src/core/config.mjs +277 -0
  100. package/src/core/conflict-detector.mjs +536 -0
  101. package/src/core/crypto.mjs +107 -0
  102. package/src/core/daemon-control.mjs +158 -0
  103. package/src/core/daemon-loop.mjs +364 -0
  104. package/src/core/dream.mjs +347 -0
  105. package/src/core/drift-detector.mjs +143 -0
  106. package/src/core/embeddings.mjs +632 -0
  107. package/src/core/emergency-alerts.mjs +137 -0
  108. package/src/core/evolution.mjs +107 -0
  109. package/src/core/fact-seeker.mjs +1602 -0
  110. package/src/core/friction.mjs +95 -0
  111. package/src/core/import-rules.mjs +308 -0
  112. package/src/core/inference-engine.mjs +367 -0
  113. package/src/core/logger.mjs +75 -0
  114. package/src/core/memory-layers.mjs +96 -0
  115. package/src/core/migrate.mjs +116 -0
  116. package/src/core/notifications.mjs +77 -0
  117. package/src/core/operation-validator.mjs +272 -0
  118. package/src/core/optimizer.mjs +330 -0
  119. package/src/core/pattern_detector.mjs +113 -0
  120. package/src/core/post-mortem.mjs +413 -0
  121. package/src/core/protect-instructions.mjs +148 -0
  122. package/src/core/quick-capture.mjs +145 -0
  123. package/src/core/research-queue.mjs +431 -0
  124. package/src/core/research.mjs +461 -0
  125. package/src/core/runtime.mjs +515 -0
  126. package/src/core/sandbox.mjs +163 -0
  127. package/src/core/scheduler.mjs +569 -0
  128. package/src/core/schema.mjs +333 -0
  129. package/src/core/search.mjs +219 -0
  130. package/src/core/semantic-index.mjs +276 -0
  131. package/src/core/session-watcher.mjs +850 -0
  132. package/src/core/snapshot.mjs +133 -0
  133. package/src/core/source-adapters.mjs +1038 -0
  134. package/src/core/steering.mjs +137 -0
  135. package/src/core/surface.mjs +444 -0
  136. package/src/core/task_runner.mjs +125 -0
  137. package/src/core/tts.mjs +142 -0
  138. package/src/core/usage-tracker.mjs +349 -0
  139. package/src/core/vault-cache.mjs +76 -0
  140. package/src/core/vault.mjs +273 -0
  141. package/src/core/watchdog.mjs +236 -0
  142. package/src/server/api.mjs +944 -0
  143. package/src/server/auth.mjs +410 -0
  144. package/src/server/index.mjs +868 -0
  145. package/src/server/keys.mjs +181 -0
  146. package/src/server/rest.mjs +1652 -0
  147. package/src/server/route-manifest.json +77 -0
  148. package/src/server/routes/_shared.mjs +93 -0
  149. package/src/server/routes/keys.mjs +55 -0
  150. package/src/server/routes/memory.mjs +249 -0
  151. package/src/server/routes/sessions.mjs +336 -0
  152. package/src/server/routes/share.mjs +138 -0
  153. package/src/server/tools.mjs +618 -0
  154. package/templates/Caddyfile +37 -0
  155. package/templates/com.totalrecall.backup.plist +42 -0
  156. package/templates/com.totalrecall.daemon.plist +40 -0
  157. package/templates/com.totalrecall.duckdns.plist +23 -0
  158. package/templates/com.totalrecall.server.plist +46 -0
  159. package/templates/default-config/budget.yml +6 -0
  160. package/templates/default-config/runtime.yml +11 -0
  161. package/templates/default-config/security.yml +53 -0
  162. package/templates/default-config/voice.yml +34 -0
  163. package/templates/obsidian-queries/conflicts.md +26 -0
  164. package/templates/obsidian-queries/daily.md +17 -0
  165. package/templates/obsidian-queries/index.md +38 -0
  166. package/templates/obsidian-queries/skills.md +14 -0
  167. package/templates/onboarding-interview.md +72 -0
  168. package/templates/skill_engineering.md +40 -0
  169. package/templates/total-recall-daemon.service +21 -0
  170. package/templates/total-recall-server.service +20 -0
@@ -0,0 +1,1793 @@
1
+ /**
2
+ * deploy-ui.mjs
3
+ *
4
+ * Full multi-phase setup wizard served during `npx total-recall deploy --ui`.
5
+ *
6
+ * Exports:
7
+ * startDeployUI(port) → Promise<string> — starts server, returns URL
8
+ * waitForInstallOptions() → Promise<object> — blocks until user clicks Install
9
+ * emitProgress(type, msg) — streams install events to Phase 2
10
+ * finishDeployUI({apiUrl, dashUrl, healthUrl}) — fires done event
11
+ * openBrowser(url) — cross-platform browser open
12
+ */
13
+
14
+ import http from 'node:http';
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import fs, { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import bcrypt from 'bcrypt';
21
+ import yaml from 'yaml';
22
+ import { BCRYPT_COST } from '../server/auth.mjs';
23
+
24
+
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+
27
+ /** Brain data dir — all user config/data lives inside the meta-skill. */
28
+ function _brainDir() {
29
+ return path.join(os.homedir(), '.agent', 'skills', 'total-recall');
30
+ }
31
+
32
+ let _server = null;
33
+ let _clients = []; // active SSE response objects
34
+ let _log = []; // buffered events (replayed to late-joining browsers)
35
+ let _done = false;
36
+
37
+ // ─── waitForInstallOptions promise ───────────────────────────────────────────
38
+
39
+ let _resolveInstallOptions = null;
40
+ let _installOptionsReceived = false;
41
+ let _installOptions = null;
42
+
43
+ export const waitForInstallOptions = () => new Promise(r => {
44
+ if (_installOptionsReceived) { r(_installOptions); return; }
45
+ _resolveInstallOptions = r;
46
+ });
47
+
48
+ // ─── HTML Wizard (src/cli/wizard.html) ───────────────────────────────────────
49
+ // HTML, CSS, and all client-side JS live in wizard.html.
50
+ // Keep this file focused on the Node.js server and API route handlers.
51
+
52
+ const HTML = readFileSync(path.join(__dirname, 'wizard.html'), 'utf8');
53
+
54
+ // ─── Sync Install Options from Disk ──────────────────────────────────────────
55
+
56
+ export function syncInstallOptionsFromDisk() {
57
+ try {
58
+ const configFile = path.join(_brainDir(), 'config', 'wizard-config.json');
59
+ if (fs.existsSync(configFile)) {
60
+ const data = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}');
61
+ _installOptions = _installOptions || {};
62
+ if (data.deployTarget) _installOptions.deployTarget = data.deployTarget;
63
+ if (data['cfg-domain']) _installOptions.domain = data['cfg-domain'];
64
+ if (data['cfg-cloudflare-token']) _installOptions.cloudflareToken = data['cfg-cloudflare-token'];
65
+ if (data.httpsMethod) _installOptions.httpsMethod = data.httpsMethod;
66
+ if (data['cfg-localnet-host']) _installOptions.localnetHost = data['cfg-localnet-host'];
67
+ if (data['cfg-localnet-user']) _installOptions.localnetUser = data['cfg-localnet-user'];
68
+ if (data.vastInstanceId) _installOptions.vastInstanceId = data.vastInstanceId;
69
+ if (data.vastSshHost) _installOptions.vastSshHost = data.vastSshHost;
70
+ if (data.vastSshPort) _installOptions.vastSshPort = data.vastSshPort;
71
+ if (data['cfg-dashboard-password']) _installOptions.dashboardPassword = data['cfg-dashboard-password'];
72
+ if (data['cfg-github-token']) _installOptions.githubToken = data['cfg-github-token'];
73
+ if (data.apiUrl) _installOptions.apiUrl = data.apiUrl;
74
+ if (data.dashUrl) _installOptions.dashUrl = data.dashUrl;
75
+ if (data.healthUrl) _installOptions.healthUrl = data.healthUrl;
76
+ if (data.skipLocalInstall != null) _installOptions.skipLocalInstall = data.skipLocalInstall;
77
+ if (data['cfg-cloud-provider']) _installOptions.cloudProvider = data['cfg-cloud-provider'];
78
+ if (data['cfg-cloud-token']) _installOptions.cloudToken = data['cfg-cloud-token'];
79
+ if (data['cfg-cloud-region']) _installOptions.cloudRegion = data['cfg-cloud-region'];
80
+ }
81
+ } catch (e) {
82
+ console.error('Failed to sync wizard config from disk:', e);
83
+ }
84
+ }
85
+
86
+ // ─── HTTP Server ──────────────────────────────────────────────────────────────
87
+
88
+ export function startDeployUI(port = 3001) {
89
+ // Load saved wizard config from disk on startup
90
+ syncInstallOptionsFromDisk();
91
+
92
+ return new Promise((resolve) => {
93
+ _server = http.createServer(async (req, res) => {
94
+ // Set global CORS headers to allow cross-origin requests (e.g., from file:// or localhost/127.0.0.1 mismatches)
95
+ res.setHeader('Access-Control-Allow-Origin', '*');
96
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
97
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
98
+
99
+ if (req.method === 'OPTIONS') {
100
+ res.writeHead(200);
101
+ res.end();
102
+ return;
103
+ }
104
+
105
+ const url = req.url.split('?')[0];
106
+
107
+ // ── SSE event stream ──
108
+ if (url === '/events') {
109
+ res.writeHead(200, {
110
+ 'Content-Type': 'text/event-stream',
111
+ 'Cache-Control': 'no-cache',
112
+ 'Connection': 'keep-alive',
113
+ 'Access-Control-Allow-Origin': '*',
114
+ });
115
+ for (const entry of _log) {
116
+ res.write(`data: ${JSON.stringify(entry)}\n\n`);
117
+ }
118
+ if (_done) { res.end(); return; }
119
+ _clients.push(res);
120
+ req.on('close', () => { _clients = _clients.filter(c => c !== res); });
121
+ return;
122
+ }
123
+
124
+ // ── POST /api/start-install — wizard clicked Install ──
125
+ if (url === '/api/start-install' && req.method === 'POST') {
126
+ let body = '';
127
+ req.on('data', d => { body += d; });
128
+ req.on('end', () => {
129
+ try {
130
+ const opts = JSON.parse(body || '{}');
131
+ _log = [];
132
+ _done = false;
133
+ _installOptions = opts;
134
+
135
+ // Persist keys to secrets.enc
136
+ try {
137
+ const configDir = path.join(_brainDir(), 'config');
138
+ const secretsPath = path.join(configDir, 'secrets.enc');
139
+ fs.mkdirSync(configDir, { recursive: true });
140
+ let secrets = {};
141
+ if (fs.existsSync(secretsPath)) {
142
+ try { secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8') || '{}'); } catch {}
143
+ }
144
+ if (opts.googleApiKey) {
145
+ secrets.google_api_key = opts.googleApiKey.trim();
146
+ }
147
+ if (opts.openaiApiKey) {
148
+ secrets.openai_api_key = opts.openaiApiKey.trim();
149
+ }
150
+ fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2), { encoding: 'utf8', mode: 0o600 });
151
+ } catch (e) {
152
+ console.error('Failed to save API keys to secrets.enc:', e);
153
+ }
154
+
155
+ // Auto-persist options to wizard-config.json on server disk
156
+ try {
157
+ const configDir = path.join(_brainDir(), 'config');
158
+ const configFile = path.join(configDir, 'wizard-config.json');
159
+ fs.mkdirSync(configDir, { recursive: true });
160
+ let current = {};
161
+ if (fs.existsSync(configFile)) {
162
+ try { current = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}'); } catch {}
163
+ }
164
+ if (opts.domain) current['cfg-domain'] = opts.domain;
165
+ if (opts.cloudflareToken) current['cfg-cloudflare-token'] = opts.cloudflareToken;
166
+ if (opts.httpsMethod) current['httpsMethod'] = opts.httpsMethod;
167
+ if (opts.deployTarget) current['deployTarget'] = opts.deployTarget;
168
+ if (opts.localnetHost) current['cfg-localnet-host'] = opts.localnetHost;
169
+ if (opts.localnetUser) current['cfg-localnet-user'] = opts.localnetUser;
170
+ if (opts.dashboardPassword) current['cfg-dashboard-password'] = opts.dashboardPassword;
171
+ if (opts.githubToken) current['cfg-github-token'] = opts.githubToken;
172
+ if (opts.cloudProvider) current['cfg-cloud-provider'] = opts.cloudProvider;
173
+ if (opts.cloudToken) current['cfg-cloud-token'] = opts.cloudToken;
174
+ if (opts.cloudRegion) current['cfg-cloud-region'] = opts.cloudRegion;
175
+ if (opts.installGlobal !== undefined) current['installGlobal'] = opts.installGlobal;
176
+ if (opts.projectPaths) current['projectPaths'] = opts.projectPaths;
177
+ fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
178
+
179
+ } catch (e) {
180
+ console.error('Failed to auto-persist install options on disk:', e);
181
+ }
182
+
183
+ // Unblock the main installer process
184
+ _installOptionsReceived = true;
185
+ if (_resolveInstallOptions) {
186
+ _resolveInstallOptions(opts);
187
+ _resolveInstallOptions = null;
188
+ }
189
+
190
+ res.writeHead(200, { 'Content-Type': 'application/json' });
191
+ res.end(JSON.stringify({ ok: true }));
192
+ } catch (e) {
193
+ res.writeHead(400, { 'Content-Type': 'application/json' });
194
+ res.end(JSON.stringify({ error: e.message }));
195
+ }
196
+ });
197
+ return;
198
+ }
199
+
200
+ // ── POST /api/generate-pat — proxy to brain server ──
201
+ if (url === '/api/generate-pat' && req.method === 'POST') {
202
+ let body = '';
203
+ req.on('data', d => { body += d; });
204
+ req.on('end', async () => {
205
+ try {
206
+ syncInstallOptionsFromDisk();
207
+ const opts = JSON.parse(body || '{}');
208
+ const name = opts.name || 'default';
209
+ const scope = opts.scope || '*';
210
+
211
+ // Check if it is a remote deploy target
212
+ if (_installOptions && (_installOptions.deployTarget === 'vastai' || _installOptions.deployTarget === 'localnet')) {
213
+ let host = '';
214
+ let port = '';
215
+ let user = 'root';
216
+ let cmdPrefix = '';
217
+
218
+ if (_installOptions.deployTarget === 'vastai') {
219
+ host = _installOptions.vastSshHost;
220
+ port = _installOptions.vastSshPort;
221
+ user = 'root';
222
+ cmdPrefix = 'export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";';
223
+ } else {
224
+ host = _installOptions.localnetHost;
225
+ user = _installOptions.localnetUser || 'root';
226
+ cmdPrefix = 'export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";';
227
+ }
228
+
229
+ if (host) {
230
+ const sshArgs = [];
231
+ if (port) {
232
+ sshArgs.push('-p', String(port));
233
+ }
234
+ sshArgs.push('-o', 'StrictHostKeyChecking=no');
235
+ sshArgs.push('-o', 'ConnectTimeout=10');
236
+ sshArgs.push(`${user}@${host}`);
237
+
238
+ // Command to run total-recall generate-pat
239
+ const remoteCmd = `${cmdPrefix} if [ -f "$HOME/total-recall/bin/total-recall.mjs" ]; then node "$HOME/total-recall/bin/total-recall.mjs" generate-pat --name "${name}" --scope "${scope}"; else npx --yes total-recall generate-pat --name "${name}" --scope "${scope}"; fi`;
240
+ sshArgs.push(remoteCmd);
241
+
242
+ console.log(`[Proxy generate-pat] SSH command: ssh ${sshArgs.join(' ')}`);
243
+ const r = spawnSync('ssh', sshArgs, { encoding: 'utf8', timeout: 25000 });
244
+
245
+ if (r.status === 0) {
246
+ const stdout = r.stdout || '';
247
+ console.log(`[Proxy generate-pat] SSH Success. Output:\n${stdout}`);
248
+ const match = stdout.match(/Token:\s*(tr_[a-zA-Z0-9_-]+)/);
249
+ if (match && match[1]) {
250
+ res.writeHead(200, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ token: match[1], note: 'Key generated successfully on remote host.' }));
252
+ return;
253
+ }
254
+ } else {
255
+ console.error(`[Proxy generate-pat] SSH Failed (exit ${r.status}). Stderr:`, r.stderr);
256
+ }
257
+ }
258
+ }
259
+
260
+ // Fallback for local setups or if remote SSH fails:
261
+ // First try to hit the local brain server on port 3000 if it is up
262
+ try {
263
+ const controller = new AbortController();
264
+ const timeoutId = setTimeout(() => controller.abort(), 3000);
265
+ const r = await fetch('http://localhost:3000/api/keys', {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ 'Authorization': 'Bearer local',
270
+ },
271
+ body,
272
+ signal: controller.signal,
273
+ });
274
+ clearTimeout(timeoutId);
275
+ if (r.ok) {
276
+ const data = await r.json();
277
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
278
+ res.end(JSON.stringify(data));
279
+ return;
280
+ }
281
+ } catch (e) {
282
+ console.log('[Proxy generate-pat] Local brain server not running on port 3000. Generating local fallback token...');
283
+ }
284
+
285
+ // Direct local fallback using issueKey so it actually gets persisted to the local VFS keys.jsonl!
286
+ const { issueKey } = await import('../server/keys.mjs');
287
+ const key = issueKey(name, { scopes: scope.split(',').map(s => s.trim()) });
288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify({
290
+ token: key.token,
291
+ note: 'Generated in local VFS key registry. Ensure the brain server is configured to use this vault.'
292
+ }));
293
+ } catch (e) {
294
+ console.error('[Proxy generate-pat] Fallback failed:', e);
295
+ res.writeHead(500, { 'Content-Type': 'application/json' });
296
+ res.end(JSON.stringify({ error: e.message }));
297
+ }
298
+ });
299
+ return;
300
+ }
301
+
302
+ // ── POST /api/reset-remote-password — reset password on remote VM or locally ──
303
+ if (url === '/api/reset-remote-password' && req.method === 'POST') {
304
+ let body = '';
305
+ req.on('data', d => { body += d; });
306
+ req.on('end', async () => {
307
+ try {
308
+ syncInstallOptionsFromDisk();
309
+ const { password } = JSON.parse(body || '{}');
310
+ if (!password || password.length < 8) {
311
+ res.writeHead(400, { 'Content-Type': 'application/json' });
312
+ res.end(JSON.stringify({ error: 'Password must be at least 8 characters long.' }));
313
+ return;
314
+ }
315
+
316
+ // Check if it is a remote deploy target
317
+ if (_installOptions && (_installOptions.deployTarget === 'vastai' || _installOptions.deployTarget === 'localnet')) {
318
+ let host = '';
319
+ let port = '';
320
+ let user = 'root';
321
+ let cmdPrefix = '';
322
+
323
+ if (_installOptions.deployTarget === 'vastai') {
324
+ host = _installOptions.vastSshHost;
325
+ port = _installOptions.vastSshPort;
326
+ user = 'root';
327
+ cmdPrefix = 'export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";';
328
+ } else {
329
+ host = _installOptions.localnetHost;
330
+ user = _installOptions.localnetUser || 'root';
331
+ cmdPrefix = 'export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"; export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh";';
332
+ }
333
+
334
+ if (host) {
335
+ const sshArgs = [];
336
+ if (port) {
337
+ sshArgs.push('-p', String(port));
338
+ }
339
+ sshArgs.push('-o', 'StrictHostKeyChecking=no');
340
+ sshArgs.push('-o', 'ConnectTimeout=10');
341
+ sshArgs.push(`${user}@${host}`);
342
+
343
+ // Command to run total-recall reset-password
344
+ const remoteCmd = `${cmdPrefix} if [ -f "$HOME/total-recall/bin/total-recall.mjs" ]; then node "$HOME/total-recall/bin/total-recall.mjs" reset-password "${password}"; else npx --yes total-recall reset-password "${password}"; fi`;
345
+ sshArgs.push(remoteCmd);
346
+
347
+ console.log(`[Proxy reset-remote-password] SSH command: ssh ${sshArgs.join(' ')}`);
348
+ const r = spawnSync('ssh', sshArgs, { encoding: 'utf8', timeout: 25000 });
349
+
350
+ if (r.status === 0) {
351
+ res.writeHead(200, { 'Content-Type': 'application/json' });
352
+ res.end(JSON.stringify({ ok: true, target: 'remote', note: 'Remote password successfully updated.' }));
353
+ return;
354
+ } else {
355
+ console.error(`[Proxy reset-remote-password] SSH Failed (exit ${r.status}). Stderr:`, r.stderr);
356
+ res.writeHead(500, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify({ error: `SSH Command failed on remote host: ${r.stderr || 'Unknown error'}` }));
358
+ return;
359
+ }
360
+ }
361
+ }
362
+
363
+ // Local fallback
364
+ const { default: resetPassword } = await import('./reset-password.mjs');
365
+ await resetPassword([password]);
366
+ res.writeHead(200, { 'Content-Type': 'application/json' });
367
+ res.end(JSON.stringify({ ok: true, target: 'local', note: 'Local password successfully updated.' }));
368
+ } catch (e) {
369
+ console.error('[Proxy reset-remote-password] Failed:', e);
370
+ res.writeHead(500, { 'Content-Type': 'application/json' });
371
+ res.end(JSON.stringify({ error: e.message }));
372
+ }
373
+ });
374
+ return;
375
+ }
376
+
377
+ // ── POST /api/save-search-config — write research.yml ──
378
+ if (url === '/api/save-search-config' && req.method === 'POST') {
379
+ let body = '';
380
+ req.on('data', (c) => { body += c; });
381
+ req.on('end', () => {
382
+ try {
383
+ const { braveKey, tavilyKey, exaKey, serperKey, dailyLimit, mergeExisting } = JSON.parse(body);
384
+ const configDir = path.join(_brainDir(), 'config');
385
+ const configFile = path.join(configDir, 'research.yml');
386
+ fs.mkdirSync(configDir, { recursive: true });
387
+
388
+ // If mergeExisting=true (Settings page), blank fields keep existing values
389
+ let existing = {};
390
+ if (mergeExisting && fs.existsSync(configFile)) {
391
+ try {
392
+ const raw = fs.readFileSync(configFile, 'utf8');
393
+ // Simple key extraction without yaml parser dependency in this context
394
+ const extract = (key) => { const m = raw.match(new RegExp(`${key}:\\s*"([^"]+)"`)); return m ? m[1] : ''; };
395
+ existing = {
396
+ tavily: extract('tavily_api_key'),
397
+ brave: extract('brave_api_key'),
398
+ exa: extract('exa_api_key'),
399
+ serper: extract('serper_api_key'),
400
+ };
401
+ } catch { /* ignore */ }
402
+ }
403
+
404
+ const resolvedTavily = tavilyKey || existing.tavily || '';
405
+ const resolvedBrave = braveKey || existing.brave || '';
406
+ const resolvedExa = exaKey || existing.exa || '';
407
+ const resolvedSerper = serperKey || existing.serper || '';
408
+ const resolvedLimit = (dailyLimit != null) ? Number(dailyLimit) : 50;
409
+
410
+ const lines = [
411
+ '# Total Recall Research Source Configuration',
412
+ '# Generated by setup wizard / settings — edit freely',
413
+ '# Fallback order: Tavily → Brave → Exa → Serper → DuckDuckGo (free)',
414
+ '',
415
+ '# Tavily: best for AI agents — returns clean extracted text, not just links.',
416
+ '# 1,000 free queries/month. https://tavily.com',
417
+ `tavily_api_key: "${resolvedTavily}"`,
418
+ '',
419
+ '# Brave Search: independent web index, not Google. ~1,000 free/month. https://brave.com/search/api/',
420
+ `brave_api_key: ${braveKey ? `"${braveKey}"` : '""'}`,
421
+ '',
422
+ '# Exa: neural/semantic search, finds pages by meaning. ~1,000 free/month. https://exa.ai',
423
+ `exa_api_key: ${exaKey ? `"${exaKey}"` : '""'}`,
424
+ '',
425
+ '# Serper: Google Search results. 2,500 one-time free credits. https://serper.dev',
426
+ `serper_api_key: ${serperKey ? `"${serperKey}"` : '""'}`,
427
+ '',
428
+ '# Daily search limit (paid queries/day). Default 50 ≈ 1,500/month.',
429
+ '# Set to 0 to disable the cap (for paid plans with high volume).',
430
+ 'daily_web_search_limit: 50',
431
+ ].join('\n');
432
+ fs.writeFileSync(path.join(configDir, 'research.yml'), lines);
433
+ const primarySource =
434
+ braveKey ? 'Brave Search' :
435
+ tavilyKey ? 'Tavily' :
436
+ exaKey ? 'Exa' :
437
+ serperKey ? 'Serper (Google)' : 'DuckDuckGo + Wikipedia (free)';
438
+ res.writeHead(200, { 'Content-Type': 'application/json' });
439
+ res.end(JSON.stringify({ ok: true, primarySource }));
440
+ } catch (err) {
441
+ res.writeHead(500, { 'Content-Type': 'application/json' });
442
+ res.end(JSON.stringify({ ok: false, error: err.message }));
443
+ }
444
+ });
445
+ return;
446
+ }
447
+
448
+ // ── GET /api/get-search-config — which keys are set + current daily limit ──
449
+ // Returns booleans only — never exposes actual key values
450
+ if (url === '/api/get-search-config' && req.method === 'GET') {
451
+ try {
452
+ const configFile = path.join(_brainDir(), 'config', 'research.yml');
453
+ let hasTavily = false, hasBrave = false, hasExa = false, hasSerper = false, dailyLimitVal = 50;
454
+ if (fs.existsSync(configFile)) {
455
+ const raw = fs.readFileSync(configFile, 'utf8');
456
+ const extract = (key) => { const m = raw.match(new RegExp(`${key}:\\s*"([^"]+)"`)); return m ? m[1] : ''; };
457
+ const limitMatch = raw.match(/daily_web_search_limit:\s*(\d+)/);
458
+ hasTavily = !!extract('tavily_api_key');
459
+ hasBrave = !!extract('brave_api_key');
460
+ hasExa = !!extract('exa_api_key');
461
+ hasSerper = !!extract('serper_api_key');
462
+ dailyLimitVal = limitMatch ? parseInt(limitMatch[1], 10) : 50;
463
+ }
464
+ res.writeHead(200, { 'Content-Type': 'application/json' });
465
+ res.end(JSON.stringify({ hasTavily, hasBrave, hasExa, hasSerper, dailyLimit: dailyLimitVal }));
466
+ } catch {
467
+ res.writeHead(200, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify({ hasTavily: false, hasBrave: false, hasExa: false, hasSerper: false, dailyLimit: 50 }));
469
+ }
470
+ return;
471
+ }
472
+
473
+ // ── GET /api/import/rules — scan local rule files ──
474
+ if (url === '/api/import/rules' && req.method === 'GET') {
475
+ try {
476
+ const query = new URL(req.url, `http://${req.headers.host || 'localhost'}`).searchParams;
477
+ const dirParam = query.get('dir');
478
+ const dirs = dirParam ? [path.resolve(dirParam)] : [process.cwd(), os.homedir()];
479
+
480
+ const { detectRuleFiles } = await import('../core/import-rules.mjs');
481
+ const detected = detectRuleFiles(dirs);
482
+ res.writeHead(200, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify({ ok: true, detected }));
484
+ } catch (e) {
485
+ res.writeHead(500, { 'Content-Type': 'application/json' });
486
+ res.end(JSON.stringify({ error: e.message }));
487
+ }
488
+ return;
489
+ }
490
+
491
+ // ── POST /api/import/rules — import selected rule files ──
492
+ if (url === '/api/import/rules' && req.method === 'POST') {
493
+ let body = '';
494
+ req.on('data', d => { body += d; });
495
+ req.on('end', async () => {
496
+ try {
497
+ const { files, force = false } = JSON.parse(body || '{}');
498
+ if (!Array.isArray(files) || files.length === 0) {
499
+ res.writeHead(400, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify({ error: 'Missing files parameter or files is empty' }));
501
+ return;
502
+ }
503
+ const { resolveAgentDir, resolveBrainDir } = await import('./agent-dir.mjs');
504
+ const { detectRuleFiles, importRuleFiles } = await import('../core/import-rules.mjs');
505
+
506
+ const brDir = resolveBrainDir();
507
+ const vaultDir = path.join(brDir, 'memory-vault');
508
+
509
+ // Gather parent directories to run detectRuleFiles and retrieve rich metadata
510
+ const parentDirs = Array.from(new Set(files.map(f => path.dirname(f))));
511
+ const detected = detectRuleFiles(parentDirs);
512
+ const selectedFileObjects = detected.filter(f => files.includes(f.absolutePath));
513
+
514
+ const result = importRuleFiles(selectedFileObjects, { force, vaultDir });
515
+ res.writeHead(200, { 'Content-Type': 'application/json' });
516
+ res.end(JSON.stringify(result));
517
+ } catch (e) {
518
+ res.writeHead(500, { 'Content-Type': 'application/json' });
519
+ res.end(JSON.stringify({ error: e.message }));
520
+ }
521
+ });
522
+ return;
523
+ }
524
+
525
+ // ── GET /api/get-search-usage — today's search count vs limit ──
526
+ if (url === '/api/get-search-usage' && req.method === 'GET') {
527
+ try {
528
+ const usageFile = path.join(_brainDir(), 'config', 'search-usage.json');
529
+ const configFile = path.join(_brainDir(), 'config', 'research.yml');
530
+ const today = new Date().toISOString().slice(0, 10);
531
+ let todayCount = 0, dailyLimitVal = 50;
532
+ if (fs.existsSync(usageFile)) {
533
+ const usage = JSON.parse(fs.readFileSync(usageFile, 'utf8'));
534
+ todayCount = usage[today] || 0;
535
+ }
536
+ if (fs.existsSync(configFile)) {
537
+ const m = fs.readFileSync(configFile, 'utf8').match(/daily_web_search_limit:\s*(\d+)/);
538
+ if (m) dailyLimitVal = parseInt(m[1], 10);
539
+ }
540
+ const unlimited = dailyLimitVal === 0;
541
+ res.writeHead(200, { 'Content-Type': 'application/json' });
542
+ res.end(JSON.stringify({
543
+ today: todayCount,
544
+ limit: unlimited ? 'unlimited' : dailyLimitVal,
545
+ remaining: unlimited ? 'unlimited' : Math.max(0, dailyLimitVal - todayCount),
546
+ }));
547
+ } catch {
548
+ res.writeHead(200, { 'Content-Type': 'application/json' });
549
+ res.end(JSON.stringify({ today: 0, limit: 50, remaining: 50 }));
550
+ }
551
+ return;
552
+ }
553
+
554
+ // ── POST /api/save-wizard-config — save setup wizard state to disk ──
555
+ if (url === '/api/save-wizard-config' && req.method === 'POST') {
556
+ let body = '';
557
+ req.on('data', c => { body += c; });
558
+ req.on('end', () => {
559
+ try {
560
+ const data = JSON.parse(body || '{}');
561
+ const configDir = path.join(_brainDir(), 'config');
562
+ const configFile = path.join(configDir, 'wizard-config.json');
563
+ fs.mkdirSync(configDir, { recursive: true });
564
+
565
+ // Read existing configuration if exists, and merge
566
+ let current = {};
567
+ if (fs.existsSync(configFile)) {
568
+ try {
569
+ current = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}');
570
+ } catch {}
571
+ }
572
+
573
+ const updated = { ...current, ...data };
574
+ fs.writeFileSync(configFile, JSON.stringify(updated, null, 2), { encoding: 'utf8', mode: 0o600 });
575
+ syncInstallOptionsFromDisk();
576
+
577
+ res.writeHead(200, { 'Content-Type': 'application/json' });
578
+ res.end(JSON.stringify({ ok: true }));
579
+ } catch (e) {
580
+ res.writeHead(400, { 'Content-Type': 'application/json' });
581
+ res.end(JSON.stringify({ error: e.message }));
582
+ }
583
+ });
584
+ return;
585
+ }
586
+
587
+ // ── GET /api/get-wizard-config — get saved setup wizard state from disk ──
588
+ if (url === '/api/get-wizard-config' && req.method === 'GET') {
589
+ try {
590
+ syncInstallOptionsFromDisk();
591
+ const configFile = path.join(_brainDir(), 'config', 'wizard-config.json');
592
+ let saved = {};
593
+ if (fs.existsSync(configFile)) {
594
+ saved = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}');
595
+ }
596
+ res.writeHead(200, { 'Content-Type': 'application/json' });
597
+ res.end(JSON.stringify(saved));
598
+ } catch {
599
+ res.writeHead(200, { 'Content-Type': 'application/json' });
600
+ res.end(JSON.stringify({}));
601
+ }
602
+ return;
603
+ }
604
+
605
+ // ── POST /api/reset-wizard-config — reset setup wizard state ──
606
+ if (url === '/api/reset-wizard-config' && req.method === 'POST') {
607
+ try {
608
+ const configFile = path.join(_brainDir(), 'config', 'wizard-config.json');
609
+ if (fs.existsSync(configFile)) {
610
+ fs.unlinkSync(configFile);
611
+ }
612
+ // Reset server-side in-memory setup logs and status
613
+ _log = [];
614
+ _done = false;
615
+ _installOptionsReceived = false;
616
+ _installOptions = null;
617
+
618
+ res.writeHead(200, { 'Content-Type': 'application/json' });
619
+ res.end(JSON.stringify({ ok: true }));
620
+ } catch (e) {
621
+ res.writeHead(500, { 'Content-Type': 'application/json' });
622
+ res.end(JSON.stringify({ error: e.message }));
623
+ }
624
+ return;
625
+ }
626
+
627
+ // ── GET /api/detect-projects — scan common code dirs for git repos ──
628
+ if (url === '/api/detect-projects' && req.method === 'GET') {
629
+ const HOME = os.homedir();
630
+ const codeDirs = [
631
+ path.join(HOME, 'Github'),
632
+ path.join(HOME, 'github'),
633
+ path.join(HOME, 'Projects'),
634
+ path.join(HOME, 'projects'),
635
+ path.join(HOME, 'Developer'),
636
+ path.join(HOME, 'developer'),
637
+ path.join(HOME, 'Code'),
638
+ path.join(HOME, 'code'),
639
+ path.join(HOME, 'repos'),
640
+ path.join(HOME, 'Repos'),
641
+ path.join(HOME, 'src'),
642
+ path.join(HOME, 'workspace'),
643
+ path.join(HOME, 'Workspace'),
644
+ path.join(HOME, 'dev'),
645
+ path.join(HOME, 'Dev'),
646
+ path.join(HOME, 'Desktop'),
647
+ ];
648
+ const projects = [];
649
+ const seen = new Set();
650
+ for (const dir of codeDirs) {
651
+ try {
652
+ if (!fs.existsSync(dir)) continue;
653
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
654
+ for (const entry of entries) {
655
+ if (!entry.isDirectory()) continue;
656
+ if (entry.name.startsWith('.')) continue;
657
+ const fullPath = path.join(dir, entry.name);
658
+ if (seen.has(fullPath)) continue;
659
+ // Check if it's a git repo or has package.json (code project)
660
+ const hasGit = fs.existsSync(path.join(fullPath, '.git'));
661
+ const hasPkg = fs.existsSync(path.join(fullPath, 'package.json'));
662
+ const hasCargo = fs.existsSync(path.join(fullPath, 'Cargo.toml'));
663
+ const hasPyproject = fs.existsSync(path.join(fullPath, 'pyproject.toml'));
664
+ const hasGoMod = fs.existsSync(path.join(fullPath, 'go.mod'));
665
+ if (hasGit || hasPkg || hasCargo || hasPyproject || hasGoMod) {
666
+ seen.add(fullPath);
667
+ // Check if already has a Total Recall project brain
668
+ const hasBrain = fs.existsSync(path.join(fullPath, '.agent', 'skills', 'total-recall'));
669
+ projects.push({ name: entry.name, path: fullPath, hasGit, hasBrain });
670
+ }
671
+ }
672
+ } catch { /* skip unreadable dirs */ }
673
+ }
674
+ // Sort alphabetically by name
675
+ projects.sort((a, b) => a.name.localeCompare(b.name));
676
+ res.writeHead(200, { 'Content-Type': 'application/json' });
677
+ res.end(JSON.stringify({ projects }));
678
+ return;
679
+ }
680
+
681
+ // ── GET /api/detect-ides — probe filesystem for installed IDEs ──
682
+ if (url === '/api/detect-ides' && req.method === 'GET') {
683
+ const HOME = os.homedir();
684
+ const detected = [];
685
+ const checks = {
686
+ 'claude-code': [HOME + '/.claude/projects', HOME + '/.claude/CLAUDE.md'],
687
+ 'codex': [HOME + '/.codex/sessions', HOME + '/.codex/AGENTS.md'],
688
+ 'cursor': [HOME + '/.cursor/projects', HOME + '/.cursor'],
689
+ 'antigravity': [HOME + '/.gemini/antigravity'],
690
+ 'vscode': [HOME + '/Library/Application Support/Code', HOME + '/.vscode'],
691
+ 'gemini': [HOME + '/.gemini'],
692
+ 'pi': [HOME + '/.pi/agent'],
693
+ 'hermes': [HOME + '/.hermes'],
694
+ 'openclaw': [HOME + '/.openclaw'],
695
+ 'obsidian': [HOME + '/Library/Application Support/obsidian', HOME + '/.config/obsidian'],
696
+ };
697
+ for (const [ide, paths] of Object.entries(checks)) {
698
+ if (paths.some(p => { try { return fs.existsSync(p); } catch { return false; } })) {
699
+ detected.push(ide);
700
+ }
701
+ }
702
+ res.writeHead(200, { 'Content-Type': 'application/json' });
703
+ res.end(JSON.stringify({ detected }));
704
+ return;
705
+ }
706
+
707
+ // ── POST /api/connect-ides — run connect + relay install ──
708
+ if (url === '/api/connect-ides' && req.method === 'POST') {
709
+ let body = '';
710
+ req.on('data', d => { body += d; });
711
+ req.on('end', async () => {
712
+ let opts;
713
+ try { opts = JSON.parse(body || '{}'); } catch { opts = {}; }
714
+ const { ides = [], installRelay = true, installObsidian = false, brainUrl, token } = opts;
715
+ const results = [];
716
+
717
+ // IDE-to-connect-client mapping (coding agents only — Obsidian handled separately)
718
+ const IDE_CLIENTS = {
719
+ 'claude-code': { client: 'claude-code', label: 'Claude Code' },
720
+ 'codex': { client: 'codex', label: 'Codex' },
721
+ 'cursor': { client: 'cursor', label: 'Cursor' },
722
+ 'antigravity': { client: 'antigravity', label: 'Antigravity' },
723
+ 'vscode': { client: 'vscode', label: 'VS Code Copilot' },
724
+ 'gemini': { client: 'gemini', label: 'Gemini CLI' },
725
+ 'pi': { client: 'pi', label: 'Pi Coding Agent' },
726
+ 'hermes': { client: 'hermes', label: 'Hermes Agent' },
727
+ 'openclaw': { client: 'openclaw', label: 'OpenClaw' },
728
+ };
729
+
730
+ const { spawnSync: sp } = await import('node:child_process');
731
+ const nodeBin = process.execPath;
732
+ const scriptPath = new URL('../../bin/total-recall.mjs', import.meta.url).pathname;
733
+
734
+ for (const ide of ides) {
735
+ const mapping = IDE_CLIENTS[ide];
736
+ if (!mapping) { results.push({ label: ide, ok: false, message: 'Unknown IDE' }); continue; }
737
+ const args = ['connect', mapping.client];
738
+ if (brainUrl) args.push('--brain', brainUrl);
739
+ if (token) args.push('--token', token);
740
+ args.push('--force');
741
+ try {
742
+ const r = sp(nodeBin, [scriptPath, ...args], { encoding: 'utf8', timeout: 30000 });
743
+ if (r.status === 0) {
744
+ results.push({ label: mapping.label, ok: true, message: 'Connected' });
745
+ } else {
746
+ const err = (r.stderr || r.stdout || '').trim().split('\n').pop() || 'failed';
747
+ results.push({ label: mapping.label, ok: false, skipped: err.includes('exists'), message: err });
748
+ }
749
+ } catch (e) {
750
+ results.push({ label: mapping.label, ok: false, message: e.message });
751
+ }
752
+ }
753
+
754
+
755
+ // Install relay as system service
756
+ let relayResult = null;
757
+ if (installRelay) {
758
+ try {
759
+ const relayArgs = [scriptPath, 'relay', 'install'];
760
+ if (brainUrl) {
761
+ // Write brain config so relay knows where to ship
762
+ const { resolveAgentDir } = await import('./agent-dir.mjs');
763
+ const agentDir = resolveAgentDir();
764
+ const configDir = path.join(agentDir, 'skills', 'total-recall', 'config');
765
+ fs.mkdirSync(configDir, { recursive: true });
766
+ const config = { url: brainUrl };
767
+ if (token) config.token = token;
768
+ fs.writeFileSync(path.join(configDir, 'brain.json'), JSON.stringify(config, null, 2));
769
+ }
770
+ const rr = sp(nodeBin, relayArgs, { encoding: 'utf8', timeout: 30000 });
771
+ relayResult = { ok: rr.status === 0, message: rr.status === 0 ? 'Installed as system service (starts on boot)' : (rr.stderr || rr.stdout || '').trim().split('\n').pop() };
772
+ } catch (e) {
773
+ relayResult = { ok: false, message: e.message };
774
+ }
775
+ }
776
+
777
+ // Connect Obsidian vault sync
778
+ let obsidianResult = null;
779
+ if (installObsidian) {
780
+ try {
781
+ const obsArgs = [scriptPath, 'connect', 'obsidian'];
782
+ if (brainUrl) obsArgs.push('--brain', brainUrl);
783
+ obsArgs.push('--force');
784
+ const or = sp(nodeBin, obsArgs, { encoding: 'utf8', timeout: 30000 });
785
+ obsidianResult = { ok: or.status === 0, message: or.status === 0 ? 'Vault sync configured' : (or.stderr || or.stdout || '').trim().split('\n').pop() };
786
+ } catch (e) {
787
+ obsidianResult = { ok: false, message: e.message };
788
+ }
789
+ }
790
+
791
+ // Persist the connected IDE choices to wizard-config.json
792
+ try {
793
+ const { resolveBrainDir } = await import('./agent-dir.mjs');
794
+ const brDir = resolveBrainDir();
795
+ const configFilePath = path.join(brDir, 'config', 'wizard-config.json');
796
+ let current = {};
797
+ if (fs.existsSync(configFilePath)) {
798
+ current = JSON.parse(fs.readFileSync(configFilePath, 'utf8') || '{}');
799
+ }
800
+ current.configuredIdes = ides;
801
+ fs.mkdirSync(path.dirname(configFilePath), { recursive: true });
802
+ fs.writeFileSync(configFilePath, JSON.stringify(current, null, 2), 'utf8');
803
+ } catch (e) {
804
+ console.error('Error persisting configuredIdes to wizard-config.json:', e);
805
+ }
806
+
807
+ res.writeHead(200, { 'Content-Type': 'application/json' });
808
+ res.end(JSON.stringify({ ok: true, results, relayResult, obsidianResult }));
809
+ });
810
+ return;
811
+ }
812
+
813
+ // ── POST /api/vastai-instances ──
814
+ if (url === '/api/vastai-instances' && req.method === 'POST') {
815
+ let body = '';
816
+ req.on('data', d => { body += d; });
817
+ req.on('end', async () => {
818
+ try {
819
+ const { vastaiKey } = JSON.parse(body || '{}');
820
+ if (!vastaiKey) {
821
+ res.writeHead(400, { 'Content-Type': 'application/json' });
822
+ res.end(JSON.stringify({ error: 'Missing vastaiKey' }));
823
+ return;
824
+ }
825
+ const data = await vastAPI(vastaiKey, 'GET', '/instances/');
826
+ if (data.success === false) {
827
+ res.writeHead(400, { 'Content-Type': 'application/json' });
828
+ res.end(JSON.stringify({ ok: false, error: data.msg }));
829
+ } else {
830
+ res.writeHead(200, { 'Content-Type': 'application/json' });
831
+ res.end(JSON.stringify({ ok: true, instances: data.instances || [] }));
832
+ }
833
+ } catch (e) {
834
+ res.writeHead(500, { 'Content-Type': 'application/json' });
835
+ res.end(JSON.stringify({ error: e.message }));
836
+ }
837
+ });
838
+ return;
839
+ }
840
+
841
+ // ── POST /api/destroy-vastai-instance ──
842
+ if (url === '/api/destroy-vastai-instance' && req.method === 'POST') {
843
+ let body = '';
844
+ req.on('data', d => { body += d; });
845
+ req.on('end', async () => {
846
+ try {
847
+ const { vastaiKey, instanceId } = JSON.parse(body || '{}');
848
+ if (!vastaiKey || !instanceId) {
849
+ res.writeHead(400, { 'Content-Type': 'application/json' });
850
+ res.end(JSON.stringify({ error: 'Missing vastaiKey or instanceId' }));
851
+ return;
852
+ }
853
+ const data = await vastAPI(vastaiKey, 'DELETE', `/instances/${instanceId}/`);
854
+ res.writeHead(200, { 'Content-Type': 'application/json' });
855
+ res.end(JSON.stringify({ ok: data.success !== false, data }));
856
+ } catch (e) {
857
+ res.writeHead(500, { 'Content-Type': 'application/json' });
858
+ res.end(JSON.stringify({ error: e.message }));
859
+ }
860
+ });
861
+ return;
862
+ }
863
+
864
+ // ── POST /api/provision-vastai ──
865
+ if (url === '/api/provision-vastai' && req.method === 'POST') {
866
+ let body = '';
867
+ req.on('data', d => { body += d; });
868
+ req.on('end', async () => {
869
+ try {
870
+ const { vastaiKey, instanceId, dashboardPassword } = JSON.parse(body || '{}');
871
+ if (!vastaiKey) {
872
+ res.writeHead(400, { 'Content-Type': 'application/json' });
873
+ res.end(JSON.stringify({ error: 'Missing vastaiKey' }));
874
+ return;
875
+ }
876
+
877
+ _log = [];
878
+ _done = false;
879
+ provisionVastAI(vastaiKey, instanceId, dashboardPassword).catch(err => {
880
+ console.error('provisionVastAI background error:', err);
881
+ emitProgress('error', err.message);
882
+ });
883
+
884
+ res.writeHead(200, { 'Content-Type': 'application/json' });
885
+ res.end(JSON.stringify({ ok: true }));
886
+ } catch (e) {
887
+ res.writeHead(500, { 'Content-Type': 'application/json' });
888
+ res.end(JSON.stringify({ error: e.message }));
889
+ }
890
+ });
891
+ return;
892
+ }
893
+
894
+ // ── POST /api/github/configure — configure automatic GitHub backup ──
895
+ if (url === '/api/github/configure' && req.method === 'POST') {
896
+ let body = '';
897
+ req.on('data', d => { body += d; });
898
+ req.on('end', async () => {
899
+ res.writeHead(200, {
900
+ 'Content-Type': 'application/x-ndjson',
901
+ 'Cache-Control': 'no-cache',
902
+ 'Connection': 'keep-alive',
903
+ 'Access-Control-Allow-Origin': '*',
904
+ });
905
+
906
+ const send = (data) => {
907
+ res.write(JSON.stringify(data) + '\n');
908
+ };
909
+
910
+ const log = (msg) => {
911
+ send({ type: 'log', msg });
912
+ };
913
+
914
+ const step = (stepIndex, status, msg) => {
915
+ send({ type: 'step', stepIndex, status, msg });
916
+ if (msg) log(msg);
917
+ };
918
+
919
+ try {
920
+ const { token } = JSON.parse(body || '{}');
921
+ if (!token) {
922
+ throw new Error('Missing Personal Access Token.');
923
+ }
924
+
925
+ // ── Step 1: Authenticate with GitHub ──
926
+ step(1, 'active', '🔄 Authenticating with GitHub API...');
927
+ const userRes = await fetch('https://api.github.com/user', {
928
+ headers: {
929
+ 'Authorization': `token ${token}`,
930
+ 'Accept': 'application/vnd.github.v3+json',
931
+ 'User-Agent': 'Total-Recall-Wizard'
932
+ }
933
+ });
934
+
935
+ if (!userRes.ok) {
936
+ const errText = await userRes.text();
937
+ throw new Error(`GitHub Authentication failed (status ${userRes.status}). Ensure your token is correct and has the required scopes.`);
938
+ }
939
+
940
+ const userData = await userRes.json();
941
+ const username = userData.login;
942
+ step(1, 'success', `✅ Authenticated successfully as @${username}`);
943
+
944
+ // ── Step 2: Create Private Repo ──
945
+ step(2, 'active', '🔄 Provisioning private repository "total-recall-brain" on GitHub...');
946
+ const repoRes = await fetch('https://api.github.com/user/repos', {
947
+ method: 'POST',
948
+ headers: {
949
+ 'Authorization': `token ${token}`,
950
+ 'Accept': 'application/vnd.github.v3+json',
951
+ 'Content-Type': 'application/json',
952
+ 'User-Agent': 'Total-Recall-Wizard'
953
+ },
954
+ body: JSON.stringify({
955
+ name: 'total-recall-brain',
956
+ private: true,
957
+ description: 'Total Recall Sovereign AI memory backup (encrypted VFS)',
958
+ has_issues: false,
959
+ has_projects: false,
960
+ has_wiki: false
961
+ })
962
+ });
963
+
964
+ if (repoRes.status === 201) {
965
+ step(2, 'success', '✅ Private repository "total-recall-brain" successfully created!');
966
+ } else if (repoRes.status === 422) {
967
+ // Gracefully handle if repository already exists
968
+ const repoErr = await repoRes.json();
969
+ if (repoErr.errors && repoErr.errors.some(e => e.message && e.message.includes('already exists'))) {
970
+ step(2, 'success', 'ℹ️ Repository "total-recall-brain" already exists. Continuing with configuration.');
971
+ } else {
972
+ throw new Error(`Failed to create repository: ${JSON.stringify(repoErr)}`);
973
+ }
974
+ } else {
975
+ const errText = await repoRes.text();
976
+ throw new Error(`Failed to create repository (status ${repoRes.status}): ${errText}`);
977
+ }
978
+
979
+ const backupUrl = `https://${username}:${token}@github.com/${username}/total-recall-brain.git`;
980
+
981
+ // ── Step 3: Setup Local Git Repository ──
982
+ step(3, 'active', '🔄 Setting up git repository in ~/.agent...');
983
+ const agentDir = path.join(os.homedir(), '.agent');
984
+ fs.mkdirSync(agentDir, { recursive: true });
985
+
986
+ const git = (...args) => {
987
+ const result = spawnSync('git', args, { cwd: agentDir, encoding: 'utf8' });
988
+ return {
989
+ ok: result.status === 0,
990
+ stdout: result.stdout?.trim() || '',
991
+ stderr: result.stderr?.trim() || '',
992
+ };
993
+ };
994
+
995
+ const gitDir = path.join(agentDir, '.git');
996
+ if (!fs.existsSync(gitDir)) {
997
+ log('🔧 Initializing git repository in ~/.agent');
998
+ const init = git('init', '-b', 'main');
999
+ if (!init.ok) {
1000
+ git('init');
1001
+ git('checkout', '-b', 'main');
1002
+ }
1003
+ const gitignore = path.join(agentDir, '.gitignore');
1004
+ if (!fs.existsSync(gitignore)) {
1005
+ fs.writeFileSync(gitignore, 'skills/total-recall/memory-derived/embeddings.jsonl\n');
1006
+ log('📝 Created default .gitignore');
1007
+ }
1008
+ }
1009
+
1010
+ // Configure identities if missing so commit doesn't fail
1011
+ const checkEmail = git('config', 'user.email');
1012
+ if (!checkEmail.ok || !checkEmail.stdout) {
1013
+ log('🔧 Setting local git email to backup@total-recall');
1014
+ git('config', 'user.email', 'backup@total-recall');
1015
+ }
1016
+ const checkName = git('config', 'user.name');
1017
+ if (!checkName.ok || !checkName.stdout) {
1018
+ log('🔧 Setting local git name to Total Recall');
1019
+ git('config', 'user.name', 'Total Recall');
1020
+ }
1021
+
1022
+ // Add or update remote
1023
+ const remoteCheck = git('remote', 'get-url', 'backup');
1024
+ if (!remoteCheck.ok) {
1025
+ git('remote', 'add', 'backup', backupUrl);
1026
+ log('🔗 Added remote "backup" pointing to your private GitHub repository');
1027
+ } else {
1028
+ git('remote', 'set-url', 'backup', backupUrl);
1029
+ log('🔗 Updated remote "backup" URL');
1030
+ }
1031
+
1032
+ // Optimize Git http.postBuffer for pushing large directories (e.g. 500MB)
1033
+ log('⚙️ Optimizing buffer size to prevent push failures (git config http.postBuffer 524288000)...');
1034
+ git('config', 'http.postBuffer', '524288000');
1035
+ git('config', 'core.bigFileThreshold', '10m');
1036
+
1037
+ step(3, 'success', '✅ Local repository optimized and configured successfully!');
1038
+
1039
+ // ── Step 4: Perform Initial Push ──
1040
+ step(4, 'active', '🔄 Staging and pushing initial vault backup (this may take a minute)...');
1041
+ log('📂 Staging files in ~/.agent...');
1042
+ git('add', '-A');
1043
+
1044
+ const status = git('status', '--porcelain');
1045
+ const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
1046
+
1047
+ if (!status.stdout) {
1048
+ log('✅ No new changes to backup.');
1049
+ } else {
1050
+ log('💾 Creating backup commit...');
1051
+ const commit = git('commit', '-m', `backup: setup ${stamp}`);
1052
+ if (!commit.ok) {
1053
+ log(`⚠️ Commit output: ${commit.stderr}`);
1054
+ }
1055
+ }
1056
+
1057
+ log('📤 Pushing files to GitHub main branch...');
1058
+ const push = git('push', 'backup', 'HEAD:main', '--force');
1059
+ if (!push.ok) {
1060
+ throw new Error(`Push failed: ${push.stderr}`);
1061
+ }
1062
+
1063
+ step(4, 'success', '✅ Initial backup successfully pushed to your GitHub repository!');
1064
+
1065
+ // ── Step 5: Register Daily Backup Scheduler ──
1066
+ step(5, 'active', '🔄 Installing automated daily backup scheduler (2:00 AM)...');
1067
+
1068
+ const nodeBin = process.execPath;
1069
+ const scriptPath = new URL('../../bin/total-recall.mjs', import.meta.url).pathname;
1070
+
1071
+ log(`⚙️ Running setup deploy helper to install cron/launchd plist...`);
1072
+ const schedResult = spawnSync(nodeBin, [scriptPath, 'deploy', '--backup-repo', backupUrl], {
1073
+ encoding: 'utf8',
1074
+ env: { ...process.env, HOME: os.homedir() }
1075
+ });
1076
+
1077
+ if (schedResult.status !== 0) {
1078
+ log(`⚠️ Scheduler setup stdout: ${schedResult.stdout}`);
1079
+ log(`⚠️ Scheduler setup stderr: ${schedResult.stderr}`);
1080
+ throw new Error(`Failed to configure automatic backup scheduler: ${schedResult.stderr || schedResult.stdout}`);
1081
+ }
1082
+
1083
+ if (schedResult.stdout) {
1084
+ log(schedResult.stdout);
1085
+ }
1086
+ step(5, 'success', '✅ Automated daily backup scheduler successfully registered!');
1087
+
1088
+ // ── Step 6: Store Secrets ──
1089
+ log('🔒 Saving token securely in local files...');
1090
+ const configDir = path.join(_brainDir(), 'config');
1091
+
1092
+ // Persist to secrets.enc
1093
+ const secretsPath = path.join(configDir, 'secrets.enc');
1094
+ let secrets = {};
1095
+ if (fs.existsSync(secretsPath)) {
1096
+ try { secrets = JSON.parse(fs.readFileSync(secretsPath, 'utf8') || '{}'); } catch {}
1097
+ }
1098
+ secrets.github_token = token;
1099
+ fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2), { encoding: 'utf8', mode: 0o600 });
1100
+
1101
+ // Persist to wizard-config.json
1102
+ const configFile = path.join(configDir, 'wizard-config.json');
1103
+ let current = {};
1104
+ if (fs.existsSync(configFile)) {
1105
+ try { current = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}'); } catch {}
1106
+ }
1107
+ current['cfg-github-token'] = token;
1108
+ current['backupRepo'] = backupUrl;
1109
+ fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1110
+
1111
+ log('✅ Encryption keys and tokens written.');
1112
+
1113
+ send({
1114
+ type: 'success',
1115
+ msg: `GitHub Automatic Backup has been configured! Your brain is backed up to the private total-recall-brain repository, and the daily backup scheduler (2:00 AM) is fully active.`
1116
+ });
1117
+ } catch (err) {
1118
+ log(`❌ Error: ${err.message}`);
1119
+ send({ type: 'error', msg: err.message });
1120
+ } finally {
1121
+ res.end();
1122
+ }
1123
+ });
1124
+ return;
1125
+ }
1126
+
1127
+ // ── POST /api/install-cli ──
1128
+ if (url === '/api/install-cli' && req.method === 'POST') {
1129
+ let body = '';
1130
+ req.on('data', d => { body += d; });
1131
+ req.on('end', async () => {
1132
+ try {
1133
+ const { agent } = JSON.parse(body || '{}');
1134
+ if (!agent) {
1135
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1136
+ res.end(JSON.stringify({ error: 'Missing agent parameter' }));
1137
+ return;
1138
+ }
1139
+
1140
+ console.log(`[Wizard] Triggering installation for CLI agent: ${agent}`);
1141
+
1142
+ let cmd = '';
1143
+ if (agent === 'claude-code') {
1144
+ cmd = 'npm install -g @anthropic-ai/claude-code';
1145
+ } else if (agent === 'antigravity') {
1146
+ cmd = 'npm link';
1147
+ } else if (agent === 'codex') {
1148
+ cmd = 'npm install -g @openai/codex || true';
1149
+ } else {
1150
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1151
+ res.end(JSON.stringify({ error: `Unknown agent: ${agent}` }));
1152
+ return;
1153
+ }
1154
+
1155
+ // Run locally or remotely depending on deployTarget
1156
+ syncInstallOptionsFromDisk();
1157
+ if (_installOptions && (_installOptions.deployTarget === 'vastai' || _installOptions.deployTarget === 'localnet')) {
1158
+ let host = '';
1159
+ let port = '';
1160
+ let user = 'root';
1161
+ let cmdPrefix = 'export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH";';
1162
+
1163
+ if (_installOptions.deployTarget === 'vastai') {
1164
+ host = _installOptions.vastSshHost;
1165
+ port = _installOptions.vastSshPort;
1166
+ user = 'root';
1167
+ } else {
1168
+ host = _installOptions.localnetHost;
1169
+ user = _installOptions.localnetUser || 'root';
1170
+ }
1171
+
1172
+ if (host) {
1173
+ const sshArgs = [];
1174
+ if (port) sshArgs.push('-p', String(port));
1175
+ sshArgs.push('-o', 'StrictHostKeyChecking=no');
1176
+ sshArgs.push('-o', 'ConnectTimeout=10');
1177
+ sshArgs.push(`${user}@${host}`);
1178
+
1179
+ // Execute the command remotely
1180
+ const remoteCmd = `${cmdPrefix} cd /Users/gregoryiteen/github/total-recall && ${cmd}`;
1181
+ sshArgs.push(remoteCmd);
1182
+
1183
+ console.log(`[Proxy install-cli] Remote SSH command: ssh ${sshArgs.join(' ')}`);
1184
+ const { spawnSync: sp } = await import('node:child_process');
1185
+ const r = sp('ssh', sshArgs, { encoding: 'utf8', timeout: 60000 });
1186
+
1187
+ if (r.status === 0) {
1188
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1189
+ res.end(JSON.stringify({ ok: true, output: r.stdout }));
1190
+ } else {
1191
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1192
+ res.end(JSON.stringify({ error: r.stderr || r.stdout || 'Remote installation failed' }));
1193
+ }
1194
+ return;
1195
+ }
1196
+ }
1197
+
1198
+ // Local installation
1199
+ const { spawnSync: sp } = await import('node:child_process');
1200
+ console.log(`[Wizard] Running local command: ${cmd}`);
1201
+ const r = sp('sh', ['-c', cmd], { encoding: 'utf8', timeout: 60000, cwd: process.cwd() });
1202
+
1203
+ if (r.status === 0) {
1204
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1205
+ res.end(JSON.stringify({ ok: true, output: r.stdout }));
1206
+ } else {
1207
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1208
+ res.end(JSON.stringify({ error: r.stderr || r.stdout || 'Local installation failed' }));
1209
+ }
1210
+ } catch (e) {
1211
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1212
+ res.end(JSON.stringify({ error: e.message }));
1213
+ }
1214
+ });
1215
+ return;
1216
+ }
1217
+
1218
+ // ── Serve wizard HTML for everything else ──
1219
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
1220
+ res.end(HTML);
1221
+ });
1222
+
1223
+
1224
+ _server.listen(port, '127.0.0.1', () => {
1225
+ resolve(`http://localhost:${port}`);
1226
+ });
1227
+ });
1228
+ }
1229
+
1230
+ // ── Vast.ai auto-provisioner ────────────────────────────────────────────────────────
1231
+
1232
+ export async function vastAPI(key, method, path, body) {
1233
+ const { default: https } = await import('node:https');
1234
+ return new Promise((resolve) => {
1235
+ const data = body ? JSON.stringify(body) : null;
1236
+ const url = new URL(`/api/v0${path}`, 'https://console.vast.ai');
1237
+ const opts = {
1238
+ method,
1239
+ headers: {
1240
+ 'Authorization': `Bearer ${key}`,
1241
+ 'Content-Type': 'application/json',
1242
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
1243
+ },
1244
+ };
1245
+ const req = https.request(url, opts, (r) => {
1246
+ let buf = '';
1247
+ r.on('data', c => { buf += c; });
1248
+ r.on('end', () => {
1249
+ const isSuccessStatus = r.statusCode >= 200 && r.statusCode < 300;
1250
+ let parsed = null;
1251
+ try {
1252
+ parsed = JSON.parse(buf);
1253
+ } catch {
1254
+ parsed = null;
1255
+ }
1256
+
1257
+ if (!isSuccessStatus) {
1258
+ console.error(`[Vast.ai API Error] ${method} ${path} -> HTTP ${r.statusCode}:`, buf);
1259
+ resolve({
1260
+ success: false,
1261
+ error: 'http_error',
1262
+ status: r.statusCode,
1263
+ msg: (parsed && (parsed.msg || parsed.error)) || buf.trim() || `HTTP status ${r.statusCode}`
1264
+ });
1265
+ return;
1266
+ }
1267
+
1268
+ if (parsed === null) {
1269
+ console.error(`[Vast.ai API Error] Failed to parse JSON response for ${method} ${path}:`, buf);
1270
+ resolve({
1271
+ success: false,
1272
+ error: 'parse_error',
1273
+ msg: buf.trim() || 'Empty response'
1274
+ });
1275
+ return;
1276
+ }
1277
+
1278
+ resolve(parsed);
1279
+ });
1280
+ });
1281
+ req.on('error', (err) => {
1282
+ console.error(`[Vast.ai API Network Error] ${method} ${path}:`, err);
1283
+ resolve({
1284
+ success: false,
1285
+ error: 'network_error',
1286
+ msg: err.message
1287
+ });
1288
+ });
1289
+ if (data) req.write(data);
1290
+ req.end();
1291
+ });
1292
+ }
1293
+
1294
+ async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
1295
+
1296
+ async function isTunnelUrlAlive(url) {
1297
+ try {
1298
+ const healthUrl = `${url}/health`;
1299
+ console.log(`[Health Check] Checking if remote endpoint is responsive: ${healthUrl}`);
1300
+ const controller = new AbortController();
1301
+ const timeoutId = setTimeout(() => controller.abort(), 4000);
1302
+ const res = await fetch(healthUrl, { signal: controller.signal });
1303
+ clearTimeout(timeoutId);
1304
+ console.log(`[Health Check] Remote endpoint responded with status: ${res.status}`);
1305
+ if (res.status === 200 || res.status === 401 || res.status === 403 || res.status === 503) {
1306
+ return true;
1307
+ }
1308
+ } catch (err) {
1309
+ console.log(`[Health Check] Connection failed for ${url}: ${err.message}`);
1310
+ }
1311
+ return false;
1312
+ }
1313
+
1314
+ export async function provisionVastAI(apiKey, instanceId = null, dashboardPassword = 'totalrecall') {
1315
+ let finalInstanceId = instanceId;
1316
+ const isAdopting = !!finalInstanceId;
1317
+ const maxAttempts = isAdopting ? 1 : 5;
1318
+ const failedOfferIds = new Set();
1319
+ let attempt = 0;
1320
+
1321
+ while (attempt < maxAttempts) {
1322
+ attempt++;
1323
+ if (!isAdopting) {
1324
+ if (attempt > 1) {
1325
+ emitProgress('log', `🔄 Retry attempt ${attempt}/${maxAttempts}: Finding another GPU offer...`);
1326
+ }
1327
+ emitProgress('log', '🔍 Searching for available GPU instances on Vast.ai...');
1328
+
1329
+ // Search for cheapest compatible GPU with Ubuntu, enough disk (strictly 24GB+ VRAM for the 26B MoE model)
1330
+ const queryObj = {
1331
+ gpu_name: {
1332
+ in: [
1333
+ "RTX 3090", "RTX 4090", "A5000", "RTX A5000", "A6000", "RTX A6000",
1334
+ "RTX 6000 Ada", "A100", "L40", "L40S"
1335
+ ]
1336
+ },
1337
+ disk_space: { gte: 40 },
1338
+ reliability2: { gte: 0.9 },
1339
+ rentable: { eq: true },
1340
+ order: [["dph_total", "asc"]],
1341
+ limit: 10
1342
+ };
1343
+
1344
+ const path = `/bundles/?q=${encodeURIComponent(JSON.stringify(queryObj))}`;
1345
+ console.log(`[Vast.ai] Querying available offers using path: ${path}`);
1346
+ const offers = await vastAPI(apiKey, 'GET', path);
1347
+
1348
+ if (offers.success === false) {
1349
+ console.error('[Vast.ai] API query failed:', offers);
1350
+ emitProgress('log', `⚠️ Vast.ai API query warning: ${offers.msg || offers.error || 'Unknown error'}`);
1351
+ if (attempt === maxAttempts) {
1352
+ emitProgress('error', `❌ Vast.ai API query failed repeatedly. Last error: ${offers.msg || offers.error || 'Unknown error'}`);
1353
+ return;
1354
+ }
1355
+ await sleep(5000);
1356
+ continue;
1357
+ }
1358
+
1359
+ const offerList = (offers.offers || []).filter(o => {
1360
+ if (!o.rentable || failedOfferIds.has(o.id)) return false;
1361
+ const geo = (o.geolocation || '').toLowerCase();
1362
+ if (geo.includes('cn') || geo.includes('china') || geo.includes('russia') || geo.includes('ru')) {
1363
+ console.log(`[Vast.ai] Skipping offer ${o.id} due to Chinese/Russian geolocation: ${o.geolocation}`);
1364
+ return false;
1365
+ }
1366
+ return true;
1367
+ });
1368
+ if (!offerList.length) {
1369
+ console.warn('[Vast.ai] No suitable rentable GPU offers found.');
1370
+ emitProgress('log', '⚠️ No suitable GPU offers available right now or all tried offers failed.');
1371
+ if (attempt === maxAttempts) {
1372
+ emitProgress('error', '❌ No suitable GPU instances available right now. Try again in a few minutes.');
1373
+ return;
1374
+ }
1375
+ await sleep(5000);
1376
+ continue;
1377
+ }
1378
+
1379
+ const best = offerList[0];
1380
+ failedOfferIds.add(best.id);
1381
+ emitProgress('log', `✅ Found: ${best.gpu_name} — $${(best.dph_total * 24 * 30).toFixed(2)}/mo at ${best.location?.country || 'unknown location'}`);
1382
+ emitProgress('log', '🚀 Creating your instance...');
1383
+
1384
+ // Create the instance
1385
+ const created = await vastAPI(apiKey, 'PUT', `/asks/${best.id}/`, {
1386
+ client_id: 'me',
1387
+ image: 'nvidia/cuda:12.1.1-runtime-ubuntu22.04',
1388
+ disk: 40,
1389
+ onstart: 'export AUTO_DEPLOY=1; export HTTPS_METHOD=cloudflare-quick; (apt-get update && apt-get install -y zstd) >/dev/null 2>&1; curl -fsSL https://raw.githubusercontent.com/gregiteen/total-recall/main/install.sh -o /tmp/install.sh && bash /tmp/install.sh && rm /tmp/install.sh',
1390
+ env: {},
1391
+ runtype: 'ssh',
1392
+ image_login: null,
1393
+ });
1394
+
1395
+ if (!created.success) {
1396
+ emitProgress('log', `⚠️ Failed to create instance on offer ${best.id}: ${created.msg || created.error || JSON.stringify(created)}`);
1397
+ if (attempt === maxAttempts) {
1398
+ emitProgress('error', `❌ Failed to create instance after ${maxAttempts} attempts.`);
1399
+ return;
1400
+ }
1401
+ await sleep(5000);
1402
+ continue;
1403
+ }
1404
+
1405
+ finalInstanceId = created.new_contract;
1406
+ emitProgress('log', `✅ Instance created (ID: ${finalInstanceId}). Waiting for it to boot...`);
1407
+ } else {
1408
+ emitProgress('log', `🔄 Adopting existing Vast.ai instance (ID: ${finalInstanceId}). Waiting for it to boot...`);
1409
+ }
1410
+
1411
+ // Poll until running
1412
+ let instance = null;
1413
+ let errorCount = 0;
1414
+ let pollFailed = false;
1415
+
1416
+ for (let i = 0; i < 120; i++) {
1417
+ await sleep(15000);
1418
+
1419
+ // Query general /instances/ due to Vast.ai API GET /instances/{id}/ bug
1420
+ const status = await vastAPI(apiKey, 'GET', '/instances/');
1421
+
1422
+ if (status.success === false) {
1423
+ errorCount++;
1424
+ console.warn(`[Vast.ai API Poll Warning] Attempt ${i + 1} failed:`, status);
1425
+ emitProgress('log', `⚠️ Vast.ai API temporary warning: ${status.msg || status.error || 'Network query issue'} (Retrying...)`);
1426
+ if (errorCount >= 10) {
1427
+ emitProgress('log', `⚠️ Vast.ai API failed repeatedly. Message: ${status.msg || status.error || 'Unknown error'}`);
1428
+ pollFailed = true;
1429
+ break;
1430
+ }
1431
+ continue;
1432
+ }
1433
+
1434
+ // Reset error count on successful query
1435
+ errorCount = 0;
1436
+
1437
+ const allInstances = status.instances || [];
1438
+ instance = allInstances.find(inst => inst.id === Number(finalInstanceId));
1439
+
1440
+ if (!instance) {
1441
+ // Right after creation, sometimes it takes a few seconds to appear in the instances list
1442
+ if (i < 3) {
1443
+ emitProgress('log', `⏳ Instance ID ${finalInstanceId} not visible in account yet. Waiting for Vast.ai synchronization...`);
1444
+ continue;
1445
+ }
1446
+ emitProgress('log', `⚠️ Instance ${finalInstanceId} was not found in your Vast.ai account. It may have been rejected or deleted by the host.`);
1447
+ pollFailed = true;
1448
+ break;
1449
+ }
1450
+
1451
+ const state = instance.actual_status || instance.status || 'loading';
1452
+ const statusMsg = instance.status_msg || '';
1453
+
1454
+ // Display status with detail if available
1455
+ if (statusMsg) {
1456
+ emitProgress('log', `⏳ Instance status: ${state} (${statusMsg})...`);
1457
+ } else {
1458
+ emitProgress('log', `⏳ Instance status: ${state}...`);
1459
+ }
1460
+
1461
+ if (state === 'running') {
1462
+ break;
1463
+ }
1464
+
1465
+ // Early failure detection: stopped, offline, or broken, or having daemon errors in status_msg
1466
+ const hasCriticalError = statusMsg && (statusMsg.toLowerCase().includes('error') || statusMsg.toLowerCase().includes('failed'));
1467
+ if (state === 'broken' || state === 'offline' || state === 'stopped' || hasCriticalError) {
1468
+ emitProgress('log', `⚠️ Instance failed to boot (state: '${state}'): ${statusMsg || 'Host failed to start container.'}`);
1469
+ pollFailed = true;
1470
+ break;
1471
+ }
1472
+ }
1473
+
1474
+ if (pollFailed || !instance || (instance.actual_status !== 'running' && instance.status !== 'running')) {
1475
+ if (finalInstanceId && !isAdopting) {
1476
+ // Best effort destroy the failed/broken contract so the user doesn't get charged
1477
+ console.log(`[Vast.ai] Best-effort destroying failed instance ${finalInstanceId}`);
1478
+ await vastAPI(apiKey, 'DELETE', `/instances/${finalInstanceId}/`);
1479
+ }
1480
+
1481
+ if (attempt < maxAttempts) {
1482
+ emitProgress('log', `⚠️ Booting failed or instance rejected for ID ${finalInstanceId || ''}. Retrying...`);
1483
+ finalInstanceId = null;
1484
+ await sleep(5000);
1485
+ continue;
1486
+ } else {
1487
+ emitProgress('error', `❌ Provisioning failed after ${maxAttempts} attempts. Check the error log or your Vast.ai dashboard.`);
1488
+ return;
1489
+ }
1490
+ }
1491
+
1492
+ // Succeeded!
1493
+ emitProgress('log', '✅ Instance is running! Total Recall is installing...');
1494
+ emitProgress('log', `📍 SSH: ssh -p ${instance.ssh_port} root@${instance.ssh_host}`);
1495
+ emitProgress('log', '⏳ Waiting for remote SSH server to start...');
1496
+
1497
+ // Wait for SSH to boot
1498
+ let sshReady = false;
1499
+ for (let attempt = 1; attempt <= 36; attempt++) {
1500
+ try {
1501
+ const res = spawnSync('ssh', [
1502
+ '-p', String(instance.ssh_port),
1503
+ '-o', 'StrictHostKeyChecking=no',
1504
+ '-o', 'ConnectTimeout=5',
1505
+ `root@${instance.ssh_host}`,
1506
+ 'echo "SSH_OK"'
1507
+ ], { encoding: 'utf8', timeout: 6000 });
1508
+ if (res.stdout && res.stdout.includes('SSH_OK')) {
1509
+ sshReady = true;
1510
+ break;
1511
+ }
1512
+ } catch (e) {
1513
+ console.error(`SSH check attempt ${attempt} failed:`, e.message);
1514
+ }
1515
+ emitProgress('log', `⏳ Waiting for SSH server to start (attempt ${attempt}/36)...`);
1516
+ await sleep(5000);
1517
+ }
1518
+
1519
+ if (!sshReady) {
1520
+ emitProgress('error', '❌ Could not connect to remote instance via SSH after 3 minutes. Please check your SSH keys or retry provisioning.');
1521
+ return;
1522
+ }
1523
+ emitProgress('log', '✅ SSH connection established! Streaming installation log...');
1524
+
1525
+ // Stream logs via tail in the background
1526
+ const tailProc = spawn('ssh', [
1527
+ '-p', String(instance.ssh_port),
1528
+ '-o', 'StrictHostKeyChecking=no',
1529
+ `root@${instance.ssh_host}`,
1530
+ 'tail -f -n +1 /var/log/onstart.log 2>/dev/null'
1531
+ ]);
1532
+
1533
+ let tunnelUrl = null;
1534
+ let buffer = '';
1535
+
1536
+ const handleData = (data) => {
1537
+ buffer += data.toString();
1538
+ const lines = buffer.split(/\r?\n/);
1539
+ buffer = lines.pop(); // keep last incomplete line in buffer
1540
+
1541
+ for (const line of lines) {
1542
+ if (!line.trim()) continue;
1543
+ emitProgress('log', `[Remote VM] ${line}`);
1544
+
1545
+ // Detect the trycloudflare URL!
1546
+ const match = line.match(/https:\/\/.*\.trycloudflare\.com/);
1547
+ if (match) {
1548
+ console.log(`[Remote VM] Log streamed trycloudflare candidate: ${match[0]}`);
1549
+ }
1550
+ }
1551
+ };
1552
+
1553
+ tailProc.stdout.on('data', handleData);
1554
+ tailProc.stderr.on('data', handleData);
1555
+ tailProc.on('error', (err) => {
1556
+ console.error('Tail process error:', err);
1557
+ });
1558
+
1559
+ // Poll in parallel for the trycloudflare URL
1560
+ let pollCount = 0;
1561
+ while (!tunnelUrl && pollCount < 360) { // poll for up to 30 minutes
1562
+ pollCount++;
1563
+ await sleep(5000);
1564
+ try {
1565
+ const res = spawnSync('ssh', [
1566
+ '-p', String(instance.ssh_port),
1567
+ '-o', 'StrictHostKeyChecking=no',
1568
+ `root@${instance.ssh_host}`,
1569
+ 'grep -o "https://.*\\.trycloudflare\\.com" /var/log/onstart.log ~/.agent/logs/cloudflared.log 2>/dev/null | tail -n 1'
1570
+ ], { encoding: 'utf8', timeout: 5000 });
1571
+ if (res.stdout && res.stdout.includes('trycloudflare.com')) {
1572
+ const found = res.stdout.match(/https:\/\/.*\.trycloudflare\.com/);
1573
+ if (found) {
1574
+ const candidateUrl = found[0];
1575
+ const alive = await isTunnelUrlAlive(candidateUrl);
1576
+ if (alive) {
1577
+ tunnelUrl = candidateUrl;
1578
+ break;
1579
+ } else {
1580
+ console.log(`[Remote VM] Found tunnel candidate ${candidateUrl} but it is not responsive yet.`);
1581
+ }
1582
+ }
1583
+ }
1584
+ } catch (e) {
1585
+ // ignore polling SSH errors
1586
+ }
1587
+ }
1588
+
1589
+ // Cleanup tail process
1590
+ try { tailProc.kill(); } catch {}
1591
+
1592
+ if (!tunnelUrl) {
1593
+ emitProgress('error', '❌ Installation completed but failed to resolve Cloudflare Quick Tunnel URL. Please check the remote VM logs.');
1594
+ return;
1595
+ }
1596
+
1597
+ // Configure remote password securely once SSH is ready and tunnel is verified
1598
+ if (dashboardPassword) {
1599
+ emitProgress('log', '🔒 Configuring remote admin dashboard password...');
1600
+ try {
1601
+ const hash = bcrypt.hashSync(dashboardPassword, BCRYPT_COST);
1602
+ const forceReset = (!dashboardPassword || dashboardPassword === 'totalrecall');
1603
+ // We run a remote node one-liner to update security.yml on the remote VM
1604
+ const remoteScript = `
1605
+ const fs = require('fs');
1606
+ const path = require('path');
1607
+ const os = require('os');
1608
+
1609
+ // Wait for config dir and security.yml to exist if needed
1610
+ const securityFile = path.join(os.homedir(), '.agent', 'skills', 'total-recall', 'config', 'security.yml');
1611
+ for (let i = 0; i < 15; i++) {
1612
+ if (fs.existsSync(securityFile)) break;
1613
+ require('child_process').spawnSync('sleep', ['1']);
1614
+ }
1615
+
1616
+ if (!fs.existsSync(securityFile)) {
1617
+ fs.mkdirSync(path.dirname(securityFile), { recursive: true });
1618
+ fs.writeFileSync(securityFile, 'dashboard:\\n password_hash: null\\n force_password_reset: ${forceReset}\\n', 'utf8');
1619
+ }
1620
+
1621
+ let content = fs.readFileSync(securityFile, 'utf8');
1622
+ if (content.includes('dashboard:')) {
1623
+ const lines = content.split('\\n');
1624
+ let inDashboard = false;
1625
+ let hashSet = false;
1626
+ let forceResetSet = false;
1627
+
1628
+ for (let j = 0; j < lines.length; j++) {
1629
+ const line = lines[j];
1630
+ if (line.startsWith('dashboard:')) {
1631
+ inDashboard = true;
1632
+ continue;
1633
+ }
1634
+ if (inDashboard && (line.startsWith('privacy:') || line.startsWith('yolo_mode:') || line.startsWith('api:') || line.startsWith('rate_limits:') || line.startsWith('bind:') || line.startsWith('network:'))) {
1635
+ inDashboard = false;
1636
+ }
1637
+
1638
+ if (inDashboard) {
1639
+ if (line.includes('password_hash:')) {
1640
+ lines[j] = ' password_hash: "${hash}"';
1641
+ hashSet = true;
1642
+ }
1643
+ if (line.includes('force_password_reset:')) {
1644
+ lines[j] = ' force_password_reset: ${forceReset}';
1645
+ forceResetSet = true;
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ // Find where dashboard starts to inject missing fields
1651
+ const dbIndex = lines.findIndex(l => l.startsWith('dashboard:'));
1652
+ if (dbIndex !== -1) {
1653
+ if (!hashSet) {
1654
+ lines.splice(dbIndex + 1, 0, ' password_hash: "${hash}"');
1655
+ }
1656
+ if (!forceResetSet) {
1657
+ lines.splice(dbIndex + 1, 0, ' force_password_reset: ${forceReset}');
1658
+ }
1659
+ }
1660
+
1661
+ fs.writeFileSync(securityFile, lines.join('\\n'), 'utf8');
1662
+ } else {
1663
+ content += '\\ndashboard:\\n password_hash: "${hash}"\\n force_password_reset: ${forceReset}\\n';
1664
+ fs.writeFileSync(securityFile, content, 'utf8');
1665
+ }
1666
+ console.log("REMOTE_PASS_OK");
1667
+ `;
1668
+
1669
+ const res = spawnSync('ssh', [
1670
+ '-p', String(instance.ssh_port),
1671
+ '-o', 'StrictHostKeyChecking=no',
1672
+ `root@${instance.ssh_host}`,
1673
+ `node -e ${JSON.stringify(remoteScript)}`
1674
+ ], { encoding: 'utf8', timeout: 15000 });
1675
+
1676
+ console.log('[Remote Security Setup] stdout:', res.stdout);
1677
+ console.log('[Remote Security Setup] stderr:', res.stderr);
1678
+ if (res.stdout && res.stdout.includes('REMOTE_PASS_OK')) {
1679
+ emitProgress('log', '🔒 Remote admin dashboard password configured successfully.');
1680
+ } else {
1681
+ emitProgress('log', '⚠️ Remote admin password setup command ran, but check remote logs.');
1682
+ }
1683
+ } catch (err) {
1684
+ emitProgress('log', `⚠️ Failed to configure remote password automatically: ${err.message}`);
1685
+ }
1686
+ }
1687
+
1688
+ emitProgress('log', `🎉 Total Recall is fully deployed and accessible at: ${tunnelUrl}`);
1689
+
1690
+ // Store instance info for later phases
1691
+ _installOptions = _installOptions || {};
1692
+ _installOptions.vastInstanceId = finalInstanceId;
1693
+ _installOptions.vastSshHost = instance.ssh_host;
1694
+ _installOptions.vastSshPort = instance.ssh_port;
1695
+ _installOptions.domain = tunnelUrl.replace('https://', '');
1696
+ _installOptions.apiUrl = tunnelUrl;
1697
+ _installOptions.dashUrl = `${tunnelUrl}/dashboard`;
1698
+ _installOptions.healthUrl = `${tunnelUrl}/health`;
1699
+ _installOptions.deployTarget = 'vastai';
1700
+ _installOptions.skipLocalInstall = true;
1701
+ if (dashboardPassword) {
1702
+ _installOptions.dashboardPassword = dashboardPassword;
1703
+ }
1704
+
1705
+ // Persist to disk so it stays across restarts!
1706
+ try {
1707
+ const configDir = path.join(_brainDir(), 'config');
1708
+ const configFile = path.join(configDir, 'wizard-config.json');
1709
+ fs.mkdirSync(configDir, { recursive: true });
1710
+ let current = {};
1711
+ if (fs.existsSync(configFile)) {
1712
+ try { current = JSON.parse(fs.readFileSync(configFile, 'utf8') || '{}'); } catch {}
1713
+ }
1714
+ current.deployTarget = 'vastai';
1715
+ current.vastInstanceId = finalInstanceId;
1716
+ current.vastSshHost = instance.ssh_host;
1717
+ current.vastSshPort = instance.ssh_port;
1718
+ current['cfg-domain'] = _installOptions.domain;
1719
+ current['cfg-api-url'] = _installOptions.apiUrl;
1720
+ current['cfg-dash-url'] = _installOptions.dashUrl;
1721
+ current['cfg-health-url'] = _installOptions.healthUrl;
1722
+ if (_installOptions.dashboardPassword) {
1723
+ current['cfg-dashboard-password'] = _installOptions.dashboardPassword;
1724
+ }
1725
+ fs.writeFileSync(configFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 });
1726
+ } catch (e) {
1727
+ console.error('Failed to auto-persist remote install options on disk:', e);
1728
+ }
1729
+
1730
+ _installOptionsReceived = true;
1731
+ if (_resolveInstallOptions) {
1732
+ _resolveInstallOptions(_installOptions);
1733
+ _resolveInstallOptions = null;
1734
+ }
1735
+
1736
+ // Finish visual installation screen
1737
+ finishDeployUI({
1738
+ apiUrl: _installOptions.apiUrl,
1739
+ dashUrl: _installOptions.dashUrl,
1740
+ healthUrl: _installOptions.healthUrl
1741
+ });
1742
+ return;
1743
+ }
1744
+ }
1745
+
1746
+ // ─── SSE helpers ──────────────────────────────────────────────────────────────
1747
+
1748
+ function broadcast(payload) {
1749
+ const data = `data: ${JSON.stringify(payload)}\n\n`;
1750
+ _log.push(payload);
1751
+ for (const client of _clients) {
1752
+ try { client.write(data); } catch {}
1753
+ }
1754
+ }
1755
+
1756
+ export function emitProgress(type, msg) {
1757
+ broadcast({ type, msg, ts: Date.now() });
1758
+ }
1759
+
1760
+ export function finishDeployUI({ apiUrl, dashUrl, healthUrl } = {}) {
1761
+ _done = true;
1762
+ broadcast({ type: 'done', apiUrl, dashUrl, healthUrl, ts: Date.now() });
1763
+ setTimeout(() => {
1764
+ for (const client of _clients) { try { client.end(); } catch {} }
1765
+ _clients = [];
1766
+ if (_server) {
1767
+ try { _server.close(); } catch {}
1768
+ }
1769
+ const finalUrl = dashUrl || apiUrl || '';
1770
+ console.log(`\n 🎉 Installation successfully completed!`);
1771
+ console.log(` 📍 Deployed Brain Site URL: \u001b[36m${finalUrl}\u001b[0m`);
1772
+ console.log(` 📍 API Endpoint: \u001b[36m${apiUrl}\u001b[0m\n`);
1773
+ process.exit(0);
1774
+ }, 4000);
1775
+ }
1776
+
1777
+ // ─── Browser open ─────────────────────────────────────────────────────────────
1778
+
1779
+ export function openBrowser(url) {
1780
+ try {
1781
+ const platform = os.platform();
1782
+ if (platform === 'darwin') {
1783
+ spawnSync('open', [url], { stdio: 'ignore', timeout: 3000 });
1784
+ } else if (platform === 'linux') {
1785
+ for (const cmd of ['xdg-open', 'sensible-browser', 'google-chrome', 'firefox']) {
1786
+ const r = spawnSync(cmd, [url], { stdio: 'ignore', timeout: 2000 });
1787
+ if (r.status === 0) break;
1788
+ }
1789
+ } else if (platform === 'win32') {
1790
+ spawnSync('cmd', ['/c', 'start', url], { stdio: 'ignore', timeout: 3000 });
1791
+ }
1792
+ } catch { /* UI is optional — never crash deploy */ }
1793
+ }