@aliwey/bmo 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,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))
@@ -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))