@aliwey/bmo 2.0.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 (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * BMO postinstall — self-contained setup
4
+ * Runs automatically after: npm install -g @aliwey/bmo
5
+ *
6
+ * Steps:
7
+ * 1. Download & extract embedded Python 3.13 (minimal, to ~/.bmo/python/)
8
+ * 2. pip install -r requirements.txt into embedded Python
9
+ * 3. Ensure opencode-ai is installed
10
+ * 4. Download cloudflared binary to ~/.bmo/bin/
11
+ * 5. npm install webchat dependencies
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { execSync, spawnSync } = require('child_process');
17
+ const https = require('https');
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const os = require('os');
21
+ const { createWriteStream } = require('fs');
22
+
23
+ const BMO_HOME = process.env.BMO_HOME || path.join(os.homedir(), '.bmo');
24
+ const BMO_BIN = path.join(BMO_HOME, 'bin');
25
+ const BMO_PY = path.join(BMO_HOME, 'python');
26
+ const PKG_DIR = path.join(__dirname, '..');
27
+
28
+ // ── Python version ───────────────────────────────────────────────────────────
29
+ const PY_VERSION = '3.13.3';
30
+ const PY_URLS = {
31
+ win32: {
32
+ x64: `https://www.python.org/ftp/python/${PY_VERSION}/python-${PY_VERSION}-embed-amd64.zip`,
33
+ arm64:`https://www.python.org/ftp/python/${PY_VERSION}/python-${PY_VERSION}-embed-arm64.zip`,
34
+ },
35
+ darwin: {
36
+ any: `https://github.com/indygreg/python-build-standalone/releases/download/20250311/cpython-${PY_VERSION}+20250311-aarch64-apple-darwin-install_only_stripped.tar.gz`,
37
+ x64: `https://github.com/indygreg/python-build-standalone/releases/download/20250311/cpython-${PY_VERSION}+20250311-x86_64-apple-darwin-install_only_stripped.tar.gz`,
38
+ },
39
+ linux: {
40
+ x64: `https://github.com/indygreg/python-build-standalone/releases/download/20250311/cpython-${PY_VERSION}+20250311-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz`,
41
+ arm64:`https://github.com/indygreg/python-build-standalone/releases/download/20250311/cpython-${PY_VERSION}+20250311-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz`,
42
+ }
43
+ };
44
+
45
+ // ── Cloudflared version ──────────────────────────────────────────────────────
46
+ const CF_VERSION = '2025.4.2';
47
+ const CF_URLS = {
48
+ win32: `https://github.com/cloudflare/cloudflared/releases/download/${CF_VERSION}/cloudflared-windows-amd64.exe`,
49
+ darwin: `https://github.com/cloudflare/cloudflared/releases/download/${CF_VERSION}/cloudflared-darwin-amd64`,
50
+ linux: `https://github.com/cloudflare/cloudflared/releases/download/${CF_VERSION}/cloudflared-linux-amd64`,
51
+ };
52
+
53
+ // ── Helpers ──────────────────────────────────────────────────────────────────
54
+
55
+ const step = (n, msg) => console.log(`\n[${n}/5] ${msg}`);
56
+ const ok = msg => console.log(` āœ… ${msg}`);
57
+ const warn = msg => console.log(` āš ļø ${msg}`);
58
+ const err = msg => console.error(` āŒ ${msg}`);
59
+
60
+ function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
61
+
62
+ function download(url, dest) {
63
+ return new Promise((resolve, reject) => {
64
+ const file = createWriteStream(dest);
65
+ const get = (u) => https.get(u, res => {
66
+ if (res.statusCode === 301 || res.statusCode === 302) return get(res.headers.location);
67
+ if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} from ${u}`));
68
+ res.pipe(file);
69
+ file.on('finish', () => file.close(resolve));
70
+ }).on('error', reject);
71
+ get(url);
72
+ });
73
+ }
74
+
75
+ function run(cmd, opts = {}) {
76
+ const r = spawnSync(cmd, { shell: true, stdio: 'inherit', ...opts });
77
+ if (r.status !== 0) throw new Error(`Command failed: ${cmd}`);
78
+ }
79
+
80
+ function hasPython() {
81
+ for (const py of ['python3', 'python']) {
82
+ try {
83
+ const r = spawnSync(py, ['--version'], { encoding: 'utf8', stdio: 'pipe' });
84
+ if (r.status === 0) {
85
+ const ver = (r.stdout || r.stderr || '').match(/(\d+)\.(\d+)/);
86
+ if (ver && (parseInt(ver[1]) > 3 || (parseInt(ver[1]) === 3 && parseInt(ver[2]) >= 11))) {
87
+ return py;
88
+ }
89
+ }
90
+ } catch {}
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ── Step 1: Python ───────────────────────────────────────────────────────────
96
+
97
+ async function installPython() {
98
+ step(1, `Setting up Python ${PY_VERSION} (embedded, minimal)...`);
99
+
100
+ // Check if already installed in BMO_HOME
101
+ const embeddedExe = process.platform === 'win32'
102
+ ? path.join(BMO_PY, 'python.exe')
103
+ : path.join(BMO_PY, 'bin', 'python3');
104
+
105
+ if (fs.existsSync(embeddedExe)) {
106
+ ok(`Embedded Python already at ${BMO_PY}`);
107
+ return embeddedExe;
108
+ }
109
+
110
+ // Check system Python (3.11+)
111
+ const sysPy = hasPython();
112
+ if (sysPy) {
113
+ ok(`Using system Python: ${sysPy}`);
114
+ return sysPy;
115
+ }
116
+
117
+ // Download embedded Python
118
+ mkdirp(BMO_PY);
119
+ const plat = process.platform;
120
+ const arch = process.arch;
121
+ let url;
122
+
123
+ if (plat === 'win32') {
124
+ url = arch === 'arm64' ? PY_URLS.win32.arm64 : PY_URLS.win32.x64;
125
+ } else if (plat === 'darwin') {
126
+ url = arch === 'arm64' ? PY_URLS.darwin.any : PY_URLS.darwin.x64;
127
+ } else {
128
+ url = arch === 'arm64' ? PY_URLS.linux.arm64 : PY_URLS.linux.x64;
129
+ }
130
+
131
+ console.log(` Downloading Python from: ${url}`);
132
+ const archiveName = url.split('/').pop();
133
+ const archivePath = path.join(BMO_HOME, archiveName);
134
+ await download(url, archivePath);
135
+
136
+ // Extract
137
+ if (archiveName.endsWith('.zip')) {
138
+ run(`powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${BMO_PY}' -Force"`);
139
+ } else {
140
+ run(`tar -xzf "${archivePath}" -C "${BMO_PY}" --strip-components=1`);
141
+ }
142
+ fs.unlinkSync(archivePath);
143
+
144
+ ok(`Python ${PY_VERSION} installed at ${BMO_PY}`);
145
+ return embeddedExe;
146
+ }
147
+
148
+ // ── Step 2: pip install ───────────────────────────────────────────────────────
149
+
150
+ function installPipDeps(pythonExe) {
151
+ step(2, 'Installing Python dependencies (pip)...');
152
+ const reqFile = path.join(PKG_DIR, 'requirements.txt');
153
+ try {
154
+ // Ensure pip is available
155
+ run(`"${pythonExe}" -m ensurepip --upgrade`);
156
+ run(`"${pythonExe}" -m pip install --upgrade pip --quiet`);
157
+ run(`"${pythonExe}" -m pip install -r "${reqFile}" --quiet`);
158
+ ok('Python dependencies installed');
159
+ } catch (e) {
160
+ warn(`pip install failed: ${e.message}`);
161
+ warn('You may need to run: pip install -r requirements.txt manually');
162
+ }
163
+ }
164
+
165
+ // ── Step 3: opencode-ai ──────────────────────────────────────────────────────
166
+
167
+ function ensureOpenCode() {
168
+ step(3, 'Checking opencode-ai...');
169
+ try {
170
+ execSync('opencode --version', { stdio: 'ignore' });
171
+ ok('opencode-ai already installed');
172
+ } catch {
173
+ console.log(' Installing opencode-ai...');
174
+ try {
175
+ run('npm install -g opencode-ai');
176
+ ok('opencode-ai installed');
177
+ } catch {
178
+ warn('Could not install opencode-ai automatically.');
179
+ warn('Run manually: npm install -g opencode-ai');
180
+ }
181
+ }
182
+ }
183
+
184
+ // ── Step 4: cloudflared ──────────────────────────────────────────────────────
185
+
186
+ async function installCloudflared() {
187
+ step(4, 'Setting up cloudflared...');
188
+ mkdirp(BMO_BIN);
189
+
190
+ const plat = process.platform;
191
+ const cfExe = plat === 'win32'
192
+ ? path.join(BMO_BIN, 'cloudflared.exe')
193
+ : path.join(BMO_BIN, 'cloudflared');
194
+
195
+ if (fs.existsSync(cfExe)) {
196
+ ok('cloudflared already at ' + cfExe);
197
+ return;
198
+ }
199
+
200
+ // Check system cloudflared
201
+ try {
202
+ execSync('cloudflared --version', { stdio: 'ignore' });
203
+ ok('Using system cloudflared');
204
+ return;
205
+ } catch {}
206
+
207
+ const url = CF_URLS[plat] || CF_URLS.linux;
208
+ console.log(` Downloading cloudflared from: ${url}`);
209
+ try {
210
+ await download(url, cfExe);
211
+ if (plat !== 'win32') fs.chmodSync(cfExe, 0o755);
212
+ ok('cloudflared installed at ' + cfExe);
213
+ } catch (e) {
214
+ warn(`cloudflared download failed: ${e.message}`);
215
+ warn('Install manually: https://developers.cloudflare.com/cloudflared/downloads/');
216
+ }
217
+ }
218
+
219
+ // ── Step 5: webchat node_modules ─────────────────────────────────────────────
220
+
221
+ function installWebchatDeps() {
222
+ step(5, 'Installing webchat dependencies...');
223
+ const webchatDir = path.join(PKG_DIR, 'webchat');
224
+ if (!fs.existsSync(path.join(webchatDir, 'package.json'))) {
225
+ warn('webchat/package.json not found, skipping');
226
+ return;
227
+ }
228
+ try {
229
+ run(`npm install --prefix "${webchatDir}" --quiet`);
230
+ ok('webchat dependencies installed');
231
+ } catch (e) {
232
+ warn(`webchat npm install failed: ${e.message}`);
233
+ }
234
+ }
235
+
236
+ // ── Main ─────────────────────────────────────────────────────────────────────
237
+
238
+ (async () => {
239
+ console.log('\n╭──────────────────────────────────────────╮');
240
+ console.log('│ BMO Installer — @aliwey/bmo │');
241
+ console.log('╰──────────────────────────────────────────╯\n');
242
+
243
+ mkdirp(BMO_HOME);
244
+ mkdirp(BMO_BIN);
245
+
246
+ try {
247
+ const pythonExe = await installPython();
248
+ installPipDeps(pythonExe);
249
+ ensureOpenCode();
250
+ await installCloudflared();
251
+ installWebchatDeps();
252
+
253
+ console.log('\n╭──────────────────────────────────────────╮');
254
+ console.log('│ āœ… BMO installed successfully! │');
255
+ console.log('│ │');
256
+ console.log('│ Next step: │');
257
+ console.log('│ bmo init ← configure your bot │');
258
+ console.log('│ bmo ← start BMO │');
259
+ console.log('╰──────────────────────────────────────────╯\n');
260
+ } catch (e) {
261
+ err(`Installation failed: ${e.message}`);
262
+ console.log('\nRun `bmo init` to retry configuration, or check the docs.');
263
+ process.exit(1);
264
+ }
265
+ })();
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bmo relay [--private] [--stop]
4
+ *
5
+ * --private Start relay + cloudflared but don't register with BFP Registry
6
+ * --stop Stop relay and deregister from BFP Registry
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const { spawn, execSync, spawnSync } = require('child_process');
12
+ const net = require('net');
13
+ const https = require('https');
14
+ const http = require('http');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const BMO_HOME = process.env.BMO_HOME || path.join(os.homedir(), '.bmo');
20
+ const BMO_BIN = path.join(BMO_HOME, 'bin');
21
+ const PKG_DIR = path.join(__dirname, '..');
22
+ const TASKS_FILE = path.join(BMO_HOME, 'data', 'background_tasks.json');
23
+ const RELAY_PORT = parseInt(process.env.BFP_RELAY_PORT || '9753');
24
+ const REGISTRY_URL = process.env.BFP_REGISTRY_URL || 'https://bfp-registry.aliwey.workers.dev';
25
+
26
+ const isPrivate = process.argv.includes('--private');
27
+ const isStop = process.argv.includes('--stop');
28
+
29
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
30
+
31
+ function loadTasks() {
32
+ try { return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8')); } catch { return {}; }
33
+ }
34
+
35
+ function saveTasks(tasks) {
36
+ fs.mkdirSync(path.dirname(TASKS_FILE), { recursive: true });
37
+ fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
38
+ }
39
+
40
+ async function isPortOpen(port) {
41
+ return new Promise(resolve => {
42
+ const s = net.createConnection({ port, host: '127.0.0.1' });
43
+ s.setTimeout(500);
44
+ s.on('connect', () => { s.destroy(); resolve(true); });
45
+ s.on('timeout', () => { s.destroy(); resolve(false); });
46
+ s.on('error', () => resolve(false));
47
+ });
48
+ }
49
+
50
+ function getCloudflaredExe() {
51
+ const embedded = process.platform === 'win32'
52
+ ? path.join(BMO_BIN, 'cloudflared.exe')
53
+ : path.join(BMO_BIN, 'cloudflared');
54
+ if (fs.existsSync(embedded)) return embedded;
55
+ try { execSync('cloudflared --version', { stdio: 'ignore' }); return 'cloudflared'; } catch {}
56
+ console.error('āŒ cloudflared not found. Run: bmo init');
57
+ process.exit(1);
58
+ }
59
+
60
+ function getPython() {
61
+ const embeddedWin = path.join(BMO_HOME, 'python', 'python.exe');
62
+ const embeddedUnix = path.join(BMO_HOME, 'python', 'bin', 'python3');
63
+ if (fs.existsSync(embeddedWin)) return embeddedWin;
64
+ if (fs.existsSync(embeddedUnix)) return embeddedUnix;
65
+ for (const cmd of ['python3', 'python']) {
66
+ try { execSync(`${cmd} --version`, { stdio: 'ignore' }); return cmd; } catch {}
67
+ }
68
+ return 'python3';
69
+ }
70
+
71
+ /** Read DID from bfp identity file */
72
+ function getDID() {
73
+ const idFile = path.join(BMO_HOME, 'data', 'bfp_identity.json');
74
+ if (!fs.existsSync(idFile)) {
75
+ // Try project-local data dir (dev mode)
76
+ const devFile = path.join(PKG_DIR, 'data', 'bfp_identity.json');
77
+ if (fs.existsSync(devFile)) return JSON.parse(fs.readFileSync(devFile)).did;
78
+ return null;
79
+ }
80
+ return JSON.parse(fs.readFileSync(idFile)).did;
81
+ }
82
+
83
+ /** POST to BFP Registry */
84
+ function registryPost(endpoint, body) {
85
+ return new Promise((resolve, reject) => {
86
+ const url = new URL(REGISTRY_URL + endpoint);
87
+ const data = JSON.stringify(body);
88
+ const opts = {
89
+ hostname: url.hostname,
90
+ path: url.pathname + url.search,
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
93
+ };
94
+ const lib = url.protocol === 'https:' ? https : http;
95
+ const req = lib.request(opts, res => {
96
+ let body = '';
97
+ res.on('data', d => body += d);
98
+ res.on('end', () => resolve({ status: res.statusCode, body }));
99
+ });
100
+ req.on('error', reject);
101
+ req.write(data);
102
+ req.end();
103
+ });
104
+ }
105
+
106
+ /** Parse cloudflared output for ws:// or wss:// tunnel URL */
107
+ function waitForTunnelUrl(proc) {
108
+ return new Promise((resolve, reject) => {
109
+ const timeout = setTimeout(() => reject(new Error('Tunnel URL timeout')), 30000);
110
+ const handler = data => {
111
+ const text = data.toString();
112
+ // cloudflared prints the https:// URL; BFP needs ws:// equivalent
113
+ const m = text.match(/https?:\/\/[a-zA-Z0-9\-]+\.trycloudflare\.com/);
114
+ if (m) {
115
+ clearTimeout(timeout);
116
+ const wsUrl = m[0].replace('https://', 'wss://').replace('http://', 'ws://');
117
+ resolve(wsUrl);
118
+ }
119
+ };
120
+ proc.stdout && proc.stdout.on('data', handler);
121
+ proc.stderr && proc.stderr.on('data', handler);
122
+ });
123
+ }
124
+
125
+ // ── Stop handler ─────────────────────────────────────────────────────────────
126
+
127
+ async function stopRelay() {
128
+ console.log('šŸ›‘ Stopping BFP relay...');
129
+ const tasks = loadTasks();
130
+
131
+ // Kill processes
132
+ for (const key of ['bfp_relay_pid', 'bfp_cf_pid']) {
133
+ const pid = tasks[key];
134
+ if (pid) {
135
+ try { process.kill(pid, 'SIGTERM'); } catch {}
136
+ }
137
+ }
138
+
139
+ // Deregister from registry
140
+ const did = tasks.bfp_did || getDID();
141
+ if (did && !isPrivate) {
142
+ try {
143
+ await registryPost('/unregister', { did });
144
+ console.log('āœ“ Deregistered from BFP Registry');
145
+ } catch (e) {
146
+ console.warn('āš ļø Could not deregister:', e.message);
147
+ }
148
+ }
149
+
150
+ delete tasks.bfp_relay_pid;
151
+ delete tasks.bfp_cf_pid;
152
+ delete tasks.bfp_tunnel_url;
153
+ delete tasks.bfp_did;
154
+ saveTasks(tasks);
155
+
156
+ console.log('āœ… BFP relay stopped\n');
157
+ process.exit(0);
158
+ }
159
+
160
+ // ── Main ─────────────────────────────────────────────────────────────────────
161
+
162
+ (async () => {
163
+ if (isStop) { await stopRelay(); return; }
164
+
165
+ // ── Start BFP relay (Python) ────────────────────────────────────────────────
166
+ if (await isPortOpen(RELAY_PORT)) {
167
+ const tasks = loadTasks();
168
+ if (tasks.bfp_tunnel_url) {
169
+ console.log(`\n🌐 BFP relay already running!`);
170
+ console.log(` DID: ${tasks.bfp_did || 'unknown'}`);
171
+ console.log(` Relay: ${tasks.bfp_tunnel_url}\n`);
172
+ process.exit(0);
173
+ }
174
+ }
175
+
176
+ console.log('ā³ Starting BFP relay server...');
177
+ const python = getPython();
178
+ const relay = spawn(python, ['-m', 'tools.bfp_relay', '--port', String(RELAY_PORT)], {
179
+ cwd: PKG_DIR,
180
+ stdio: ['ignore', 'pipe', 'pipe'],
181
+ detached: true,
182
+ env: { ...process.env, BMO_HOME, PYTHONUNBUFFERED: '1' },
183
+ });
184
+ relay.unref();
185
+
186
+ // Wait for relay to bind
187
+ for (let i = 0; i < 20; i++) {
188
+ await sleep(500);
189
+ if (await isPortOpen(RELAY_PORT)) break;
190
+ if (i === 19) { console.error('āŒ BFP relay failed to start'); process.exit(1); }
191
+ }
192
+ console.log(`āœ“ BFP relay running on port ${RELAY_PORT}`);
193
+
194
+ // ── Start cloudflared tunnel ────────────────────────────────────────────────
195
+ console.log('ā³ Starting cloudflared tunnel...');
196
+ const cfExe = getCloudflaredExe();
197
+ const cf = spawn(cfExe, ['tunnel', '--url', `ws://localhost:${RELAY_PORT}`], {
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ detached: true,
200
+ });
201
+ cf.unref();
202
+
203
+ let tunnelUrl;
204
+ try {
205
+ tunnelUrl = await waitForTunnelUrl(cf);
206
+ } catch {
207
+ console.error('āŒ Could not get tunnel URL from cloudflared');
208
+ relay.kill();
209
+ process.exit(1);
210
+ }
211
+ console.log(`āœ“ Tunnel active: ${tunnelUrl}`);
212
+
213
+ // ── Get DID ─────────────────────────────────────────────────────────────────
214
+ // Run the bfp_identity module to get (or create) the DID
215
+ const didResult = spawnSync(python, ['-c',
216
+ `import sys; sys.path.insert(0, '${PKG_DIR.replace(/\\/g, '\\\\')}'); from core.bfp_identity import get_did; print(get_did())`
217
+ ], { encoding: 'utf8', env: { ...process.env, BMO_HOME } });
218
+ const did = (didResult.stdout || '').trim() || getDID();
219
+
220
+ // ── Register with BFP Registry ───────────────────────────────────────────────
221
+ const caps = ['code', 'research', 'files', 'web', 'terminal', 'memory'];
222
+ if (!isPrivate && did) {
223
+ try {
224
+ await registryPost('/register', { did, endpoint: tunnelUrl, caps });
225
+ console.log(`āœ“ Registered with BFP Registry (${REGISTRY_URL})`);
226
+ } catch (e) {
227
+ console.warn(`āš ļø Registry registration failed: ${e.message}`);
228
+ }
229
+ } else if (isPrivate) {
230
+ console.log('ā„¹ļø Private mode — skipping registry registration');
231
+ }
232
+
233
+ // Save to task registry
234
+ const tasks = loadTasks();
235
+ tasks.bfp_relay_pid = relay.pid;
236
+ tasks.bfp_cf_pid = cf.pid;
237
+ tasks.bfp_tunnel_url = tunnelUrl;
238
+ tasks.bfp_did = did;
239
+ saveTasks(tasks);
240
+
241
+ // ── Periodic re-registration (every 30 min) ──────────────────────────────────
242
+ let refreshTimer;
243
+ if (!isPrivate && did) {
244
+ refreshTimer = setInterval(async () => {
245
+ try {
246
+ await registryPost('/register', { did, endpoint: tunnelUrl, caps });
247
+ } catch {}
248
+ }, 30 * 60 * 1000);
249
+ }
250
+
251
+ console.log('\n╭───────────────────────────────────────────────────────╮');
252
+ console.log('│ 🌐 BMO BFP Relay is ONLINE │');
253
+ console.log(`│ DID: ${(did || 'unknown').substring(0, 43).padEnd(43)} │`);
254
+ console.log(`│ Relay: ${tunnelUrl.padEnd(43)} │`);
255
+ console.log(`│ Mode: ${(isPrivate ? 'Private (not in registry)' : 'Public (discoverable)').padEnd(43)} │`);
256
+ console.log('│ │');
257
+ console.log('│ Press Ctrl+C to stop and go offline │');
258
+ console.log('╰───────────────────────────────────────────────────────╯\n');
259
+
260
+ // Keep process alive, handle graceful shutdown
261
+ process.stdin.resume();
262
+ process.on('SIGINT', async () => {
263
+ if (refreshTimer) clearInterval(refreshTimer);
264
+ console.log('\nšŸ›‘ Shutting down BFP relay...');
265
+ if (!isPrivate && did) {
266
+ try { await registryPost('/unregister', { did }); console.log('āœ“ Deregistered'); } catch {}
267
+ }
268
+ try { relay.kill(); } catch {}
269
+ try { cf.kill(); } catch {}
270
+ const t = loadTasks();
271
+ delete t.bfp_relay_pid; delete t.bfp_cf_pid;
272
+ delete t.bfp_tunnel_url; delete t.bfp_did;
273
+ saveTasks(t);
274
+ process.exit(0);
275
+ });
276
+ })();
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bmo web — Start webchat + cloudflared tunnel, return public URL.
4
+ * If already running, return existing URL from task registry.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { spawn, execSync } = require('child_process');
10
+ const net = require('net');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const https = require('https');
15
+
16
+ const BMO_HOME = process.env.BMO_HOME || path.join(os.homedir(), '.bmo');
17
+ const BMO_BIN = path.join(BMO_HOME, 'bin');
18
+ const PKG_DIR = path.join(__dirname, '..');
19
+ const TASKS_FILE = path.join(BMO_HOME, 'data', 'background_tasks.json');
20
+ const WEBCHAT_PORT = parseInt(process.env.PORT || '3456');
21
+
22
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
23
+
24
+ async function isPortOpen(port) {
25
+ return new Promise(resolve => {
26
+ const s = net.createConnection({ port, host: '127.0.0.1' });
27
+ s.setTimeout(500);
28
+ s.on('connect', () => { s.destroy(); resolve(true); });
29
+ s.on('timeout', () => { s.destroy(); resolve(false); });
30
+ s.on('error', () => resolve(false));
31
+ });
32
+ }
33
+
34
+ function getCloudflaredExe() {
35
+ const embedded = process.platform === 'win32'
36
+ ? path.join(BMO_BIN, 'cloudflared.exe')
37
+ : path.join(BMO_BIN, 'cloudflared');
38
+ if (fs.existsSync(embedded)) return embedded;
39
+ try { execSync('cloudflared --version', { stdio: 'ignore' }); return 'cloudflared'; } catch {}
40
+ console.error('āŒ cloudflared not found. Run: bmo init');
41
+ process.exit(1);
42
+ }
43
+
44
+ function loadTasks() {
45
+ try { return JSON.parse(fs.readFileSync(TASKS_FILE, 'utf8')); } catch { return {}; }
46
+ }
47
+
48
+ function saveTasks(tasks) {
49
+ fs.mkdirSync(path.dirname(TASKS_FILE), { recursive: true });
50
+ fs.writeFileSync(TASKS_FILE, JSON.stringify(tasks, null, 2));
51
+ }
52
+
53
+ /** Parse cloudflared stdout/stderr for the public URL */
54
+ function waitForTunnelUrl(proc) {
55
+ return new Promise((resolve, reject) => {
56
+ const timeout = setTimeout(() => reject(new Error('Tunnel URL timeout')), 30000);
57
+ const handler = data => {
58
+ const text = data.toString();
59
+ const m = text.match(/https?:\/\/[a-zA-Z0-9\-]+\.trycloudflare\.com/);
60
+ if (m) { clearTimeout(timeout); resolve(m[0]); }
61
+ };
62
+ proc.stdout && proc.stdout.on('data', handler);
63
+ proc.stderr && proc.stderr.on('data', handler);
64
+ });
65
+ }
66
+
67
+ (async () => {
68
+ // Check if webchat is already running
69
+ if (await isPortOpen(WEBCHAT_PORT)) {
70
+ const tasks = loadTasks();
71
+ const existing = tasks.webchat_tunnel_url;
72
+ if (existing) {
73
+ console.log(`\nšŸ’¬ Webchat already running!`);
74
+ console.log(` Local: http://127.0.0.1:${WEBCHAT_PORT}`);
75
+ console.log(` Public: ${existing}\n`);
76
+ process.exit(0);
77
+ }
78
+ }
79
+
80
+ // Start webchat server
81
+ console.log('ā³ Starting webchat server...');
82
+ const webchatDir = path.join(PKG_DIR, 'webchat');
83
+ const webchat = spawn('node', ['server.js'], {
84
+ cwd: webchatDir,
85
+ stdio: 'ignore',
86
+ detached: true,
87
+ env: {
88
+ ...process.env,
89
+ PORT: String(WEBCHAT_PORT),
90
+ BMO_HOME,
91
+ BMO_DB_PATH: path.join(BMO_HOME, 'data', 'bot.db'),
92
+ }
93
+ });
94
+ webchat.unref();
95
+
96
+ // Wait for webchat to be ready
97
+ for (let i = 0; i < 20; i++) {
98
+ await sleep(500);
99
+ if (await isPortOpen(WEBCHAT_PORT)) break;
100
+ if (i === 19) { console.error('āŒ Webchat failed to start'); process.exit(1); }
101
+ }
102
+ console.log('āœ“ Webchat server running on port', WEBCHAT_PORT);
103
+
104
+ // Start cloudflared tunnel
105
+ console.log('ā³ Starting cloudflared tunnel...');
106
+ const cfExe = getCloudflaredExe();
107
+ const cf = spawn(cfExe, ['tunnel', '--url', `http://localhost:${WEBCHAT_PORT}`], {
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ detached: true,
110
+ });
111
+ cf.unref();
112
+
113
+ let tunnelUrl;
114
+ try {
115
+ tunnelUrl = await waitForTunnelUrl(cf);
116
+ } catch {
117
+ console.error('āŒ Could not get tunnel URL from cloudflared');
118
+ process.exit(1);
119
+ }
120
+
121
+ // Save to task registry
122
+ const tasks = loadTasks();
123
+ tasks.webchat_pid = webchat.pid;
124
+ tasks.webchat_cf_pid = cf.pid;
125
+ tasks.webchat_tunnel_url = tunnelUrl;
126
+ tasks.webchat_local_url = `http://127.0.0.1:${WEBCHAT_PORT}`;
127
+ saveTasks(tasks);
128
+
129
+ console.log('\n╭────────────────────────────────────────────╮');
130
+ console.log('│ šŸ’¬ BMO Webchat is live! │');
131
+ console.log(`│ Local: http://127.0.0.1:${WEBCHAT_PORT} │`);
132
+ console.log(`│ Public: ${tunnelUrl.padEnd(34)} │`);
133
+ console.log('╰────────────────────────────────────────────╯\n');
134
+
135
+ process.exit(0);
136
+ })();