@aliwey/bmo 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/bmo.js CHANGED
@@ -163,20 +163,34 @@ Data lives in: ${BMO_HOME_DISPLAY}
163
163
  // Ignore failures
164
164
  }
165
165
 
166
- console.log(`đŸ—‘ī¸ Uninstalling old version of @aliwey/bmo...`);
167
- try {
168
- execSync('npm uninstall -g @aliwey/bmo', { stdio: 'inherit' });
169
- } catch (e) {
170
- // Ignore uninstall failures if it wasn't already installed
171
- }
172
- console.log(`🔄 Installing @aliwey/bmo${ver}...`);
173
- try {
174
- execSync(`npm install -g @aliwey/bmo${ver}`, { stdio: 'inherit' });
175
- } catch {
176
- console.error('❌ Update failed. Try manually: npm install -g @aliwey/bmo');
177
- process.exit(1);
166
+ if (os.platform() === 'win32') {
167
+ console.log('Launching updater in a new window to release folder/file locks...');
168
+
169
+ const cmdStr = `timeout /t 2 /nobreak >nul && echo [BMO Updater] Upgrading to @aliwey/bmo${ver}... && npm install -g @aliwey/bmo${ver} && echo [OK] BMO updated successfully! Press any key to close. && pause`;
170
+
171
+ spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/c', cmdStr], {
172
+ detached: true,
173
+ stdio: 'ignore'
174
+ });
175
+
176
+ console.log('Updater launched. This window will now close to release file locks.');
177
+ process.exit(0);
178
+ } else {
179
+ console.log(`Uninstalling old version of @aliwey/bmo...`);
180
+ try {
181
+ execSync('npm uninstall -g @aliwey/bmo', { stdio: 'inherit' });
182
+ } catch (e) {
183
+ // Ignore uninstall failures if it wasn't already installed
184
+ }
185
+ console.log(`Installing @aliwey/bmo${ver}...`);
186
+ try {
187
+ execSync(`npm install -g @aliwey/bmo${ver}`, { stdio: 'inherit' });
188
+ } catch {
189
+ console.error('Update failed. Try manually: npm install -g @aliwey/bmo');
190
+ process.exit(1);
191
+ }
192
+ process.exit(0);
178
193
  }
179
- process.exit(0);
180
194
  }
181
195
 
182
196
  // ── bmo init ──────────────────────────────────────────────────────────────
@@ -12,7 +12,7 @@ from uuid import uuid4
12
12
  from datetime import datetime
13
13
  from typing import Optional, List, Dict, AsyncGenerator
14
14
 
15
- from config.settings import OPENCODE_BASE_URL, BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT
15
+ from config.settings import OPENCODE_BASE_URL, BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT, OWNER_ID, ALLOWED_USER_IDS
16
16
  from core.bot_client import OpenCodeBotClient
17
17
  from core.worker_manager import WorkerManager
18
18
  from models.chat_models import ChatSession, ChatMessage
@@ -38,6 +38,7 @@ class BMOEngine:
38
38
  self.storage = SQLiteStorage(db_path=db_path)
39
39
  else:
40
40
  self.storage = get_storage()
41
+ self.storage.sync_session_groups(OWNER_ID, ALLOWED_USER_IDS)
41
42
  self.client = OpenCodeBotClient()
42
43
  self.worker_manager = WorkerManager(backend=worker_backend)
43
44
  self.client.set_worker_manager(self.worker_manager)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliwey/bmo",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "BMO — AI coding assistant with Telegram, CLI & Web sync. One command, all frontends.",
5
5
  "keywords": ["ai", "coding-assistant", "telegram-bot", "cli", "opencode", "bfp"],
6
6
  "homepage": "https://github.com/aliwey/bmo",
@@ -13,6 +13,7 @@ const os = require('os');
13
13
 
14
14
  const BMO_HOME = process.env.BMO_HOME || path.join(os.homedir(), '.bmo');
15
15
  const ENV_PATH = path.join(BMO_HOME, '.env');
16
+ const PKG_DIR = path.join(__dirname, '..');
16
17
 
17
18
  function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
18
19
 
@@ -26,9 +27,9 @@ function ask(rl, question, defaultValue) {
26
27
  }
27
28
 
28
29
  (async () => {
29
- console.log('\n╭───────────────────────────────────────────────────╮');
30
+ console.log('\n╭───────────────────────────────────────────╮');
30
31
  console.log('│ BMO Setup Wizard │');
31
- console.log('╰───────────────────────────────────────────────────╯\n');
32
+ console.log('╰───────────────────────────────────────────╯\n');
32
33
 
33
34
  // Load existing .env values as defaults
34
35
  let existing = {};
@@ -38,7 +39,24 @@ function ask(rl, question, defaultValue) {
38
39
  const m = line.match(/^([^#=]+)=(.*)$/);
39
40
  if (m) existing[m[1].trim()] = m[2].trim();
40
41
  }
41
- console.log(` â„šī¸ Existing config found at ${ENV_PATH} — press Enter to keep values.\n`);
42
+ console.log(` [Info] Existing config found at ${ENV_PATH} — press Enter to keep values.\n`);
43
+ }
44
+
45
+ // Load existing memory.md values if available
46
+ let existing_name = '';
47
+ let existing_role = '';
48
+ let existing_preference = '';
49
+ const userMemoryPath = path.join(BMO_HOME, 'data', 'memory.md');
50
+ if (fs.existsSync(userMemoryPath)) {
51
+ try {
52
+ const memoryContent = fs.readFileSync(userMemoryPath, 'utf8');
53
+ const nameMatch = memoryContent.match(/-\s*Name:\s*(.*)/i);
54
+ const roleMatch = memoryContent.match(/-\s*Role:\s*(.*)/i);
55
+ const prefMatch = memoryContent.match(/-\s*Preference:\s*(.*)/i);
56
+ if (nameMatch && nameMatch[1].trim() !== '[Enter Name]') existing_name = nameMatch[1].trim();
57
+ if (roleMatch && roleMatch[1].trim() !== '[Enter Role]') existing_role = roleMatch[1].trim();
58
+ if (prefMatch && prefMatch[1].trim() !== '[Enter Preference]') existing_preference = prefMatch[1].trim();
59
+ } catch (e) {}
42
60
  }
43
61
 
44
62
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -73,6 +91,13 @@ function ask(rl, question, defaultValue) {
73
91
  existing.BFP_REGISTRY_URL || 'https://bfp-registry.aliwey.workers.dev'
74
92
  );
75
93
 
94
+ // ── User Profile ──────────────────────────────────────────────────────────────
95
+ console.log('\n── User Profile ─────────────────────────────────────');
96
+ console.log(' Provide details to personalize your BMO companion.');
97
+ const userName = await ask(rl, 'Your Name', existing_name || 'Developer');
98
+ const userRole = await ask(rl, 'Your Knowledge / Background Description', existing_role || 'Python/Web Developer');
99
+ const userPref = await ask(rl, 'What should BMO do / Where should BMO focus', existing_preference || 'Assist with software development, debugging, and task automation');
100
+
76
101
  rl.close();
77
102
 
78
103
  // ── Write .env ────────────────────────────────────────────────────────────────
@@ -106,8 +131,25 @@ function ask(rl, question, defaultValue) {
106
131
 
107
132
  fs.writeFileSync(ENV_PATH, envContent, 'utf8');
108
133
 
134
+ // ── Update memory.md ──────────────────────────────────────────────────────────
135
+ const packageMemoryPath = path.join(PKG_DIR, 'memory.md');
136
+
137
+ let memoryContent = '';
138
+ if (fs.existsSync(userMemoryPath)) {
139
+ memoryContent = fs.readFileSync(userMemoryPath, 'utf8');
140
+ } else if (fs.existsSync(packageMemoryPath)) {
141
+ memoryContent = fs.readFileSync(packageMemoryPath, 'utf8');
142
+ }
143
+
144
+ if (memoryContent) {
145
+ memoryContent = memoryContent.replace(/-\s*Name:\s*[^\r\n]*/i, `- Name: ${userName}`);
146
+ memoryContent = memoryContent.replace(/-\s*Role:\s*[^\r\n]*/i, `- Role: ${userRole}`);
147
+ memoryContent = memoryContent.replace(/-\s*Preference:\s*[^\r\n]*/i, `- Preference: ${userPref}`);
148
+ fs.writeFileSync(userMemoryPath, memoryContent, 'utf8');
149
+ }
150
+
109
151
  console.log('\n╭───────────────────────────────────────────────────╮');
110
- console.log(`│ ✅ Config saved to ${ENV_PATH.padEnd(27)}│`);
152
+ console.log('│' + ` [OK] Config saved to ${ENV_PATH}`.padEnd(51) + '│');
111
153
  console.log('│ │');
112
154
  console.log('│ Run: bmo ← start BMO │');
113
155
  console.log('│ bmo relay ← go online for BFP discovery │');
@@ -53,9 +53,9 @@ const CF_URLS = {
53
53
  // ── Helpers ──────────────────────────────────────────────────────────────────
54
54
 
55
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}`);
56
+ const ok = msg => console.log(` [OK] ${msg}`);
57
+ const warn = msg => console.log(` [Warning] ${msg}`);
58
+ const err = msg => console.error(` [Error] ${msg}`);
59
59
 
60
60
  function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
61
61
 
@@ -250,16 +250,18 @@ function installWebchatDeps() {
250
250
  await installCloudflared();
251
251
  installWebchatDeps();
252
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');
253
+ console.log('\n╭───────────────────────────────────────────────────╮');
254
+ console.log('│ [OK] BMO installed successfully! │');
255
+ console.log('│ │');
256
+ console.log('│ IMPORTANT: You must run the setup wizard first: │');
257
+ console.log('│ bmo --init │');
258
+ console.log('│ │');
259
+ console.log('│ Then start BMO: │');
260
+ console.log('│ bmo │');
261
+ console.log('╰───────────────────────────────────────────────────╯\n');
260
262
  } catch (e) {
261
263
  err(`Installation failed: ${e.message}`);
262
- console.log('\nRun `bmo init` to retry configuration, or check the docs.');
264
+ console.log('\nRun `bmo --init` to retry configuration, or check the docs.');
263
265
  process.exit(1);
264
266
  }
265
267
  })();
@@ -37,7 +37,7 @@ function getCloudflaredExe() {
37
37
  : path.join(BMO_BIN, 'cloudflared');
38
38
  if (fs.existsSync(embedded)) return embedded;
39
39
  try { execSync('cloudflared --version', { stdio: 'ignore' }); return 'cloudflared'; } catch {}
40
- console.error('❌ cloudflared not found. Run: bmo init');
40
+ console.error('[Error] cloudflared not found. Run: bmo init');
41
41
  process.exit(1);
42
42
  }
43
43
 
@@ -70,7 +70,7 @@ function waitForTunnelUrl(proc) {
70
70
  const tasks = loadTasks();
71
71
  const existing = tasks.webchat_tunnel_url;
72
72
  if (existing) {
73
- console.log(`\nđŸ’Ŧ Webchat already running!`);
73
+ console.log(`\n[Info] Webchat already running!`);
74
74
  console.log(` Local: http://127.0.0.1:${WEBCHAT_PORT}`);
75
75
  console.log(` Public: ${existing}\n`);
76
76
  process.exit(0);
@@ -78,11 +78,18 @@ function waitForTunnelUrl(proc) {
78
78
  }
79
79
 
80
80
  // Start webchat server
81
- console.log('âŗ Starting webchat server...');
81
+ console.log('Starting webchat server...');
82
82
  const webchatDir = path.join(PKG_DIR, 'webchat');
83
+
84
+ // Ensure logs directory exists
85
+ const logDir = path.join(BMO_HOME, 'logs');
86
+ fs.mkdirSync(logDir, { recursive: true });
87
+ const webchatLogPath = path.join(logDir, 'webchat.log');
88
+ const logStream = fs.openSync(webchatLogPath, 'a');
89
+
83
90
  const webchat = spawn('node', ['server.js'], {
84
91
  cwd: webchatDir,
85
- stdio: 'ignore',
92
+ stdio: ['ignore', logStream, logStream],
86
93
  detached: true,
87
94
  env: {
88
95
  ...process.env,
@@ -94,15 +101,32 @@ function waitForTunnelUrl(proc) {
94
101
  webchat.unref();
95
102
 
96
103
  // Wait for webchat to be ready
104
+ let started = false;
97
105
  for (let i = 0; i < 20; i++) {
98
106
  await sleep(500);
99
- if (await isPortOpen(WEBCHAT_PORT)) break;
100
- if (i === 19) { console.error('❌ Webchat failed to start'); process.exit(1); }
107
+ if (await isPortOpen(WEBCHAT_PORT)) {
108
+ started = true;
109
+ break;
110
+ }
111
+ }
112
+
113
+ if (!started) {
114
+ console.error('[Error] Webchat failed to start');
115
+ try {
116
+ const logContent = fs.readFileSync(webchatLogPath, 'utf8').trim();
117
+ const lines = logContent.split('\n');
118
+ const lastLines = lines.slice(-15).join('\n');
119
+ console.error('\nLast log output:');
120
+ console.error(lastLines);
121
+ } catch (e) {
122
+ console.error('Could not read webchat log file.');
123
+ }
124
+ process.exit(1);
101
125
  }
102
- console.log('✓ Webchat server running on port', WEBCHAT_PORT);
126
+ console.log('[OK] Webchat server running on port', WEBCHAT_PORT);
103
127
 
104
128
  // Start cloudflared tunnel
105
- console.log('âŗ Starting cloudflared tunnel...');
129
+ console.log('Starting cloudflared tunnel...');
106
130
  const cfExe = getCloudflaredExe();
107
131
  const cf = spawn(cfExe, ['tunnel', '--url', `http://localhost:${WEBCHAT_PORT}`], {
108
132
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -114,7 +138,7 @@ function waitForTunnelUrl(proc) {
114
138
  try {
115
139
  tunnelUrl = await waitForTunnelUrl(cf);
116
140
  } catch {
117
- console.error('❌ Could not get tunnel URL from cloudflared');
141
+ console.error('[Error] Could not get tunnel URL from cloudflared');
118
142
  process.exit(1);
119
143
  }
120
144
 
@@ -127,7 +151,7 @@ function waitForTunnelUrl(proc) {
127
151
  saveTasks(tasks);
128
152
 
129
153
  console.log('\n╭────────────────────────────────────────────╮');
130
- console.log('│ đŸ’Ŧ BMO Webchat is live! │');
154
+ console.log('│ [OK] BMO Webchat is live! │');
131
155
  console.log(`│ Local: http://127.0.0.1:${WEBCHAT_PORT} │`);
132
156
  console.log(`│ Public: ${tunnelUrl.padEnd(34)} │`);
133
157
  console.log('╰────────────────────────────────────────────╯\n');
@@ -117,6 +117,11 @@ class SQLiteStorage:
117
117
  created_at REAL NOT NULL,
118
118
  UNIQUE(user_id, scope)
119
119
  );
120
+
121
+ CREATE TABLE IF NOT EXISTS session_groups (
122
+ chat_id INTEGER PRIMARY KEY,
123
+ group_id INTEGER NOT NULL
124
+ );
120
125
  """)
121
126
  conn.commit()
122
127
 
@@ -254,6 +259,22 @@ class SQLiteStorage:
254
259
  except Exception as e:
255
260
  print(f"_fix_active_sessions error: {e}")
256
261
 
262
+ # ── Session Groups ─────────────────────────────────────────────────────
263
+
264
+ def sync_session_groups(self, owner_id: int, allowed_ids: set[int]):
265
+ """Populate session_groups table from OWNER_ID + ALLOWED_USER_IDS config."""
266
+ if not owner_id and not allowed_ids:
267
+ return
268
+ group_id = owner_id or next(iter(allowed_ids))
269
+ ids_to_map = {owner_id} if owner_id else set()
270
+ ids_to_map.update(allowed_ids)
271
+ for cid in ids_to_map:
272
+ self._exec(
273
+ "INSERT OR REPLACE INTO session_groups (chat_id, group_id) VALUES (?, ?)",
274
+ (cid, group_id),
275
+ )
276
+ self._conn().commit()
277
+
257
278
  # ── Session CRUD ──────────────────────────────────────────────────────
258
279
 
259
280
  def save_session(self, session: ChatSession) -> bool:
@@ -292,16 +313,17 @@ class SQLiteStorage:
292
313
  ),
293
314
  )
294
315
 
295
- # Only set active session pointer if none exists yet for this chat.
316
+ # Only set active session pointer if none exists yet for this group.
296
317
  # This prevents save_session from overwriting a manual session switch.
318
+ group_id = self._resolve_group(session.chat_id)
297
319
  existing_active = self._fetchone(
298
320
  "SELECT session_id FROM active_sessions WHERE chat_id = ?",
299
- (session.chat_id,),
321
+ (group_id,),
300
322
  )
301
323
  if not existing_active:
302
324
  self._exec(
303
325
  "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
304
- (session.chat_id, session.session_id),
326
+ (group_id, session.session_id),
305
327
  )
306
328
 
307
329
  self._conn().commit()
@@ -312,9 +334,10 @@ class SQLiteStorage:
312
334
 
313
335
  def load_session(self, chat_id: int) -> Optional[ChatSession]:
314
336
  try:
337
+ group_id = self._resolve_group(chat_id)
315
338
  active = self._fetchone(
316
339
  "SELECT session_id FROM active_sessions WHERE chat_id = ?",
317
- (chat_id,),
340
+ (group_id,),
318
341
  )
319
342
  session_id = active["session_id"] if active else None
320
343
 
@@ -326,14 +349,19 @@ class SQLiteStorage:
326
349
  return self._row_to_session(row)
327
350
 
328
351
  candidates = self._fetchall(
329
- "SELECT * FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC LIMIT 1",
330
- (chat_id,),
352
+ "SELECT s.* FROM sessions s JOIN session_groups sg ON s.chat_id = sg.chat_id WHERE sg.group_id = ? ORDER BY s.updated_at DESC LIMIT 1",
353
+ (group_id,),
331
354
  )
355
+ if not candidates:
356
+ candidates = self._fetchall(
357
+ "SELECT * FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC LIMIT 1",
358
+ (chat_id,),
359
+ )
332
360
  if candidates:
333
361
  row = candidates[0]
334
362
  self._exec(
335
363
  "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
336
- (chat_id, dict(row)["id"]),
364
+ (group_id, dict(row)["id"]),
337
365
  )
338
366
  self._conn().commit()
339
367
  return self._row_to_session(row)
@@ -346,9 +374,10 @@ class SQLiteStorage:
346
374
  def set_active_session(self, chat_id: int, session_id: str) -> bool:
347
375
  """Explicitly switch the active session pointer for a chat."""
348
376
  try:
377
+ group_id = self._resolve_group(chat_id)
349
378
  self._exec(
350
379
  "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
351
- (chat_id, session_id),
380
+ (group_id, session_id),
352
381
  )
353
382
  self._conn().commit()
354
383
  return True
@@ -358,9 +387,10 @@ class SQLiteStorage:
358
387
 
359
388
  def delete_session(self, chat_id: int) -> bool:
360
389
  try:
390
+ group_id = self._resolve_group(chat_id)
361
391
  active = self._fetchone(
362
392
  "SELECT session_id FROM active_sessions WHERE chat_id = ?",
363
- (chat_id,),
393
+ (group_id,),
364
394
  )
365
395
  if active:
366
396
  self._exec("DELETE FROM messages WHERE session_id = ?", (active["session_id"],))
@@ -375,9 +405,10 @@ class SQLiteStorage:
375
405
  def add_message(self, chat_id: int, sender: str, content: str, interface: str = 'telegram') -> bool:
376
406
  """Insert a single message directly into DB without rewriting the whole session."""
377
407
  try:
408
+ group_id = self._resolve_group(chat_id)
378
409
  active = self._fetchone(
379
410
  "SELECT session_id FROM active_sessions WHERE chat_id = ?",
380
- (chat_id,),
411
+ (group_id,),
381
412
  )
382
413
  if not active:
383
414
  return False
@@ -415,14 +446,26 @@ class SQLiteStorage:
415
446
  return [dict(r) for r in reversed(rows)]
416
447
 
417
448
  def list_chat_sessions(self, chat_id: int) -> List[dict]:
449
+ group_id = self._resolve_group(chat_id)
418
450
  rows = self._fetchall(
419
451
  """SELECT id, title, summary,
420
452
  (SELECT COUNT(*) FROM messages WHERE session_id = sessions.id) as msg_count,
421
453
  updated_at,
422
454
  (SELECT session_id FROM active_sessions WHERE chat_id = ?) as active_id
423
- FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC""",
424
- (chat_id, chat_id),
455
+ FROM sessions
456
+ WHERE chat_id IN (SELECT chat_id FROM session_groups WHERE group_id = ?)
457
+ ORDER BY updated_at DESC""",
458
+ (group_id, group_id),
425
459
  )
460
+ if not rows:
461
+ rows = self._fetchall(
462
+ """SELECT id, title, summary,
463
+ (SELECT COUNT(*) FROM messages WHERE session_id = sessions.id) as msg_count,
464
+ updated_at,
465
+ (SELECT session_id FROM active_sessions WHERE chat_id = ?) as active_id
466
+ FROM sessions WHERE chat_id = ? ORDER BY updated_at DESC""",
467
+ (group_id, chat_id),
468
+ )
426
469
  return [
427
470
  {
428
471
  "session_id": r["id"],
@@ -441,9 +484,10 @@ class SQLiteStorage:
441
484
  )
442
485
  if not exists:
443
486
  return False
487
+ group_id = self._resolve_group(chat_id)
444
488
  self._exec(
445
489
  "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
446
- (chat_id, session_id),
490
+ (group_id, session_id),
447
491
  )
448
492
  self._conn().commit()
449
493
  return True
@@ -546,6 +590,13 @@ class SQLiteStorage:
546
590
  session_id=row_dict["id"],
547
591
  )
548
592
 
593
+ def _resolve_group(self, chat_id: int) -> int:
594
+ """Return the group_id for a chat_id, or chat_id itself if not in a group."""
595
+ row = self._fetchone(
596
+ "SELECT group_id FROM session_groups WHERE chat_id = ?", (chat_id,)
597
+ )
598
+ return row["group_id"] if row else chat_id
599
+
549
600
  def get_stats(self) -> dict:
550
601
  """Returns global statistics for the admin."""
551
602
  user_count = self._fetchone("SELECT COUNT(DISTINCT chat_id) FROM sessions")[0]