@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
package/cli.py
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BMO Interactive CLI Client
|
|
3
|
+
Provides a premium terminal experience inspired by high-end AI agents (Claude Code, Aider).
|
|
4
|
+
Integrates with the unified BMOEngine backend.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
from prompt_toolkit import PromptSession
|
|
15
|
+
from prompt_toolkit.completion import WordCompleter
|
|
16
|
+
from prompt_toolkit.styles import Style
|
|
17
|
+
from prompt_toolkit.application import Application
|
|
18
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
19
|
+
from prompt_toolkit.layout import Layout, HSplit, VSplit, Window
|
|
20
|
+
from prompt_toolkit.layout.controls import FormattedTextControl, BufferControl
|
|
21
|
+
from prompt_toolkit.buffer import Buffer
|
|
22
|
+
from prompt_toolkit.widgets import Frame
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
from core.bmo_engine import BMOEngine
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from core.goal_runner import GoalRunner
|
|
28
|
+
from core.budget_tracker import BudgetTracker
|
|
29
|
+
from core.cli_renderer import (
|
|
30
|
+
print_banner,
|
|
31
|
+
print_assistant_message,
|
|
32
|
+
print_system_status,
|
|
33
|
+
print_sessions_list,
|
|
34
|
+
print_error,
|
|
35
|
+
print_success,
|
|
36
|
+
print_info,
|
|
37
|
+
bmo_spinner
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from config.settings import OWNER_ID
|
|
41
|
+
# Configuration: Default admin user chat ID
|
|
42
|
+
CHAT_ID = int(os.getenv("TELEGRAM_BOT_TOKEN_ADMIN_ID", str(OWNER_ID)))
|
|
43
|
+
USER_ID = CHAT_ID
|
|
44
|
+
USERNAME = "cli_user"
|
|
45
|
+
|
|
46
|
+
# CLI Commands lists for autocomplete
|
|
47
|
+
COMMANDS = [
|
|
48
|
+
"/help",
|
|
49
|
+
"/new",
|
|
50
|
+
"/sessions",
|
|
51
|
+
"/model",
|
|
52
|
+
"/clear",
|
|
53
|
+
"/status",
|
|
54
|
+
"/share",
|
|
55
|
+
"/exit",
|
|
56
|
+
"/goal",
|
|
57
|
+
"/budget",
|
|
58
|
+
"/bfp",
|
|
59
|
+
"/web",
|
|
60
|
+
"/interrupt",
|
|
61
|
+
"/stop",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
completer = WordCompleter(COMMANDS, ignore_case=True)
|
|
65
|
+
|
|
66
|
+
# Styling for the prompt_toolkit interface
|
|
67
|
+
cli_style = Style.from_dict({
|
|
68
|
+
"bottom-toolbar": "bg:#222222 #00ffff bold",
|
|
69
|
+
"bottom-toolbar.text": "#ffffff",
|
|
70
|
+
"prompt": "#00ffff bold",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
from prompt_toolkit.filters import Condition
|
|
74
|
+
|
|
75
|
+
class ModelSelectorTUI:
|
|
76
|
+
def __init__(self, raw_providers, favorites, save_fav_callback):
|
|
77
|
+
self.raw_providers = raw_providers
|
|
78
|
+
self.favorites = set(favorites)
|
|
79
|
+
self.save_fav_callback = save_fav_callback
|
|
80
|
+
|
|
81
|
+
self.provider_list = []
|
|
82
|
+
for p in raw_providers:
|
|
83
|
+
p_models = []
|
|
84
|
+
raw_models = p.get("models", {})
|
|
85
|
+
if isinstance(raw_models, dict):
|
|
86
|
+
for m_id, m_data in raw_models.items():
|
|
87
|
+
p_models.append({
|
|
88
|
+
"id": m_id,
|
|
89
|
+
"name": m_data.get("name", m_id) if isinstance(m_data, dict) else m_id
|
|
90
|
+
})
|
|
91
|
+
elif isinstance(raw_models, list):
|
|
92
|
+
for m in raw_models:
|
|
93
|
+
p_models.append({
|
|
94
|
+
"id": m.get("id") if isinstance(m, dict) else m,
|
|
95
|
+
"name": m.get("name") or m.get("id") if isinstance(m, dict) else m
|
|
96
|
+
})
|
|
97
|
+
if p_models:
|
|
98
|
+
self.provider_list.append({
|
|
99
|
+
"id": p.get("id"),
|
|
100
|
+
"name": p.get("name") or p.get("id"),
|
|
101
|
+
"models": p_models
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
self.filtered_providers = list(self.provider_list)
|
|
105
|
+
self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
|
|
106
|
+
|
|
107
|
+
self.selected_prov_idx = 0
|
|
108
|
+
self.selected_model_idx = 0
|
|
109
|
+
self.focus_pane = "search" # "search", "providers", or "models"
|
|
110
|
+
self.search_text = ""
|
|
111
|
+
self.selected_model_id = None
|
|
112
|
+
self.selected_provider_id = None
|
|
113
|
+
|
|
114
|
+
# Build UI components
|
|
115
|
+
self.search_buffer = Buffer()
|
|
116
|
+
|
|
117
|
+
@self.search_buffer.on_text_changed.add_handler
|
|
118
|
+
def _on_text_changed(buf):
|
|
119
|
+
self.search_text = buf.text.lower()
|
|
120
|
+
self.filtered_providers = []
|
|
121
|
+
for p in self.provider_list:
|
|
122
|
+
if self.search_text in p["name"].lower() or self.search_text in p["id"].lower():
|
|
123
|
+
self.filtered_providers.append(p)
|
|
124
|
+
# Sort: favorites first, then alphabetically by name
|
|
125
|
+
self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
|
|
126
|
+
self.selected_prov_idx = 0
|
|
127
|
+
self.selected_model_idx = 0
|
|
128
|
+
|
|
129
|
+
self.kb = KeyBindings()
|
|
130
|
+
self._setup_keybindings()
|
|
131
|
+
|
|
132
|
+
def _setup_keybindings(self):
|
|
133
|
+
in_search = Condition(lambda: self.focus_pane == "search")
|
|
134
|
+
in_providers = Condition(lambda: self.focus_pane == "providers")
|
|
135
|
+
in_models = Condition(lambda: self.focus_pane == "models")
|
|
136
|
+
|
|
137
|
+
# Global exit bindings
|
|
138
|
+
@self.kb.add("c-c")
|
|
139
|
+
def _global_exit(event):
|
|
140
|
+
event.app.exit(result=None)
|
|
141
|
+
|
|
142
|
+
# Search Pane Keybindings
|
|
143
|
+
@self.kb.add("escape", filter=in_search)
|
|
144
|
+
def _search_exit(event):
|
|
145
|
+
event.app.exit(result=None)
|
|
146
|
+
|
|
147
|
+
@self.kb.add("down", filter=in_search)
|
|
148
|
+
@self.kb.add("enter", filter=in_search)
|
|
149
|
+
@self.kb.add("tab", filter=in_search)
|
|
150
|
+
def _search_go_to_providers(event):
|
|
151
|
+
if self.filtered_providers:
|
|
152
|
+
self.focus_pane = "providers"
|
|
153
|
+
event.app.layout.focus(self.providers_window)
|
|
154
|
+
|
|
155
|
+
# Providers Pane Keybindings
|
|
156
|
+
@self.kb.add("escape", filter=in_providers)
|
|
157
|
+
@self.kb.add("left", filter=in_providers)
|
|
158
|
+
def _providers_exit(event):
|
|
159
|
+
self.focus_pane = "search"
|
|
160
|
+
event.app.layout.focus(self.search_window)
|
|
161
|
+
|
|
162
|
+
@self.kb.add("up", filter=in_providers)
|
|
163
|
+
def _providers_up(event):
|
|
164
|
+
if self.filtered_providers:
|
|
165
|
+
self.selected_prov_idx = (self.selected_prov_idx - 1) % len(self.filtered_providers)
|
|
166
|
+
self.selected_model_idx = 0
|
|
167
|
+
|
|
168
|
+
@self.kb.add("down", filter=in_providers)
|
|
169
|
+
def _providers_down(event):
|
|
170
|
+
if self.filtered_providers:
|
|
171
|
+
self.selected_prov_idx = (self.selected_prov_idx + 1) % len(self.filtered_providers)
|
|
172
|
+
self.selected_model_idx = 0
|
|
173
|
+
|
|
174
|
+
@self.kb.add("space", filter=in_providers)
|
|
175
|
+
def _providers_toggle_fav(event):
|
|
176
|
+
if self.filtered_providers:
|
|
177
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
178
|
+
prov_id = prov["id"]
|
|
179
|
+
if prov_id in self.favorites:
|
|
180
|
+
self.favorites.remove(prov_id)
|
|
181
|
+
else:
|
|
182
|
+
self.favorites.add(prov_id)
|
|
183
|
+
self.save_fav_callback(list(self.favorites))
|
|
184
|
+
|
|
185
|
+
# Re-sort list and update index to stay focused on same item
|
|
186
|
+
self.filtered_providers.sort(key=lambda p: (0 if p["id"] in self.favorites else 1, p["name"].lower()))
|
|
187
|
+
for idx, p in enumerate(self.filtered_providers):
|
|
188
|
+
if p["id"] == prov_id:
|
|
189
|
+
self.selected_prov_idx = idx
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
@self.kb.add("enter", filter=in_providers)
|
|
193
|
+
@self.kb.add("right", filter=in_providers)
|
|
194
|
+
@self.kb.add("tab", filter=in_providers)
|
|
195
|
+
def _providers_select(event):
|
|
196
|
+
if self.filtered_providers:
|
|
197
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
198
|
+
if prov["models"]:
|
|
199
|
+
self.focus_pane = "models"
|
|
200
|
+
self.selected_model_idx = 0
|
|
201
|
+
event.app.layout.focus(self.models_window)
|
|
202
|
+
|
|
203
|
+
# Models Pane Keybindings
|
|
204
|
+
@self.kb.add("escape", filter=in_models)
|
|
205
|
+
@self.kb.add("left", filter=in_models)
|
|
206
|
+
@self.kb.add("tab", filter=in_models)
|
|
207
|
+
def _models_back(event):
|
|
208
|
+
self.focus_pane = "providers"
|
|
209
|
+
event.app.layout.focus(self.providers_window)
|
|
210
|
+
|
|
211
|
+
@self.kb.add("up", filter=in_models)
|
|
212
|
+
def _models_up(event):
|
|
213
|
+
if self.filtered_providers:
|
|
214
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
215
|
+
if prov["models"]:
|
|
216
|
+
self.selected_model_idx = (self.selected_model_idx - 1) % len(prov["models"])
|
|
217
|
+
|
|
218
|
+
@self.kb.add("down", filter=in_models)
|
|
219
|
+
def _models_down(event):
|
|
220
|
+
if self.filtered_providers:
|
|
221
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
222
|
+
if prov["models"]:
|
|
223
|
+
self.selected_model_idx = (self.selected_model_idx + 1) % len(prov["models"])
|
|
224
|
+
|
|
225
|
+
@self.kb.add("enter", filter=in_models)
|
|
226
|
+
def _models_select(event):
|
|
227
|
+
if self.filtered_providers:
|
|
228
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
229
|
+
if prov["models"]:
|
|
230
|
+
model = prov["models"][self.selected_model_idx]
|
|
231
|
+
self.selected_provider_id = prov["id"]
|
|
232
|
+
self.selected_model_id = model["id"]
|
|
233
|
+
event.app.exit(result=(self.selected_provider_id, self.selected_model_id))
|
|
234
|
+
|
|
235
|
+
def _get_visible_slice(self, items, selected_idx, max_visible=12):
|
|
236
|
+
total = len(items)
|
|
237
|
+
if total <= max_visible:
|
|
238
|
+
return items, 0
|
|
239
|
+
|
|
240
|
+
half = max_visible // 2
|
|
241
|
+
start = selected_idx - half
|
|
242
|
+
if start < 0:
|
|
243
|
+
start = 0
|
|
244
|
+
elif start + max_visible > total:
|
|
245
|
+
start = total - max_visible
|
|
246
|
+
|
|
247
|
+
end = start + max_visible
|
|
248
|
+
return items[start:end], start
|
|
249
|
+
|
|
250
|
+
def _render_providers(self):
|
|
251
|
+
if not self.filtered_providers:
|
|
252
|
+
return [("", " No matching providers found.\n")]
|
|
253
|
+
|
|
254
|
+
visible_providers, start_idx = self._get_visible_slice(
|
|
255
|
+
self.filtered_providers,
|
|
256
|
+
self.selected_prov_idx,
|
|
257
|
+
max_visible=15
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
lines = []
|
|
261
|
+
if start_idx > 0:
|
|
262
|
+
lines.append(("class:scroll-indicator", " ▲ ... more providers above ...\n"))
|
|
263
|
+
|
|
264
|
+
for idx, prov in enumerate(visible_providers):
|
|
265
|
+
actual_idx = start_idx + idx
|
|
266
|
+
is_selected = (actual_idx == self.selected_prov_idx)
|
|
267
|
+
is_fav = prov["id"] in self.favorites
|
|
268
|
+
|
|
269
|
+
prefix = " > " if is_selected else " "
|
|
270
|
+
fav_star = "⭐" if is_fav else " "
|
|
271
|
+
|
|
272
|
+
if is_selected:
|
|
273
|
+
if self.focus_pane == "providers":
|
|
274
|
+
style = "class:selected"
|
|
275
|
+
else:
|
|
276
|
+
style = "class:selected-inactive"
|
|
277
|
+
else:
|
|
278
|
+
style = ""
|
|
279
|
+
|
|
280
|
+
lines.append((style, f"{prefix}{fav_star} {prov['name']} ({prov['id']})\n"))
|
|
281
|
+
|
|
282
|
+
if start_idx + len(visible_providers) < len(self.filtered_providers):
|
|
283
|
+
lines.append(("class:scroll-indicator", " ▼ ... more providers below ...\n"))
|
|
284
|
+
|
|
285
|
+
return lines
|
|
286
|
+
|
|
287
|
+
def _render_models(self):
|
|
288
|
+
if not self.filtered_providers:
|
|
289
|
+
return [("", " No provider selected.\n")]
|
|
290
|
+
|
|
291
|
+
prov = self.filtered_providers[self.selected_prov_idx]
|
|
292
|
+
if not prov["models"]:
|
|
293
|
+
return [("", " No models available.\n")]
|
|
294
|
+
|
|
295
|
+
visible_models, start_idx = self._get_visible_slice(
|
|
296
|
+
prov["models"],
|
|
297
|
+
self.selected_model_idx,
|
|
298
|
+
max_visible=15
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
lines = []
|
|
302
|
+
if start_idx > 0:
|
|
303
|
+
lines.append(("class:scroll-indicator", " ▲ ... more models above ...\n"))
|
|
304
|
+
|
|
305
|
+
for idx, model in enumerate(visible_models):
|
|
306
|
+
actual_idx = start_idx + idx
|
|
307
|
+
is_selected = (actual_idx == self.selected_model_idx)
|
|
308
|
+
|
|
309
|
+
prefix = " > " if is_selected else " "
|
|
310
|
+
if is_selected:
|
|
311
|
+
if self.focus_pane == "models":
|
|
312
|
+
style = "class:selected"
|
|
313
|
+
else:
|
|
314
|
+
style = "class:selected-inactive"
|
|
315
|
+
else:
|
|
316
|
+
style = ""
|
|
317
|
+
|
|
318
|
+
lines.append((style, f"{prefix}{model['id']} ({model['name']})\n"))
|
|
319
|
+
|
|
320
|
+
if start_idx + len(visible_models) < len(prov["models"]):
|
|
321
|
+
lines.append(("class:scroll-indicator", " ▼ ... more models below ...\n"))
|
|
322
|
+
|
|
323
|
+
return lines
|
|
324
|
+
|
|
325
|
+
def _get_search_title(self):
|
|
326
|
+
if self.focus_pane == "search":
|
|
327
|
+
return [("class:frame.label.active", " Search Provider (Start typing...) ")]
|
|
328
|
+
return [("class:frame.label", " Search Provider ")]
|
|
329
|
+
|
|
330
|
+
def _get_providers_title(self):
|
|
331
|
+
if self.focus_pane == "providers":
|
|
332
|
+
return [("class:frame.label.active", " Providers List (⭐ Favorites) ")]
|
|
333
|
+
return [("class:frame.label", " Providers List ")]
|
|
334
|
+
|
|
335
|
+
def _get_models_title(self):
|
|
336
|
+
if self.focus_pane == "models":
|
|
337
|
+
return [("class:frame.label.active", " Available Models ")]
|
|
338
|
+
return [("class:frame.label", " Available Models ")]
|
|
339
|
+
|
|
340
|
+
def _render_status(self):
|
|
341
|
+
if self.focus_pane == "search":
|
|
342
|
+
return [("class:help", " [Down/Enter] Navigate Providers | [Esc] Cancel Selection")]
|
|
343
|
+
elif self.focus_pane == "providers":
|
|
344
|
+
return [("class:help", " [Up/Down] Choose Provider | [Space] Toggle Favorite ⭐ | [Enter/Right] Show Models | [Esc/Left] Back to Search")]
|
|
345
|
+
else:
|
|
346
|
+
return [("class:help", " [Up/Down] Choose Model | [Enter] Confirm Model Selection | [Esc/Left] Back to Providers")]
|
|
347
|
+
|
|
348
|
+
def get_app(self):
|
|
349
|
+
style = Style.from_dict({
|
|
350
|
+
"selected": "bg:#00ffff #000000 bold",
|
|
351
|
+
"selected-inactive": "bg:#333333 #ffffff italic",
|
|
352
|
+
"help": "bg:#222222 #00ffff bold",
|
|
353
|
+
"frame.label": "#888888",
|
|
354
|
+
"frame.label.active": "#00ffff bold",
|
|
355
|
+
"scroll-indicator": "#555555 italic",
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
self.search_window = Window(content=BufferControl(buffer=self.search_buffer), height=1)
|
|
359
|
+
self.providers_window = Window(content=FormattedTextControl(self._render_providers, focusable=True))
|
|
360
|
+
self.models_window = Window(content=FormattedTextControl(self._render_models, focusable=True))
|
|
361
|
+
|
|
362
|
+
self.layout = Layout(
|
|
363
|
+
HSplit([
|
|
364
|
+
Frame(self.search_window, title=self._get_search_title),
|
|
365
|
+
VSplit([
|
|
366
|
+
Frame(self.providers_window, title=self._get_providers_title),
|
|
367
|
+
Frame(self.models_window, title=self._get_models_title),
|
|
368
|
+
]),
|
|
369
|
+
Window(content=FormattedTextControl(self._render_status), height=1),
|
|
370
|
+
]),
|
|
371
|
+
focused_element=self.search_window
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
app = Application(
|
|
375
|
+
layout=self.layout,
|
|
376
|
+
key_bindings=self.kb,
|
|
377
|
+
style=style,
|
|
378
|
+
full_screen=True
|
|
379
|
+
)
|
|
380
|
+
return app
|
|
381
|
+
|
|
382
|
+
def is_bot_running():
|
|
383
|
+
import socket
|
|
384
|
+
try:
|
|
385
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
386
|
+
s.settimeout(0.2)
|
|
387
|
+
s.connect(("127.0.0.1", 4097))
|
|
388
|
+
return True
|
|
389
|
+
except Exception:
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
def start_bot_background():
|
|
393
|
+
import subprocess
|
|
394
|
+
import sys
|
|
395
|
+
import os
|
|
396
|
+
creationflags = 0
|
|
397
|
+
if sys.platform == "win32":
|
|
398
|
+
creationflags = 0x08000000
|
|
399
|
+
try:
|
|
400
|
+
subprocess.Popen(
|
|
401
|
+
[sys.executable, "main.py"],
|
|
402
|
+
cwd=os.path.dirname(os.path.abspath(__file__)),
|
|
403
|
+
stdout=subprocess.DEVNULL,
|
|
404
|
+
stderr=subprocess.DEVNULL,
|
|
405
|
+
creationflags=creationflags,
|
|
406
|
+
close_fds=True
|
|
407
|
+
)
|
|
408
|
+
return True
|
|
409
|
+
except Exception:
|
|
410
|
+
return False
|
|
411
|
+
|
|
412
|
+
async def main():
|
|
413
|
+
# Automatically ensure the BMO Bot is running in the background
|
|
414
|
+
if not is_bot_running():
|
|
415
|
+
print_info("BMO Telegram Bot/MCP Server is not running. Starting it in the background...")
|
|
416
|
+
if start_bot_background():
|
|
417
|
+
# Wait briefly for server to bind to port 4097
|
|
418
|
+
for _ in range(10):
|
|
419
|
+
await asyncio.sleep(0.3)
|
|
420
|
+
if is_bot_running():
|
|
421
|
+
print_success("BMO Bot / MCP Server is online.")
|
|
422
|
+
break
|
|
423
|
+
else:
|
|
424
|
+
print_info("BMO Bot started but not responding yet. It will continue loading in the background.")
|
|
425
|
+
else:
|
|
426
|
+
print_error("Failed to start BMO Bot in the background.")
|
|
427
|
+
|
|
428
|
+
# Initialize Engine, GoalRunner, and BudgetTracker
|
|
429
|
+
engine = BMOEngine()
|
|
430
|
+
# CLI has its own async loop — disable the worker subprocess so all queries
|
|
431
|
+
# go through _send_direct. The worker is only needed for Telegram's sync handlers.
|
|
432
|
+
engine.client._worker = None
|
|
433
|
+
goal_runner = GoalRunner(engine)
|
|
434
|
+
budget_tracker = BudgetTracker()
|
|
435
|
+
|
|
436
|
+
# ── Start BFP Agent (independent of the worker, always start in CLI) ──
|
|
437
|
+
# BFP is gated inside ensure_worker() which returns early when _worker=None,
|
|
438
|
+
# so we start it explicitly here instead.
|
|
439
|
+
if not engine._bfp_started:
|
|
440
|
+
from config.settings import BFP_RELAY_URL, BFP_TRANSPORT_PORT, BFP_A2A_PORT
|
|
441
|
+
_relay = BFP_RELAY_URL or "http://localhost:9753"
|
|
442
|
+
asyncio.create_task(engine.bfp.start(
|
|
443
|
+
bfp_port=BFP_TRANSPORT_PORT,
|
|
444
|
+
a2a_port=BFP_A2A_PORT,
|
|
445
|
+
relay_url=_relay,
|
|
446
|
+
))
|
|
447
|
+
engine._bfp_started = True
|
|
448
|
+
print_info(f"BFP Agent starting... (relay: {_relay})")
|
|
449
|
+
|
|
450
|
+
# Check OpenCode Connection
|
|
451
|
+
status = await engine.get_status()
|
|
452
|
+
connection_state = [status.get("connected", False)] # mutable so toolbar closure can reflect updates
|
|
453
|
+
|
|
454
|
+
# Ensure session exists
|
|
455
|
+
session = await engine.get_or_create_session(CHAT_ID, USER_ID, USERNAME)
|
|
456
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
457
|
+
|
|
458
|
+
# Print welcome banner
|
|
459
|
+
print_banner(model_id, connection_state[0])
|
|
460
|
+
print_info(f"Connected to SQLite database: {engine.storage.db_path}")
|
|
461
|
+
print_info(f"Active Session: [bold cyan]{session.title or session.session_id[:8]}[/bold cyan]")
|
|
462
|
+
print_info("Type /help to see all interactive commands.\n")
|
|
463
|
+
|
|
464
|
+
# Define bottom toolbar function
|
|
465
|
+
def get_toolbar_text():
|
|
466
|
+
conn_indicator = "● Online" if connection_state[0] else "● Offline"
|
|
467
|
+
curr_title = session.title or session.session_id[:8]
|
|
468
|
+
curr_mode = session.metadata.get("active_mode", "execute")
|
|
469
|
+
|
|
470
|
+
# Budget info
|
|
471
|
+
limit = budget_tracker.get_limit(session.session_id)
|
|
472
|
+
cost = budget_tracker.get_session_cost(session.session_id)
|
|
473
|
+
budget_str = f"${cost:.2f}/${limit:.2f}"
|
|
474
|
+
|
|
475
|
+
return [
|
|
476
|
+
("class:bottom-toolbar", f" BMO CLI | Server: {conn_indicator} | Session: {curr_title} | Model: {model_id} | Mode: {curr_mode} | Budget: {budget_str} ")
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
prompt_kb = KeyBindings()
|
|
480
|
+
|
|
481
|
+
@prompt_kb.add("c-c", eager=True)
|
|
482
|
+
def _clear_input_ctrl_c(event):
|
|
483
|
+
event.app.current_buffer.reset()
|
|
484
|
+
|
|
485
|
+
@prompt_kb.add("escape", eager=True)
|
|
486
|
+
def _clear_input_esc(event):
|
|
487
|
+
event.app.current_buffer.reset()
|
|
488
|
+
|
|
489
|
+
# Storage for collapsed pastes: placeholder → full text
|
|
490
|
+
_paste_store: dict[str, str] = {}
|
|
491
|
+
_paste_counter = [0]
|
|
492
|
+
PASTE_COLLAPSE_THRESHOLD = 5 # lines
|
|
493
|
+
|
|
494
|
+
from prompt_toolkit.keys import Keys
|
|
495
|
+
@prompt_kb.add(Keys.BracketedPaste, eager=True)
|
|
496
|
+
def _handle_paste(event):
|
|
497
|
+
"""Collapse large pastes into a [Pasted text #N +M lines] placeholder."""
|
|
498
|
+
data = event.data
|
|
499
|
+
lines = data.splitlines()
|
|
500
|
+
if len(lines) >= PASTE_COLLAPSE_THRESHOLD:
|
|
501
|
+
_paste_counter[0] += 1
|
|
502
|
+
n = _paste_counter[0]
|
|
503
|
+
placeholder = f"[Pasted text #{n} +{len(lines)} lines]"
|
|
504
|
+
_paste_store[placeholder] = data
|
|
505
|
+
event.app.current_buffer.insert_text(placeholder)
|
|
506
|
+
else:
|
|
507
|
+
event.app.current_buffer.insert_text(data)
|
|
508
|
+
|
|
509
|
+
prompt_session = PromptSession(
|
|
510
|
+
message="BMO > ",
|
|
511
|
+
completer=completer,
|
|
512
|
+
bottom_toolbar=get_toolbar_text,
|
|
513
|
+
style=cli_style,
|
|
514
|
+
key_bindings=prompt_kb,
|
|
515
|
+
)
|
|
516
|
+
_PS = PromptSession # shorthand for sub-prompts — each call creates a fresh instance
|
|
517
|
+
_pending_inject: str = None # holds mid-run injection text to send on next iteration
|
|
518
|
+
|
|
519
|
+
while True:
|
|
520
|
+
try:
|
|
521
|
+
# Sync active session from database (in case Telegram changed it)
|
|
522
|
+
active_session = engine.storage.load_session(CHAT_ID)
|
|
523
|
+
if active_session and active_session.session_id != session.session_id:
|
|
524
|
+
session = active_session
|
|
525
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
526
|
+
|
|
527
|
+
# If an inject was requested during thinking, use it directly
|
|
528
|
+
# instead of waiting for new user input from the prompt.
|
|
529
|
+
if _pending_inject is not None:
|
|
530
|
+
user_input = _pending_inject
|
|
531
|
+
_pending_inject = None
|
|
532
|
+
else:
|
|
533
|
+
user_input = await prompt_session.prompt_async()
|
|
534
|
+
user_input = user_input.strip()
|
|
535
|
+
|
|
536
|
+
# Check again after user input to ensure we don't send to a stale session
|
|
537
|
+
active_session = engine.storage.load_session(CHAT_ID)
|
|
538
|
+
if active_session and active_session.session_id != session.session_id:
|
|
539
|
+
session = active_session
|
|
540
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
541
|
+
print_info(f"\n[System] Session synced with Telegram. Active session is now: {session.title}")
|
|
542
|
+
|
|
543
|
+
if not user_input:
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
# Expand paste placeholders back to full text before sending.
|
|
547
|
+
# Handles two cases:
|
|
548
|
+
# 1. user_input IS the placeholder (simple paste + Enter)
|
|
549
|
+
# 2. user_input CONTAINS placeholder(s) embedded in longer text
|
|
550
|
+
if _paste_store:
|
|
551
|
+
if user_input in _paste_store:
|
|
552
|
+
# Simple case: the whole input is a placeholder
|
|
553
|
+
user_input = _paste_store.pop(user_input)
|
|
554
|
+
else:
|
|
555
|
+
# Complex case: scan for embedded placeholders
|
|
556
|
+
for placeholder, content in list(_paste_store.items()):
|
|
557
|
+
if placeholder in user_input:
|
|
558
|
+
user_input = user_input.replace(placeholder, content)
|
|
559
|
+
_paste_store.pop(placeholder, None)
|
|
560
|
+
|
|
561
|
+
# Command routing
|
|
562
|
+
if user_input.startswith("/"):
|
|
563
|
+
cmd_parts = user_input.split(" ", 1)
|
|
564
|
+
cmd = cmd_parts[0].lower()
|
|
565
|
+
|
|
566
|
+
if cmd in ("/exit", "/quit", "/q"):
|
|
567
|
+
print_success("Goodbye!")
|
|
568
|
+
break
|
|
569
|
+
|
|
570
|
+
elif cmd in ("/help", "/h"):
|
|
571
|
+
print_info("Available Commands:")
|
|
572
|
+
print_info(" /help or /h - Display this help message")
|
|
573
|
+
print_info(" /new or /n - Start a fresh conversation session")
|
|
574
|
+
print_info(" /sessions or /s - List recent conversation sessions and switch")
|
|
575
|
+
print_info(" /model or /m - Interactively choose provider and model")
|
|
576
|
+
print_info(" /clear or /c - Clear message history of current session")
|
|
577
|
+
print_info(" /status or /t - Display server connection status and stats")
|
|
578
|
+
print_info(" /web - Start/show webchat URL (Cloudflare tunnel)")
|
|
579
|
+
print_info(" /goal <task> - Run autonomous goal loop (updates Telegram bot)")
|
|
580
|
+
print_info(" /budget <limit> - Set session token cost budget limit in USD")
|
|
581
|
+
print_info(" /share - Export current session as .txt or .json file")
|
|
582
|
+
print_info(" /bfp status - Show BFP identity, capabilities, relay connection")
|
|
583
|
+
print_info(" /bfp find <cap> - Find agents by capability via relay")
|
|
584
|
+
print_info(" /bfp delegate <did> <task> - Send a task to another agent")
|
|
585
|
+
print_info(" /bfp talk <did> <msg> - Send a message to another agent")
|
|
586
|
+
print_info(" /exit or /q - Exit the CLI interface")
|
|
587
|
+
|
|
588
|
+
elif cmd in ("/new", "/n"):
|
|
589
|
+
print_info("Creating new session...")
|
|
590
|
+
session = await engine.create_new_session(CHAT_ID, USER_ID, USERNAME)
|
|
591
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
592
|
+
print_success(f"Started new session: {session.title}")
|
|
593
|
+
|
|
594
|
+
elif cmd in ("/clear", "/c"):
|
|
595
|
+
print_info("Clearing current session history...")
|
|
596
|
+
if await engine.clear_session_history(CHAT_ID):
|
|
597
|
+
print_success("Session message history cleared successfully.")
|
|
598
|
+
else:
|
|
599
|
+
print_error("Failed to clear session history.")
|
|
600
|
+
|
|
601
|
+
elif cmd in ("/status", "/t"):
|
|
602
|
+
status = await engine.get_status()
|
|
603
|
+
print_system_status(status, model_id)
|
|
604
|
+
|
|
605
|
+
elif cmd in ("/budget", "/b"):
|
|
606
|
+
if len(cmd_parts) < 2:
|
|
607
|
+
current_limit = budget_tracker.get_limit(session.session_id)
|
|
608
|
+
print_info(f"Current budget limit: ${current_limit:.2f}")
|
|
609
|
+
limit_input = (await _PS().prompt_async("Enter new budget limit in USD: ")).strip()
|
|
610
|
+
else:
|
|
611
|
+
limit_input = cmd_parts[1].strip()
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
new_limit = float(limit_input)
|
|
615
|
+
budget_tracker.set_limit(session.session_id, new_limit)
|
|
616
|
+
print_success(f"Session budget set to: ${new_limit:.2f}")
|
|
617
|
+
except ValueError:
|
|
618
|
+
print_error("Invalid budget limit value.")
|
|
619
|
+
|
|
620
|
+
elif cmd in ("/goal", "/g"):
|
|
621
|
+
if len(cmd_parts) < 2:
|
|
622
|
+
objective = (await _PS().prompt_async("Enter autonomous objective goal: ")).strip()
|
|
623
|
+
else:
|
|
624
|
+
objective = cmd_parts[1].strip()
|
|
625
|
+
|
|
626
|
+
if not objective:
|
|
627
|
+
print_error("Objective cannot be empty.")
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
await goal_runner.run_goal(CHAT_ID, USER_ID, objective)
|
|
631
|
+
|
|
632
|
+
elif cmd in ("/sessions", "/s"):
|
|
633
|
+
sessions = await engine.list_sessions(CHAT_ID)
|
|
634
|
+
if not sessions:
|
|
635
|
+
print_info("No saved sessions found.")
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
print_sessions_list(sessions)
|
|
639
|
+
|
|
640
|
+
choice = (await _PS().prompt_async("\nEnter session index number to switch (or press Enter to cancel): ")).strip()
|
|
641
|
+
if choice.isdigit():
|
|
642
|
+
idx = int(choice) - 1
|
|
643
|
+
if 0 <= idx < len(sessions):
|
|
644
|
+
selected_sid = sessions[idx]["session_id"]
|
|
645
|
+
new_sess = await engine.switch_session(CHAT_ID, selected_sid)
|
|
646
|
+
if new_sess:
|
|
647
|
+
session = new_sess
|
|
648
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
649
|
+
print_success(f"Switched active session to: {session.title}")
|
|
650
|
+
else:
|
|
651
|
+
print_error("Failed to switch session.")
|
|
652
|
+
else:
|
|
653
|
+
print_error("Invalid session index.")
|
|
654
|
+
|
|
655
|
+
elif cmd in ("/model", "/m"):
|
|
656
|
+
raw_providers = await engine.list_models()
|
|
657
|
+
if not raw_providers:
|
|
658
|
+
print_error("Could not fetch available models from server.")
|
|
659
|
+
continue
|
|
660
|
+
|
|
661
|
+
import time
|
|
662
|
+
from models.chat_models import UserMemory
|
|
663
|
+
memory = engine.storage.load_memory(CHAT_ID)
|
|
664
|
+
if not memory:
|
|
665
|
+
memory = UserMemory(
|
|
666
|
+
user_id=USER_ID,
|
|
667
|
+
chat_id=CHAT_ID,
|
|
668
|
+
preferences={"favorite_providers": []},
|
|
669
|
+
knowledge={},
|
|
670
|
+
created_at=time.time(),
|
|
671
|
+
updated_at=time.time()
|
|
672
|
+
)
|
|
673
|
+
if "favorite_providers" not in memory.preferences:
|
|
674
|
+
memory.preferences["favorite_providers"] = []
|
|
675
|
+
|
|
676
|
+
def save_fav_callback(fav_list):
|
|
677
|
+
memory.preferences["favorite_providers"] = fav_list
|
|
678
|
+
memory.updated_at = time.time()
|
|
679
|
+
engine.storage.save_memory(memory)
|
|
680
|
+
|
|
681
|
+
tui = ModelSelectorTUI(
|
|
682
|
+
raw_providers=raw_providers,
|
|
683
|
+
favorites=memory.preferences["favorite_providers"],
|
|
684
|
+
save_fav_callback=save_fav_callback
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
app = tui.get_app()
|
|
688
|
+
result = await app.run_async()
|
|
689
|
+
|
|
690
|
+
if result:
|
|
691
|
+
selected_provider_id, selected_model_id = result
|
|
692
|
+
if await engine.set_model(CHAT_ID, selected_provider_id, selected_model_id):
|
|
693
|
+
provider_id, model_id = await engine.get_model_info(CHAT_ID)
|
|
694
|
+
print_success(f"Active model set to: {model_id} ({provider_id})")
|
|
695
|
+
else:
|
|
696
|
+
print_error("Failed to switch model.")
|
|
697
|
+
else:
|
|
698
|
+
print_info("Cancelled model selection.")
|
|
699
|
+
|
|
700
|
+
elif cmd in ("/share",):
|
|
701
|
+
current_session = engine.storage.get_session_by_id(session.session_id)
|
|
702
|
+
if not current_session or not current_session.messages:
|
|
703
|
+
print_error("No messages in current session to export.")
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
print_info("Export format:")
|
|
707
|
+
print_info(" [1] Text file (.txt)")
|
|
708
|
+
print_info(" [2] JSON file (.json)")
|
|
709
|
+
fmt_choice = (await _PS().prompt_async("Choose [1/2] (default: 1): ")).strip()
|
|
710
|
+
fmt = "json" if fmt_choice == "2" else "txt"
|
|
711
|
+
|
|
712
|
+
import json as _json
|
|
713
|
+
from datetime import datetime as _dt
|
|
714
|
+
|
|
715
|
+
exports_dir = os.path.join(os.path.dirname(__file__), "data", "exports")
|
|
716
|
+
safe_title = (session.title or session.session_id[:8]).replace(" ", "_").replace("/", "-")
|
|
717
|
+
timestamp = _dt.now().strftime("%Y%m%d_%H%M%S")
|
|
718
|
+
filename = f"bmo_session_{safe_title}_{timestamp}.{fmt}"
|
|
719
|
+
out_path = os.path.join(exports_dir, filename)
|
|
720
|
+
os.makedirs(exports_dir, exist_ok=True)
|
|
721
|
+
|
|
722
|
+
# Messages are dicts with key "sender" (values: "user" or "assistant")
|
|
723
|
+
msgs = current_session.messages
|
|
724
|
+
|
|
725
|
+
def _sender(m):
|
|
726
|
+
if isinstance(m, dict):
|
|
727
|
+
return m.get("sender") or m.get("role", "")
|
|
728
|
+
return getattr(m, "sender", None) or getattr(m, "role", "")
|
|
729
|
+
|
|
730
|
+
def _content(m):
|
|
731
|
+
return m["content"] if isinstance(m, dict) else getattr(m, "content", "")
|
|
732
|
+
|
|
733
|
+
def _timestamp(m):
|
|
734
|
+
return m.get("timestamp") if isinstance(m, dict) else getattr(m, "timestamp", None)
|
|
735
|
+
|
|
736
|
+
if fmt == "json":
|
|
737
|
+
export_data = {
|
|
738
|
+
"session_id": session.session_id,
|
|
739
|
+
"title": session.title,
|
|
740
|
+
"exported_at": _dt.now().isoformat(),
|
|
741
|
+
"messages": [
|
|
742
|
+
{
|
|
743
|
+
"sender": _sender(m),
|
|
744
|
+
"content": _content(m),
|
|
745
|
+
"timestamp": _timestamp(m),
|
|
746
|
+
}
|
|
747
|
+
for m in msgs
|
|
748
|
+
]
|
|
749
|
+
}
|
|
750
|
+
with open(out_path, "w", encoding="utf-8") as _f:
|
|
751
|
+
_json.dump(export_data, _f, ensure_ascii=False, indent=2)
|
|
752
|
+
else:
|
|
753
|
+
lines = [
|
|
754
|
+
"BMO Session Export",
|
|
755
|
+
f"Title : {session.title}",
|
|
756
|
+
f"ID : {session.session_id}",
|
|
757
|
+
f"Exported: {_dt.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
758
|
+
"=" * 60,
|
|
759
|
+
"",
|
|
760
|
+
]
|
|
761
|
+
for m in msgs:
|
|
762
|
+
role_label = "You" if _sender(m) == "user" else "BMO"
|
|
763
|
+
lines.append(f"[{role_label}]")
|
|
764
|
+
lines.append(_content(m))
|
|
765
|
+
lines.append("")
|
|
766
|
+
with open(out_path, "w", encoding="utf-8") as _f:
|
|
767
|
+
_f.write("\n".join(lines))
|
|
768
|
+
|
|
769
|
+
print_success(f"Session exported → {out_path}")
|
|
770
|
+
|
|
771
|
+
elif cmd == "/bfp":
|
|
772
|
+
parts = user_input.split(maxsplit=2)
|
|
773
|
+
subcmd = parts[1] if len(parts) > 1 else ""
|
|
774
|
+
if subcmd == "status":
|
|
775
|
+
s = engine.bfp.get_status()
|
|
776
|
+
print_info("── BFP Status ──")
|
|
777
|
+
print_info(f" DID : {s['did']}")
|
|
778
|
+
print_info(f" Running : {'Yes' if s['running'] else 'No'}")
|
|
779
|
+
print_info(f" Relay : {s['relay_url'] or 'Not connected'}")
|
|
780
|
+
print_info(f" Relay Status : {'Connected' if s['connected_to_relay'] else 'Disconnected'}")
|
|
781
|
+
print_info(f" Transport Port: {s['bfp_port'] or 'N/A'}")
|
|
782
|
+
print_info(f" Capabilities : {', '.join(s['capabilities'])}")
|
|
783
|
+
|
|
784
|
+
elif subcmd == "find":
|
|
785
|
+
if len(parts) < 3:
|
|
786
|
+
print_error("Usage: /bfp find <capability>")
|
|
787
|
+
continue
|
|
788
|
+
capability = parts[2]
|
|
789
|
+
if not engine.bfp.is_running:
|
|
790
|
+
print_error("BFP not started")
|
|
791
|
+
continue
|
|
792
|
+
try:
|
|
793
|
+
agents = await engine.bfp.find_agents(capability)
|
|
794
|
+
if not agents:
|
|
795
|
+
print_info(f"No agents found with capability: {capability}")
|
|
796
|
+
else:
|
|
797
|
+
print_info(f"Agents with '{capability}' capability ({len(agents)}):")
|
|
798
|
+
for a in agents:
|
|
799
|
+
name = a.get("name", a["did"][:30])
|
|
800
|
+
did = a["did"]
|
|
801
|
+
caps = ", ".join(a.get("capabilities", []))
|
|
802
|
+
print_info(f" {name}")
|
|
803
|
+
print_info(f" DID: {did}")
|
|
804
|
+
print_info(f" Capabilities: {caps}")
|
|
805
|
+
except Exception as e:
|
|
806
|
+
print_error(f"Discovery failed: {e}")
|
|
807
|
+
|
|
808
|
+
elif subcmd == "delegate":
|
|
809
|
+
if len(parts) < 4:
|
|
810
|
+
print_error("Usage: /bfp delegate <did> <task>")
|
|
811
|
+
continue
|
|
812
|
+
target_did = parts[2]
|
|
813
|
+
task_text = parts[3]
|
|
814
|
+
if not engine.bfp.is_running:
|
|
815
|
+
print_error("BFP not started")
|
|
816
|
+
continue
|
|
817
|
+
try:
|
|
818
|
+
print_info(f"Delegating task to {target_did[:40]}...")
|
|
819
|
+
result = await engine.bfp.delegate(target_did, {"query": task_text})
|
|
820
|
+
print_success(f"Result: {result.get('result', 'No result')}")
|
|
821
|
+
except Exception as e:
|
|
822
|
+
print_error(f"Delegation failed: {e}")
|
|
823
|
+
|
|
824
|
+
elif subcmd == "talk":
|
|
825
|
+
if len(parts) < 4:
|
|
826
|
+
print_error("Usage: /bfp talk <did> <message>")
|
|
827
|
+
continue
|
|
828
|
+
target_did = parts[2]
|
|
829
|
+
message = parts[3]
|
|
830
|
+
if not engine.bfp.is_running:
|
|
831
|
+
print_error("BFP not started")
|
|
832
|
+
continue
|
|
833
|
+
try:
|
|
834
|
+
print_info(f"Sending message to {target_did[:40]}...")
|
|
835
|
+
response = await engine.bfp.talk(target_did, message)
|
|
836
|
+
print_info(f"Response: {response}")
|
|
837
|
+
except Exception as e:
|
|
838
|
+
print_error(f"Talk failed: {e}")
|
|
839
|
+
|
|
840
|
+
else:
|
|
841
|
+
print_info("BFP Commands:")
|
|
842
|
+
print_info(" /bfp status - Show BFP identity and connection status")
|
|
843
|
+
print_info(" /bfp find <capability> - Find agents by capability via relay")
|
|
844
|
+
print_info(" /bfp delegate <did> <msg> - Send a task to another agent")
|
|
845
|
+
print_info(" /bfp talk <did> <msg> - Chat with another agent")
|
|
846
|
+
|
|
847
|
+
elif cmd == "/web":
|
|
848
|
+
# Check task registry for existing webchat/tunnel
|
|
849
|
+
import json as _json
|
|
850
|
+
from config.settings import BMO_HOME
|
|
851
|
+
_tasks_file = BMO_HOME / "data" / "background_tasks.json"
|
|
852
|
+
_existing_url = None
|
|
853
|
+
if _tasks_file.exists():
|
|
854
|
+
try:
|
|
855
|
+
_tasks = _json.loads(_tasks_file.read_text("utf-8"))
|
|
856
|
+
_existing_url = _tasks.get("webchat_tunnel_url")
|
|
857
|
+
except Exception:
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
if _existing_url:
|
|
861
|
+
print_success(f"💬 Webchat is already running!")
|
|
862
|
+
print_info(f" Local: http://127.0.0.1:3456")
|
|
863
|
+
print_info(f" Public: {_existing_url}")
|
|
864
|
+
else:
|
|
865
|
+
print_info("⏳ Starting webchat + Cloudflare tunnel...")
|
|
866
|
+
try:
|
|
867
|
+
from tools.task_registry import get_active_tasks
|
|
868
|
+
# Check if webchat is actually already running on port 3456
|
|
869
|
+
import socket as _sock
|
|
870
|
+
_s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
|
|
871
|
+
_s.settimeout(0.5)
|
|
872
|
+
_webchat_up = _s.connect_ex(("127.0.0.1", 3456)) == 0
|
|
873
|
+
_s.close()
|
|
874
|
+
|
|
875
|
+
if _webchat_up:
|
|
876
|
+
# Start only the tunnel
|
|
877
|
+
tunnel_result = await engine.client._call_mcp_tool(
|
|
878
|
+
"tunnel_webchat", {"port": 3456}
|
|
879
|
+
) if hasattr(engine.client, "_call_mcp_tool") else None
|
|
880
|
+
if tunnel_result:
|
|
881
|
+
print_success(f"🌐 Tunnel started: {tunnel_result}")
|
|
882
|
+
else:
|
|
883
|
+
print_info(" Webchat running locally: http://127.0.0.1:3456")
|
|
884
|
+
print_info(" To get a public URL, run: bmo web")
|
|
885
|
+
else:
|
|
886
|
+
print_info(" Webchat not running. Start it with: bmo web")
|
|
887
|
+
print_info(" (Run `bmo web` from your terminal)")
|
|
888
|
+
except Exception as _e:
|
|
889
|
+
print_error(f"Web command failed: {_e}")
|
|
890
|
+
print_info(" Alternative: run `bmo web` from your terminal")
|
|
891
|
+
|
|
892
|
+
else:
|
|
893
|
+
print_error(f"Unknown command: {cmd}")
|
|
894
|
+
continue
|
|
895
|
+
|
|
896
|
+
# Check budget limit before sending
|
|
897
|
+
if budget_tracker.is_exceeded(session.session_id):
|
|
898
|
+
print_error("⚠️ Session budget limit exceeded. Increase budget via /budget to continue.")
|
|
899
|
+
continue
|
|
900
|
+
|
|
901
|
+
# Offline guard — return immediately if server is unreachable
|
|
902
|
+
if not connection_state[0]:
|
|
903
|
+
alive = await engine.client.is_alive()
|
|
904
|
+
connection_state[0] = alive
|
|
905
|
+
if not alive:
|
|
906
|
+
print_error(
|
|
907
|
+
"BMO is offline — OpenCode server not reachable.\n"
|
|
908
|
+
" Start it with: opencode serve --port 4800"
|
|
909
|
+
)
|
|
910
|
+
continue
|
|
911
|
+
|
|
912
|
+
# Regular message — stream response
|
|
913
|
+
import time as _time
|
|
914
|
+
from core.cli_renderer import console
|
|
915
|
+
|
|
916
|
+
# ── Dual-channel input system ────────────────────────────────────
|
|
917
|
+
# Channel 1: Ctrl+C / ESC → hard cancel
|
|
918
|
+
# Channel 2: keystroke + Enter → mid-run injection (abort + new message)
|
|
919
|
+
_cancel_event = threading.Event()
|
|
920
|
+
_inject_event = threading.Event()
|
|
921
|
+
_inject_buffer = [] # chars typed during thinking
|
|
922
|
+
_spinner_active = [True] # shared flag for permission prompt suspension
|
|
923
|
+
|
|
924
|
+
def _key_watcher():
|
|
925
|
+
"""Reads keystrokes during thinking. Supports abort and mid-run injection."""
|
|
926
|
+
try:
|
|
927
|
+
import msvcrt
|
|
928
|
+
while not _cancel_event.is_set() and not _inject_event.is_set():
|
|
929
|
+
if not _spinner_active[0]:
|
|
930
|
+
import time as _t; _t.sleep(0.1)
|
|
931
|
+
continue
|
|
932
|
+
if msvcrt.kbhit():
|
|
933
|
+
ch = msvcrt.getwch()
|
|
934
|
+
if ch in ('\x03', '\x1b'): # Ctrl+C or ESC → hard cancel
|
|
935
|
+
_cancel_event.set()
|
|
936
|
+
return
|
|
937
|
+
elif ch in ('\r', '\n'): # Enter → inject buffer as new message
|
|
938
|
+
if _inject_buffer:
|
|
939
|
+
_inject_event.set()
|
|
940
|
+
return
|
|
941
|
+
elif ch == '\x08': # Backspace
|
|
942
|
+
if _inject_buffer:
|
|
943
|
+
_inject_buffer.pop()
|
|
944
|
+
sys.stdout.write('\b \b')
|
|
945
|
+
sys.stdout.flush()
|
|
946
|
+
elif ch.isprintable():
|
|
947
|
+
_inject_buffer.append(ch)
|
|
948
|
+
sys.stdout.write(ch)
|
|
949
|
+
sys.stdout.flush()
|
|
950
|
+
import time as _t; _t.sleep(0.05)
|
|
951
|
+
except Exception:
|
|
952
|
+
pass
|
|
953
|
+
|
|
954
|
+
threading.Thread(target=_key_watcher, daemon=True).start()
|
|
955
|
+
|
|
956
|
+
_current_activity = ["thinking..."]
|
|
957
|
+
|
|
958
|
+
def on_token(partial: str):
|
|
959
|
+
pass # no live display — kept for API compatibility
|
|
960
|
+
|
|
961
|
+
def on_activity(kind: str, detail: str):
|
|
962
|
+
if kind == "thinking":
|
|
963
|
+
return # Skip full thinking blocks to avoid clutter
|
|
964
|
+
_current_activity[0] = detail
|
|
965
|
+
|
|
966
|
+
async def on_permission(perm_id: str, perm_type: str, patterns: list) -> str:
|
|
967
|
+
"""Called when the OpenCode server requests permission. Pauses spinner and prompts user."""
|
|
968
|
+
_spinner_active[0] = False
|
|
969
|
+
sys.stdout.write("\r" + " " * 80 + "\r")
|
|
970
|
+
sys.stdout.flush()
|
|
971
|
+
panel = Panel(
|
|
972
|
+
f"[bold yellow]⚠️ BMO SECURITY PERMISSION REQUEST[/bold yellow]\n\n"
|
|
973
|
+
f"[white]Type:[/white] [bold]{perm_type}[/bold]\n"
|
|
974
|
+
f"[white]Scope:[/white] [code]{', '.join(patterns[:3]) if patterns else '*'}[/code]\n\n"
|
|
975
|
+
f"[dim]Choose an option (Auto-approves 'allow' after 7 minutes of inactivity):[/dim]\n"
|
|
976
|
+
f" [bold cyan]a[/bold cyan] → Allow once\n"
|
|
977
|
+
f" [bold cyan]d[/bold cyan] → Deny\n"
|
|
978
|
+
f" [bold cyan]y[/bold cyan] → Yes, always (save to vault)",
|
|
979
|
+
border_style="yellow",
|
|
980
|
+
width=70,
|
|
981
|
+
)
|
|
982
|
+
console.print(panel)
|
|
983
|
+
|
|
984
|
+
try:
|
|
985
|
+
# 7 minutes timeout (420 seconds)
|
|
986
|
+
choice = await asyncio.wait_for(
|
|
987
|
+
_PS().prompt_async("Your choice [a/d/y]: "),
|
|
988
|
+
timeout=420.0
|
|
989
|
+
)
|
|
990
|
+
choice = choice.strip().lower()
|
|
991
|
+
except asyncio.TimeoutError:
|
|
992
|
+
from datetime import datetime as _dt
|
|
993
|
+
print_info("\n⚠️ Permission request timed out after 7 minutes of inactivity. Auto-approving once to avoid freezing.")
|
|
994
|
+
# Log the auto-approval to logs/permissions.log
|
|
995
|
+
try:
|
|
996
|
+
log_dir = os.path.join(os.path.dirname(__file__), "logs")
|
|
997
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
998
|
+
with open(os.path.join(log_dir, "permissions.log"), "a", encoding="utf-8") as lf:
|
|
999
|
+
lf.write(f"[{_dt.now().isoformat()}] AUTO-APPROVED (Timeout): type={perm_type}, scope={patterns}\n")
|
|
1000
|
+
except Exception:
|
|
1001
|
+
pass
|
|
1002
|
+
choice = "allow"
|
|
1003
|
+
|
|
1004
|
+
if choice in ("a", "allow"):
|
|
1005
|
+
reply = "allow"
|
|
1006
|
+
elif choice in ("y", "yes", "always"):
|
|
1007
|
+
reply = "always"
|
|
1008
|
+
else:
|
|
1009
|
+
reply = "deny"
|
|
1010
|
+
_spinner_active[0] = True
|
|
1011
|
+
sys.stdout.write(f" {_DIM}BMO is thinking...{_RESET}\n")
|
|
1012
|
+
sys.stdout.flush()
|
|
1013
|
+
return reply
|
|
1014
|
+
|
|
1015
|
+
stream_task = asyncio.ensure_future(
|
|
1016
|
+
engine.send_message_streaming(
|
|
1017
|
+
CHAT_ID, USER_ID, user_input,
|
|
1018
|
+
interface="cli", on_token=on_token, on_activity=on_activity,
|
|
1019
|
+
on_permission=on_permission,
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
start_t = _time.monotonic()
|
|
1023
|
+
response = None
|
|
1024
|
+
elapsed_total = 0.0
|
|
1025
|
+
_dots = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
1026
|
+
_tick = 0
|
|
1027
|
+
_DIM = "\x1b[2m"
|
|
1028
|
+
_CYAN = "\x1b[36m"
|
|
1029
|
+
_RESET = "\x1b[0m"
|
|
1030
|
+
sys.stdout.write(f" {_DIM}BMO is thinking... (Ctrl+C/ESC=cancel | type+Enter=inject){_RESET}\n")
|
|
1031
|
+
sys.stdout.flush()
|
|
1032
|
+
_inj_prompt_shown = False
|
|
1033
|
+
try:
|
|
1034
|
+
while not stream_task.done():
|
|
1035
|
+
if _cancel_event.is_set():
|
|
1036
|
+
raise asyncio.CancelledError("cancelled via Ctrl+C / ESC")
|
|
1037
|
+
if _inject_event.is_set():
|
|
1038
|
+
raise asyncio.CancelledError("inject")
|
|
1039
|
+
await asyncio.sleep(0.1)
|
|
1040
|
+
if _spinner_active[0]:
|
|
1041
|
+
elapsed_total = _time.monotonic() - start_t
|
|
1042
|
+
_tick += 1
|
|
1043
|
+
if _tick % 10 == 0:
|
|
1044
|
+
injected_preview = ''.join(_inject_buffer)
|
|
1045
|
+
act = _current_activity[0]
|
|
1046
|
+
import re
|
|
1047
|
+
act_clean = re.sub(r'<[^>]+>', '', act)
|
|
1048
|
+
if len(act_clean) > 40:
|
|
1049
|
+
act_clean = act_clean[:37] + "..."
|
|
1050
|
+
if injected_preview:
|
|
1051
|
+
line = f"\r {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET} {_CYAN}↩ {injected_preview}{_RESET} "
|
|
1052
|
+
else:
|
|
1053
|
+
line = f"\r {_DIM}{_dots[(_tick // 10) % len(_dots)]} {elapsed_total:.0f}s{_RESET} {_CYAN}[{act_clean}]{_RESET} "
|
|
1054
|
+
sys.stdout.write(line + " " * max(0, 80 - len(line)))
|
|
1055
|
+
sys.stdout.flush()
|
|
1056
|
+
elapsed_total = _time.monotonic() - start_t
|
|
1057
|
+
sys.stdout.write("\r" + " " * 70 + "\r")
|
|
1058
|
+
sys.stdout.flush()
|
|
1059
|
+
response = await stream_task
|
|
1060
|
+
except (KeyboardInterrupt, asyncio.CancelledError) as exc:
|
|
1061
|
+
stream_task.cancel()
|
|
1062
|
+
_cancel_event.set()
|
|
1063
|
+
# ── Abort OpenCode server-side stream ──
|
|
1064
|
+
_oc_sid = session.metadata.get("opencode_session_id")
|
|
1065
|
+
if _oc_sid:
|
|
1066
|
+
try:
|
|
1067
|
+
await engine.client.abort_session(_oc_sid)
|
|
1068
|
+
logger.info("CLI abort_session(%s) sent", _oc_sid)
|
|
1069
|
+
except Exception:
|
|
1070
|
+
pass
|
|
1071
|
+
elapsed_total = _time.monotonic() - start_t
|
|
1072
|
+
response = None
|
|
1073
|
+
sys.stdout.write("\r" + " " * 70 + "\r")
|
|
1074
|
+
sys.stdout.flush()
|
|
1075
|
+
|
|
1076
|
+
# ── Mid-run injection: queue the typed text for next iteration ──
|
|
1077
|
+
injected_text = ''.join(_inject_buffer).strip()
|
|
1078
|
+
if injected_text and str(exc) == "inject":
|
|
1079
|
+
await engine.reset_opencode_session(CHAT_ID)
|
|
1080
|
+
console.print(f"\n [bold cyan]↩ Injecting:[/bold cyan] [italic]{injected_text}[/italic]\n")
|
|
1081
|
+
_pending_inject = injected_text # picked up at top of next loop iteration
|
|
1082
|
+
continue # skip cancel message, go straight to sending
|
|
1083
|
+
finally:
|
|
1084
|
+
_cancel_event.set()
|
|
1085
|
+
|
|
1086
|
+
connection_state[0] = engine.client.is_connected
|
|
1087
|
+
|
|
1088
|
+
if response is None:
|
|
1089
|
+
console.print("\n [bold yellow]⚡ Request cancelled.[/bold yellow] [dim](New OpenCode session will be used for next message)[/dim]\n")
|
|
1090
|
+
await engine.reset_opencode_session(CHAT_ID)
|
|
1091
|
+
continue
|
|
1092
|
+
|
|
1093
|
+
budget_tracker.record_usage(session.session_id, model_id, len(user_input) * 4, len(response) * 4)
|
|
1094
|
+
print_assistant_message(response, elapsed_total)
|
|
1095
|
+
|
|
1096
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
1097
|
+
continue
|
|
1098
|
+
except EOFError:
|
|
1099
|
+
print_success("\nGoodbye!")
|
|
1100
|
+
break
|
|
1101
|
+
except Exception as e:
|
|
1102
|
+
print_error(f"An unexpected error occurred: {e}")
|
|
1103
|
+
|
|
1104
|
+
# Cleanup: ensure worker subprocess and httpx client are closed
|
|
1105
|
+
# before the event loop tears down. Prevents unclosed
|
|
1106
|
+
# _ProactorBasePipeTransport warnings on /exit.
|
|
1107
|
+
try:
|
|
1108
|
+
if engine.worker_manager and engine.worker_manager.is_alive:
|
|
1109
|
+
await engine.worker_manager.shutdown()
|
|
1110
|
+
except Exception:
|
|
1111
|
+
pass
|
|
1112
|
+
try:
|
|
1113
|
+
if hasattr(engine.client._http, 'aclose'):
|
|
1114
|
+
await engine.client._http.aclose()
|
|
1115
|
+
except Exception:
|
|
1116
|
+
pass
|
|
1117
|
+
|
|
1118
|
+
def main_run():
|
|
1119
|
+
import warnings
|
|
1120
|
+
warnings.filterwarnings("ignore", category=ResourceWarning)
|
|
1121
|
+
# Suppress Windows asyncio pipe ValueError on exit (closed pipe repr noise)
|
|
1122
|
+
warnings.filterwarnings("ignore", message=".*I/O operation on closed pipe.*")
|
|
1123
|
+
try:
|
|
1124
|
+
asyncio.run(main())
|
|
1125
|
+
except KeyboardInterrupt:
|
|
1126
|
+
sys.exit(0)
|
|
1127
|
+
|
|
1128
|
+
if __name__ == "__main__":
|
|
1129
|
+
main_run()
|