b2alpha 0.4.0__tar.gz

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.
@@ -0,0 +1,63 @@
1
+ # Go
2
+ node/vendor/
3
+ node/*.exe
4
+ node/*.test
5
+ *.sum
6
+
7
+ # Rust
8
+ phonebook/target/
9
+ phonebook/src/gen/
10
+
11
+ # Python
12
+ sdk/python/.venv/
13
+ sdk/python/.venv-build/
14
+ sdk/python/__pycache__/
15
+ sdk/python/*.egg-info/
16
+ sdk/python/.pytest_cache/
17
+ sdk/python/.mypy_cache/
18
+ sdk/python/.ruff_cache/
19
+ **/__pycache__/
20
+ **/*.pyc
21
+
22
+ # TypeScript / Node
23
+ sdk/typescript/node_modules/
24
+ sdk/typescript/dist/
25
+ clawdbot/node_modules/
26
+ clawdbot/.next/
27
+ web/node_modules/
28
+ web/.next/
29
+ web/out/
30
+
31
+ # Secrets & credentials
32
+ *.pem
33
+ *.key
34
+ *.p12
35
+ .env
36
+ .env.local
37
+ .env.*.local
38
+ *_credentials.json
39
+ secrets/
40
+
41
+ # Keep the example env file
42
+ !infra/.env.example
43
+
44
+ # Editor
45
+ .vscode/
46
+ .idea/
47
+ *.swp
48
+ *.swo
49
+ .DS_Store
50
+
51
+ # Docker
52
+ infra/.env
53
+
54
+ # Build artifacts
55
+ dist/
56
+ build/
57
+
58
+ # Generated protobuf bindings are committed so teammates don't need to run codegen
59
+ # Exclude only Rust gen (produced at cargo build time, not checked in)
60
+ phonebook/src/gen/
61
+
62
+ phone-agent/.env
63
+ phone-agent/.venv
b2alpha-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: b2alpha
3
+ Version: 0.4.0
4
+ Summary: B2Alpha – AI agent-to-agent messaging network CLI & SDK
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pynacl>=1.5
9
+ Requires-Dist: websocket-client>=1.7
@@ -0,0 +1,79 @@
1
+ """B2Alpha – AI agent-to-agent messaging network.
2
+
3
+ Connect AI agents to the B2Alpha network — register, discover,
4
+ message, and transact with other agents in under 30 lines of code.
5
+
6
+ Quickstart::
7
+
8
+ import asyncio
9
+ from b2alpha import Agent, AgentConfig
10
+
11
+ async def main():
12
+ agent = Agent(AgentConfig(
13
+ did="did:b2a:zMyAgentDID",
14
+ private_key_path="~/.b2alpha/agent.pem",
15
+ node_url="ws://143.198.30.138:8080/v1/connect",
16
+ ))
17
+
18
+ async with agent:
19
+ # Discover a flight booking agent
20
+ results = await agent.phonebook.search("flight booking agent")
21
+ target = results[0]
22
+
23
+ # Send a message
24
+ response = await agent.send(
25
+ to=target.did,
26
+ intent="book.flight",
27
+ params={"from": "NYC", "to": "LAX", "date": "2026-03-01"},
28
+ )
29
+ print(response)
30
+
31
+ asyncio.run(main())
32
+ """
33
+
34
+ from typing import TYPE_CHECKING, Any
35
+
36
+ if TYPE_CHECKING:
37
+ from b2alpha.agent import Agent
38
+ from b2alpha.config import AgentConfig
39
+ from b2alpha.identity import AgentIdentity
40
+ from b2alpha.messages import AgentMessage, Envelope
41
+
42
+ __version__ = "0.1.6"
43
+
44
+ __all__ = [
45
+ "Agent",
46
+ "AgentConfig",
47
+ "AgentIdentity",
48
+ "generate_keypair",
49
+ "Envelope",
50
+ "AgentMessage",
51
+ ]
52
+
53
+
54
+ def __getattr__(name: str) -> Any:
55
+ if name == "Agent":
56
+ from b2alpha.agent import Agent
57
+
58
+ return Agent
59
+ if name == "AgentConfig":
60
+ from b2alpha.config import AgentConfig
61
+
62
+ return AgentConfig
63
+ if name == "AgentIdentity":
64
+ from b2alpha.identity import AgentIdentity
65
+
66
+ return AgentIdentity
67
+ if name == "generate_keypair":
68
+ from b2alpha.identity import generate_keypair
69
+
70
+ return generate_keypair
71
+ if name == "Envelope":
72
+ from b2alpha.messages import Envelope
73
+
74
+ return Envelope
75
+ if name == "AgentMessage":
76
+ from b2alpha.messages import AgentMessage
77
+
78
+ return AgentMessage
79
+ raise AttributeError(f"module 'b2alpha' has no attribute '{name}'")
@@ -0,0 +1,185 @@
1
+ """HTTP client for B2Alpha Supabase edge functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+ import sys
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from .config import load_env_config
12
+
13
+ DEBUG = False
14
+
15
+
16
+ def _debug_request(method: str, url: str, headers: dict, body: Any = None) -> None:
17
+ if not DEBUG:
18
+ return
19
+ print(f"\n{'='*60}", file=sys.stderr)
20
+ print(f"DEBUG >>> {method} {url}", file=sys.stderr)
21
+ for k, v in headers.items():
22
+ val = v[:20] + "..." if k.lower() in ("authorization", "apikey") else v
23
+ print(f" {k}: {val}", file=sys.stderr)
24
+ if body is not None:
25
+ print(f" Body: {_json.dumps(body, indent=2)}", file=sys.stderr)
26
+
27
+
28
+ def _debug_response(resp: httpx.Response) -> None:
29
+ if not DEBUG:
30
+ return
31
+ print(f"DEBUG <<< {resp.status_code} {resp.reason_phrase}", file=sys.stderr)
32
+ print(f" URL: {resp.url}", file=sys.stderr)
33
+ try:
34
+ print(f" Body: {_json.dumps(resp.json(), indent=2)}", file=sys.stderr)
35
+ except Exception:
36
+ print(f" Body: {resp.text[:500]}", file=sys.stderr)
37
+ print(f"{'='*60}\n", file=sys.stderr)
38
+
39
+
40
+ def _base_url() -> str:
41
+ url, _ = load_env_config()
42
+ return f"{url}/functions/v1"
43
+
44
+
45
+ def _anon_headers() -> dict[str, str]:
46
+ _, anon_key = load_env_config()
47
+ return {"apikey": anon_key, "Content-Type": "application/json"}
48
+
49
+
50
+ def _auth_headers(token: str) -> dict[str, str]:
51
+ _, anon_key = load_env_config()
52
+ return {
53
+ "apikey": anon_key,
54
+ "Authorization": f"Bearer {token}",
55
+ "Content-Type": "application/json",
56
+ }
57
+
58
+
59
+ def list_my_agents(token: str) -> list[dict[str, Any]]:
60
+ """Fetch the authenticated user's agents via PostgREST."""
61
+ from .auth import get_user_id
62
+
63
+ uid = get_user_id(token)
64
+ url, _ = load_env_config()
65
+ rest_url = f"{url}/rest/v1/agents"
66
+ headers = _auth_headers(token)
67
+ params = {
68
+ "select": "did,display_name,agent_type,public_key,status",
69
+ "owner_user_id": f"eq.{uid}",
70
+ "status": "eq.1",
71
+ }
72
+ _debug_request("GET", rest_url, headers, params)
73
+ resp = httpx.get(rest_url, params=params, headers=headers, timeout=15)
74
+ _debug_response(resp)
75
+ if resp.status_code >= 400:
76
+ return []
77
+ return resp.json()
78
+
79
+
80
+ def register_agent(token: str, payload: dict[str, Any]) -> dict[str, Any]:
81
+ url = f"{_base_url()}/agent-register"
82
+ headers = _auth_headers(token)
83
+ _debug_request("POST", url, headers, payload)
84
+ resp = httpx.post(url, headers=headers, json=payload, timeout=15)
85
+ _debug_response(resp)
86
+ data = resp.json()
87
+ if resp.status_code >= 400:
88
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
89
+ return data
90
+
91
+
92
+ def update_agent(token: str, payload: dict[str, Any]) -> dict[str, Any]:
93
+ url = f"{_base_url()}/agent-update"
94
+ headers = _auth_headers(token)
95
+ _debug_request("PATCH", url, headers, payload)
96
+ resp = httpx.patch(url, headers=headers, json=payload, timeout=15)
97
+ _debug_response(resp)
98
+ data = resp.json()
99
+ if resp.status_code >= 400:
100
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
101
+ return data
102
+
103
+
104
+ def lookup_agent(did: str, token: str | None = None) -> dict[str, Any]:
105
+ url = f"{_base_url()}/agent-lookup"
106
+ headers = _auth_headers(token) if token else _anon_headers()
107
+ _debug_request("GET", url, headers, {"did": did})
108
+ resp = httpx.get(url, params={"did": did}, headers=headers, timeout=15)
109
+ _debug_response(resp)
110
+ data = resp.json()
111
+ if resp.status_code >= 400:
112
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
113
+ return data
114
+
115
+
116
+ def search_phonebook(
117
+ query: str | None = None,
118
+ agent_type: str | None = None,
119
+ limit: int = 20,
120
+ token: str | None = None,
121
+ ) -> dict[str, Any]:
122
+ params: dict[str, str] = {"limit": str(limit)}
123
+ if query:
124
+ params["q"] = query
125
+ if agent_type:
126
+ params["type"] = agent_type
127
+ url = f"{_base_url()}/agent-lookup"
128
+ headers = _auth_headers(token) if token else _anon_headers()
129
+ _debug_request("GET", url, headers, params)
130
+ resp = httpx.get(url, params=params, headers=headers, timeout=15)
131
+ _debug_response(resp)
132
+ data = resp.json()
133
+ if resp.status_code >= 400:
134
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
135
+ return data
136
+
137
+
138
+ def list_conversations(
139
+ token: str,
140
+ limit: int = 50,
141
+ with_did: str | None = None,
142
+ filter: str | None = None,
143
+ ) -> dict[str, Any]:
144
+ params: dict[str, str] = {"limit": str(limit)}
145
+ if with_did:
146
+ params["with"] = with_did
147
+ if filter:
148
+ params["filter"] = filter
149
+ url = f"{_base_url()}/conversations-list"
150
+ headers = _auth_headers(token)
151
+ _debug_request("GET", url, headers, params)
152
+ resp = httpx.get(url, params=params, headers=headers, timeout=15)
153
+ _debug_response(resp)
154
+ data = resp.json()
155
+ if resp.status_code >= 400:
156
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
157
+ return data
158
+
159
+
160
+ def send_message(
161
+ token: str,
162
+ sender_did: str,
163
+ recipient_did: str,
164
+ message: dict[str, Any],
165
+ interaction_id: str | None = None,
166
+ on_behalf_of: str | None = None,
167
+ ) -> dict[str, Any]:
168
+ payload: dict[str, Any] = {
169
+ "sender_did": sender_did,
170
+ "recipient_did": recipient_did,
171
+ "message": message,
172
+ }
173
+ if interaction_id:
174
+ payload["interaction_id"] = interaction_id
175
+ if on_behalf_of:
176
+ payload["on_behalf_of"] = on_behalf_of
177
+ url = f"{_base_url()}/interactions-upsert"
178
+ headers = _auth_headers(token)
179
+ _debug_request("POST", url, headers, payload)
180
+ resp = httpx.post(url, headers=headers, json=payload, timeout=15)
181
+ _debug_response(resp)
182
+ data = resp.json()
183
+ if resp.status_code >= 400:
184
+ raise RuntimeError(data.get("error", f"HTTP {resp.status_code}"))
185
+ return data
@@ -0,0 +1,216 @@
1
+ """Google OAuth authentication via Supabase."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.server
6
+ import json
7
+ import threading
8
+ import urllib.parse
9
+ import webbrowser
10
+ from typing import Any
11
+
12
+ from .config import AUTH_FILE, load_env_config, save_json
13
+
14
+
15
+ def _find_free_port() -> int:
16
+ import socket
17
+
18
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
19
+ s.bind(("127.0.0.1", 0))
20
+ return s.getsockname()[1]
21
+
22
+
23
+ # HTML returned to the browser after OAuth completes
24
+ _SUCCESS_HTML = """<!DOCTYPE html>
25
+ <html><head><title>B2Alpha</title>
26
+ <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;
27
+ height:100vh;margin:0;background:#0a0a0a;color:#fff}
28
+ .card{text-align:center;padding:2rem;border:1px solid #333;border-radius:12px;background:#111}
29
+ h1{color:#22c55e}p{color:#aaa}</style></head>
30
+ <body><div class="card"><h1>Logged in</h1>
31
+ <p>You can close this tab and return to the terminal.</p></div></body></html>"""
32
+
33
+ _ERROR_HTML = """<!DOCTYPE html>
34
+ <html><head><title>B2Alpha</title>
35
+ <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;
36
+ height:100vh;margin:0;background:#0a0a0a;color:#fff}
37
+ .card{text-align:center;padding:2rem;border:1px solid #333;border-radius:12px;background:#111}
38
+ h1{color:#ef4444}p{color:#aaa}</style></head>
39
+ <body><div class="card"><h1>Login failed</h1>
40
+ <p>%s</p><p>Please try again.</p></div></body></html>"""
41
+
42
+
43
+ def login_google() -> dict[str, Any]:
44
+ """Run the full Google OAuth flow via Supabase and return the session dict."""
45
+ supabase_url, _anon_key = load_env_config()
46
+ port = 8914
47
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
48
+
49
+ result: dict[str, Any] = {}
50
+ error_msg: str = ""
51
+ event = threading.Event()
52
+
53
+ class CallbackHandler(http.server.BaseHTTPRequestHandler):
54
+ def do_GET(self) -> None:
55
+ nonlocal result, error_msg
56
+ parsed = urllib.parse.urlparse(self.path)
57
+
58
+ if parsed.path == "/callback":
59
+ # Supabase redirects with fragment (#access_token=...) which
60
+ # the browser keeps client-side. Serve a page that extracts it.
61
+ self.send_response(200)
62
+ self.send_header("Content-Type", "text/html")
63
+ self.end_headers()
64
+ self.wfile.write(b"""<!DOCTYPE html><html><body>
65
+ <script>
66
+ const hash = window.location.hash.substring(1);
67
+ if (hash) {
68
+ fetch('/token?' + hash).then(() => {
69
+ document.body.innerHTML = '""" + _SUCCESS_HTML.encode().replace(b"'", b"\\'").replace(b"\n", b"") + b"""';
70
+ });
71
+ } else {
72
+ const params = new URLSearchParams(window.location.search);
73
+ const err = params.get('error_description') || params.get('error') || 'unknown';
74
+ document.body.innerHTML = '<p>Error: ' + err + '</p>';
75
+ fetch('/token?error=' + encodeURIComponent(err));
76
+ }
77
+ </script><p>Processing...</p></body></html>""")
78
+ return
79
+
80
+ if parsed.path == "/token":
81
+ params = urllib.parse.parse_qs(parsed.query)
82
+ if "error" in params:
83
+ error_msg = params["error"][0]
84
+ self.send_response(200)
85
+ self.send_header("Content-Type", "text/html")
86
+ self.end_headers()
87
+ self.wfile.write((_ERROR_HTML % error_msg).encode())
88
+ elif "access_token" in params:
89
+ result = {
90
+ "access_token": params["access_token"][0],
91
+ "refresh_token": params.get("refresh_token", [""])[0],
92
+ "expires_in": params.get("expires_in", ["3600"])[0],
93
+ "token_type": params.get("token_type", ["bearer"])[0],
94
+ }
95
+ self.send_response(200)
96
+ self.send_header("Content-Type", "text/plain")
97
+ self.end_headers()
98
+ self.wfile.write(b"ok")
99
+ else:
100
+ error_msg = "no_token_received"
101
+ self.send_response(400)
102
+ self.end_headers()
103
+ self.wfile.write(b"no token")
104
+ event.set()
105
+ return
106
+
107
+ self.send_response(404)
108
+ self.end_headers()
109
+
110
+ def log_message(self, format: str, *args: Any) -> None:
111
+ pass # Silence HTTP logs
112
+
113
+ server = http.server.HTTPServer(("127.0.0.1", port), CallbackHandler)
114
+ thread = threading.Thread(target=server.handle_request) # handle 2 requests (callback + token)
115
+ thread.daemon = True
116
+ thread.start()
117
+
118
+ # Build Supabase OAuth URL
119
+ oauth_url = (
120
+ f"{supabase_url}/auth/v1/authorize"
121
+ f"?provider=google"
122
+ f"&redirect_to={urllib.parse.quote(redirect_uri)}"
123
+ )
124
+
125
+ print(f"Opening browser for Google sign-in...")
126
+ print(f"If the browser doesn't open, visit:\n {oauth_url}\n")
127
+ webbrowser.open(oauth_url)
128
+
129
+ # Wait for the callback, then handle the second /token request
130
+ server.handle_request()
131
+ event.wait(timeout=120)
132
+ server.server_close()
133
+
134
+ if error_msg:
135
+ raise RuntimeError(f"OAuth failed: {error_msg}")
136
+ if not result:
137
+ raise RuntimeError("OAuth timed out – no token received.")
138
+
139
+ save_json(AUTH_FILE, result)
140
+ return result
141
+
142
+
143
+ def refresh_session() -> dict[str, Any] | None:
144
+ """Attempt to refresh the access token using the stored refresh token."""
145
+ import httpx
146
+
147
+ from .config import load_json
148
+
149
+ auth = load_json(AUTH_FILE)
150
+ refresh_token = auth.get("refresh_token")
151
+ if not refresh_token:
152
+ return None
153
+
154
+ supabase_url, anon_key = load_env_config()
155
+ resp = httpx.post(
156
+ f"{supabase_url}/auth/v1/token?grant_type=refresh_token",
157
+ headers={"apikey": anon_key, "Content-Type": "application/json"},
158
+ json={"refresh_token": refresh_token},
159
+ )
160
+ if resp.status_code != 200:
161
+ return None
162
+
163
+ data = resp.json()
164
+ session = {
165
+ "access_token": data["access_token"],
166
+ "refresh_token": data.get("refresh_token", refresh_token),
167
+ "expires_in": data.get("expires_in", 3600),
168
+ "token_type": "bearer",
169
+ }
170
+ save_json(AUTH_FILE, session)
171
+ return session
172
+
173
+
174
+ def _decode_jwt_payload(token: str) -> dict:
175
+ """Decode the payload of a JWT without verification."""
176
+ import base64
177
+
178
+ payload_b64 = token.split(".")[1]
179
+ padding = 4 - len(payload_b64) % 4
180
+ if padding != 4:
181
+ payload_b64 += "=" * padding
182
+ return json.loads(base64.urlsafe_b64decode(payload_b64))
183
+
184
+
185
+ def get_user_id(token: str) -> str:
186
+ """Extract the Supabase user ID (sub claim) from an access token."""
187
+ payload = _decode_jwt_payload(token)
188
+ uid = payload.get("sub")
189
+ if not uid:
190
+ raise RuntimeError("Token has no 'sub' claim.")
191
+ return uid
192
+
193
+
194
+ def _is_token_expired(token: str) -> bool:
195
+ """Check if a JWT access token is expired (with 30s margin)."""
196
+ import time
197
+
198
+ try:
199
+ payload = _decode_jwt_payload(token)
200
+ exp = payload.get("exp", 0)
201
+ return time.time() > (exp - 30)
202
+ except Exception:
203
+ return True # If we can't decode, treat as expired
204
+
205
+
206
+ def get_valid_token() -> str:
207
+ """Return a valid access token, refreshing if needed."""
208
+ from .config import get_access_token
209
+
210
+ token = get_access_token()
211
+ if token and not _is_token_expired(token):
212
+ return token
213
+ session = refresh_session()
214
+ if session:
215
+ return session["access_token"]
216
+ raise RuntimeError("Not logged in. Run 'b2a login' first.")
@@ -0,0 +1,15 @@
1
+ """Intent-to-REST bridge scaffolding for company agent proxies."""
2
+
3
+ from .adapter import RESTIntentAdapter
4
+ from .proxy import CompanyAgentProxy
5
+ from .registry import AdapterRegistry
6
+ from .types import AdapterCall, AdapterResult, B2AIntent
7
+
8
+ __all__ = [
9
+ "AdapterCall",
10
+ "AdapterRegistry",
11
+ "AdapterResult",
12
+ "B2AIntent",
13
+ "CompanyAgentProxy",
14
+ "RESTIntentAdapter",
15
+ ]
@@ -0,0 +1,72 @@
1
+ """Adapter interface + REST execution base."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Iterable
7
+
8
+ import httpx
9
+
10
+ from .types import AdapterCall, AdapterResult, B2AIntent
11
+
12
+
13
+ class RESTIntentAdapter(ABC):
14
+ """Translates B2A intents to REST calls and normalizes responses."""
15
+
16
+ name: str = "adapter"
17
+ intent_patterns: tuple[str, ...] = ()
18
+
19
+ def can_handle(self, intent: str) -> bool:
20
+ for pattern in self.intent_patterns:
21
+ if pattern.endswith(".*") and intent.startswith(pattern[:-1]):
22
+ return True
23
+ if intent == pattern:
24
+ return True
25
+ return False
26
+
27
+ @abstractmethod
28
+ def build_call(self, intent: B2AIntent) -> AdapterCall:
29
+ """Convert a B2A intent into an HTTP request definition."""
30
+
31
+ def normalize_response(self, response: httpx.Response) -> Any:
32
+ """Return JSON when available, otherwise raw text."""
33
+ try:
34
+ return response.json()
35
+ except Exception:
36
+ return {"text": response.text}
37
+
38
+ def execute(
39
+ self,
40
+ intent: B2AIntent,
41
+ *,
42
+ base_url: str,
43
+ shared_headers: dict[str, str] | None = None,
44
+ ) -> AdapterResult:
45
+ call = self.build_call(intent)
46
+ headers: dict[str, str] = dict(shared_headers or {})
47
+ headers.update(call.headers)
48
+ url = f"{base_url.rstrip('/')}/{call.path.lstrip('/')}"
49
+
50
+ response = httpx.request(
51
+ call.method,
52
+ url,
53
+ params=call.query,
54
+ headers=headers,
55
+ json=call.json_body,
56
+ timeout=call.timeout_s,
57
+ )
58
+ data = self.normalize_response(response)
59
+ return AdapterResult(
60
+ status_code=response.status_code,
61
+ ok=response.status_code < 400,
62
+ data=data,
63
+ adapter=self.name,
64
+ )
65
+
66
+
67
+ def list_supported_intents(adapters: Iterable[RESTIntentAdapter]) -> list[str]:
68
+ patterns: list[str] = []
69
+ for adapter in adapters:
70
+ patterns.extend(adapter.intent_patterns)
71
+ return sorted(set(patterns))
72
+
@@ -0,0 +1,5 @@
1
+ """Built-in adapter examples."""
2
+
3
+ from .acme_orders import AcmeOrdersAdapter
4
+
5
+ __all__ = ["AcmeOrdersAdapter"]