@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,838 @@
1
+ """
2
+ OpenCode integration client for the bot.
3
+ Communicates directly with a running `opencode serve` instance via HTTP REST API.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ import httpx
12
+ import json
13
+ from typing import Optional, Callable
14
+
15
+ from core.worker_manager import WorkerManager
16
+
17
+ from config.settings import OPENCODE_BASE_URL, OPENCODE_TIMEOUT, OPENCODE_POLL_INTERVAL, OPENCODE_POLL_TIMEOUT
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ HEADERS = {"Content-Type": "application/json"}
22
+
23
+ # ── Load shared system prompt config (single source of truth) ──
24
+ _config_path = os.path.join(os.path.dirname(__file__), "..", "config", "system-prompt.json")
25
+ with open(_config_path, "r", encoding="utf-8") as _f:
26
+ _system_config = json.load(_f)
27
+
28
+ TELEGRAM_SYSTEM_PROMPT = _system_config["base_prompt"]
29
+
30
+
31
+ class OpenCodeBotClient:
32
+ """Client that talks directly to the opencode serve HTTP API."""
33
+
34
+ _CACHE_TTL = 60 # seconds
35
+
36
+ def __init__(self):
37
+ self.is_connected = False
38
+ self.last_session_id: Optional[str] = None
39
+ self._http = httpx.AsyncClient(
40
+ base_url=OPENCODE_BASE_URL,
41
+ headers=HEADERS,
42
+ timeout=float(OPENCODE_TIMEOUT),
43
+ )
44
+ self._memory_cache: Optional[str] = None
45
+ self._memory_cache_time: float = 0
46
+ self._agents_cache: Optional[list] = None
47
+ self._agents_cache_time: float = 0
48
+ self._memory_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data", "memory.md"))
49
+ self._worker: Optional[WorkerManager] = None
50
+
51
+ def set_worker_manager(self, worker: WorkerManager):
52
+ self._worker = worker
53
+
54
+ async def is_alive(self) -> bool:
55
+ """Lightweight check if the server is responding."""
56
+ try:
57
+ r = await self._http.get("/session", timeout=5.0)
58
+ return r.status_code < 500
59
+ except Exception:
60
+ return False
61
+
62
+ async def connect(self) -> bool:
63
+ """Verify that opencode serve is reachable."""
64
+ try:
65
+ r = await self._http.get("/session")
66
+ if r.status_code < 500:
67
+ self.is_connected = True
68
+ logger.info("Connected to opencode serve at %s", OPENCODE_BASE_URL)
69
+ else:
70
+ logger.warning("opencode serve returned %d", r.status_code)
71
+ self.is_connected = False
72
+ except Exception as e:
73
+ logger.error("Cannot reach opencode serve: %s", e)
74
+ self.is_connected = False
75
+ return self.is_connected
76
+
77
+ async def create_session(self, provider_id: Optional[str] = None, model_id: Optional[str] = None, env: Optional[dict] = None) -> Optional[str]:
78
+ """Create a new opencode session and return its ID."""
79
+ try:
80
+ # Force defaults if None/Empty
81
+ p_id = provider_id if (provider_id and str(provider_id) != "None") else "opencode"
82
+ m_id = model_id if (model_id and str(model_id) != "None") else "big-pickle"
83
+
84
+ # The server expects a nested 'model' object
85
+ payload = {
86
+ "model": {
87
+ "id": m_id,
88
+ "providerID": p_id
89
+ }
90
+ }
91
+
92
+ if env:
93
+ payload["env"] = env
94
+
95
+ logger.info("--- OPENCODE SESSION REQUEST ---")
96
+ logger.info("Payload: %s", json.dumps(payload))
97
+
98
+ r = await self._http.post("/session", json=payload)
99
+ r.raise_for_status()
100
+
101
+ data = r.json()
102
+ session_id = data.get("id")
103
+
104
+ actual_model = data.get("model", {}).get("id", "Unknown")
105
+ logger.info("--- OPENCODE SESSION CREATED: %s (Model: %s) ---", session_id, actual_model)
106
+ return session_id
107
+ except Exception as e:
108
+ logger.error("Failed to create session: %s", e)
109
+ return None
110
+
111
+ async def get_session_info(self, session_id: str) -> Optional[dict]:
112
+ """Fetch session details from the server to verify active model etc."""
113
+ try:
114
+ r = await self._http.get(f"/session/{session_id}")
115
+ if r.status_code == 200:
116
+ return r.json()
117
+ return None
118
+ except Exception as e:
119
+ logger.debug("Failed to get session info: %s", e)
120
+ return None
121
+
122
+ async def get_providers(self) -> dict:
123
+ """Fetch available providers and their models."""
124
+ try:
125
+ r = await self._http.get("/provider", timeout=10)
126
+ r.raise_for_status()
127
+ return r.json()
128
+ except Exception as e:
129
+ logger.error("Failed to fetch providers: %s", e)
130
+ return {}
131
+
132
+ async def abort_session(self, session_id: str) -> bool:
133
+ """Send abort signal to the server to stop the current in-progress LLM stream."""
134
+ try:
135
+ r = await self._http.post(f"/session/{session_id}/abort", timeout=5.0)
136
+ return r.status_code < 400
137
+ except Exception as e:
138
+ logger.warning("abort_session failed (non-fatal): %s", e)
139
+ return False
140
+
141
+ async def delete_messages(self, session_id: str) -> bool:
142
+ """Clears all messages in a session (Reset History)."""
143
+ try:
144
+ r = await self._http.delete(f"/session/{session_id}/message")
145
+ return r.status_code == 200
146
+ except Exception as e:
147
+ logger.error("Failed to delete session messages: %s", e)
148
+ return False
149
+
150
+ async def get_agents(self) -> list:
151
+ """Fetch available agents (skills)."""
152
+ try:
153
+ r = await self._http.get("/agent", timeout=10)
154
+ r.raise_for_status()
155
+ return r.json()
156
+ except Exception as e:
157
+ logger.error("Failed to fetch agents: %s", e)
158
+ return []
159
+
160
+ async def get_mcp_tools(self) -> dict:
161
+ """Fetch available MCP tools."""
162
+ try:
163
+ r = await self._http.get("/config", timeout=10)
164
+ r.raise_for_status()
165
+ return r.json().get("mcp", {})
166
+ except Exception as e:
167
+ logger.error("Failed to fetch MCP tools: %s", e)
168
+ return {}
169
+
170
+ async def _get_memory_content(self) -> str:
171
+ """Read memory.md with caching to avoid disk I/O on every message."""
172
+ now = time.monotonic()
173
+ if self._memory_cache is not None and (now - self._memory_cache_time) < self._CACHE_TTL:
174
+ return self._memory_cache
175
+ try:
176
+ if os.path.exists(self._memory_path):
177
+ with open(self._memory_path, "r", encoding="utf-8") as f:
178
+ self._memory_cache = f.read()
179
+ else:
180
+ self._memory_cache = ""
181
+ except Exception as e:
182
+ logger.warning("Could not read memory file: %s", e)
183
+ self._memory_cache = ""
184
+ self._memory_cache_time = now
185
+ return self._memory_cache
186
+
187
+ async def _get_agents_cached(self) -> list:
188
+ """Fetch agents/skills list with caching to avoid HTTP call on every message."""
189
+ now = time.monotonic()
190
+ if self._agents_cache is not None and (now - self._agents_cache_time) < self._CACHE_TTL:
191
+ return self._agents_cache
192
+ try:
193
+ r = await self._http.get("/agent", timeout=5)
194
+ if r.status_code == 200:
195
+ self._agents_cache = r.json()
196
+ else:
197
+ self._agents_cache = []
198
+ except Exception:
199
+ self._agents_cache = self._agents_cache or []
200
+ self._agents_cache_time = now
201
+ return self._agents_cache
202
+
203
+ async def _build_tool_context(self) -> str:
204
+ """Discover available MCP tools and inject as context before each message."""
205
+ try:
206
+ tools = await self.get_mcp_tools()
207
+ if tools and "tools" in tools:
208
+ lines = ["\n\n[AVAILABLE MCP TOOLS — USE THESE, NOT BASH]"]
209
+ for t in tools["tools"]:
210
+ name = t.get("name", "")
211
+ desc = (t.get("description", "") or "")[:100]
212
+ lines.append(f"- {name}: {desc}")
213
+ lines.append("MANDATORY: For servers/tunnels/long-running processes, use these tools. NEVER run via bash.")
214
+ return "\n".join(lines)
215
+ except Exception as e:
216
+ logger.debug("Failed to build tool context: %s", e)
217
+ return ""
218
+
219
+ async def send_query(
220
+ self,
221
+ query: str,
222
+ session_id: Optional[str] = None,
223
+ files: Optional[list[dict]] = None,
224
+ provider_id: Optional[str] = None,
225
+ model_id: Optional[str] = None,
226
+ active_mode: str = "ask",
227
+ active_agent: str = "default",
228
+ provider_env: Optional[dict] = None,
229
+ chat_id: Optional[int] = None,
230
+ session_uuid: Optional[str] = None,
231
+ on_token: Optional[Callable[[str], None]] = None,
232
+ on_activity: Optional[Callable[[str, str], None]] = None,
233
+ on_permission: Optional[Callable[[str, str, list], str]] = None,
234
+ ) -> str:
235
+ """Send a query via worker process, falling back to direct HTTP."""
236
+ if self._worker and self._worker.is_alive:
237
+ return await self._send_via_worker(
238
+ query=query, session_id=session_id, files=files,
239
+ provider_id=provider_id, model_id=model_id,
240
+ active_mode=active_mode, active_agent=active_agent,
241
+ provider_env=provider_env, chat_id=chat_id,
242
+ session_uuid=session_uuid, on_token=on_token,
243
+ )
244
+ return await self._send_direct(
245
+ query=query, session_id=session_id, files=files,
246
+ provider_id=provider_id, model_id=model_id,
247
+ active_mode=active_mode, active_agent=active_agent,
248
+ provider_env=provider_env, chat_id=chat_id,
249
+ session_uuid=session_uuid, on_token=on_token,
250
+ on_activity=on_activity,
251
+ on_permission=on_permission,
252
+ )
253
+
254
+ async def _send_direct(
255
+ self,
256
+ query: str,
257
+ session_id: Optional[str] = None,
258
+ files: Optional[list[dict]] = None,
259
+ provider_id: Optional[str] = None,
260
+ model_id: Optional[str] = None,
261
+ active_mode: str = "ask",
262
+ active_agent: str = "default",
263
+ provider_env: Optional[dict] = None,
264
+ chat_id: Optional[int] = None,
265
+ session_uuid: Optional[str] = None,
266
+ on_token: Optional[Callable[[str], None]] = None,
267
+ on_activity: Optional[Callable[[str, str], None]] = None,
268
+ on_permission: Optional[Callable[[str, str, list], str]] = None,
269
+ ) -> str:
270
+ """
271
+ Send a query directly via HTTP. Fallback when worker is unavailable.
272
+ Creates a new session if session_id is None.
273
+ Accepts optional files list: [{"path": "...", "mime": "..."}]
274
+ Returns the assistant's Telegram-HTML-formatted response.
275
+ """
276
+ if not self.is_connected:
277
+ connected = await self.connect()
278
+ if not connected:
279
+ return "❌ Error: Could not connect to OpenCode backend. Is the server running?"
280
+
281
+ if not session_id:
282
+ # Inject keys if provided
283
+ session_id = await self.create_session(provider_id, model_id, env=provider_env)
284
+ if not session_id:
285
+ return "Error: Could not create OpenCode session."
286
+
287
+ self.last_session_id = session_id
288
+
289
+ memory_content = await self._get_memory_content()
290
+
291
+ # Mode-specific instructions
292
+ mode_prompts = _system_config["mode_prompts"]
293
+ mode_instruction = mode_prompts.get(active_mode, mode_prompts["execute"])
294
+
295
+ # Anti-loop directive: prevent re-reading old tasks from history
296
+ anti_loop = _system_config["anti_loop"]
297
+
298
+ # Agent-specific personas
299
+ agent_prompts = _system_config["agent_prompts"]
300
+ agent_instruction = agent_prompts.get(active_agent, "")
301
+
302
+ # Inject available skills/agents
303
+ skills_block = ""
304
+ try:
305
+ agents = await self._get_agents_cached()
306
+ if agents:
307
+ lines = ["\n\n<AVAILABLE SKILLS>"]
308
+ for a in agents[:10]:
309
+ name = a.get("name", "")
310
+ desc = a.get("description", "")
311
+ lines.append(f"- {name}: {desc}")
312
+ lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
313
+ skills_block = "\n".join(lines)
314
+ except Exception:
315
+ pass
316
+
317
+ # Build final system context with Memory and Tools
318
+ chat_context = ""
319
+ if chat_id:
320
+ chat_context = f"\n\n[USER_CONTEXT]\nCURRENT_CHAT_ID: {chat_id}\nCURRENT_SESSION_ID: {session_uuid or 'unknown'}\n[END USER_CONTEXT]"
321
+ chat_context += "\n\n<b>FILE STORAGE</b>: When creating files, save them inside <code>data/files/</code>. Use date-based subfolders: <code>data/files/{{YYYY-MM-DD}}/{{CURRENT_SESSION_ID}}_{{HHMMSS}}_{{filename}}</code> so files are linked to sessions and dates."
322
+
323
+ tool_instruction = _system_config["tool_instruction"]
324
+
325
+ # Inject available skills/agents
326
+ skills_block = ""
327
+ try:
328
+ agents = await self._get_agents_cached()
329
+ if agents:
330
+ lines = ["\n\n<AVAILABLE SKILLS>"]
331
+ for a in agents[:10]:
332
+ name = a.get("name", "")
333
+ desc = a.get("description", "")
334
+ lines.append(f"- {name}: {desc}")
335
+ lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
336
+ skills_block = "\n".join(lines)
337
+ except Exception:
338
+ pass
339
+
340
+ # Resolve project root dynamically for portability
341
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
342
+ system_base = TELEGRAM_SYSTEM_PROMPT.replace("{PROJECT_ROOT}", project_root)
343
+
344
+ # Proactive Security Guard: Detect external paths in query and remind the model
345
+ security_warning = ""
346
+ external_paths = re.findall(r'([a-zA-Z]:\\[^`"\'\s\n<>]+|/[^`"\'\s\n<>]+)', query)
347
+ for p in external_paths:
348
+ try:
349
+ p_abs = os.path.abspath(p)
350
+ if not p_abs.startswith(project_root):
351
+ security_warning = f"\n\n[SECURITY ALERT]\nUser requested access to external path: {p_abs}\nYou MUST call request_permission(chat_id={chat_id}, reason='...', scope='{p_abs}') before reading or processing this file.\n[END ALERT]"
352
+ break
353
+ except Exception:
354
+ continue
355
+
356
+ # Inject available MCP tools dynamically
357
+ tool_context = await self._build_tool_context()
358
+
359
+ memory_content = await self._get_memory_content()
360
+ memory_block = f"\n\n[LONG-TERM MEMORY]\n{memory_content}\n[END MEMORY]" if memory_content else ""
361
+ memory_instruction = _system_config["memory_instruction"]
362
+
363
+ full_system_context = system_base + memory_instruction + tool_instruction + chat_context + skills_block + memory_block + security_warning + tool_context + f"\n\nCURRENT PROTOCOL: {mode_instruction}" + _system_config["anti_loop"] + agent_instruction
364
+
365
+ async def _do_send(sid):
366
+ try:
367
+ payload = {
368
+ "parts": [
369
+ {
370
+ "type": "text",
371
+ "text": full_system_context, "synthetic": True,
372
+ },
373
+ {
374
+ "type": "text",
375
+ "text": query,
376
+ },
377
+ ]
378
+ }
379
+
380
+ if files:
381
+ for f in files:
382
+ file_path = f["path"].replace('\\', '/')
383
+ payload["parts"].append({
384
+ "type": "file",
385
+ "url": f"file:///{file_path}",
386
+ "mime": f["mime"]
387
+ })
388
+
389
+ r = await asyncio.wait_for(
390
+ self._http.post(f"/session/{sid}/message", json=payload),
391
+ timeout=float(OPENCODE_TIMEOUT),
392
+ )
393
+ return r, None
394
+ except asyncio.TimeoutError as e:
395
+ logger.error("Hard timeout (asyncio.wait_for) sending to session %s", sid)
396
+ return None, f"Request hard-cancelled after {OPENCODE_TIMEOUT}s (asyncio.wait_for)"
397
+ except httpx.TimeoutException as e:
398
+ logger.error("Timeout sending prompt to session %s [%ss]: %s", sid, self._http.timeout, e)
399
+ return None, f"Request timed out after {self._http.timeout}s"
400
+ except httpx.ConnectError as e:
401
+ logger.error("Cannot connect to opencode serve at %s: %s", OPENCODE_BASE_URL, e)
402
+ return None, f"Cannot reach OpenCode at {OPENCODE_BASE_URL} — is the server running?"
403
+ except Exception as e:
404
+ logger.error("Error in _do_send: %s [%s]", e, type(e).__name__)
405
+ return None, f"Network error ({type(e).__name__}): {e}"
406
+
407
+ r, err = await _do_send(session_id)
408
+
409
+ # Automatic recovery: if session not found, clear it and try once more
410
+ if r is not None and r.status_code == 404:
411
+ logger.warning("Session %s not found on server. Creating new session...", session_id)
412
+ session_id = await self.create_session(provider_id, model_id, env=provider_env)
413
+ if not session_id:
414
+ return "Error: Session lost and could not create a new one."
415
+ self.last_session_id = session_id
416
+ r, err = await _do_send(session_id)
417
+
418
+ if r is None or r.status_code not in (200, 201):
419
+ status_code = r.status_code if r else "None"
420
+ text = (r.text[:200] if r else (err or "Network error"))
421
+ logger.error("Prompt failed (%s): %s", status_code, text)
422
+ return f"Error: opencode returned status {status_code} - {text[:200]}"
423
+
424
+ # Poll for response instead of blocking on /wait
425
+ deadline = time.monotonic() + OPENCODE_POLL_TIMEOUT
426
+ poll_client = httpx.AsyncClient(
427
+ base_url=OPENCODE_BASE_URL,
428
+ headers=HEADERS,
429
+ timeout=10.0,
430
+ )
431
+ # Track which part indices we've already emitted activity for (non-thinking parts only)
432
+ _seen_part_indices: set = set()
433
+ # Track last thinking text emitted to avoid redundant fires
434
+ _last_thinking: str = ""
435
+ try:
436
+ while time.monotonic() < deadline:
437
+ await asyncio.sleep(OPENCODE_POLL_INTERVAL)
438
+ try:
439
+ r = await poll_client.get(
440
+ f"/session/{session_id}/message",
441
+ params={"limit": "20"},
442
+ )
443
+ if r.status_code != 200:
444
+ continue
445
+ messages = r.json()
446
+ if not messages:
447
+ continue
448
+
449
+ # Only check the LATEST message to avoid returning old responses
450
+ msg = messages[-1]
451
+ info = msg.get("info", {})
452
+ role = info.get("role")
453
+
454
+ # Skip if latest message is not from assistant
455
+ if role != "assistant":
456
+ continue
457
+
458
+ parts = msg.get("parts", [])
459
+
460
+ # Check completion status
461
+ has_finish = any(p.get("type") == "step-finish" for p in parts)
462
+ finish_reason = info.get("finish") # "stop", "error", etc.
463
+ is_complete = has_finish or finish_reason in ("stop", "error", "length")
464
+
465
+ # Check for error parts
466
+ error_parts = [p for p in parts if p.get("type") == "error"]
467
+ if error_parts:
468
+ error_msg = error_parts[0].get("message", "Unknown error")
469
+ logger.error("Model returned error: %s", error_msg)
470
+ return f"Error: {error_msg}"
471
+
472
+ # --- Activity tracking: emit events for new parts ---
473
+ if on_activity:
474
+ # Accumulate full thinking text across all reasoning parts on this poll
475
+ accumulated_thinking = "\n\n".join(
476
+ (p.get("text", "") or "").strip()
477
+ for p in parts
478
+ if p.get("type") in ("reasoning", "thinking")
479
+ and not p.get("synthetic")
480
+ and (p.get("text", "") or "").strip()
481
+ )
482
+ if accumulated_thinking and accumulated_thinking != _last_thinking:
483
+ _last_thinking = accumulated_thinking
484
+ on_activity("thinking", accumulated_thinking)
485
+
486
+ for idx, p in enumerate(parts):
487
+ if idx in _seen_part_indices:
488
+ continue
489
+ _seen_part_indices.add(idx)
490
+ p_type = p.get("type", "")
491
+ if p.get("synthetic"):
492
+ continue
493
+
494
+ if p_type == "step-start":
495
+ on_activity("step", "🔄 New reasoning step")
496
+ elif p_type == "tool-use" or p_type == "tool_use":
497
+ tool_name = p.get("name") or p.get("tool", {}).get("name", "unknown")
498
+ tool_input = p.get("input") or p.get("tool", {}).get("input", {})
499
+ detail = str(tool_input)[:120].replace("\n", " ") if tool_input else ""
500
+ on_activity("tool_call", f"🔧 {tool_name}({detail})")
501
+ elif p_type == "tool-result" or p_type == "tool_response":
502
+ tool_name = p.get("name", "tool")
503
+ is_error = p.get("isError", False)
504
+ icon = "❌" if is_error else "✅"
505
+ on_activity("tool_result", f"{icon} {tool_name} done")
506
+ elif p_type == "text" and not p.get("synthetic"):
507
+ on_activity("text_start", "✍️ Writing response...")
508
+
509
+ # Extract text from text-type parts only (not reasoning)
510
+ text_parts = []
511
+ for p in parts:
512
+ p_type = p.get("type")
513
+ p_text = p.get("text", "")
514
+ p_synthetic = p.get("synthetic")
515
+
516
+ # Skip synthetic parts
517
+ if p_synthetic:
518
+ continue
519
+
520
+ # Only collect actual text parts (not reasoning/step markers)
521
+ if p_type == "text" and p_text:
522
+ text_parts.append(p_text)
523
+
524
+ text = "\n".join(text_parts).strip()
525
+
526
+ # Fire streaming callback with partial text on every poll
527
+ if text and on_token:
528
+ on_token(text)
529
+
530
+ # Return if we have text and response is complete
531
+ if text and is_complete:
532
+ logger.info("Response received (%d chars, finish=%s)", len(text), finish_reason or "step-finish")
533
+ return text
534
+ elif text:
535
+ logger.debug("Response partial (%d chars, waiting for completion)", len(text))
536
+
537
+ except httpx.TimeoutException:
538
+ continue
539
+ except Exception as e:
540
+ logger.debug("Poll error (non-fatal): %s", e)
541
+ continue
542
+
543
+ logger.error("Polling timed out after %ds", OPENCODE_POLL_TIMEOUT)
544
+ # Abort server-side stream on timeout to prevent the server from
545
+ # continuing to process a request nobody is listening to anymore.
546
+ if session_id:
547
+ try:
548
+ await self.abort_session(session_id)
549
+ except Exception:
550
+ pass
551
+ return "Error: Request timed out after 30 minutes."
552
+ finally:
553
+ await poll_client.aclose()
554
+
555
+ async def _send_via_worker(
556
+ self,
557
+ query: str,
558
+ session_id: Optional[str] = None,
559
+ files: Optional[list[dict]] = None,
560
+ provider_id: Optional[str] = None,
561
+ model_id: Optional[str] = None,
562
+ active_mode: str = "ask",
563
+ active_agent: str = "default",
564
+ provider_env: Optional[dict] = None,
565
+ chat_id: Optional[int] = None,
566
+ session_uuid: Optional[str] = None,
567
+ on_token: Optional[Callable[[str], None]] = None,
568
+ ) -> str:
569
+ """Send query via worker process (fully async, never blocks the event loop)."""
570
+ if not self.is_connected:
571
+ connected = await self.connect()
572
+ if not connected:
573
+ return "❌ Error: Could not connect to OpenCode backend. Is the server running?"
574
+
575
+ if not session_id:
576
+ session_id = await self.create_session(provider_id, model_id, env=provider_env)
577
+ if not session_id:
578
+ return "Error: Could not create OpenCode session."
579
+
580
+ self.last_session_id = session_id
581
+
582
+ memory_content = await self._get_memory_content()
583
+
584
+ mode_prompts = _system_config["mode_prompts"]
585
+ mode_instruction = mode_prompts.get(active_mode, mode_prompts["execute"])
586
+ anti_loop = _system_config["anti_loop"]
587
+ agent_prompts = _system_config["agent_prompts"]
588
+ agent_instruction = agent_prompts.get(active_agent, "")
589
+
590
+ skills_block = ""
591
+ try:
592
+ agents = await self._get_agents_cached()
593
+ if agents:
594
+ lines = ["\n\n<AVAILABLE SKILLS>"]
595
+ for a in agents[:10]:
596
+ name = a.get("name", "")
597
+ desc = a.get("description", "")
598
+ lines.append(f"- {name}: {desc}")
599
+ lines.append("</AVAILABLE SKILLS>\nUse these proactively when needed.")
600
+ skills_block = "\n".join(lines)
601
+ except Exception:
602
+ pass
603
+
604
+ chat_context = ""
605
+ if chat_id:
606
+ chat_context = f"\n\n[USER_CONTEXT]\nCURRENT_CHAT_ID: {chat_id}\nCURRENT_SESSION_ID: {session_uuid or 'unknown'}\n[END USER_CONTEXT]"
607
+ chat_context += "\n\n<b>FILE STORAGE</b>: When creating files, save them inside <code>data/files/</code>. Use date-based subfolders: <code>data/files/{{YYYY-MM-DD}}/{{CURRENT_SESSION_ID}}_{{HHMMSS}}_{{filename}}</code> so files are linked to sessions and dates."
608
+
609
+ tool_instruction = _system_config["tool_instruction"]
610
+
611
+ project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
612
+ system_base = TELEGRAM_SYSTEM_PROMPT.replace("{PROJECT_ROOT}", project_root)
613
+
614
+ security_warning = ""
615
+ external_paths = re.findall(r'([a-zA-Z]:\\[^`"\'\s\n<>]+|/[^`"\'\s\n<>]+)', query)
616
+ for p in external_paths:
617
+ try:
618
+ p_abs = os.path.abspath(p)
619
+ if not p_abs.startswith(project_root):
620
+ security_warning = f"\n\n[SECURITY ALERT]\nUser requested access to external path: {p_abs}\nYou MUST call request_permission(chat_id={chat_id}, reason='...', scope='{p_abs}') before reading or processing this file.\n[END ALERT]"
621
+ break
622
+ except Exception:
623
+ continue
624
+
625
+ tool_context = await self._build_tool_context()
626
+ memory_content = await self._get_memory_content()
627
+ memory_block = f"\n\n[LONG-TERM MEMORY]\n{memory_content}\n[END MEMORY]" if memory_content else ""
628
+ memory_instruction = _system_config["memory_instruction"]
629
+
630
+ full_system_context = system_base + memory_instruction + tool_instruction + chat_context + skills_block + memory_block + security_warning + tool_context + f"\n\nCURRENT PROTOCOL: {mode_instruction}" + anti_loop + agent_instruction
631
+
632
+ payload = {
633
+ "parts": [
634
+ {
635
+ "type": "text",
636
+ "text": full_system_context,
637
+ "synthetic": True,
638
+ },
639
+ {
640
+ "type": "text",
641
+ "text": query,
642
+ },
643
+ ]
644
+ }
645
+
646
+ if files:
647
+ for f in files:
648
+ file_path = f["path"].replace('\\', '/')
649
+ payload["parts"].append({
650
+ "type": "file",
651
+ "url": f"file:///{file_path}",
652
+ "mime": f["mime"]
653
+ })
654
+
655
+ base_url = OPENCODE_BASE_URL.rstrip("/")
656
+
657
+ return await self._worker.send_query(
658
+ session_id=session_id,
659
+ payload=payload,
660
+ base_url=base_url,
661
+ callback=on_token,
662
+ )
663
+
664
+ async def get_session_status(self, session_id: str) -> str:
665
+ """Fetch current assistant status from the session. Returns an HTML status string."""
666
+ try:
667
+ # Get more messages to see the flow of tool calls/responses
668
+ r = await self._http.get(f"/session/{session_id}/message", params={"limit": "10"})
669
+ if r.status_code != 200:
670
+ return "🧠 <i>BMO is thinking...</i>"
671
+
672
+ msgs = r.json()
673
+ if not msgs:
674
+ return "🧠 <i>BMO is preparing...</i>"
675
+
676
+ # Only check the LATEST message for status
677
+ msg = msgs[-1]
678
+ info = msg.get("info", {})
679
+ role = info.get("role")
680
+ parts = msg.get("parts", [])
681
+
682
+ if role == "tool":
683
+ # This is a tool response message
684
+ for p in parts:
685
+ if p.get("type") == "tool_response":
686
+ name = p.get("name", "tool")
687
+ is_error = p.get("isError", False)
688
+ status = "❌ Failed" if is_error else "✅ Done"
689
+ status_str = f"⚙️ <b>{name}</b>: {status}"
690
+
691
+ # Log to terminal if changed
692
+ if not hasattr(self, '_last_log') or self._last_log != status_str:
693
+ self._last_log = status_str
694
+ print(f"[BMO STATUS] {status_str.replace('<b>','').replace('</b>','')}")
695
+
696
+ return status_str
697
+
698
+ if role == "assistant":
699
+ if not parts:
700
+ return "🧠 <i>BMO is thinking...</i>"
701
+
702
+ # Check for tool calls first (highest priority for status)
703
+ for p in reversed(parts):
704
+ if p.get("type") == "tool_call":
705
+ name = p.get("name")
706
+ args = p.get("arguments", "")
707
+ # Truncate args for display
708
+ arg_snippet = str(args)[:40] + "..." if len(str(args)) > 40 else str(args)
709
+ status_str = f"🛠️ <b>Using {name}</b>\n<code>{arg_snippet}</code>"
710
+
711
+ # Log to terminal if changed
712
+ if not hasattr(self, '_last_log') or self._last_log != status_str:
713
+ self._last_log = status_str
714
+ print(f"[BMO TOOL] {name}({arg_snippet})")
715
+
716
+ return status_str
717
+
718
+ # Show reasoning text for UX (user wants to see what model is thinking)
719
+ for p in reversed(parts):
720
+ if p.get("type") == "reasoning" and p.get("text"):
721
+ snippet = p.get("text").strip()[:80].replace("\n", " ")
722
+ status_str = f"🧠 <i>{snippet}...</i>"
723
+ return status_str
724
+
725
+ # Fallback to text snippets
726
+ for p in reversed(parts):
727
+ if p.get("type") == "text" and p.get("text") and not p.get("synthetic"):
728
+ snippet = p.get("text").strip()[:60].replace("\n", " ")
729
+ status_str = f"✍️ <i>{snippet}...</i>"
730
+ return status_str
731
+
732
+ return "🧠 <i>BMO is thinking...</i>"
733
+ except Exception as e:
734
+ logger.debug("Error in get_session_status: %s", e)
735
+ return "🧠 <i>BMO is thinking...</i>"
736
+
737
+ # ── Provider API verification map ──────────────────────────────────────────
738
+ # Maps provider_id → (api_base_url, env_key_name, test_endpoint, auth_header)
739
+ PROVIDER_API_MAP = {
740
+ "groq": ("https://api.groq.com/openai/v1", "GROQ_API_KEY", "/models", "Bearer"),
741
+ "openai": ("https://api.openai.com/v1", "OPENAI_API_KEY", "/models", "Bearer"),
742
+ "anthropic": ("https://api.anthropic.com/v1", "ANTHROPIC_API_KEY", "/models", "x-api-key"),
743
+ "deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", "/models", "Bearer"),
744
+ "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY", "/models", "Bearer"),
745
+ "google": ("https://generativelanguage.googleapis.com", "GOOGLE_API_KEY", "/v1beta/models", "query"),
746
+ "mistral": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", "/models", "Bearer"),
747
+ "xai": ("https://api.x.ai/v1", "XAI_API_KEY", "/models", "Bearer"),
748
+ }
749
+
750
+ async def _verify_key_directly(self, provider_id: str, env: dict) -> tuple[bool, str]:
751
+ """Try to verify an API key via a direct call to the provider's API."""
752
+ provider_cfg = self.PROVIDER_API_MAP.get(provider_id)
753
+ if not provider_cfg:
754
+ return False, "" # No direct verification method for this provider
755
+
756
+ base_url, key_name, endpoint, auth_type = provider_cfg
757
+ api_key = env.get(key_name)
758
+ if not api_key:
759
+ return False, ""
760
+
761
+ try:
762
+ headers = {"Content-Type": "application/json"}
763
+ url = f"{base_url}{endpoint}"
764
+
765
+ if auth_type == "query":
766
+ url = f"{url}?key={api_key}"
767
+ elif auth_type == "x-api-key":
768
+ headers["x-api-key"] = api_key
769
+ else:
770
+ headers["Authorization"] = f"{auth_type} {api_key}"
771
+
772
+ async with httpx.AsyncClient(timeout=15.0) as client:
773
+ r = await client.get(url, headers=headers)
774
+ if r.status_code == 200:
775
+ return True, "Verification successful."
776
+ elif r.status_code == 401:
777
+ return False, "Invalid API key — provider returned 401 Unauthorized."
778
+ elif r.status_code == 403:
779
+ return False, "API key lacks permission — provider returned 403 Forbidden."
780
+ else:
781
+ return False, f"Provider returned HTTP {r.status_code}."
782
+ except httpx.TimeoutException:
783
+ return False, "Provider API timed out."
784
+ except Exception as e:
785
+ logger.debug("Direct key verification failed for %s: %s", provider_id, e)
786
+ return False, ""
787
+
788
+ async def test_provider_key(self, provider_id: str, model_id: str, env: dict) -> tuple[bool, str]:
789
+ """
790
+ Tests an API key by making a minimal request with a short timeout.
791
+ Returns (is_success, detail_message)
792
+ """
793
+ # 1. Try direct provider API verification first (more reliable)
794
+ direct_success, direct_message = await self._verify_key_directly(provider_id, env)
795
+ if direct_message:
796
+ return direct_success, direct_message
797
+
798
+ # 2. Fall back to OpenCode session-based test
799
+ try:
800
+ session_id = await self.create_session(provider_id, model_id, env=env)
801
+ if not session_id:
802
+ return False, "Failed to initialize test session on OpenCode server."
803
+
804
+ payload = {
805
+ "parts": [{"type": "text", "text": "respond only with 'ok'"}]
806
+ }
807
+ r = await self._http.post(f"/session/{session_id}/message", json=payload, timeout=30.0)
808
+ if r.status_code not in (200, 201):
809
+ return False, f"Server error ({r.status_code}): {r.text[:100]}"
810
+
811
+ for _ in range(60):
812
+ await asyncio.sleep(1)
813
+ r_poll = await self._http.get(f"/session/{session_id}/message", params={"limit": "5"})
814
+ if r_poll.status_code == 200:
815
+ msgs = r_poll.json()
816
+ for m in msgs:
817
+ role = m.get("info", {}).get("role")
818
+ if role == "assistant":
819
+ parts = m.get("parts", [])
820
+ text_parts = [p.get("text", "") for p in parts if p.get("type") == "text" and not p.get("synthetic")]
821
+ if any(text_parts):
822
+ return True, "Verification successful."
823
+
824
+ error_parts = [p.get("message", "Unknown error") for p in parts if p.get("type") == "error"]
825
+ if error_parts:
826
+ return False, f"Provider Error: {error_parts[0]}"
827
+
828
+ return False, "Test timed out. The provider took too long to respond (likely invalid key or network issue)."
829
+
830
+ except httpx.TimeoutException:
831
+ return False, "Request timed out. Check your internet connection or the API key status."
832
+ except Exception as e:
833
+ logger.error("Test key error: %s", e)
834
+ return False, f"System error during test: {str(e)}"
835
+
836
+ async def close(self):
837
+ await self._http.aclose()
838
+