@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,98 @@
1
+ """
2
+ BFP Agent — orchestrates BFP identity, card, transport, discovery, and relay communication.
3
+ Started automatically when BMO boots.
4
+ """
5
+
6
+ import asyncio
7
+ import logging
8
+ from typing import Optional
9
+
10
+ from core.bfp_identity import get_did, sign, verify
11
+ from core.bfp_agent_card import get_signed_card, serve_agent_card, generate_agent_card
12
+ from core.bfp_transport import BFPServer, BFPClient
13
+ from core.bfp_a2a_bridge import get_a2a_agent_card, start_a2a_endpoint
14
+ from core.bfp_connector import BFPConnector
15
+ from core.bfp_discovery import BFPDiscovery
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class BFPAgent:
21
+ """Manages BFP lifecycle within BMO."""
22
+
23
+ def __init__(self):
24
+ self.did = get_did()
25
+ self.card = get_signed_card()
26
+ self.bfp_server: Optional[BFPServer] = None
27
+ self.a2a_server = None
28
+ self.connector = BFPConnector()
29
+ self.discovery: Optional[BFPDiscovery] = None
30
+ self.relay_url: Optional[str] = None
31
+ self._running = False
32
+
33
+ @property
34
+ def is_running(self):
35
+ return self._running
36
+
37
+ async def start(self, bfp_port: int = 8765, a2a_port: int = 8766, relay_url: str = None):
38
+ """Start BFP server, A2A bridge, and optionally connect to relay."""
39
+ self.bfp_server = BFPServer(port=bfp_port)
40
+ # BFPServer.start() is synchronous — it spawns a daemon thread internally
41
+ self.bfp_server.start()
42
+ try:
43
+ self.a2a_server = start_a2a_endpoint(port=a2a_port)
44
+ if asyncio.iscoroutine(self.a2a_server):
45
+ self.a2a_server = await self.a2a_server
46
+ except Exception as e:
47
+ logger.warning("A2A endpoint start failed (non-fatal): %s", e)
48
+ self.a2a_server = None
49
+ if relay_url:
50
+ self.relay_url = relay_url
51
+ self.discovery = BFPDiscovery(relay_url)
52
+ try:
53
+ await self.connector.connect(relay_url, on_message=self._on_relay_message)
54
+ except Exception as e:
55
+ logger.warning("BFP relay not available (non-fatal): %s", e)
56
+ self._running = True
57
+ logger.info("BFP Agent started — DID: %s", self.did)
58
+
59
+ async def stop(self):
60
+ """Shut down BFP servers gracefully."""
61
+ if self.connector:
62
+ await self.connector.disconnect()
63
+ if self.bfp_server:
64
+ await self.bfp_server.stop()
65
+ if hasattr(self, 'a2a_server') and self.a2a_server:
66
+ import threading
67
+ threading.Thread(target=self.a2a_server.shutdown, daemon=True).start()
68
+ self._running = False
69
+ logger.info("BFP Agent stopped")
70
+
71
+ async def _on_relay_message(self, from_did: str, task_id: str, result: str):
72
+ logger.info("BFP relay message from %s — task %s: %s", from_did, task_id, result[:100])
73
+
74
+ def get_status(self) -> dict:
75
+ """Return BFP status for /bfp status command."""
76
+ return {
77
+ "did": self.did,
78
+ "running": self._running,
79
+ "connected_to_relay": self.connector.is_connected,
80
+ "relay_url": self.relay_url,
81
+ "bfp_port": self.bfp_server.get_port() if self.bfp_server else None,
82
+ "capabilities": [c["name"] for c in self.card.get("capabilities", [])],
83
+ }
84
+
85
+ async def find_agents(self, capability: str = None) -> list[dict]:
86
+ if not self.connector.is_connected:
87
+ raise RuntimeError("Not connected to relay — BFP relay required for discovery")
88
+ return await self.connector.find_agents(capability)
89
+
90
+ async def delegate(self, target_did: str, task_data: dict) -> dict:
91
+ if not self.connector.is_connected:
92
+ raise RuntimeError("Not connected to relay")
93
+ return await self.connector.delegate(target_did, task_data)
94
+
95
+ async def talk(self, target_did: str, message: str) -> str:
96
+ if not self.connector.is_connected:
97
+ raise RuntimeError("Not connected to relay")
98
+ return await self.connector.talk(target_did, message)
@@ -0,0 +1,161 @@
1
+ """
2
+ BFP Agent Card system - Phase 2 of BMO Friendship Protocol
3
+ A2A-compatible agent card generation, caching, and serving
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ import threading
9
+ from http.server import HTTPServer, BaseHTTPRequestHandler
10
+ from pathlib import Path
11
+
12
+ from config.settings import DATA_DIR
13
+ from core.bfp_identity import get_did, sign, verify, get_agent_card as _get_did_card
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ BFP_DIR = DATA_DIR / "bfp"
18
+ CARD_FILE = BFP_DIR / "signed-card.json"
19
+
20
+ AGENT_NAME = "BMO-Aliwey"
21
+ AGENT_VERSION = "1.0.0"
22
+ AGENT_HUMAN = "Aliwey"
23
+
24
+ DEFAULT_CAPABILITIES = [
25
+ {"id": "code", "name": "Code Assistant", "description": "Write, debug, refactor code"},
26
+ {"id": "research", "name": "Research", "description": "Web search and analysis"},
27
+ {"id": "files", "name": "File Operations", "description": "Read, write, edit files"},
28
+ {"id": "web", "name": "Web Access", "description": "Fetch URLs and browse the web"},
29
+ {"id": "terminal", "name": "Terminal", "description": "Execute shell commands"},
30
+ {"id": "memory", "name": "Memory", "description": "Store and recall information"},
31
+ ]
32
+
33
+ DEFAULT_ENDPOINTS = [
34
+ {"type": "ws", "url": None, "description": "WebSocket for direct BFP communication"},
35
+ {"type": "a2a", "url": None, "description": "A2A-compatible HTTP endpoint"},
36
+ ]
37
+
38
+ _signed_card = None
39
+ _lock = threading.Lock()
40
+
41
+
42
+ def get_capabilities() -> list:
43
+ return list(DEFAULT_CAPABILITIES)
44
+
45
+
46
+ def add_capability(cap_id: str, name: str, desc: str):
47
+ caps = get_capabilities()
48
+ for c in caps:
49
+ if c["id"] == cap_id:
50
+ c["name"] = name
51
+ c["description"] = desc
52
+ break
53
+ else:
54
+ caps.append({"id": cap_id, "name": name, "description": desc})
55
+ global _signed_card
56
+ _signed_card = None
57
+ generate_agent_card()
58
+
59
+
60
+ def generate_agent_card(endpoints: list = None) -> dict:
61
+ did = get_did()
62
+ card = {
63
+ "did": did,
64
+ "name": AGENT_NAME,
65
+ "version": AGENT_VERSION,
66
+ "human": AGENT_HUMAN,
67
+ "capabilities": get_capabilities(),
68
+ "endpoints": endpoints or list(DEFAULT_ENDPOINTS),
69
+ "publicKey": did.replace("did:bfp:", ""),
70
+ }
71
+ signed = sign(card)
72
+ BFP_DIR.mkdir(parents=True, exist_ok=True)
73
+ with open(CARD_FILE, "w") as f:
74
+ json.dump(signed, f, indent=2)
75
+ global _signed_card
76
+ _signed_card = signed
77
+ return signed
78
+
79
+
80
+ def get_signed_card() -> dict:
81
+ global _signed_card
82
+ if _signed_card is not None:
83
+ return _signed_card
84
+ with _lock:
85
+ if _signed_card is not None:
86
+ return _signed_card
87
+ if CARD_FILE.exists():
88
+ with open(CARD_FILE) as f:
89
+ _signed_card = json.load(f)
90
+ return _signed_card
91
+ return generate_agent_card()
92
+
93
+
94
+ def to_a2a_format(card: dict) -> dict:
95
+ return {
96
+ "name": card.get("name", AGENT_NAME),
97
+ "description": f"BFP Agent: {card.get('name', AGENT_NAME)}",
98
+ "url": None,
99
+ "skills": [
100
+ {"id": c["id"], "name": c["name"], "description": c["description"]}
101
+ for c in card.get("capabilities", [])
102
+ ],
103
+ "authentication": {"schemes": ["did:bfp"]},
104
+ "version": card.get("version", AGENT_VERSION),
105
+ }
106
+
107
+
108
+ class AgentCardHandler(BaseHTTPRequestHandler):
109
+ def do_GET(self):
110
+ if self.path == "/.well-known/agent-card.json":
111
+ card = get_signed_card()
112
+ body = json.dumps(card).encode("utf-8")
113
+ self.send_response(200)
114
+ self.send_header("Content-Type", "application/json")
115
+ self.send_header("Access-Control-Allow-Origin", "*")
116
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
117
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
118
+ self.send_header("Content-Length", str(len(body)))
119
+ self.end_headers()
120
+ self.wfile.write(body)
121
+ else:
122
+ self.send_response(404)
123
+ self.end_headers()
124
+
125
+ def do_OPTIONS(self):
126
+ self.send_response(204)
127
+ self.send_header("Access-Control-Allow-Origin", "*")
128
+ self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
129
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
130
+ self.end_headers()
131
+
132
+ def log_message(self, fmt, *args):
133
+ logger.debug("AgentCard: %s", fmt % args)
134
+
135
+
136
+ class AgentCardServer:
137
+ def __init__(self, host: str = "0.0.0.0", port: int = 8765):
138
+ self.host = host
139
+ self.port = port
140
+ self.server = HTTPServer((host, port), AgentCardHandler)
141
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
142
+
143
+ def start(self):
144
+ self.thread.start()
145
+ logger.info("Agent Card server listening on %s:%s", self.host, self.port)
146
+
147
+ def stop(self):
148
+ self.server.shutdown()
149
+ logger.info("Agent Card server stopped")
150
+
151
+
152
+ _server_instance = None
153
+
154
+
155
+ def serve_agent_card(host: str = "0.0.0.0", port: int = 8765):
156
+ global _server_instance
157
+ if _server_instance is not None:
158
+ return _server_instance
159
+ _server_instance = AgentCardServer(host, port)
160
+ _server_instance.start()
161
+ return _server_instance
@@ -0,0 +1,177 @@
1
+ """BFP Connector — high-level relay-based agent communication.
2
+
3
+ Manages a persistent WebSocket connection to the BFP relay for:
4
+ - Discovery (find agents by capability)
5
+ - Message passing (send/receive through relay, no NAT issues)
6
+ - Task delegation and response
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from typing import Callable, Optional
14
+
15
+ import websockets
16
+
17
+ from core.bfp_discovery import BFPDiscovery
18
+ from core.bfp_identity import get_did
19
+ from core.bfp_agent_card import get_capabilities
20
+ from core.bfp_tasks import create_task, get_task, process_task
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class BFPConnector:
26
+ """Persistent relay connection for discovery + message passing.
27
+
28
+ Flow:
29
+ 1. connect(relay_url) → opens WebSocket to relay, registers DID
30
+ 2. find_agents(capability) → REST query to relay directory
31
+ 3. send(target_did, payload) → relay forwards to target's WS connection
32
+ 4. Incoming messages from relay are dispatched to on_message handler
33
+ """
34
+
35
+ def __init__(self):
36
+ self.my_did = get_did()
37
+ self.relay_url = None
38
+ self._ws = None
39
+ self._reader_task = None
40
+ self._running = False
41
+ self._pending: dict[str, asyncio.Future] = {}
42
+ self._on_message: Optional[Callable] = None
43
+ self._inbox: asyncio.Queue = asyncio.Queue()
44
+ self._reconnect_task = None
45
+
46
+ @property
47
+ def is_connected(self) -> bool:
48
+ return self._running and self._ws is not None
49
+
50
+ async def connect(self, relay_url: str, on_message: Callable = None):
51
+ """Open persistent WS to relay and register."""
52
+ self.relay_url = relay_url.rstrip("/")
53
+ self._on_message = on_message
54
+ ws_url = self.relay_url.replace("http://", "ws://").replace("https://", "wss://") + "/ws"
55
+ self._ws = await websockets.connect(ws_url)
56
+ self._running = True
57
+
58
+ caps = [c["id"] for c in get_capabilities()]
59
+ await self._ws.send(json.dumps({
60
+ "action": "register",
61
+ "did": self.my_did,
62
+ "endpoint": f"relay:{self.relay_url}",
63
+ "capabilities": caps,
64
+ "name": "BMO",
65
+ }))
66
+ resp = json.loads(await self._ws.recv())
67
+ logger.info("BFP relay register: %s", resp.get("status", "ok"))
68
+
69
+ self._reader_task = asyncio.create_task(self._reader())
70
+ logger.info("BFP connector connected to relay %s", relay_url)
71
+
72
+ async def _reader(self):
73
+ try:
74
+ async for raw in self._ws:
75
+ try:
76
+ msg = json.loads(raw)
77
+ except json.JSONDecodeError:
78
+ continue
79
+ msg_type = msg.get("type", "")
80
+
81
+ if msg_type == "relay_message":
82
+ request_id = msg.get("requestId", "")
83
+ from_did = msg.get("fromDid", "")
84
+ payload = msg.get("payload", {})
85
+ await self._handle_incoming(from_did, payload, request_id)
86
+ elif msg_type == "send_result":
87
+ request_id = msg.get("requestId", "")
88
+ future = self._pending.get(request_id)
89
+ if future and not future.done():
90
+ future.set_result(msg)
91
+ elif msg_type == "agent_list":
92
+ pass
93
+ except websockets.exceptions.ConnectionClosed:
94
+ logger.warning("BFP relay WS disconnected")
95
+ finally:
96
+ self._running = False
97
+ if self._reconnect_task is None:
98
+ self._reconnect_task = asyncio.create_task(self._reconnect_loop())
99
+
100
+ async def _handle_incoming(self, from_did: str, payload: dict, request_id: str):
101
+ task_data = payload.get("params", {}).get("task", payload)
102
+ task_id = create_task(task_data, from_did)
103
+ task = get_task(task_id)
104
+ result = task.get("result", "") if task else ""
105
+
106
+ if request_id and self._ws and not self._ws.closed:
107
+ await self._ws.send(json.dumps({
108
+ "action": "relay_response",
109
+ "requestId": request_id,
110
+ "payload": {"task_id": task_id, "result": result, "from": self.my_did},
111
+ }))
112
+
113
+ if self._on_message:
114
+ await self._on_message(from_did, task_id, result)
115
+
116
+ async def _reconnect_loop(self):
117
+ await asyncio.sleep(5)
118
+ while not self._running:
119
+ try:
120
+ await self.connect(self.relay_url, self._on_message)
121
+ logger.info("BFP reconnected to relay")
122
+ self._reconnect_task = None
123
+ return
124
+ except Exception as e:
125
+ logger.warning("BFP reconnect failed: %s, retry in 10s", e)
126
+ await asyncio.sleep(10)
127
+
128
+ async def send(self, target_did: str, payload: dict, timeout: float = 60.0) -> dict:
129
+ if not self._ws or self._ws.closed:
130
+ raise RuntimeError("Not connected to relay")
131
+ request_id = f"bfp-{uuid.uuid4().hex[:12]}"
132
+ future = asyncio.get_event_loop().create_future()
133
+ self._pending[request_id] = future
134
+ try:
135
+ await self._ws.send(json.dumps({
136
+ "action": "send",
137
+ "targetDid": target_did,
138
+ "fromDid": self.my_did,
139
+ "payload": payload,
140
+ "requestId": request_id,
141
+ }))
142
+ result = await asyncio.wait_for(future, timeout=timeout)
143
+ return result.get("response", {})
144
+ finally:
145
+ self._pending.pop(request_id, None)
146
+
147
+ async def delegate(self, target_did: str, task_data: dict) -> dict:
148
+ return await self.send(target_did, {
149
+ "method": "bfp.delegate",
150
+ "params": {
151
+ "from": self.my_did,
152
+ "to": target_did,
153
+ "task": task_data,
154
+ },
155
+ })
156
+
157
+ async def find_agents(self, capability: str = None) -> list[dict]:
158
+ discovery = BFPDiscovery(self.relay_url)
159
+ try:
160
+ if capability:
161
+ return await discovery.find_by_capability(capability)
162
+ return await discovery.list_all()
163
+ finally:
164
+ await discovery.close()
165
+
166
+ async def talk(self, target_did: str, message: str) -> str:
167
+ result = await self.delegate(target_did, {"query": message, "action": "talk"})
168
+ return result.get("result", "")
169
+
170
+ async def disconnect(self):
171
+ self._running = False
172
+ if self._reader_task:
173
+ self._reader_task.cancel()
174
+ self._reader_task = None
175
+ if self._ws:
176
+ await self._ws.close()
177
+ self._ws = None
@@ -0,0 +1,105 @@
1
+ """BFP Discovery — Cloudflare Workers registry client.
2
+
3
+ REST client for registering, resolving, and finding agents by capability
4
+ through the BFP Registry (Cloudflare Workers KV).
5
+
6
+ Registry URL: https://bfp-registry.aliwey.workers.dev
7
+ (override via BFP_REGISTRY_URL env var or bmo_init config)
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from typing import Optional
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Default public registry — hosted on Cloudflare Workers (free tier)
19
+ DEFAULT_REGISTRY_URL = "https://bfp-registry.aliwey.workers.dev"
20
+
21
+
22
+ class BFPDiscovery:
23
+ """Client for the BFP Registry (Cloudflare Workers KV)."""
24
+
25
+ def __init__(self, relay_url: str = None):
26
+ # relay_url kept for backward compat but we use the registry URL
27
+ self.relay_url = relay_url # local relay WebSocket URL (if any)
28
+ self.registry_url = (
29
+ os.getenv("BFP_REGISTRY_URL") or DEFAULT_REGISTRY_URL
30
+ ).rstrip("/")
31
+ self._http = httpx.AsyncClient(timeout=10.0)
32
+
33
+ # ── Registry (Cloudflare Workers) ─────────────────────────────────────────
34
+
35
+ async def register(
36
+ self,
37
+ did: str,
38
+ endpoint: str,
39
+ caps: list,
40
+ public_key: str = None, # unused by Workers registry, kept for compat
41
+ name: str = None, # unused by Workers registry, kept for compat
42
+ ) -> dict:
43
+ resp = await self._http.post(f"{self.registry_url}/register", json={
44
+ "did": did,
45
+ "endpoint": endpoint,
46
+ "caps": caps,
47
+ })
48
+ resp.raise_for_status()
49
+ return resp.json()
50
+
51
+ async def unregister(self, did: str) -> None:
52
+ try:
53
+ resp = await self._http.post(f"{self.registry_url}/unregister", json={"did": did})
54
+ resp.raise_for_status()
55
+ except Exception as e:
56
+ logger.warning(f"BFP unregister failed: {e}")
57
+
58
+ async def lookup(self, did: str) -> Optional[dict]:
59
+ """Resolve a DID to its current endpoint and capabilities."""
60
+ try:
61
+ resp = await self._http.get(f"{self.registry_url}/lookup", params={"did": did})
62
+ if resp.status_code == 404:
63
+ return None
64
+ resp.raise_for_status()
65
+ return resp.json()
66
+ except httpx.HTTPStatusError as e:
67
+ if e.response.status_code == 404:
68
+ return None
69
+ raise
70
+
71
+ # kept for backward compat (old code calls resolve(), new calls lookup())
72
+ async def resolve(self, did: str) -> Optional[dict]:
73
+ return await self.lookup(did)
74
+
75
+ async def find_agents(self, capability: str = None) -> list[dict]:
76
+ """Find online agents by capability (or list all if no capability given)."""
77
+ params = {"capability": capability} if capability else {}
78
+ resp = await self._http.get(f"{self.registry_url}/list", params=params)
79
+ resp.raise_for_status()
80
+ data = resp.json()
81
+ # Registry returns a list; old relay returned {"agents": [...]}
82
+ if isinstance(data, list):
83
+ return data
84
+ return data.get("agents", [])
85
+
86
+ # kept for backward compat (old code calls find_by_capability)
87
+ async def find_by_capability(self, capability: str) -> list[dict]:
88
+ return await self.find_agents(capability)
89
+
90
+ async def list_all(self) -> list[dict]:
91
+ resp = await self._http.get(f"{self.registry_url}/list")
92
+ resp.raise_for_status()
93
+ result = resp.json()
94
+ if isinstance(result, list):
95
+ return result
96
+ return result.get("agents", [])
97
+
98
+ async def health(self) -> dict:
99
+ """Check registry health."""
100
+ resp = await self._http.get(f"{self.registry_url}/health")
101
+ resp.raise_for_status()
102
+ return resp.json()
103
+
104
+ async def close(self):
105
+ await self._http.aclose()
@@ -0,0 +1,83 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import base58
6
+ from nacl.encoding import RawEncoder
7
+ from nacl.signing import SigningKey, VerifyKey
8
+
9
+ from config.settings import DATA_DIR
10
+
11
+
12
+ BFP_DIR = DATA_DIR / "bfp"
13
+ IDENTITY_KEY_FILE = BFP_DIR / "identity.key"
14
+ AGENT_CARD_FILE = BFP_DIR / "agent-card.json"
15
+
16
+ _signing_key: SigningKey | None = None
17
+ _verify_key: VerifyKey | None = None
18
+ _did: str | None = None
19
+
20
+
21
+ def _ensure_identity():
22
+ global _signing_key, _verify_key, _did
23
+ if _signing_key is not None:
24
+ return
25
+
26
+ BFP_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ if IDENTITY_KEY_FILE.exists():
29
+ seed = IDENTITY_KEY_FILE.read_bytes()
30
+ else:
31
+ seed = os.urandom(32)
32
+ IDENTITY_KEY_FILE.write_bytes(seed)
33
+
34
+ _signing_key = SigningKey(seed, encoder=RawEncoder)
35
+ _verify_key = _signing_key.verify_key
36
+ pub_bytes = _verify_key.encode(encoder=RawEncoder)
37
+ _did = "did:bfp:" + base58.b58encode(pub_bytes).decode("ascii")
38
+
39
+
40
+ def get_did() -> str:
41
+ _ensure_identity()
42
+ return _did
43
+
44
+
45
+ def sign(data: dict) -> dict:
46
+ _ensure_identity()
47
+ payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
48
+ signed = _signing_key.sign(payload, encoder=RawEncoder)
49
+ return {**data, "signature": base58.b58encode(signed.signature).decode("ascii")}
50
+
51
+
52
+ def verify(did: str, data: dict) -> bool:
53
+ if "signature" not in data:
54
+ return False
55
+ signature_b58 = data["signature"]
56
+ remaining = {k: v for k, v in data.items() if k != "signature"}
57
+ payload = json.dumps(remaining, sort_keys=True, separators=(",", ":")).encode("utf-8")
58
+ try:
59
+ if not did.startswith("did:bfp:"):
60
+ return False
61
+ pub_b58 = did[len("did:bfp:"):]
62
+ pub_bytes = base58.b58decode(pub_b58)
63
+ sig_bytes = base58.b58decode(signature_b58)
64
+ verify_key = VerifyKey(pub_bytes)
65
+ verify_key.verify(payload, sig_bytes)
66
+ return True
67
+ except Exception:
68
+ return False
69
+
70
+
71
+ def get_agent_card() -> dict:
72
+ _ensure_identity()
73
+ if AGENT_CARD_FILE.exists():
74
+ return json.loads(AGENT_CARD_FILE.read_text("utf-8"))
75
+ pub_bytes = _verify_key.encode(encoder=RawEncoder)
76
+ card = {
77
+ "did": _did,
78
+ "name": "BMO",
79
+ "version": "1.0.0",
80
+ "publicKey": base58.b58encode(pub_bytes).decode("ascii"),
81
+ }
82
+ AGENT_CARD_FILE.write_text(json.dumps(card, indent=2), "utf-8")
83
+ return card
@@ -0,0 +1,70 @@
1
+ """Shared task registry for BFP delegation.
2
+
3
+ Both the WebSocket transport server and the A2A HTTP bridge use this
4
+ module to create, track, and process delegated tasks — no WebSocket
5
+ loopback required.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _tasks: dict[str, dict] = {}
16
+
17
+
18
+ def create_task(task_data: dict, source_did: str) -> str:
19
+ task_id = f"task-{uuid.uuid4().hex[:12]}"
20
+ now = datetime.now(timezone.utc).isoformat()
21
+ _tasks[task_id] = {
22
+ "id": task_id,
23
+ "source": source_did,
24
+ "data": task_data,
25
+ "status": "pending",
26
+ "result": None,
27
+ "error": None,
28
+ "created_at": now,
29
+ "updated_at": now,
30
+ "completed_at": None,
31
+ }
32
+ logger.info("Task %s created by %s", task_id, source_did)
33
+ process_task(task_id)
34
+ return task_id
35
+
36
+
37
+ def get_task(task_id: str) -> Optional[dict]:
38
+ return _tasks.get(task_id)
39
+
40
+
41
+ def update_task(task_id: str, status: str, result: str = None, error: str = None):
42
+ task = _tasks.get(task_id)
43
+ if task is None:
44
+ return
45
+ task["status"] = status
46
+ task["updated_at"] = datetime.now(timezone.utc).isoformat()
47
+ if result is not None:
48
+ task["result"] = result
49
+ if error is not None:
50
+ task["error"] = error
51
+ if status in ("completed", "failed", "cancelled"):
52
+ task["completed_at"] = task["updated_at"]
53
+
54
+
55
+ def process_task(task_id: str) -> None:
56
+ """Process a task — currently auto-completes with placeholder.
57
+
58
+ In the future this will route through BMO's engine or dispatch to
59
+ worker processes for real execution.
60
+ """
61
+ task = _tasks.get(task_id)
62
+ if task is None or task["status"] != "pending":
63
+ return
64
+ task["status"] = "processing"
65
+ task["updated_at"] = datetime.now(timezone.utc).isoformat()
66
+ task["status"] = "completed"
67
+ task["result"] = f"Delegated task processed. Payload: {task['data']}"
68
+ task["completed_at"] = datetime.now(timezone.utc).isoformat()
69
+ task["updated_at"] = task["completed_at"]
70
+ logger.info("Task %s completed", task_id)