nowy-sdk 0.1.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.
- nowy_sdk-0.1.0/PKG-INFO +57 -0
- nowy_sdk-0.1.0/README.md +38 -0
- nowy_sdk-0.1.0/nowy_sdk/__init__.py +34 -0
- nowy_sdk-0.1.0/nowy_sdk/auth.py +95 -0
- nowy_sdk-0.1.0/nowy_sdk/client.py +213 -0
- nowy_sdk-0.1.0/nowy_sdk/generated/__init__.py +1 -0
- nowy_sdk-0.1.0/nowy_sdk/generated/capabilities.py +57 -0
- nowy_sdk-0.1.0/nowy_sdk/wire.py +49 -0
- nowy_sdk-0.1.0/nowy_sdk.egg-info/PKG-INFO +57 -0
- nowy_sdk-0.1.0/nowy_sdk.egg-info/SOURCES.txt +13 -0
- nowy_sdk-0.1.0/nowy_sdk.egg-info/dependency_links.txt +1 -0
- nowy_sdk-0.1.0/nowy_sdk.egg-info/requires.txt +3 -0
- nowy_sdk-0.1.0/nowy_sdk.egg-info/top_level.txt +1 -0
- nowy_sdk-0.1.0/pyproject.toml +33 -0
- nowy_sdk-0.1.0/setup.cfg +4 -0
nowy_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nowy-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nowy protocol WebSocket client SDK with generated capability types
|
|
5
|
+
Author: Nowy contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: nowy,websocket,agent,sdk
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: websockets>=13.0
|
|
17
|
+
Requires-Dist: python-ulid>=3.0.0
|
|
18
|
+
Requires-Dist: httpx>=0.28.0
|
|
19
|
+
|
|
20
|
+
# nowy-sdk
|
|
21
|
+
|
|
22
|
+
Python client for the [Nowy](https://github.com/) protocol — WebSocket Node Agent, Supabase auth helpers, and generated capability types.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nowy-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from nowy_sdk import NowyClient
|
|
35
|
+
from nowy_sdk.auth import resolve_auth_token
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
token = resolve_auth_token() # NOWY_EMAIL/PASSWORD in .env
|
|
39
|
+
client = NowyClient()
|
|
40
|
+
await client.connect(auth_token=token)
|
|
41
|
+
req = await client.send_text("hola nowy")
|
|
42
|
+
await client.wait_for_request(req)
|
|
43
|
+
print(client.last_response_text)
|
|
44
|
+
await client.close()
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Types
|
|
50
|
+
|
|
51
|
+
Generated from `specs/capabilities/*.yaml`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from nowy_sdk import ReminderCreateInput, CAPABILITY_IDS
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Regenerate locally: `python scripts/codegen_capabilities.py` (from repo root).
|
nowy_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# nowy-sdk
|
|
2
|
+
|
|
3
|
+
Python client for the [Nowy](https://github.com/) protocol — WebSocket Node Agent, Supabase auth helpers, and generated capability types.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nowy-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from nowy_sdk import NowyClient
|
|
16
|
+
from nowy_sdk.auth import resolve_auth_token
|
|
17
|
+
|
|
18
|
+
async def main():
|
|
19
|
+
token = resolve_auth_token() # NOWY_EMAIL/PASSWORD in .env
|
|
20
|
+
client = NowyClient()
|
|
21
|
+
await client.connect(auth_token=token)
|
|
22
|
+
req = await client.send_text("hola nowy")
|
|
23
|
+
await client.wait_for_request(req)
|
|
24
|
+
print(client.last_response_text)
|
|
25
|
+
await client.close()
|
|
26
|
+
|
|
27
|
+
asyncio.run(main())
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Types
|
|
31
|
+
|
|
32
|
+
Generated from `specs/capabilities/*.yaml`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from nowy_sdk import ReminderCreateInput, CAPABILITY_IDS
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Regenerate locally: `python scripts/codegen_capabilities.py` (from repo root).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Nowy Python SDK — Phase 2."""
|
|
2
|
+
|
|
3
|
+
from nowy_sdk.client import NowyClient
|
|
4
|
+
from nowy_sdk.generated.capabilities import (
|
|
5
|
+
CAPABILITY_IDS,
|
|
6
|
+
CalendarTodayInput,
|
|
7
|
+
CalendarTodayOutput,
|
|
8
|
+
ReminderCreateInput,
|
|
9
|
+
ReminderCreateOutput,
|
|
10
|
+
ReminderListInput,
|
|
11
|
+
ReminderListOutput,
|
|
12
|
+
SearchDocumentInput,
|
|
13
|
+
SearchDocumentOutput,
|
|
14
|
+
)
|
|
15
|
+
from nowy_sdk.wire import DEFAULT_PROTOCOL_VERSION, DEFAULT_WS_PATH, new_request_id, new_runtime_id, wire_message
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = [
|
|
19
|
+
"CAPABILITY_IDS",
|
|
20
|
+
"CalendarTodayInput",
|
|
21
|
+
"CalendarTodayOutput",
|
|
22
|
+
"DEFAULT_PROTOCOL_VERSION",
|
|
23
|
+
"DEFAULT_WS_PATH",
|
|
24
|
+
"NowyClient",
|
|
25
|
+
"ReminderCreateInput",
|
|
26
|
+
"ReminderCreateOutput",
|
|
27
|
+
"ReminderListInput",
|
|
28
|
+
"ReminderListOutput",
|
|
29
|
+
"SearchDocumentInput",
|
|
30
|
+
"SearchDocumentOutput",
|
|
31
|
+
"new_request_id",
|
|
32
|
+
"new_runtime_id",
|
|
33
|
+
"wire_message",
|
|
34
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Resolve a Supabase JWT for SDK smoke tests and scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_dotenv(path: Path) -> dict[str, str]:
|
|
15
|
+
if not path.is_file():
|
|
16
|
+
return {}
|
|
17
|
+
values: dict[str, str] = {}
|
|
18
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
19
|
+
stripped = line.strip()
|
|
20
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
21
|
+
continue
|
|
22
|
+
key, value = stripped.split("=", 1)
|
|
23
|
+
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
24
|
+
return values
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_supabase_config() -> tuple[str, str]:
|
|
28
|
+
client_url = ""
|
|
29
|
+
client_key = ""
|
|
30
|
+
client_json = REPO_ROOT / "clients" / "nowy_app" / ".env.client.json"
|
|
31
|
+
if client_json.is_file():
|
|
32
|
+
data = json.loads(client_json.read_text(encoding="utf-8"))
|
|
33
|
+
client_url = str(data.get("SUPABASE_URL", "")).strip()
|
|
34
|
+
client_key = str(data.get("SUPABASE_ANON_KEY", "")).strip()
|
|
35
|
+
|
|
36
|
+
root_env = _parse_dotenv(REPO_ROOT / ".env")
|
|
37
|
+
url = (
|
|
38
|
+
os.environ.get("SUPABASE_URL", "").strip()
|
|
39
|
+
or root_env.get("SUPABASE_URL", "")
|
|
40
|
+
or client_url
|
|
41
|
+
)
|
|
42
|
+
# Client login must use the same publishable key as Flutter (.env.client.json).
|
|
43
|
+
anon_key = (
|
|
44
|
+
client_key
|
|
45
|
+
or os.environ.get("SUPABASE_ANON_KEY", "").strip()
|
|
46
|
+
or root_env.get("SUPABASE_ANON_KEY", "")
|
|
47
|
+
)
|
|
48
|
+
return url, anon_key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _login_with_password(supabase_url: str, anon_key: str, email: str, password: str) -> str:
|
|
52
|
+
endpoint = f"{supabase_url.rstrip('/')}/auth/v1/token?grant_type=password"
|
|
53
|
+
headers = {
|
|
54
|
+
"apikey": anon_key,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
}
|
|
57
|
+
# Legacy JWT anon keys may be mirrored in Authorization; publishable keys must not.
|
|
58
|
+
if anon_key.startswith("eyJ"):
|
|
59
|
+
headers["Authorization"] = f"Bearer {anon_key}"
|
|
60
|
+
response = httpx.post(
|
|
61
|
+
endpoint,
|
|
62
|
+
headers=headers,
|
|
63
|
+
json={"email": email, "password": password},
|
|
64
|
+
timeout=15.0,
|
|
65
|
+
)
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
detail = response.text
|
|
68
|
+
try:
|
|
69
|
+
detail = response.json().get("error_description") or response.json().get("msg") or detail
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
pass
|
|
72
|
+
raise RuntimeError(f"Supabase login failed ({response.status_code}): {detail}")
|
|
73
|
+
token = response.json().get("access_token")
|
|
74
|
+
if not token:
|
|
75
|
+
raise RuntimeError("Supabase login OK but access_token missing")
|
|
76
|
+
return str(token)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_auth_token() -> str | None:
|
|
80
|
+
"""Return JWT from env, or sign in with NOWY_EMAIL + NOWY_PASSWORD."""
|
|
81
|
+
direct = os.environ.get("NOWY_AUTH_TOKEN", "").strip()
|
|
82
|
+
if direct:
|
|
83
|
+
return direct
|
|
84
|
+
|
|
85
|
+
root_env = _parse_dotenv(REPO_ROOT / ".env")
|
|
86
|
+
email = os.environ.get("NOWY_EMAIL", root_env.get("NOWY_EMAIL", "")).strip()
|
|
87
|
+
password = os.environ.get("NOWY_PASSWORD", root_env.get("NOWY_PASSWORD", "")).strip()
|
|
88
|
+
if not email or not password:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
supabase_url, anon_key = _load_supabase_config()
|
|
92
|
+
if not supabase_url or not anon_key:
|
|
93
|
+
raise RuntimeError("SUPABASE_URL y SUPABASE_ANON_KEY requeridos para login automatico")
|
|
94
|
+
|
|
95
|
+
return _login_with_password(supabase_url, anon_key, email, password)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Minimal async WebSocket client for the Nowy runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
from websockets.asyncio.client import ClientConnection
|
|
12
|
+
|
|
13
|
+
from nowy_sdk.wire import DEFAULT_PROTOCOL_VERSION, new_request_id, new_runtime_id, wire_message
|
|
14
|
+
|
|
15
|
+
MessageHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NowyClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
url: str = "ws://127.0.0.1:8000/nowy/v1/connect",
|
|
23
|
+
client_name: str = "nowy-sdk-python",
|
|
24
|
+
client_version: str = "0.1.0",
|
|
25
|
+
platform: str = "web",
|
|
26
|
+
) -> None:
|
|
27
|
+
self.url = url
|
|
28
|
+
self.client_name = client_name
|
|
29
|
+
self.client_version = client_version
|
|
30
|
+
self.platform = platform
|
|
31
|
+
self._ws: ClientConnection | None = None
|
|
32
|
+
self._recv_task: asyncio.Task[None] | None = None
|
|
33
|
+
self._handlers: list[MessageHandler] = []
|
|
34
|
+
self.runtime_id = new_runtime_id()
|
|
35
|
+
self.session_id: str | None = None
|
|
36
|
+
self.protocol_version = DEFAULT_PROTOCOL_VERSION
|
|
37
|
+
self.heartbeat_interval_ms = 30_000
|
|
38
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
39
|
+
self._ready = asyncio.Event()
|
|
40
|
+
self.last_response_text = ""
|
|
41
|
+
self.last_notification: dict[str, Any] | None = None
|
|
42
|
+
|
|
43
|
+
def on_message(self, handler: MessageHandler) -> None:
|
|
44
|
+
self._handlers.append(handler)
|
|
45
|
+
|
|
46
|
+
async def connect(self, *, auth_token: str | None = None) -> None:
|
|
47
|
+
await self.close()
|
|
48
|
+
self._ws = await websockets.connect(self.url)
|
|
49
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
50
|
+
await self._send(
|
|
51
|
+
wire_message(
|
|
52
|
+
type="protocol.hello",
|
|
53
|
+
payload={
|
|
54
|
+
"protocolVersion": DEFAULT_PROTOCOL_VERSION,
|
|
55
|
+
"supportedVersions": [DEFAULT_PROTOCOL_VERSION],
|
|
56
|
+
"client": {
|
|
57
|
+
"name": self.client_name,
|
|
58
|
+
"version": self.client_version,
|
|
59
|
+
"platform": self.platform,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
welcome = await self._wait_for("protocol.welcome")
|
|
65
|
+
payload = welcome.get("payload", {})
|
|
66
|
+
if payload.get("accepted") is False:
|
|
67
|
+
raise ConnectionError(f"protocol rejected: {payload.get('reason')}")
|
|
68
|
+
self.protocol_version = int(payload.get("negotiatedVersion", DEFAULT_PROTOCOL_VERSION))
|
|
69
|
+
|
|
70
|
+
reg_request_id = new_request_id()
|
|
71
|
+
register_payload: dict[str, Any] = {
|
|
72
|
+
"platform": self.platform,
|
|
73
|
+
"nodeType": self.platform,
|
|
74
|
+
"locale": "es",
|
|
75
|
+
"declaredHardware": ["display"],
|
|
76
|
+
}
|
|
77
|
+
if auth_token:
|
|
78
|
+
register_payload["auth"] = {"token": auth_token}
|
|
79
|
+
await self._send(
|
|
80
|
+
wire_message(
|
|
81
|
+
type="runtime.register",
|
|
82
|
+
protocol_version=self.protocol_version,
|
|
83
|
+
runtime_id=self.runtime_id,
|
|
84
|
+
request_id=reg_request_id,
|
|
85
|
+
correlation_id=reg_request_id,
|
|
86
|
+
payload=register_payload,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
registered = await self._wait_for("runtime.registered", request_id=reg_request_id)
|
|
90
|
+
reg_payload = registered.get("payload", {})
|
|
91
|
+
self.session_id = reg_payload.get("sessionId")
|
|
92
|
+
self.heartbeat_interval_ms = int(reg_payload.get("heartbeatIntervalMs", 30_000))
|
|
93
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
94
|
+
self._ready.set()
|
|
95
|
+
|
|
96
|
+
async def send_text(self, text: str, *, locale: str = "es") -> str:
|
|
97
|
+
await self._ready.wait()
|
|
98
|
+
if not self.session_id:
|
|
99
|
+
raise RuntimeError("not registered")
|
|
100
|
+
request_id = new_request_id()
|
|
101
|
+
await self._send(
|
|
102
|
+
wire_message(
|
|
103
|
+
type="context.input.text",
|
|
104
|
+
protocol_version=self.protocol_version,
|
|
105
|
+
runtime_id=self.runtime_id,
|
|
106
|
+
session_id=self.session_id,
|
|
107
|
+
request_id=request_id,
|
|
108
|
+
correlation_id=request_id,
|
|
109
|
+
payload={"text": text.strip(), "locale": locale},
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return request_id
|
|
113
|
+
|
|
114
|
+
async def wait_for_request(self, request_id: str, *, timeout: float = 60.0) -> dict[str, Any]:
|
|
115
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
116
|
+
|
|
117
|
+
async def on_message(msg: dict[str, Any]) -> None:
|
|
118
|
+
if msg.get("requestId") != request_id:
|
|
119
|
+
return
|
|
120
|
+
if msg.get("type") == "request.completed" and not future.done():
|
|
121
|
+
future.set_result(msg)
|
|
122
|
+
|
|
123
|
+
self.on_message(on_message)
|
|
124
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
125
|
+
|
|
126
|
+
async def close(self) -> None:
|
|
127
|
+
self._ready.clear()
|
|
128
|
+
if self._heartbeat_task:
|
|
129
|
+
self._heartbeat_task.cancel()
|
|
130
|
+
self._heartbeat_task = None
|
|
131
|
+
if self._recv_task:
|
|
132
|
+
self._recv_task.cancel()
|
|
133
|
+
self._recv_task = None
|
|
134
|
+
if self._ws:
|
|
135
|
+
await self._ws.close()
|
|
136
|
+
self._ws = None
|
|
137
|
+
self.session_id = None
|
|
138
|
+
|
|
139
|
+
async def _send(self, message: dict[str, Any]) -> None:
|
|
140
|
+
if not self._ws:
|
|
141
|
+
raise RuntimeError("not connected")
|
|
142
|
+
await self._ws.send(json.dumps(message))
|
|
143
|
+
|
|
144
|
+
async def _recv_loop(self) -> None:
|
|
145
|
+
assert self._ws is not None
|
|
146
|
+
try:
|
|
147
|
+
async for raw in self._ws:
|
|
148
|
+
msg = json.loads(raw)
|
|
149
|
+
self._track_wire_state(msg)
|
|
150
|
+
for handler in self._handlers:
|
|
151
|
+
result = handler(msg)
|
|
152
|
+
if asyncio.iscoroutine(result):
|
|
153
|
+
await result
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
def _track_wire_state(self, msg: dict[str, Any]) -> None:
|
|
158
|
+
msg_type = msg.get("type", "")
|
|
159
|
+
payload = msg.get("payload", {})
|
|
160
|
+
if msg_type in ("response.partial", "response.completed"):
|
|
161
|
+
text = payload.get("text")
|
|
162
|
+
if isinstance(text, str):
|
|
163
|
+
self.last_response_text = text
|
|
164
|
+
if msg_type == "notification.reminder":
|
|
165
|
+
self.last_notification = payload if isinstance(payload, dict) else None
|
|
166
|
+
|
|
167
|
+
async def _heartbeat_loop(self) -> None:
|
|
168
|
+
interval = max(self.heartbeat_interval_ms, 5_000) / 1000
|
|
169
|
+
while True:
|
|
170
|
+
await asyncio.sleep(interval)
|
|
171
|
+
if not self.session_id:
|
|
172
|
+
continue
|
|
173
|
+
await self._send(
|
|
174
|
+
wire_message(
|
|
175
|
+
type="runtime.heartbeat",
|
|
176
|
+
protocol_version=self.protocol_version,
|
|
177
|
+
runtime_id=self.runtime_id,
|
|
178
|
+
session_id=self.session_id,
|
|
179
|
+
payload={},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def _wait_for(
|
|
184
|
+
self,
|
|
185
|
+
message_type: str,
|
|
186
|
+
*,
|
|
187
|
+
request_id: str | None = None,
|
|
188
|
+
timeout: float = 10.0,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
191
|
+
|
|
192
|
+
async def on_message(msg: dict[str, Any]) -> None:
|
|
193
|
+
if request_id is not None and msg.get("requestId") != request_id:
|
|
194
|
+
return
|
|
195
|
+
if msg.get("type") == "protocol.error" and not future.done():
|
|
196
|
+
payload = msg.get("payload", {})
|
|
197
|
+
code = payload.get("code", "PROTOCOL_ERROR")
|
|
198
|
+
message = payload.get("message", "protocol error")
|
|
199
|
+
future.set_exception(ConnectionError(f"{code}: {message}"))
|
|
200
|
+
return
|
|
201
|
+
if msg.get("type") != message_type:
|
|
202
|
+
return
|
|
203
|
+
if not future.done():
|
|
204
|
+
future.set_result(msg)
|
|
205
|
+
|
|
206
|
+
self.on_message(on_message)
|
|
207
|
+
try:
|
|
208
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
209
|
+
except TimeoutError as exc:
|
|
210
|
+
hint = ""
|
|
211
|
+
if message_type == "runtime.registered":
|
|
212
|
+
hint = " (¿auth requerido? exporta NOWY_AUTH_TOKEN con un JWT de Supabase)"
|
|
213
|
+
raise TimeoutError(f"timed out waiting for {message_type}{hint}") from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generated capability types."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""AUTO-GENERATED — do not edit. Run: python scripts/codegen_capabilities.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
class CalendarTodayEventsItem(TypedDict, total=False):
|
|
8
|
+
title: str
|
|
9
|
+
start: str
|
|
10
|
+
end: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ReminderListRemindersItem(TypedDict, total=False):
|
|
14
|
+
id: str
|
|
15
|
+
text: str
|
|
16
|
+
fire_at: str
|
|
17
|
+
status: str
|
|
18
|
+
created_at: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SearchDocumentDocumentsItem(TypedDict, total=False):
|
|
22
|
+
id: str
|
|
23
|
+
title: str
|
|
24
|
+
snippet: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CalendarTodayInput(TypedDict, total=False):
|
|
28
|
+
timezone: str
|
|
29
|
+
|
|
30
|
+
class CalendarTodayOutput(TypedDict, total=False):
|
|
31
|
+
events: list[CalendarTodayEventsItem]
|
|
32
|
+
|
|
33
|
+
class ReminderCreateInput(TypedDict, total=False):
|
|
34
|
+
text: str
|
|
35
|
+
datetime: str
|
|
36
|
+
|
|
37
|
+
class ReminderCreateOutput(TypedDict, total=False):
|
|
38
|
+
reminderId: str
|
|
39
|
+
|
|
40
|
+
class ReminderListInput(TypedDict, total=False):
|
|
41
|
+
status: str
|
|
42
|
+
|
|
43
|
+
class ReminderListOutput(TypedDict, total=False):
|
|
44
|
+
reminders: list[ReminderListRemindersItem]
|
|
45
|
+
|
|
46
|
+
class SearchDocumentInput(TypedDict, total=False):
|
|
47
|
+
query: str
|
|
48
|
+
|
|
49
|
+
class SearchDocumentOutput(TypedDict, total=False):
|
|
50
|
+
documents: list[SearchDocumentDocumentsItem]
|
|
51
|
+
|
|
52
|
+
CAPABILITY_IDS: tuple[str, ...] = (
|
|
53
|
+
"calendar.today",
|
|
54
|
+
"reminder.create",
|
|
55
|
+
"reminder.list",
|
|
56
|
+
"search.document",
|
|
57
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Wire message helpers for the Nowy protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ulid import ULID
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROTOCOL_VERSION = 1
|
|
12
|
+
DEFAULT_WS_PATH = "/nowy/v1/connect"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def new_runtime_id() -> str:
|
|
16
|
+
return f"rt_{ULID()}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def new_request_id() -> str:
|
|
20
|
+
return f"req_{ULID()}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def wire_message(
|
|
24
|
+
*,
|
|
25
|
+
type: str,
|
|
26
|
+
payload: dict[str, Any],
|
|
27
|
+
protocol_version: int = DEFAULT_PROTOCOL_VERSION,
|
|
28
|
+
runtime_id: str | None = None,
|
|
29
|
+
session_id: str | None = None,
|
|
30
|
+
request_id: str | None = None,
|
|
31
|
+
correlation_id: str | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
msg: dict[str, Any] = {
|
|
34
|
+
"messageId": str(uuid.uuid4()),
|
|
35
|
+
"type": type,
|
|
36
|
+
"timestamp": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
|
37
|
+
"payload": payload,
|
|
38
|
+
}
|
|
39
|
+
if type != "protocol.hello":
|
|
40
|
+
msg["protocolVersion"] = protocol_version
|
|
41
|
+
if runtime_id:
|
|
42
|
+
msg["runtimeId"] = runtime_id
|
|
43
|
+
if session_id:
|
|
44
|
+
msg["sessionId"] = session_id
|
|
45
|
+
if request_id:
|
|
46
|
+
msg["requestId"] = request_id
|
|
47
|
+
if correlation_id:
|
|
48
|
+
msg["correlationId"] = correlation_id
|
|
49
|
+
return msg
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nowy-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Nowy protocol WebSocket client SDK with generated capability types
|
|
5
|
+
Author: Nowy contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: nowy,websocket,agent,sdk
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: websockets>=13.0
|
|
17
|
+
Requires-Dist: python-ulid>=3.0.0
|
|
18
|
+
Requires-Dist: httpx>=0.28.0
|
|
19
|
+
|
|
20
|
+
# nowy-sdk
|
|
21
|
+
|
|
22
|
+
Python client for the [Nowy](https://github.com/) protocol — WebSocket Node Agent, Supabase auth helpers, and generated capability types.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install nowy-sdk
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from nowy_sdk import NowyClient
|
|
35
|
+
from nowy_sdk.auth import resolve_auth_token
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
token = resolve_auth_token() # NOWY_EMAIL/PASSWORD in .env
|
|
39
|
+
client = NowyClient()
|
|
40
|
+
await client.connect(auth_token=token)
|
|
41
|
+
req = await client.send_text("hola nowy")
|
|
42
|
+
await client.wait_for_request(req)
|
|
43
|
+
print(client.last_response_text)
|
|
44
|
+
await client.close()
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Types
|
|
50
|
+
|
|
51
|
+
Generated from `specs/capabilities/*.yaml`:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from nowy_sdk import ReminderCreateInput, CAPABILITY_IDS
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Regenerate locally: `python scripts/codegen_capabilities.py` (from repo root).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
nowy_sdk/__init__.py
|
|
4
|
+
nowy_sdk/auth.py
|
|
5
|
+
nowy_sdk/client.py
|
|
6
|
+
nowy_sdk/wire.py
|
|
7
|
+
nowy_sdk.egg-info/PKG-INFO
|
|
8
|
+
nowy_sdk.egg-info/SOURCES.txt
|
|
9
|
+
nowy_sdk.egg-info/dependency_links.txt
|
|
10
|
+
nowy_sdk.egg-info/requires.txt
|
|
11
|
+
nowy_sdk.egg-info/top_level.txt
|
|
12
|
+
nowy_sdk/generated/__init__.py
|
|
13
|
+
nowy_sdk/generated/capabilities.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nowy_sdk
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nowy-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Nowy protocol WebSocket client SDK with generated capability types"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Nowy contributors" }]
|
|
13
|
+
keywords = ["nowy", "websocket", "agent", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"websockets>=13.0",
|
|
24
|
+
"python-ulid>=3.0.0",
|
|
25
|
+
"httpx>=0.28.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
include = ["nowy_sdk*"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
nowy_sdk = ["py.typed"]
|
nowy_sdk-0.1.0/setup.cfg
ADDED