@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,405 @@
1
+ """
2
+ Unified business logic engine for all BMO interfaces (CLI, Telegram, Webchat).
3
+ Centralizes session management, model selection, SQLite DB queries, and OpenCode client calls.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ import time
10
+ import json
11
+ from uuid import uuid4
12
+ from datetime import datetime
13
+ from typing import Optional, List, Dict, AsyncGenerator
14
+
15
+ from config.settings import OPENCODE_BASE_URL, BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT
16
+ from core.bot_client import OpenCodeBotClient
17
+ from core.worker_manager import WorkerManager
18
+ from models.chat_models import ChatSession, ChatMessage
19
+ from storage.storage import get_storage
20
+ from core.bfp_agent import BFPAgent
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Load shared system prompt config
25
+ _config_path = os.path.join(os.path.dirname(__file__), "..", "config", "system-prompt.json")
26
+ try:
27
+ with open(_config_path, "r", encoding="utf-8") as _f:
28
+ _system_config = json.load(_f)
29
+ except Exception as e:
30
+ logger.error("Failed to load system prompt config: %s", e)
31
+ _system_config = {}
32
+
33
+
34
+ class BMOEngine:
35
+ def __init__(self, db_path: Optional[str] = None, worker_backend: Optional[str] = None):
36
+ if db_path:
37
+ from storage.sqlite_storage import SQLiteStorage
38
+ self.storage = SQLiteStorage(db_path=db_path)
39
+ else:
40
+ self.storage = get_storage()
41
+ self.client = OpenCodeBotClient()
42
+ self.worker_manager = WorkerManager(backend=worker_backend)
43
+ self.client.set_worker_manager(self.worker_manager)
44
+ self._running_tasks: Dict[str, asyncio.Task] = {}
45
+ self._worker_started = False
46
+ self._bfp_started = False
47
+ self.bfp = BFPAgent()
48
+
49
+ async def ensure_worker(self):
50
+ # Don't start the worker if the client has it disabled (e.g. CLI uses
51
+ # `_send_direct` and sets `client._worker = None` to skip the worker).
52
+ # Starting it anyway would spawn a stranded subprocess whose pipes
53
+ # fire `__del__` warnings on interpreter shutdown.
54
+ if not self.client._worker:
55
+ return
56
+ # Start the worker if not started yet, OR if it was killed by a
57
+ # previous cancel (e.g. SIGINT in CLI). is_alive is the source of
58
+ # truth for whether the subprocess is actually running.
59
+ if not self._worker_started or not self.worker_manager.is_alive:
60
+ await self.worker_manager.start()
61
+ self._worker_started = True
62
+ if not self._bfp_started:
63
+ asyncio.create_task(self.bfp.start(
64
+ bfp_port=BFP_TRANSPORT_PORT,
65
+ a2a_port=BFP_A2A_PORT,
66
+ relay_url=BFP_RELAY_URL,
67
+ ))
68
+ self._bfp_started = True
69
+
70
+ async def get_or_create_session(self, chat_id: int, user_id: int, username: Optional[str] = None) -> ChatSession:
71
+ """Loads the active session for a chat. If none exists, creates a fresh one."""
72
+ session = self.storage.load_session(chat_id)
73
+ if session:
74
+ return session
75
+ return await self.create_new_session(chat_id, user_id, username)
76
+
77
+ async def create_new_session(self, chat_id: int, user_id: int, username: Optional[str] = None) -> ChatSession:
78
+ """Creates a brand new session, auto-summarizing the previous one if applicable.
79
+
80
+ Soft-reset semantics (matches Claude Code / OpenCode CLI /new behavior):
81
+ - Local DB history is wiped (fresh chat row, empty messages).
82
+ - Backend model context is wiped via delete_messages (fresh turn 1).
83
+ - opencode_session_id is PRESERVED so the system prompt stays warm.
84
+ - Provider/model/agent/mode inherit from the previous session if available.
85
+ """
86
+ # 1. Summarize old session if it contains messages and hasn't been summarized
87
+ old_session = self.storage.load_session(chat_id)
88
+
89
+ # 2. Capture preserved backend state from the old session (if any)
90
+ preserved_opencode_sid = None
91
+ inherited_provider = os.getenv("OPENCODE_PROVIDER", "opencode")
92
+ inherited_model = os.getenv("OPENCODE_MODEL", "big-pickle")
93
+ inherited_mode = "execute"
94
+ inherited_agent = "default"
95
+
96
+ if old_session:
97
+ preserved_opencode_sid = old_session.metadata.get("opencode_session_id")
98
+ inherited_provider = old_session.metadata.get("provider_id", inherited_provider)
99
+ inherited_model = old_session.metadata.get("model_id", inherited_model)
100
+ inherited_mode = old_session.metadata.get("active_mode", inherited_mode)
101
+ inherited_agent = old_session.metadata.get("active_agent", inherited_agent)
102
+
103
+ if old_session.messages and not old_session.get_summary():
104
+ await self.summarize_session(old_session.session_id)
105
+
106
+ # 3. Wipe backend model context for the preserved session (keeps the slot warm)
107
+ if preserved_opencode_sid:
108
+ try:
109
+ await self.client.delete_messages(preserved_opencode_sid)
110
+ except Exception as e:
111
+ logger.warning("Failed to wipe backend history on /new: %s", e)
112
+ # If the backend session is dead, fall back to a cold start
113
+ preserved_opencode_sid = None
114
+ self.client.last_session_id = None
115
+
116
+ # 4. Setup a new ChatSession row, inheriting backend + provider state
117
+ now = time.time()
118
+ new_sid = str(uuid4())
119
+
120
+ new_session = ChatSession(
121
+ chat_id=chat_id,
122
+ user_id=user_id,
123
+ username=username,
124
+ created_at=now,
125
+ updated_at=now,
126
+ messages=[],
127
+ metadata={
128
+ "opencode_session_id": preserved_opencode_sid,
129
+ "provider_id": inherited_provider,
130
+ "model_id": inherited_model,
131
+ "active_mode": inherited_mode,
132
+ "active_agent": inherited_agent
133
+ },
134
+ title=f"Session {datetime.now().strftime('%y%m%d_%H%M')}",
135
+ session_id=new_sid
136
+ )
137
+
138
+ self.storage.save_session(new_session)
139
+ self.storage.set_active_session(chat_id, new_sid)
140
+ return new_session
141
+
142
+ async def reset_opencode_session(self, chat_id: int):
143
+ """Clears the cached OpenCode session ID so the next request gets a fresh server-side session.
144
+ Call this after a Ctrl+C cancellation to avoid queuing behind the abandoned request."""
145
+ session = self.storage.load_session(chat_id)
146
+ if session:
147
+ session.metadata["opencode_session_id"] = None
148
+ self.storage.save_session(session)
149
+ # Also clear client-side cache
150
+ self.client.last_session_id = None
151
+
152
+ async def switch_session(self, chat_id: int, session_id: str) -> Optional[ChatSession]:
153
+ """Switches the active session pointer for a chat."""
154
+ if self.storage.switch_session(chat_id, session_id):
155
+ # Load active session from active_sessions table
156
+ session = self.storage.load_session(chat_id)
157
+ if session:
158
+ # Update global active sessions table
159
+ self._update_global_active_session(chat_id, session_id)
160
+ return session
161
+ return None
162
+
163
+ def _update_global_active_session(self, chat_id: int, session_id: str):
164
+ """Internal helper to update active_sessions metadata for global synchronization."""
165
+ try:
166
+ conn = self.storage._conn()
167
+ # Ensure table has interface and updated_at (it should have been migrated in sqlite_storage.py)
168
+ conn.execute(
169
+ "INSERT OR REPLACE INTO active_sessions (chat_id, session_id) VALUES (?, ?)",
170
+ (chat_id, session_id)
171
+ )
172
+ conn.commit()
173
+ except Exception as e:
174
+ logger.error("Failed to sync active session metadata: %s", e)
175
+
176
+ def shutdown(self):
177
+ """Shut down the worker manager and BFP agent (sync - fire-and-forget for atexit)."""
178
+ if hasattr(self, 'bfp') and self.bfp.is_running:
179
+ try:
180
+ loop = asyncio.get_event_loop()
181
+ if loop.is_running():
182
+ loop.create_task(self.bfp.stop())
183
+ else:
184
+ loop.run_until_complete(self.bfp.stop())
185
+ except RuntimeError:
186
+ loop = asyncio.new_event_loop()
187
+ loop.run_until_complete(self.bfp.stop())
188
+ loop.close()
189
+ if hasattr(self, 'worker_manager'):
190
+ try:
191
+ loop = asyncio.get_event_loop()
192
+ if loop.is_running():
193
+ loop.create_task(self.worker_manager.shutdown())
194
+ else:
195
+ loop.run_until_complete(self.worker_manager.shutdown())
196
+ except RuntimeError:
197
+ loop = asyncio.new_event_loop()
198
+ loop.run_until_complete(self.worker_manager.shutdown())
199
+ loop.close()
200
+
201
+ async def list_sessions(self, chat_id: int) -> List[dict]:
202
+ """Lists all saved sessions for a chat."""
203
+ return self.storage.list_chat_sessions(chat_id)
204
+
205
+ async def set_session_title(self, session_id: str, title: str) -> bool:
206
+ """Sets a custom title for a session."""
207
+ try:
208
+ self.storage.update_session_title(session_id, title)
209
+ return True
210
+ except Exception as e:
211
+ logger.error("Failed to set session title: %s", e)
212
+ return False
213
+
214
+ async def clear_session_history(self, chat_id: int) -> bool:
215
+ """Resets the history of the active session in DB and OpenCode server."""
216
+ session = self.storage.load_session(chat_id)
217
+ if not session:
218
+ return False
219
+
220
+ # Clear backend messages if OpenCode session is active
221
+ opencode_sid = session.metadata.get("opencode_session_id")
222
+ if opencode_sid:
223
+ await self.client.delete_messages(opencode_sid)
224
+
225
+ # Remove local database messages for the session
226
+ try:
227
+ conn = self.storage._conn()
228
+ conn.execute("DELETE FROM messages WHERE session_id = ?", (session.session_id,))
229
+ conn.commit()
230
+ return True
231
+ except Exception as e:
232
+ logger.error("Failed to clear DB history: %s", e)
233
+ return False
234
+
235
+ async def get_model_info(self, chat_id: int) -> tuple[str, str]:
236
+ """Gets active model's provider and ID."""
237
+ session = self.storage.load_session(chat_id)
238
+ if not session:
239
+ env_p = os.getenv("OPENCODE_PROVIDER", "opencode")
240
+ env_m = os.getenv("OPENCODE_MODEL", "big-pickle")
241
+ return env_p, env_m
242
+
243
+ p_id = session.metadata.get("provider_id")
244
+ m_id = session.metadata.get("model_id")
245
+ if p_id and m_id:
246
+ return p_id, m_id
247
+
248
+ env_p = os.getenv("OPENCODE_PROVIDER")
249
+ env_m = os.getenv("OPENCODE_MODEL")
250
+ if env_p and env_m:
251
+ return env_p, env_m
252
+
253
+ return ("opencode", "big-pickle")
254
+
255
+ async def set_model(self, chat_id: int, provider_id: str, model_id: str) -> bool:
256
+ """Switches the active model for a chat session."""
257
+ session = self.storage.load_session(chat_id)
258
+ if not session:
259
+ return False
260
+
261
+ session.metadata["provider_id"] = provider_id
262
+ session.metadata["model_id"] = model_id
263
+ session.metadata["opencode_session_id"] = None # Force fresh backend session creation
264
+ self.storage.save_session(session)
265
+
266
+ # Sync to environment for global consistency
267
+ os.environ["OPENCODE_PROVIDER"] = provider_id
268
+ os.environ["OPENCODE_MODEL"] = model_id
269
+ return True
270
+
271
+ async def list_models(self) -> List[dict]:
272
+ """Fetches available providers and models from OpenCode serve."""
273
+ providers_data = await self.client.get_providers()
274
+ return providers_data.get("all", [])
275
+
276
+ async def get_status(self) -> dict:
277
+ """Returns OpenCode server connection status and stats."""
278
+ is_up = await self.client.is_alive()
279
+ stats = self.storage.get_stats()
280
+ return {
281
+ "connected": is_up,
282
+ "stats": stats,
283
+ "base_url": OPENCODE_BASE_URL
284
+ }
285
+
286
+ async def get_agents(self) -> List[dict]:
287
+ """Fetches available agents/skills from the server."""
288
+ return await self.client.get_agents()
289
+
290
+ async def summarize_session(self, session_id: str) -> str:
291
+ """Generates an AI summary of a session and saves it."""
292
+ session = self.storage.get_session_by_id(session_id)
293
+ if not session or not session.messages:
294
+ return ""
295
+
296
+ history = session.get_context_text(max_messages=100)
297
+ prompt = (
298
+ "لخص هذه الجلسة البرمجية بشكل احترافي ومفصل باللغة العربية.\n"
299
+ "يجب أن يتضمن الملخص:\n"
300
+ "1. الهدف الرئيسي من الجلسة.\n"
301
+ "2. المشاكل التي تم حلها والكود الذي تم كتابته.\n"
302
+ "3. القرارات التقنية المهمة.\n"
303
+ "4. ما الذي يجب إكماله في الجلسة القادمة.\n\n"
304
+ f"السياق:\n{history}"
305
+ )
306
+
307
+ try:
308
+ summary = await asyncio.wait_for(
309
+ self.client.send_query(prompt, active_mode="ask", chat_id=session.chat_id),
310
+ timeout=30.0
311
+ )
312
+ if summary and not summary.startswith("Error"):
313
+ self.storage.update_session_summary(session_id, summary)
314
+ return summary
315
+ except Exception as e:
316
+ logger.warning("Summarization failed for session %s: %s", session_id, e)
317
+ return ""
318
+
319
+ async def send_message(
320
+ self,
321
+ chat_id: int,
322
+ user_id: int,
323
+ user_message: str,
324
+ interface: str = "cli",
325
+ files: Optional[list] = None
326
+ ) -> str:
327
+ """Sends a query to OpenCode and records conversation history in SQLite."""
328
+ return await self.send_message_streaming(
329
+ chat_id=chat_id,
330
+ user_id=user_id,
331
+ user_message=user_message,
332
+ interface=interface,
333
+ files=files,
334
+ on_token=None,
335
+ )
336
+
337
+ async def send_message_streaming(
338
+ self,
339
+ chat_id: int,
340
+ user_id: int,
341
+ user_message: str,
342
+ interface: str = "cli",
343
+ files: Optional[list] = None,
344
+ on_token=None,
345
+ on_activity=None,
346
+ on_permission=None,
347
+ ) -> str:
348
+ """Sends a query with optional callbacks: on_token(partial_text), on_activity(kind, detail), on_permission(perm_id, perm_type, patterns) -> reply."""
349
+ await self.ensure_worker()
350
+ from typing import Callable
351
+ session = await self.get_or_create_session(chat_id, user_id)
352
+
353
+ # 1. Save user message to database
354
+ self.storage.add_message(chat_id, "user", user_message, interface=interface)
355
+
356
+ # 2. Build history context for continuity
357
+ history_messages = session.get_context_text(max_messages=5)
358
+ active_agent = session.metadata.get("active_agent", "default")
359
+ agent_info = None
360
+ if active_agent.startswith("custom_"):
361
+ try:
362
+ agent_id = int(active_agent.split("_")[1])
363
+ agent_info = self.storage.get_custom_agent_by_id(agent_id)
364
+ except Exception:
365
+ pass
366
+
367
+ query_with_context = f"[Message Source: {interface.upper()}]\nPrevious context for continuity:\n{history_messages}\n\nLatest User Message: {user_message}"
368
+ if agent_info:
369
+ query_with_context = f"[CUSTOM AGENT SYSTEM PROMPT: {agent_info['system_prompt']}]\n\n{query_with_context}"
370
+
371
+ # Inject CLI interface context to lock BMO identity when using the terminal
372
+ if interface == "cli":
373
+ cli_ctx = _system_config.get("cli_context", "")
374
+ if cli_ctx:
375
+ query_with_context = cli_ctx + "\n\n" + query_with_context
376
+
377
+ opencode_sid = session.metadata.get("opencode_session_id")
378
+ provider_id, model_id = await self.get_model_info(chat_id)
379
+ active_mode = session.metadata.get("active_mode", "execute")
380
+
381
+ # 3. Call OpenCode server with optional streaming callback
382
+ response = await self.client.send_query(
383
+ query=query_with_context,
384
+ session_id=opencode_sid,
385
+ provider_id=provider_id,
386
+ model_id=model_id,
387
+ active_mode=active_mode,
388
+ active_agent=active_agent,
389
+ chat_id=chat_id,
390
+ session_uuid=session.session_id,
391
+ files=files,
392
+ on_token=on_token,
393
+ on_activity=on_activity,
394
+ on_permission=on_permission,
395
+ )
396
+
397
+ # 4. Save backend session ID if it was created/changed
398
+ if self.client.last_session_id:
399
+ session.metadata["opencode_session_id"] = self.client.last_session_id
400
+ self.storage.save_session(session)
401
+
402
+ # 5. Save assistant response to database
403
+ self.storage.add_message(chat_id, "assistant", response, interface=interface)
404
+
405
+ return response