@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.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- 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
|
+
})();
|