@aliwey/bmo 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/bin/bmo.js +188 -0
- package/cli.py +1129 -0
- package/config/__init__.py +0 -0
- package/config/__pycache__/__init__.cpython-313.pyc +0 -0
- package/config/__pycache__/settings.cpython-313.pyc +0 -0
- package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
- package/config/settings.py +104 -0
- package/config/system-prompt.json +18 -0
- package/core/__init__.py +0 -0
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
- package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
- package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
- package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
- package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
- package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
- package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
- package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
- package/core/__pycache__/security.cpython-313.pyc +0 -0
- package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
- package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
- package/core/bfp_a2a_bridge.py +399 -0
- package/core/bfp_agent.py +98 -0
- package/core/bfp_agent_card.py +161 -0
- package/core/bfp_connector.py +177 -0
- package/core/bfp_discovery.py +105 -0
- package/core/bfp_identity.py +83 -0
- package/core/bfp_tasks.py +70 -0
- package/core/bfp_transport.py +368 -0
- package/core/bmo_engine.py +405 -0
- package/core/bot_client.py +838 -0
- package/core/budget_tracker.py +62 -0
- package/core/cli_renderer.py +177 -0
- package/core/goal_runner.py +129 -0
- package/core/request_worker.py +242 -0
- package/core/security.py +42 -0
- package/core/shared_state.py +4 -0
- package/core/worker_manager.py +71 -0
- package/core/worker_multiproc.py +155 -0
- package/core/worker_protocol.py +30 -0
- package/core/worker_subprocess.py +222 -0
- package/handlers/__init__.py +0 -0
- package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
- package/handlers/messages.py +2761 -0
- package/main.py +125 -0
- package/memory.md +43 -0
- package/models/__init__.py +0 -0
- package/models/__pycache__/__init__.cpython-313.pyc +0 -0
- package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
- package/models/chat_models.py +143 -0
- package/package.json +50 -0
- package/registry/worker.js +108 -0
- package/registry/wrangler.toml +11 -0
- package/requirements.txt +13 -0
- package/scripts/bmo_init.js +115 -0
- package/scripts/postinstall.js +265 -0
- package/scripts/relay_cmd.js +276 -0
- package/scripts/web_cmd.js +136 -0
- package/setup.py +26 -0
- package/storage/__init__.py +0 -0
- package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
- package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
- package/storage/__pycache__/storage.cpython-313.pyc +0 -0
- package/storage/sqlite_storage.py +658 -0
- package/storage/storage.py +265 -0
- package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
- package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
- package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
- package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
- package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
- package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
- package/tools/bfp_relay.py +359 -0
- package/tools/bot.db +0 -0
- package/tools/get_session_summaries.py +45 -0
- package/tools/mcp_bridge.py +109 -0
- package/tools/mcp_server.py +531 -0
- package/tools/register_mcp_task.py +20 -0
- package/tools/run_detached.bat +32 -0
- package/tools/run_mcp_standalone.py +16 -0
- package/tools/task_registry.py +184 -0
- package/tools/test_mcp_connection.py +80 -0
- package/webchat/package-lock.json +1528 -0
- package/webchat/package.json +12 -0
- package/webchat/public/app.js +1293 -0
- package/webchat/public/index.html +226 -0
- package/webchat/public/index.html.bak +416 -0
- package/webchat/public/styles.css +2435 -0
- package/webchat/server.js +645 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tracks API token usage and estimates session costs in USD.
|
|
3
|
+
Provides automatic warnings when a specified dollar budget is reached.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
# Approximate pricing per 1 million tokens (Input / Output USD)
|
|
9
|
+
MODEL_PRICING = {
|
|
10
|
+
"qwen3.6-plus-free": {"input": 0.0, "output": 0.0},
|
|
11
|
+
"big-pickle": {"input": 0.0, "output": 0.0},
|
|
12
|
+
"claude-3-5-sonnet": {"input": 3.0, "output": 15.0},
|
|
13
|
+
"gpt-4o": {"input": 2.5, "output": 10.0},
|
|
14
|
+
"deepseek-chat": {"input": 0.14, "output": 0.28},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class BudgetTracker:
|
|
18
|
+
def __init__(self, default_limit: float = 5.00):
|
|
19
|
+
self.limits: Dict[str, float] = {} # session_id -> budget limit in USD
|
|
20
|
+
self.usage: Dict[str, Dict[str, float]] = {} # session_id -> {"cost": float, "tokens": int}
|
|
21
|
+
self.default_limit = default_limit
|
|
22
|
+
|
|
23
|
+
def set_limit(self, session_id: str, limit: float):
|
|
24
|
+
"""Sets the budget limit for a specific session."""
|
|
25
|
+
self.limits[session_id] = limit
|
|
26
|
+
|
|
27
|
+
def get_limit(self, session_id: str) -> float:
|
|
28
|
+
"""Gets the budget limit for a session, fallback to default."""
|
|
29
|
+
return self.limits.get(session_id, self.default_limit)
|
|
30
|
+
|
|
31
|
+
def record_usage(self, session_id: str, model_id: str, prompt_tokens: int, completion_tokens: int) -> float:
|
|
32
|
+
"""Calculates and stores cost for the current turn."""
|
|
33
|
+
model_key = model_id.lower()
|
|
34
|
+
pricing = {"input": 0.0, "output": 0.0}
|
|
35
|
+
|
|
36
|
+
# Match substring for flexibility (e.g. claude-3-5-sonnet-latest -> claude-3-5-sonnet)
|
|
37
|
+
for key, price in MODEL_PRICING.items():
|
|
38
|
+
if key in model_key:
|
|
39
|
+
pricing = price
|
|
40
|
+
break
|
|
41
|
+
|
|
42
|
+
cost = ((prompt_tokens / 1_000_000.0) * pricing["input"]) + \
|
|
43
|
+
((completion_tokens / 1_000_000.0) * pricing["output"])
|
|
44
|
+
|
|
45
|
+
if session_id not in self.usage:
|
|
46
|
+
self.usage[session_id] = {"cost": 0.0, "tokens": 0}
|
|
47
|
+
|
|
48
|
+
self.usage[session_id]["cost"] += cost
|
|
49
|
+
self.usage[session_id]["tokens"] += (prompt_tokens + completion_tokens)
|
|
50
|
+
return cost
|
|
51
|
+
|
|
52
|
+
def get_session_cost(self, session_id: str) -> float:
|
|
53
|
+
"""Gets total estimated cost for a session."""
|
|
54
|
+
if session_id in self.usage:
|
|
55
|
+
return self.usage[session_id]["cost"]
|
|
56
|
+
return 0.0
|
|
57
|
+
|
|
58
|
+
def is_exceeded(self, session_id: str) -> bool:
|
|
59
|
+
"""Checks if current session cost exceeds the set limit."""
|
|
60
|
+
current_cost = self.get_session_cost(session_id)
|
|
61
|
+
limit = self.get_limit(session_id)
|
|
62
|
+
return current_cost >= limit
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Formatting and rendering utilities for the BMO CLI using the rich library.
|
|
3
|
+
Provides colored text, panels, status tables, and markdown rendering.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Dict, Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.box import ROUNDED, DOUBLE_EDGE
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.spinner import Spinner
|
|
17
|
+
from rich.columns import Columns
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def bmo_spinner(message: str = "BMO is thinking..."):
|
|
23
|
+
"""Async context manager that shows a live animated spinner while awaiting something."""
|
|
24
|
+
import asyncio
|
|
25
|
+
spinner = Spinner("dots", text=f"[bold cyan] {message}[/bold cyan]", style="cyan")
|
|
26
|
+
done = asyncio.Event()
|
|
27
|
+
|
|
28
|
+
async def _animate():
|
|
29
|
+
with Live(spinner, console=console, refresh_per_second=12, transient=True):
|
|
30
|
+
await done.wait()
|
|
31
|
+
|
|
32
|
+
task = asyncio.ensure_future(_animate())
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
done.set()
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(task, timeout=0.5)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
BANNER_TEXT = """\
|
|
43
|
+
██████╗ ███╗ ███╗ ██████╗
|
|
44
|
+
██╔══██╗████╗ ████║██╔═══██╗
|
|
45
|
+
██████╔╝██╔████╔██║██║ ██║ [dim]v2.0[/dim]
|
|
46
|
+
██╔══██╗██║╚██╔╝██║██║ ██║ [dim]CLI Client[/dim]
|
|
47
|
+
██████╔╝██║ ╚═╝ ██║╚██████╔╝ [dim]OpenCode Integration[/dim]
|
|
48
|
+
╚═════╝ ╚═╝ ╚═╝ ╚═════╝ \
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def print_banner(active_model: str, is_connected: bool):
|
|
52
|
+
"""Renders the startup banner inside a neat rounded panel."""
|
|
53
|
+
status_indicator = "[bold green]● Connected[/bold green]" if is_connected else "[bold red]● Offline[/bold red]"
|
|
54
|
+
|
|
55
|
+
content = (
|
|
56
|
+
f"[bold cyan]{BANNER_TEXT}[/bold cyan]\n\n"
|
|
57
|
+
f" [dim]Model:[/dim] [bold white]{active_model}[/bold white]\n"
|
|
58
|
+
f" [dim]Status:[/dim] {status_indicator}\n"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
panel = Panel(
|
|
62
|
+
content,
|
|
63
|
+
box=ROUNDED,
|
|
64
|
+
title="[bold cyan]BMO Terminal User Interface[/bold cyan]",
|
|
65
|
+
border_style="cyan",
|
|
66
|
+
padding=(1, 4),
|
|
67
|
+
expand=False,
|
|
68
|
+
)
|
|
69
|
+
console.print()
|
|
70
|
+
console.print(panel)
|
|
71
|
+
console.print()
|
|
72
|
+
|
|
73
|
+
def _html_to_md(text: str) -> str:
|
|
74
|
+
"""Convert Telegram HTML tags to Markdown and strip remaining HTML."""
|
|
75
|
+
import re
|
|
76
|
+
# Code blocks before inline code (order matters)
|
|
77
|
+
text = re.sub(r'<pre><code[^>]*>(.*?)</code></pre>', r'```\n\1\n```', text, flags=re.DOTALL)
|
|
78
|
+
text = re.sub(r'<pre>(.*?)</pre>', r'```\n\1\n```', text, flags=re.DOTALL)
|
|
79
|
+
text = re.sub(r'<code>(.*?)</code>', r'`\1`', text, flags=re.DOTALL)
|
|
80
|
+
# Inline formatting
|
|
81
|
+
text = re.sub(r'<b>(.*?)</b>', r'**\1**', text, flags=re.DOTALL)
|
|
82
|
+
text = re.sub(r'<strong>(.*?)</strong>', r'**\1**', text, flags=re.DOTALL)
|
|
83
|
+
text = re.sub(r'<i>(.*?)</i>', r'*\1*', text, flags=re.DOTALL)
|
|
84
|
+
text = re.sub(r'<em>(.*?)</em>', r'*\1*', text, flags=re.DOTALL)
|
|
85
|
+
text = re.sub(r'<u>(.*?)</u>', r'__\1__', text, flags=re.DOTALL)
|
|
86
|
+
text = re.sub(r'<s>(.*?)</s>', r'~~\1~~', text, flags=re.DOTALL)
|
|
87
|
+
# Links
|
|
88
|
+
text = re.sub(r'<a href="(.*?)">(.*?)</a>', r'[\2](\1)', text, flags=re.DOTALL)
|
|
89
|
+
# Line breaks
|
|
90
|
+
text = text.replace('<br>', '\n').replace('<br/>', '\n').replace('<br />', '\n')
|
|
91
|
+
# Strip remaining HTML tags
|
|
92
|
+
text = re.sub(r'<[^>]+>', '', text)
|
|
93
|
+
# Unescape HTML entities
|
|
94
|
+
text = text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace(''', "'")
|
|
95
|
+
return text
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_markdown(text: str):
|
|
99
|
+
"""Renders markdown text inside the terminal."""
|
|
100
|
+
console.print(Markdown(_html_to_md(text), code_theme="monokai"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_assistant_message(content: str, elapsed_secs: float = None):
|
|
104
|
+
"""Displays the assistant's message with full Markdown rendering — tables, code, headings, bold."""
|
|
105
|
+
cleaned = _html_to_md(content)
|
|
106
|
+
time_label = f" [dim]⏱ {elapsed_secs:.1f}s[/dim]" if elapsed_secs is not None else ""
|
|
107
|
+
panel = Panel(
|
|
108
|
+
Markdown(cleaned, code_theme="monokai", justify="left"),
|
|
109
|
+
border_style="cyan",
|
|
110
|
+
title=f"[bold cyan]BMO[/bold cyan]{time_label}",
|
|
111
|
+
title_align="left",
|
|
112
|
+
padding=(1, 3),
|
|
113
|
+
)
|
|
114
|
+
console.print()
|
|
115
|
+
console.print(panel)
|
|
116
|
+
console.print()
|
|
117
|
+
|
|
118
|
+
def print_system_status(status: dict, active_model: str):
|
|
119
|
+
"""Prints the status of BMO and the OpenCode server in a table."""
|
|
120
|
+
table = Table(title="BMO System Status", box=ROUNDED, show_header=True, header_style="bold cyan", padding=(0, 2))
|
|
121
|
+
table.add_column("Property", style="bold cyan")
|
|
122
|
+
table.add_column("Value")
|
|
123
|
+
|
|
124
|
+
conn_status = "[bold green]● Connected[/bold green]" if status.get("connected") else "[bold red]● Disconnected[/bold red]"
|
|
125
|
+
table.add_row("OpenCode Connection", conn_status)
|
|
126
|
+
table.add_row("OpenCode Endpoint", status.get("base_url", "Unknown"))
|
|
127
|
+
table.add_row("Active Model", f"[bold white]{active_model}[/bold white]")
|
|
128
|
+
|
|
129
|
+
db_stats = status.get("stats", {})
|
|
130
|
+
table.add_row("Total Conversations", str(db_stats.get("sessions", 0)))
|
|
131
|
+
table.add_row("Total Messages", str(db_stats.get("messages", 0)))
|
|
132
|
+
|
|
133
|
+
console.print()
|
|
134
|
+
console.print(table)
|
|
135
|
+
console.print()
|
|
136
|
+
|
|
137
|
+
def print_sessions_list(sessions: List[dict]):
|
|
138
|
+
"""Renders the list of available sessions in a table."""
|
|
139
|
+
table = Table(title="Conversation Sessions History", box=ROUNDED, show_header=True, header_style="bold cyan", padding=(0, 2))
|
|
140
|
+
table.add_column("#", justify="right", style="cyan")
|
|
141
|
+
table.add_column("Status", justify="center")
|
|
142
|
+
table.add_column("Session Title", style="bold")
|
|
143
|
+
table.add_column("Messages", justify="right")
|
|
144
|
+
table.add_column("Last Updated", style="dim")
|
|
145
|
+
|
|
146
|
+
for idx, s in enumerate(sessions):
|
|
147
|
+
active_marker = "[bold green]★ Active[/bold green]" if s.get("is_active") else "[dim]○[/dim]"
|
|
148
|
+
title = s.get("title") or s.get("session_id")[:8]
|
|
149
|
+
msg_count = str(s.get("msg_count", 0))
|
|
150
|
+
|
|
151
|
+
updated_time = "Unknown"
|
|
152
|
+
if s.get("updated_at"):
|
|
153
|
+
updated_time = datetime.fromtimestamp(s["updated_at"]).strftime("%Y-%m-%d %H:%M")
|
|
154
|
+
|
|
155
|
+
table.add_row(
|
|
156
|
+
str(idx + 1),
|
|
157
|
+
active_marker,
|
|
158
|
+
title,
|
|
159
|
+
msg_count,
|
|
160
|
+
updated_time
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
console.print()
|
|
164
|
+
console.print(table)
|
|
165
|
+
console.print()
|
|
166
|
+
|
|
167
|
+
def print_error(message: str):
|
|
168
|
+
"""Prints an error message in red."""
|
|
169
|
+
console.print(f"\n [bold red]❌ {message}[/bold red]\n")
|
|
170
|
+
|
|
171
|
+
def print_success(message: str):
|
|
172
|
+
"""Prints a success message in green."""
|
|
173
|
+
console.print(f"\n [bold green]✅ {message}[/bold green]\n")
|
|
174
|
+
|
|
175
|
+
def print_info(message: str):
|
|
176
|
+
"""Prints an informational message in yellow/dim."""
|
|
177
|
+
console.print(f"[yellow]ℹ️ {message}[/yellow]")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Autonomous task runner for BMO.
|
|
3
|
+
Decomposes a user objective into subtasks and executes them iteratively.
|
|
4
|
+
Bridges status and blocker notifications to Telegram.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional, List
|
|
12
|
+
|
|
13
|
+
from core.bmo_engine import BMOEngine
|
|
14
|
+
from core.cli_renderer import print_info, print_success, print_error, console, bmo_spinner
|
|
15
|
+
from rich.rule import Rule
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
class GoalRunner:
|
|
21
|
+
def __init__(self, engine: BMOEngine):
|
|
22
|
+
self.engine = engine
|
|
23
|
+
self.is_running = False
|
|
24
|
+
|
|
25
|
+
async def run_goal(self, chat_id: int, user_id: int, objective: str, max_steps: int = 10):
|
|
26
|
+
"""Runs the autonomous goal execution loop."""
|
|
27
|
+
self.is_running = True
|
|
28
|
+
|
|
29
|
+
console.print()
|
|
30
|
+
console.print(Rule(f"[bold cyan]🎯 BMO AUTONOMOUS GOAL[/bold cyan]", style="cyan"))
|
|
31
|
+
console.print(f" [bold white]{objective}[/bold white]")
|
|
32
|
+
console.print(Rule(style="dim"))
|
|
33
|
+
console.print()
|
|
34
|
+
|
|
35
|
+
# 1. Ask BMO to create a step-by-step checklist
|
|
36
|
+
planning_prompt = (
|
|
37
|
+
f"Objective: {objective}\n\n"
|
|
38
|
+
"Create a numbered list of subtasks needed to accomplish this objective. "
|
|
39
|
+
"Respond ONLY with the list of subtasks."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async with bmo_spinner("Decomposing objective into subtasks..."):
|
|
43
|
+
plan_response = await self.engine.send_message(chat_id, user_id, planning_prompt, interface="cli")
|
|
44
|
+
|
|
45
|
+
console.print(f" [bold cyan]📋 Plan:[/bold cyan]")
|
|
46
|
+
for line in plan_response.strip().split("\n"):
|
|
47
|
+
if line.strip():
|
|
48
|
+
console.print(f" [dim]{line.strip()}[/dim]")
|
|
49
|
+
console.print()
|
|
50
|
+
|
|
51
|
+
# Parse subtasks (basic newline parsing)
|
|
52
|
+
subtasks = [
|
|
53
|
+
line.strip()
|
|
54
|
+
for line in plan_response.split("\n")
|
|
55
|
+
if line.strip() and (line.strip()[0].isdigit() or line.strip().startswith("-"))
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
if not subtasks:
|
|
59
|
+
subtasks = [objective] # Fallback to single objective
|
|
60
|
+
|
|
61
|
+
total = len(subtasks)
|
|
62
|
+
|
|
63
|
+
# 2. Iterate through subtasks
|
|
64
|
+
for idx, task in enumerate(subtasks):
|
|
65
|
+
if not self.is_running:
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
step_num = idx + 1
|
|
69
|
+
console.print(Rule(
|
|
70
|
+
f"[bold cyan]Step {step_num}/{total}[/bold cyan] [white]{task}[/white]",
|
|
71
|
+
style="dim cyan"
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
step_prompt = (
|
|
75
|
+
f"Currently working on step {step_num} of {total}: {task}\n"
|
|
76
|
+
f"Objective context: {objective}\n\n"
|
|
77
|
+
"Please execute this step now. If you need any tool execution, run it. "
|
|
78
|
+
"Report back when complete."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Send notification to Telegram (Bridge status)
|
|
82
|
+
await self._notify_telegram(chat_id, f"🚀 Working on step {step_num}/{total}: {task}")
|
|
83
|
+
|
|
84
|
+
t0 = time.time()
|
|
85
|
+
async with bmo_spinner(f"Executing step {step_num}/{total}..."):
|
|
86
|
+
response = await self.engine.send_message(chat_id, user_id, step_prompt, interface="cli")
|
|
87
|
+
elapsed = time.time() - t0
|
|
88
|
+
|
|
89
|
+
console.print()
|
|
90
|
+
console.print(response)
|
|
91
|
+
console.print(f"\n [dim]⏱ Step completed in {elapsed:.1f}s[/dim]")
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
# Simple heuristic checking for failure
|
|
95
|
+
lower_resp = response.lower()
|
|
96
|
+
if "error" in lower_resp or "fail" in lower_resp or "blocked" in lower_resp:
|
|
97
|
+
print_error(f"Goal blocked at step {step_num}!")
|
|
98
|
+
await self._notify_telegram(chat_id, f"⚠️ Goal Blocked: {task}\nError: {response[:150]}...")
|
|
99
|
+
print_info("Waiting for user intervention...")
|
|
100
|
+
self.is_running = False
|
|
101
|
+
break
|
|
102
|
+
else:
|
|
103
|
+
print_success(f"Step {step_num} complete.")
|
|
104
|
+
|
|
105
|
+
console.print()
|
|
106
|
+
console.print(Rule("[bold green]🏁 Goal Execution Complete![/bold green]", style="green"))
|
|
107
|
+
console.print()
|
|
108
|
+
await self._notify_telegram(chat_id, f"🏁 Goal completed successfully: {objective[:100]}")
|
|
109
|
+
self.is_running = False
|
|
110
|
+
|
|
111
|
+
async def _notify_telegram(self, chat_id: int, message: str):
|
|
112
|
+
"""Uses python-telegram-bot wrapper to send progress messages to the Telegram chat."""
|
|
113
|
+
token = os.getenv("TELEGRAM_BOT_TOKEN") or os.getenv("TELEGRAM_TOKEN")
|
|
114
|
+
if not token:
|
|
115
|
+
logger.info("[Telegram Bridge Notification] %s", message)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
import httpx
|
|
119
|
+
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
|
120
|
+
payload = {
|
|
121
|
+
"chat_id": chat_id,
|
|
122
|
+
"text": f"🤖 <b>BMO Goal Bridge:</b>\n{message}",
|
|
123
|
+
"parse_mode": "HTML"
|
|
124
|
+
}
|
|
125
|
+
try:
|
|
126
|
+
async with httpx.AsyncClient() as client:
|
|
127
|
+
await client.post(url, json=payload, timeout=5)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.error("Failed to send Telegram Bridge notification: %s", e)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import multiprocessing
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
+
|
|
13
|
+
from core.worker_protocol import (
|
|
14
|
+
encode_msg, decode_msg, MSG_QUERY, MSG_RESULT, MSG_UPDATE,
|
|
15
|
+
MSG_ERROR, MSG_CANCELLED, MSG_CONNECT, MSG_SHUTDOWN,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RequestWorker:
|
|
22
|
+
"""Runs in a child process. Handles all OpenCode API HTTP calls.
|
|
23
|
+
Supports both multiprocessing.Queue transport and stdin/stdout JSON-RPC.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, task_queue: Optional[multiprocessing.Queue] = None,
|
|
27
|
+
result_queue: Optional[multiprocessing.Queue] = None):
|
|
28
|
+
self.task_queue = task_queue
|
|
29
|
+
self.result_queue = result_queue
|
|
30
|
+
self._send_fn = None
|
|
31
|
+
self.http_client: Optional[httpx.AsyncClient] = None
|
|
32
|
+
self._running = False
|
|
33
|
+
self._msg_id_counter = 0
|
|
34
|
+
|
|
35
|
+
def _set_stdio_transport(self, writer):
|
|
36
|
+
self._send_fn = writer
|
|
37
|
+
|
|
38
|
+
async def _send_result(self, data: dict):
|
|
39
|
+
if self._send_fn:
|
|
40
|
+
await self._send_fn(data)
|
|
41
|
+
elif self.result_queue:
|
|
42
|
+
loop = asyncio.get_event_loop()
|
|
43
|
+
await loop.run_in_executor(None, self.result_queue.put, data)
|
|
44
|
+
|
|
45
|
+
async def _init_client(self):
|
|
46
|
+
self.http_client = httpx.AsyncClient(
|
|
47
|
+
timeout=httpx.Timeout(600.0, connect=30.0),
|
|
48
|
+
follow_redirects=True
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async def _handle_query(self, task: dict):
|
|
52
|
+
session_id = task["session_id"]
|
|
53
|
+
payload = task["payload"]
|
|
54
|
+
base_url = task["base_url"]
|
|
55
|
+
poll_interval = task.get("poll_interval", 2.0)
|
|
56
|
+
poll_timeout = task.get("poll_timeout", 600.0)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
post_url = f"{base_url}/session/{session_id}/message"
|
|
60
|
+
resp = await self.http_client.post(post_url, json=payload)
|
|
61
|
+
if resp.status_code != 200:
|
|
62
|
+
await self._send_result({
|
|
63
|
+
"type": "error",
|
|
64
|
+
"message": f"POST failed: {resp.status_code} {resp.text[:200]}"
|
|
65
|
+
})
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
get_url = f"{base_url}/session/{session_id}/message?limit=20"
|
|
69
|
+
start = asyncio.get_event_loop().time()
|
|
70
|
+
last_text = ""
|
|
71
|
+
|
|
72
|
+
while True:
|
|
73
|
+
elapsed = asyncio.get_event_loop().time() - start
|
|
74
|
+
if elapsed > poll_timeout:
|
|
75
|
+
await self._send_result({
|
|
76
|
+
"type": "error",
|
|
77
|
+
"message": f"Poll timeout after {poll_timeout}s"
|
|
78
|
+
})
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
resp = await self.http_client.get(get_url)
|
|
82
|
+
if resp.status_code != 200:
|
|
83
|
+
await asyncio.sleep(poll_interval)
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
data = resp.json()
|
|
87
|
+
messages = data if isinstance(data, list) else data.get("messages", [])
|
|
88
|
+
if not messages:
|
|
89
|
+
await asyncio.sleep(poll_interval)
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
latest = messages[-1]
|
|
93
|
+
role = latest.get("role", "")
|
|
94
|
+
content = latest.get("content", "") or ""
|
|
95
|
+
parts = latest.get("parts", [])
|
|
96
|
+
text_parts = [p.get("text", "") for p in parts if p.get("type") == "text"]
|
|
97
|
+
full_text = content or "".join(text_parts)
|
|
98
|
+
|
|
99
|
+
status = latest.get("status", "")
|
|
100
|
+
if status == "completed" or latest.get("completed", False):
|
|
101
|
+
await self._send_result({
|
|
102
|
+
"type": "result",
|
|
103
|
+
"content": full_text,
|
|
104
|
+
"message_id": latest.get("id", ""),
|
|
105
|
+
"done": True
|
|
106
|
+
})
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if full_text != last_text:
|
|
110
|
+
diff = full_text[len(last_text):] if len(full_text) > len(last_text) else full_text
|
|
111
|
+
await self._send_result({
|
|
112
|
+
"type": "update",
|
|
113
|
+
"content": diff,
|
|
114
|
+
"full_text": full_text,
|
|
115
|
+
"done": False
|
|
116
|
+
})
|
|
117
|
+
last_text = full_text
|
|
118
|
+
|
|
119
|
+
await asyncio.sleep(poll_interval)
|
|
120
|
+
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
await self._send_result({"type": "cancelled"})
|
|
123
|
+
except Exception as e:
|
|
124
|
+
await self._send_result({
|
|
125
|
+
"type": "error",
|
|
126
|
+
"message": f"{type(e).__name__}: {str(e)}"
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
async def _handle_connect(self, base_url: str):
|
|
130
|
+
try:
|
|
131
|
+
resp = await self.http_client.get(f"{base_url}/session", timeout=10.0)
|
|
132
|
+
ok = resp.status_code < 500
|
|
133
|
+
await self._send_result({"type": "connect_result", "ok": ok})
|
|
134
|
+
except Exception as e:
|
|
135
|
+
await self._send_result({"type": "connect_result", "ok": False, "error": str(e)})
|
|
136
|
+
|
|
137
|
+
async def run(self):
|
|
138
|
+
self._running = True
|
|
139
|
+
await self._init_client()
|
|
140
|
+
|
|
141
|
+
while self._running:
|
|
142
|
+
try:
|
|
143
|
+
task = None
|
|
144
|
+
if self.task_queue is not None:
|
|
145
|
+
try:
|
|
146
|
+
task = self.task_queue.get_nowait()
|
|
147
|
+
except Exception:
|
|
148
|
+
await asyncio.sleep(0.1)
|
|
149
|
+
continue
|
|
150
|
+
else:
|
|
151
|
+
await asyncio.sleep(0.1)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
task_type = task.get("type", "")
|
|
155
|
+
if task_type == "query":
|
|
156
|
+
await self._handle_query(task)
|
|
157
|
+
elif task_type == "connect":
|
|
158
|
+
await self._handle_connect(task["base_url"])
|
|
159
|
+
elif task_type == "shutdown":
|
|
160
|
+
self._running = False
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
break
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.error(f"Worker error: {e}")
|
|
167
|
+
await asyncio.sleep(1)
|
|
168
|
+
|
|
169
|
+
if self.http_client:
|
|
170
|
+
await self.http_client.aclose()
|
|
171
|
+
|
|
172
|
+
async def run_stdio(self):
|
|
173
|
+
"""Run worker reading JSON-RPC from stdin, writing to stdout.
|
|
174
|
+
Uses run_in_executor for stdin reads (Windows proactor compatible).
|
|
175
|
+
"""
|
|
176
|
+
self._running = True
|
|
177
|
+
await self._init_client()
|
|
178
|
+
|
|
179
|
+
loop = asyncio.get_event_loop()
|
|
180
|
+
|
|
181
|
+
async def write_json(data):
|
|
182
|
+
line = json.dumps(data, default=str) + "\n"
|
|
183
|
+
sys.stdout.write(line)
|
|
184
|
+
sys.stdout.flush()
|
|
185
|
+
|
|
186
|
+
self._send_fn = write_json
|
|
187
|
+
|
|
188
|
+
while self._running:
|
|
189
|
+
try:
|
|
190
|
+
line = await asyncio.wait_for(
|
|
191
|
+
loop.run_in_executor(None, sys.stdin.readline),
|
|
192
|
+
timeout=0.5,
|
|
193
|
+
)
|
|
194
|
+
except asyncio.TimeoutError:
|
|
195
|
+
continue
|
|
196
|
+
except Exception:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if not line:
|
|
200
|
+
await asyncio.sleep(0.1)
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
cmd = decode_msg(line)
|
|
204
|
+
if not cmd:
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
task_type = cmd.get("type")
|
|
208
|
+
if task_type == MSG_QUERY:
|
|
209
|
+
await self._handle_query(cmd)
|
|
210
|
+
elif task_type == MSG_CONNECT:
|
|
211
|
+
await self._handle_connect(cmd["base_url"])
|
|
212
|
+
elif task_type == MSG_SHUTDOWN:
|
|
213
|
+
self._running = False
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if self.http_client:
|
|
217
|
+
await self.http_client.aclose()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def worker_main(task_queue, result_queue):
|
|
221
|
+
"""Entry point for multiprocessing.Process worker."""
|
|
222
|
+
worker = RequestWorker(task_queue, result_queue)
|
|
223
|
+
asyncio.run(worker.run())
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def worker_main_stdio():
|
|
227
|
+
"""Entry point for subprocess worker (stdin/stdout JSON-RPC)."""
|
|
228
|
+
worker = RequestWorker(task_queue=None, result_queue=None)
|
|
229
|
+
asyncio.run(worker.run_stdio())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
if "--mode" in sys.argv:
|
|
234
|
+
idx = sys.argv.index("--mode")
|
|
235
|
+
if idx + 1 < len(sys.argv) and sys.argv[idx + 1] == "stdio":
|
|
236
|
+
worker_main_stdio()
|
|
237
|
+
else:
|
|
238
|
+
print("Usage: request_worker.py --mode stdio")
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
else:
|
|
241
|
+
print("Usage: request_worker.py --mode stdio")
|
|
242
|
+
sys.exit(1)
|
package/core/security.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import base64
|
|
3
|
+
from cryptography.fernet import Fernet
|
|
4
|
+
from config.settings import DATA_DIR
|
|
5
|
+
|
|
6
|
+
KEY_FILE = DATA_DIR / ".master.key"
|
|
7
|
+
|
|
8
|
+
def _get_or_create_master_key():
|
|
9
|
+
"""Retrieves existing master key or creates a new one."""
|
|
10
|
+
if os.path.exists(KEY_FILE):
|
|
11
|
+
with open(KEY_FILE, "rb") as f:
|
|
12
|
+
return f.read()
|
|
13
|
+
else:
|
|
14
|
+
# Generate a new random key
|
|
15
|
+
key = Fernet.generate_key()
|
|
16
|
+
with open(KEY_FILE, "wb") as f:
|
|
17
|
+
f.write(key)
|
|
18
|
+
|
|
19
|
+
# Try to make the file hidden on Windows
|
|
20
|
+
try:
|
|
21
|
+
import ctypes
|
|
22
|
+
ctypes.windll.kernel32.SetFileAttributesW(str(KEY_FILE), 0x02) # FILE_ATTRIBUTE_HIDDEN
|
|
23
|
+
except:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
return key
|
|
27
|
+
|
|
28
|
+
_MASTER_KEY = _get_or_create_master_key()
|
|
29
|
+
_FERNET = Fernet(_MASTER_KEY)
|
|
30
|
+
|
|
31
|
+
def encrypt_value(value: str) -> str:
|
|
32
|
+
"""Encrypts a string value."""
|
|
33
|
+
if not value: return ""
|
|
34
|
+
return _FERNET.encrypt(value.encode()).decode()
|
|
35
|
+
|
|
36
|
+
def decrypt_value(encrypted_value: str) -> str:
|
|
37
|
+
"""Decrypts an encrypted string value."""
|
|
38
|
+
if not encrypted_value: return ""
|
|
39
|
+
try:
|
|
40
|
+
return _FERNET.decrypt(encrypted_value.encode()).decode()
|
|
41
|
+
except Exception:
|
|
42
|
+
return "" # Return empty if decryption fails (e.g. wrong key)
|