@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,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage layer for chat history, sessions, and memory.
|
|
3
|
+
Supports JSONL backend with multi-session per chat.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional, List, Dict
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
|
|
12
|
+
from models.chat_models import ChatSession, UserMemory
|
|
13
|
+
from config.settings import (
|
|
14
|
+
STORAGE_TYPE, SESSIONS_FILE, ACTIVE_SESSIONS_FILE,
|
|
15
|
+
USER_MEMORY_FILE, MAX_HISTORY_PER_CHAT, DATABASE_FILE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StorageBackend(ABC):
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def save_session(self, session: ChatSession) -> bool:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def load_session(self, chat_id: int) -> Optional[ChatSession]:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def delete_session(self, chat_id: int) -> bool:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def add_message(self, chat_id: int, sender: str, content: str, interface: str = 'telegram') -> bool:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def save_memory(self, memory: UserMemory) -> bool:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def load_memory(self, chat_id: int) -> Optional[UserMemory]:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class JSONLStorage(StorageBackend):
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.sessions_file = SESSIONS_FILE
|
|
48
|
+
self.active_file = ACTIVE_SESSIONS_FILE
|
|
49
|
+
self.memory_file = USER_MEMORY_FILE
|
|
50
|
+
self.sessions_file.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self.memory_file.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self._migrate_from_json()
|
|
53
|
+
|
|
54
|
+
# ── Migration ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def _migrate_from_json(self):
|
|
57
|
+
old_file = self.sessions_file.with_suffix(".json")
|
|
58
|
+
if not self.sessions_file.exists() and old_file.exists():
|
|
59
|
+
try:
|
|
60
|
+
with open(old_file, "r") as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
if data:
|
|
63
|
+
self._save_all_sessions(data)
|
|
64
|
+
print(f"Migrated sessions from {old_file} to {self.sessions_file}")
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Migration error: {e}")
|
|
67
|
+
|
|
68
|
+
# ── Sessions file (JSONL) ──────────────────────────────────────────────
|
|
69
|
+
# Internal format: {session_id: session_dict}
|
|
70
|
+
|
|
71
|
+
def _load_all_sessions(self) -> Dict[str, dict]:
|
|
72
|
+
if not self.sessions_file.exists():
|
|
73
|
+
return {}
|
|
74
|
+
data = {}
|
|
75
|
+
try:
|
|
76
|
+
with open(self.sessions_file, "r", encoding="utf-8") as f:
|
|
77
|
+
for line in f:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
obj = json.loads(line)
|
|
83
|
+
sid = obj.get("session_id") or obj.get("chat_id")
|
|
84
|
+
if sid:
|
|
85
|
+
data[str(sid)] = obj
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
continue
|
|
88
|
+
except Exception:
|
|
89
|
+
return {}
|
|
90
|
+
return data
|
|
91
|
+
|
|
92
|
+
def _save_all_sessions(self, data: Dict[str, dict]):
|
|
93
|
+
with open(self.sessions_file, "w", encoding="utf-8") as f:
|
|
94
|
+
for sid in sorted(data.keys()):
|
|
95
|
+
f.write(json.dumps(data[sid], ensure_ascii=False) + "\n")
|
|
96
|
+
|
|
97
|
+
# ── Active sessions map ─────────────────────────────────────────────────
|
|
98
|
+
# Format: {chat_id: active_session_id}
|
|
99
|
+
|
|
100
|
+
def _load_active_map(self) -> Dict[str, str]:
|
|
101
|
+
if self.active_file.exists():
|
|
102
|
+
try:
|
|
103
|
+
with open(self.active_file, "r") as f:
|
|
104
|
+
return json.load(f)
|
|
105
|
+
except Exception:
|
|
106
|
+
return {}
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def _save_active_map(self, data: Dict[str, str]):
|
|
110
|
+
with open(self.active_file, "w") as f:
|
|
111
|
+
json.dump(data, f, indent=2)
|
|
112
|
+
|
|
113
|
+
# ── Session CRUD (multi-session aware) ──────────────────────────────────
|
|
114
|
+
|
|
115
|
+
def save_session(self, session: ChatSession) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
if not session.session_id:
|
|
118
|
+
session.session_id = str(uuid4())
|
|
119
|
+
db = self._load_all_sessions()
|
|
120
|
+
db[session.session_id] = session.to_dict()
|
|
121
|
+
self._save_all_sessions(db)
|
|
122
|
+
active = self._load_active_map()
|
|
123
|
+
active[str(session.chat_id)] = session.session_id
|
|
124
|
+
self._save_active_map(active)
|
|
125
|
+
return True
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"Error saving session: {e}")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def load_session(self, chat_id: int) -> Optional[ChatSession]:
|
|
131
|
+
try:
|
|
132
|
+
active = self._load_active_map()
|
|
133
|
+
session_id = active.get(str(chat_id))
|
|
134
|
+
db = self._load_all_sessions()
|
|
135
|
+
|
|
136
|
+
if session_id and session_id in db:
|
|
137
|
+
return ChatSession.from_dict(db[session_id])
|
|
138
|
+
|
|
139
|
+
# Auto-migrate: find any session for this chat, pick newest
|
|
140
|
+
candidates = []
|
|
141
|
+
for sid, data in db.items():
|
|
142
|
+
if data.get("chat_id") == chat_id:
|
|
143
|
+
candidates.append((sid, data))
|
|
144
|
+
if candidates:
|
|
145
|
+
candidates.sort(key=lambda x: x[1].get("updated_at", 0), reverse=True)
|
|
146
|
+
best_sid, best_data = candidates[0]
|
|
147
|
+
if not best_data.get("session_id"):
|
|
148
|
+
best_data["session_id"] = best_sid
|
|
149
|
+
db[best_sid] = best_data
|
|
150
|
+
self._save_all_sessions(db)
|
|
151
|
+
active[str(chat_id)] = best_sid
|
|
152
|
+
self._save_active_map(active)
|
|
153
|
+
return ChatSession.from_dict(best_data)
|
|
154
|
+
|
|
155
|
+
return None
|
|
156
|
+
except Exception as e:
|
|
157
|
+
print(f"Error loading session: {e}")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def delete_session(self, chat_id: int) -> bool:
|
|
161
|
+
try:
|
|
162
|
+
active = self._load_active_map()
|
|
163
|
+
session_id = active.pop(str(chat_id), None)
|
|
164
|
+
if session_id:
|
|
165
|
+
db = self._load_all_sessions()
|
|
166
|
+
db.pop(session_id, None)
|
|
167
|
+
self._save_all_sessions(db)
|
|
168
|
+
self._save_active_map(active)
|
|
169
|
+
return True
|
|
170
|
+
except Exception as e:
|
|
171
|
+
print(f"Error deleting session: {e}")
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def add_message(self, chat_id: int, sender: str, content: str, interface: str = 'telegram') -> bool:
|
|
175
|
+
try:
|
|
176
|
+
session = self.load_session(chat_id)
|
|
177
|
+
if session:
|
|
178
|
+
session.add_message(sender, content)
|
|
179
|
+
if len(session.messages) > MAX_HISTORY_PER_CHAT:
|
|
180
|
+
session.messages = session.messages[-MAX_HISTORY_PER_CHAT:]
|
|
181
|
+
return self.save_session(session)
|
|
182
|
+
return False
|
|
183
|
+
except Exception as e:
|
|
184
|
+
print(f"Error adding message: {e}")
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
def get_session_by_id(self, session_id: str) -> Optional[ChatSession]:
|
|
188
|
+
try:
|
|
189
|
+
db = self._load_all_sessions()
|
|
190
|
+
data = db.get(session_id)
|
|
191
|
+
return ChatSession.from_dict(data) if data else None
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"Error loading session by ID: {e}")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def list_chat_sessions(self, chat_id: int) -> List[dict]:
|
|
197
|
+
db = self._load_all_sessions()
|
|
198
|
+
active = self._load_active_map()
|
|
199
|
+
active_id = active.get(str(chat_id))
|
|
200
|
+
result = []
|
|
201
|
+
for sid, data in db.items():
|
|
202
|
+
if data.get("chat_id") == chat_id:
|
|
203
|
+
result.append({
|
|
204
|
+
"session_id": sid,
|
|
205
|
+
"title": data.get("title", "") or sid[:8],
|
|
206
|
+
"msg_count": len(data.get("messages", [])),
|
|
207
|
+
"updated_at": data.get("updated_at", 0),
|
|
208
|
+
"is_active": sid == active_id,
|
|
209
|
+
})
|
|
210
|
+
result.sort(key=lambda x: x["updated_at"], reverse=True)
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
def switch_session(self, chat_id: int, session_id: str) -> bool:
|
|
214
|
+
db = self._load_all_sessions()
|
|
215
|
+
if session_id not in db:
|
|
216
|
+
return False
|
|
217
|
+
active = self._load_active_map()
|
|
218
|
+
active[str(chat_id)] = session_id
|
|
219
|
+
self._save_active_map(active)
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
# ── Memory ────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def _load_memory_db(self) -> Dict:
|
|
225
|
+
if self.memory_file.exists():
|
|
226
|
+
try:
|
|
227
|
+
with open(self.memory_file, "r") as f:
|
|
228
|
+
return json.load(f)
|
|
229
|
+
except Exception:
|
|
230
|
+
return {}
|
|
231
|
+
return {}
|
|
232
|
+
|
|
233
|
+
def _save_memory_db(self, data: Dict):
|
|
234
|
+
with open(self.memory_file, "w") as f:
|
|
235
|
+
json.dump(data, f, indent=2)
|
|
236
|
+
|
|
237
|
+
def save_memory(self, memory: UserMemory) -> bool:
|
|
238
|
+
try:
|
|
239
|
+
db = self._load_memory_db()
|
|
240
|
+
db[str(memory.chat_id)] = memory.to_dict()
|
|
241
|
+
self._save_memory_db(db)
|
|
242
|
+
return True
|
|
243
|
+
except Exception as e:
|
|
244
|
+
print(f"Error saving memory: {e}")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
def load_memory(self, chat_id: int) -> Optional[UserMemory]:
|
|
248
|
+
try:
|
|
249
|
+
db = self._load_memory_db()
|
|
250
|
+
data = db.get(str(chat_id))
|
|
251
|
+
if data:
|
|
252
|
+
return UserMemory.from_dict(data)
|
|
253
|
+
return None
|
|
254
|
+
except Exception as e:
|
|
255
|
+
print(f"Error loading memory: {e}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def list_sessions(self) -> List[int]:
|
|
259
|
+
db = self._load_all_sessions()
|
|
260
|
+
return list({d.get("chat_id") for d in db.values() if d.get("chat_id")})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_storage() -> StorageBackend:
|
|
264
|
+
from storage.sqlite_storage import SQLiteStorage
|
|
265
|
+
return SQLiteStorage(db_path=str(DATABASE_FILE))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
import uvicorn
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.INFO,
|
|
17
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
18
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
19
|
+
)
|
|
20
|
+
log = logging.getLogger("bfp-relay")
|
|
21
|
+
|
|
22
|
+
DEFAULT_PORT = 9753
|
|
23
|
+
TTL_SECONDS = 300
|
|
24
|
+
CLEANUP_INTERVAL = 60
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BFPRelay:
|
|
28
|
+
def __init__(self, host: str = "0.0.0.0", port: int = DEFAULT_PORT, persist_path: str = None):
|
|
29
|
+
self.host = host
|
|
30
|
+
self.port = port
|
|
31
|
+
self.persist_path = persist_path
|
|
32
|
+
self._agents: dict[str, dict] = {}
|
|
33
|
+
self._ws_clients: set[WebSocket] = set()
|
|
34
|
+
self._ws_by_did: dict[str, WebSocket] = {}
|
|
35
|
+
self._pending_responses: dict[str, asyncio.Future] = {}
|
|
36
|
+
self._app: FastAPI | None = None
|
|
37
|
+
self._cleanup_task: asyncio.Task | None = None
|
|
38
|
+
|
|
39
|
+
def _validate_did(self, did: str) -> None:
|
|
40
|
+
if not did.startswith("did:bfp:"):
|
|
41
|
+
raise ValueError(f"Invalid DID format '{did}' — must start with 'did:bfp:'")
|
|
42
|
+
|
|
43
|
+
def register(self, did: str, endpoint: str, caps: list, pubkey: str, name: str = None) -> dict:
|
|
44
|
+
self._validate_did(did)
|
|
45
|
+
now = time.time()
|
|
46
|
+
self._agents[did] = {
|
|
47
|
+
"endpoint": endpoint,
|
|
48
|
+
"capabilities": caps or [],
|
|
49
|
+
"publicKey": pubkey,
|
|
50
|
+
"name": name or did.split(":")[-1][:24],
|
|
51
|
+
"lastSeen": now,
|
|
52
|
+
}
|
|
53
|
+
self._persist()
|
|
54
|
+
log.info("REGISTER did=%s endpoint=%s caps=%s", did, endpoint, caps)
|
|
55
|
+
return {"status": "ok", "ttl": TTL_SECONDS}
|
|
56
|
+
|
|
57
|
+
def resolve(self, did: str) -> dict | None:
|
|
58
|
+
agent = self._agents.get(did)
|
|
59
|
+
if agent is None:
|
|
60
|
+
return None
|
|
61
|
+
now = time.time()
|
|
62
|
+
if now - agent["lastSeen"] > TTL_SECONDS:
|
|
63
|
+
self.unregister(did)
|
|
64
|
+
return None
|
|
65
|
+
log.info("RESOLVE did=%s endpoint=%s", did, agent["endpoint"])
|
|
66
|
+
return {
|
|
67
|
+
"did": did,
|
|
68
|
+
"endpoint": agent["endpoint"],
|
|
69
|
+
"capabilities": agent["capabilities"],
|
|
70
|
+
"lastSeen": agent["lastSeen"],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def list_agents(self, capability: str = None) -> list[dict]:
|
|
74
|
+
now = time.time()
|
|
75
|
+
stale = [did for did, a in self._agents.items() if now - a["lastSeen"] > TTL_SECONDS]
|
|
76
|
+
for did in stale:
|
|
77
|
+
self.unregister(did)
|
|
78
|
+
agents = []
|
|
79
|
+
for did, a in self._agents.items():
|
|
80
|
+
if capability and capability not in a["capabilities"]:
|
|
81
|
+
continue
|
|
82
|
+
agents.append({
|
|
83
|
+
"did": did,
|
|
84
|
+
"name": a["name"],
|
|
85
|
+
"capabilities": a["capabilities"],
|
|
86
|
+
"lastSeen": a["lastSeen"],
|
|
87
|
+
})
|
|
88
|
+
agents.sort(key=lambda x: x["lastSeen"], reverse=True)
|
|
89
|
+
return agents
|
|
90
|
+
|
|
91
|
+
def unregister(self, did: str) -> None:
|
|
92
|
+
if did in self._agents:
|
|
93
|
+
del self._agents[did]
|
|
94
|
+
self._persist()
|
|
95
|
+
log.info("UNREGISTER did=%s", did)
|
|
96
|
+
|
|
97
|
+
def get_agent_count(self) -> int:
|
|
98
|
+
now = time.time()
|
|
99
|
+
self._agents = {d: a for d, a in self._agents.items() if now - a["lastSeen"] <= TTL_SECONDS}
|
|
100
|
+
return len(self._agents)
|
|
101
|
+
|
|
102
|
+
def _persist(self) -> None:
|
|
103
|
+
if not self.persist_path:
|
|
104
|
+
return
|
|
105
|
+
data = {did: info for did, info in self._agents.items()}
|
|
106
|
+
tmp = self.persist_path + ".tmp"
|
|
107
|
+
try:
|
|
108
|
+
with open(tmp, "w") as f:
|
|
109
|
+
json.dump(data, f)
|
|
110
|
+
os.replace(tmp, self.persist_path)
|
|
111
|
+
except OSError as e:
|
|
112
|
+
log.warning("persist failed: %s", e)
|
|
113
|
+
|
|
114
|
+
def _load(self) -> None:
|
|
115
|
+
if not self.persist_path or not os.path.exists(self.persist_path):
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
with open(self.persist_path) as f:
|
|
119
|
+
data = json.load(f)
|
|
120
|
+
now = time.time()
|
|
121
|
+
for did, info in data.items():
|
|
122
|
+
if now - info.get("lastSeen", 0) <= TTL_SECONDS:
|
|
123
|
+
self._agents[did] = info
|
|
124
|
+
log.info("loaded %d agents from %s", len(self._agents), self.persist_path)
|
|
125
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
126
|
+
log.warning("load failed: %s", e)
|
|
127
|
+
|
|
128
|
+
def _build_app(self) -> FastAPI:
|
|
129
|
+
app = FastAPI(title="BFP Relay", version="1.0.0")
|
|
130
|
+
|
|
131
|
+
app.add_middleware(
|
|
132
|
+
CORSMiddleware,
|
|
133
|
+
allow_origins=["*"],
|
|
134
|
+
allow_credentials=True,
|
|
135
|
+
allow_methods=["*"],
|
|
136
|
+
allow_headers=["*"],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@app.post("/register")
|
|
140
|
+
async def register_endpoint(body: dict):
|
|
141
|
+
did = body.get("did", "").strip()
|
|
142
|
+
endpoint = body.get("endpoint", "").strip()
|
|
143
|
+
caps = body.get("capabilities", [])
|
|
144
|
+
pubkey = body.get("publicKey", "")
|
|
145
|
+
name = body.get("name")
|
|
146
|
+
if not did or not endpoint:
|
|
147
|
+
raise HTTPException(status_code=400, detail="did and endpoint are required")
|
|
148
|
+
try:
|
|
149
|
+
result = self.register(did, endpoint, caps, pubkey, name)
|
|
150
|
+
except ValueError as e:
|
|
151
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
@app.get("/resolve/{did:path}")
|
|
155
|
+
async def resolve_endpoint(did: str):
|
|
156
|
+
did = did.strip()
|
|
157
|
+
result = self.resolve(did)
|
|
158
|
+
if result is None:
|
|
159
|
+
raise HTTPException(status_code=404, detail=f"agent {did} not found")
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
@app.get("/list")
|
|
163
|
+
async def list_endpoint(capability: str = None):
|
|
164
|
+
return {"agents": self.list_agents(capability)}
|
|
165
|
+
|
|
166
|
+
@app.delete("/unregister/{did:path}")
|
|
167
|
+
async def unregister_endpoint(did: str):
|
|
168
|
+
did = did.strip()
|
|
169
|
+
self.unregister(did)
|
|
170
|
+
return {"status": "ok"}
|
|
171
|
+
|
|
172
|
+
@app.get("/health")
|
|
173
|
+
async def health_endpoint():
|
|
174
|
+
return {"status": "ok", "agentCount": self.get_agent_count()}
|
|
175
|
+
|
|
176
|
+
@app.websocket("/ws")
|
|
177
|
+
async def websocket_endpoint(ws: WebSocket):
|
|
178
|
+
await ws.accept()
|
|
179
|
+
self._ws_clients.add(ws)
|
|
180
|
+
log.info("WS connect clients=%d", len(self._ws_clients))
|
|
181
|
+
registered_did = None
|
|
182
|
+
try:
|
|
183
|
+
while True:
|
|
184
|
+
data = await ws.receive_text()
|
|
185
|
+
msg = json.loads(data)
|
|
186
|
+
action = msg.get("action")
|
|
187
|
+
if action == "register":
|
|
188
|
+
try:
|
|
189
|
+
did = msg["did"]
|
|
190
|
+
result = self.register(
|
|
191
|
+
did, msg["endpoint"],
|
|
192
|
+
msg.get("capabilities", []),
|
|
193
|
+
msg.get("publicKey", ""),
|
|
194
|
+
msg.get("name"),
|
|
195
|
+
)
|
|
196
|
+
self._ws_by_did[did] = ws
|
|
197
|
+
registered_did = did
|
|
198
|
+
await ws.send_json({"type": "register_result", **result})
|
|
199
|
+
await self._broadcast_agent_list()
|
|
200
|
+
except (ValueError, KeyError) as e:
|
|
201
|
+
await ws.send_json({"type": "error", "detail": str(e)})
|
|
202
|
+
elif action == "resolve":
|
|
203
|
+
result = self.resolve(msg["did"])
|
|
204
|
+
if result is None:
|
|
205
|
+
await ws.send_json({"type": "resolve_result", "found": False})
|
|
206
|
+
else:
|
|
207
|
+
await ws.send_json({"type": "resolve_result", "found": True, **result})
|
|
208
|
+
elif action == "list":
|
|
209
|
+
agents = self.list_agents(msg.get("capability"))
|
|
210
|
+
await ws.send_json({"type": "list_result", "agents": agents})
|
|
211
|
+
elif action == "forward":
|
|
212
|
+
target_did = msg.get("targetDid")
|
|
213
|
+
payload = msg.get("payload", {})
|
|
214
|
+
target = self.resolve(target_did)
|
|
215
|
+
if target is None:
|
|
216
|
+
await ws.send_json({"type": "forward_result", "status": "not_found"})
|
|
217
|
+
else:
|
|
218
|
+
await self._relay_forward(target_did, target["endpoint"], payload, ws)
|
|
219
|
+
elif action == "send":
|
|
220
|
+
target_did = msg.get("targetDid")
|
|
221
|
+
payload = msg.get("payload", {})
|
|
222
|
+
request_id = msg.get("requestId", "")
|
|
223
|
+
target_ws = self._ws_by_did.get(target_did)
|
|
224
|
+
if target_ws is None:
|
|
225
|
+
await ws.send_json({"type": "send_result", "status": "offline", "requestId": request_id})
|
|
226
|
+
else:
|
|
227
|
+
future = asyncio.get_event_loop().create_future()
|
|
228
|
+
self._pending_responses[request_id] = future
|
|
229
|
+
try:
|
|
230
|
+
await target_ws.send_json({
|
|
231
|
+
"type": "relay_message",
|
|
232
|
+
"fromDid": msg.get("fromDid", ""),
|
|
233
|
+
"payload": payload,
|
|
234
|
+
"requestId": request_id,
|
|
235
|
+
})
|
|
236
|
+
resp = await asyncio.wait_for(future, timeout=60.0)
|
|
237
|
+
await ws.send_json({"type": "send_result", "status": "delivered", "response": resp, "requestId": request_id})
|
|
238
|
+
except asyncio.TimeoutError:
|
|
239
|
+
await ws.send_json({"type": "send_result", "status": "timeout", "requestId": request_id})
|
|
240
|
+
finally:
|
|
241
|
+
self._pending_responses.pop(request_id, None)
|
|
242
|
+
elif action == "relay_response":
|
|
243
|
+
request_id = msg.get("requestId", "")
|
|
244
|
+
future = self._pending_responses.get(request_id)
|
|
245
|
+
if future and not future.done():
|
|
246
|
+
future.set_result(msg.get("payload", {}))
|
|
247
|
+
else:
|
|
248
|
+
await ws.send_json({"type": "error", "detail": f"unknown action: {action}"})
|
|
249
|
+
except WebSocketDisconnect:
|
|
250
|
+
pass
|
|
251
|
+
finally:
|
|
252
|
+
self._ws_clients.discard(ws)
|
|
253
|
+
if registered_did:
|
|
254
|
+
self._ws_by_did.pop(registered_did, None)
|
|
255
|
+
log.info("WS disconnect clients=%d", len(self._ws_clients))
|
|
256
|
+
await self._broadcast_agent_list()
|
|
257
|
+
|
|
258
|
+
return app
|
|
259
|
+
|
|
260
|
+
async def _broadcast_agent_list(self) -> None:
|
|
261
|
+
agents = self.list_agents()
|
|
262
|
+
msg = json.dumps({"type": "agent_list", "agents": agents})
|
|
263
|
+
stale = set()
|
|
264
|
+
for ws in self._ws_clients:
|
|
265
|
+
try:
|
|
266
|
+
await ws.send_text(msg)
|
|
267
|
+
except Exception:
|
|
268
|
+
stale.add(ws)
|
|
269
|
+
self._ws_clients -= stale
|
|
270
|
+
|
|
271
|
+
async def _relay_forward(self, target_did: str, target_endpoint: str, payload: dict, source_ws: WebSocket) -> None:
|
|
272
|
+
import aiohttp
|
|
273
|
+
try:
|
|
274
|
+
async with aiohttp.ClientSession() as session:
|
|
275
|
+
async with session.post(
|
|
276
|
+
f"{target_endpoint}/bfp/inbox",
|
|
277
|
+
json={"from": "relay", "payload": payload},
|
|
278
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
279
|
+
) as resp:
|
|
280
|
+
if resp.ok:
|
|
281
|
+
body = await resp.json()
|
|
282
|
+
await source_ws.send_json({
|
|
283
|
+
"type": "forward_result",
|
|
284
|
+
"status": "delivered",
|
|
285
|
+
"response": body,
|
|
286
|
+
})
|
|
287
|
+
else:
|
|
288
|
+
await source_ws.send_json({
|
|
289
|
+
"type": "forward_result",
|
|
290
|
+
"status": "delivery_failed",
|
|
291
|
+
"httpStatus": resp.status,
|
|
292
|
+
})
|
|
293
|
+
except Exception as e:
|
|
294
|
+
await source_ws.send_json({
|
|
295
|
+
"type": "forward_result",
|
|
296
|
+
"status": "delivery_failed",
|
|
297
|
+
"detail": str(e),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
async def _cleanup_loop(self) -> None:
|
|
301
|
+
while True:
|
|
302
|
+
await asyncio.sleep(CLEANUP_INTERVAL)
|
|
303
|
+
before = len(self._agents)
|
|
304
|
+
self.get_agent_count()
|
|
305
|
+
after = len(self._agents)
|
|
306
|
+
if before != after:
|
|
307
|
+
log.info("cleanup removed=%d remaining=%d", before - after, after)
|
|
308
|
+
await self._broadcast_agent_list()
|
|
309
|
+
|
|
310
|
+
def start(self) -> None:
|
|
311
|
+
self._load()
|
|
312
|
+
app = self._build_app()
|
|
313
|
+
self._app = app
|
|
314
|
+
|
|
315
|
+
config = uvicorn.Config(
|
|
316
|
+
app,
|
|
317
|
+
host=self.host,
|
|
318
|
+
port=self.port,
|
|
319
|
+
log_level="info",
|
|
320
|
+
access_log=True,
|
|
321
|
+
)
|
|
322
|
+
server = uvicorn.Server(config)
|
|
323
|
+
loop = asyncio.new_event_loop()
|
|
324
|
+
asyncio.set_event_loop(loop)
|
|
325
|
+
|
|
326
|
+
self._cleanup_task = loop.create_task(self._cleanup_loop())
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
log.info("BFP Relay starting on %s:%s", self.host, self.port)
|
|
330
|
+
server.run()
|
|
331
|
+
except KeyboardInterrupt:
|
|
332
|
+
pass
|
|
333
|
+
finally:
|
|
334
|
+
self.stop()
|
|
335
|
+
|
|
336
|
+
def stop(self) -> None:
|
|
337
|
+
if self._cleanup_task:
|
|
338
|
+
self._cleanup_task.cancel()
|
|
339
|
+
self._persist()
|
|
340
|
+
log.info("BFP Relay stopped")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def main():
|
|
344
|
+
parser = argparse.ArgumentParser(description="BFP Relay Directory Server")
|
|
345
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
|
|
346
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Port (default: {DEFAULT_PORT})")
|
|
347
|
+
parser.add_argument("--persist", default=None, help="Path to JSON file for persistence")
|
|
348
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
349
|
+
args = parser.parse_args()
|
|
350
|
+
|
|
351
|
+
if args.debug:
|
|
352
|
+
logging.getLogger("bfp-relay").setLevel(logging.DEBUG)
|
|
353
|
+
|
|
354
|
+
relay = BFPRelay(host=args.host, port=args.port, persist_path=args.persist)
|
|
355
|
+
relay.start()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|
package/tools/bot.db
ADDED
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BMO Session Summary Tool
|
|
3
|
+
Built-in alternative to MCP get_session_summaries
|
|
4
|
+
Usage: python get_session_summaries.py <chat_id> [count]
|
|
5
|
+
"""
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
DB_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "bot.db")
|
|
11
|
+
|
|
12
|
+
def get_session_summaries(chat_id, count=5):
|
|
13
|
+
conn = sqlite3.connect(DB_PATH)
|
|
14
|
+
cursor = conn.cursor()
|
|
15
|
+
|
|
16
|
+
cursor.execute("""
|
|
17
|
+
SELECT id, created_at, summary
|
|
18
|
+
FROM sessions
|
|
19
|
+
WHERE chat_id = ?
|
|
20
|
+
ORDER BY created_at DESC
|
|
21
|
+
LIMIT ?
|
|
22
|
+
""", (str(chat_id), count))
|
|
23
|
+
|
|
24
|
+
results = cursor.fetchall()
|
|
25
|
+
conn.close()
|
|
26
|
+
|
|
27
|
+
if not results:
|
|
28
|
+
return f"No sessions found for chat_id: {chat_id}"
|
|
29
|
+
|
|
30
|
+
output = []
|
|
31
|
+
for session_id, created_at, summary in results:
|
|
32
|
+
import datetime
|
|
33
|
+
dt = datetime.datetime.fromtimestamp(created_at)
|
|
34
|
+
output.append(f"📅 {dt.strftime('%Y-%m-%d %H:%M')} | ID: {session_id[:8]}...")
|
|
35
|
+
if summary:
|
|
36
|
+
output.append(f" {summary[:200]}...")
|
|
37
|
+
output.append("")
|
|
38
|
+
|
|
39
|
+
return "\n".join(output)
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
from config.settings import OWNER_ID
|
|
43
|
+
chat_id = sys.argv[1] if len(sys.argv) > 1 else str(OWNER_ID)
|
|
44
|
+
count = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
|
45
|
+
print(get_session_summaries(chat_id, count))
|