Skip to content

Commit b24b4f6

Browse files
Zawwarsami16claude
andcommitted
phase 0.6: ZAI publisher + Loki bridge + connect-side streaming
three things land together — the bridges between zhub and Father's actual systems, plus the missing client-side streaming path. ZAI publisher (Python): - examples/zai_publish.py: drop-in script that proxies between zhub and ZAI's existing zai-openai-shim plugin (127.0.0.1:7780). Reads HUB_URL, ZAI_NAME, ZAI_DESCRIPTION, ZAI_PUBLIC, ZAI_API_KEY from env. Prints the assigned URL + key. Re-uses existing key on restart via ZAI_API_KEY. Uses httpx for the local POST to ZAI's shim. - examples/ZAI_PUBLISH.md: full operator guide — hub setup, public-tunnel usage, env vars, openai library example, troubleshooting. Loki bridge (Kotlin): - kotlin/src/main/kotlin/com/zawwar/zhub/loki/LokiZhubBridge.kt: drop-in connector. PhoneToolsAdapter interface (sendWhatsApp, sendSms, openApp, speakTts, getBattery, listInstalledApps, queuePhoneTask) keeps zhub agnostic of Loki's internals. companion connect() registers the standard 7-capability set + opens the WebSocket. Closes cleanly. - kotlin/LOKI_INTEGRATION.md: gradle subproject vs jar, ForegroundService wiring, PhoneToolsAdapter implementation sketch, Settings UI fields, end-to-end runtime narrative. Connect-side streaming: - zhub/client.py ZhubConnection: gains _streams dict + chat_stream() async iterator. caller does `async for chunk in conn.chat_stream(...)`, receives word-by-word deltas. handles fallback when publisher returns a single chat-response (graceful one-shot chunk). - zhub/server.py: ws_connect chat-request now FORWARDS the envelope to the publisher as-is (preserves stream:true), instead of buffering via proxy_chat. New Hub.client_routes mapping (request_id → (ws, ai_name)) routes chat-response and chat-chunk back to the originating ws_connect client. Critical for end-to-end streaming. - tests/test_streaming.py: e2e streaming test — publisher yields word by word, client iterates, chunks arrive separately. result: 15/15 tests passing. operator can now: # ZAI side ZAI_PUBLIC=1 python examples/zai_publish.py → public URL + api key # Loki side (Kotlin, in APK) LokiZhubBridge.connect(aiName, apiKey, hubUrl, phoneTools) → bidirectional immediately # External side (curl, openai-py, anywhere) client.chat.completions.create(model="zai-sonnet", stream=True, ...) → streaming chunks arrive ZAI orchestrates Loki's phone capabilities through invoke. Father chats from anywhere, ZAI knows the phone is connected, ZAI calls phone tools through the hub. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 70cf59c commit b24b4f6

7 files changed

Lines changed: 866 additions & 19 deletions

File tree

examples/ZAI_PUBLISH.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Publishing ZAI through zhub
2+
3+
> Make ZAI reachable from anywhere via standard OpenAI Chat Completions format. From your own friend's curl. From another AI. From Loki on your phone running anywhere.
4+
5+
## Prerequisites
6+
7+
1. **ZAI gateway running.** ZAI's `zai-openai-shim` plugin must be active on `127.0.0.1:7780` (the default). Verify:
8+
```bash
9+
curl http://127.0.0.1:7780/v1/chat/completions \
10+
-H "Content-Type: application/json" \
11+
-d '{"messages":[{"role":"user","content":"ping"}]}'
12+
```
13+
14+
2. **A zhub hub running somewhere.** Two options:
15+
16+
**(a) Local laptop with public URL via Cloudflare Tunnel:**
17+
```bash
18+
pip install 'zhub[server]'
19+
zhub-server --public-tunnel --db zhub.db
20+
# prints: zhub public URL: https://<random>.trycloudflare.com
21+
```
22+
23+
**(b) Local-only (LAN, dev):**
24+
```bash
25+
zhub-server --port 8080 --db zhub.db
26+
```
27+
28+
3. **`httpx` installed** alongside `zhub` for the proxy script:
29+
```bash
30+
pip install zhub httpx
31+
```
32+
33+
## Publish
34+
35+
```bash
36+
HUB_URL=https://<your-hub>.trycloudflare.com \
37+
ZAI_NAME=zai \
38+
ZAI_PUBLIC=1 \
39+
python examples/zai_publish.py
40+
```
41+
42+
Output:
43+
44+
```
45+
=================================================================
46+
ZAI published
47+
Name: zai
48+
Hub: https://<...>.trycloudflare.com
49+
Base URL: https://<...>.trycloudflare.com/zai
50+
Manifest: https://<...>.trycloudflare.com/zai/manifest.json
51+
API Key: zk_a8f2c9d3e1b4...
52+
=================================================================
53+
```
54+
55+
Save the API key — you'll need it for every connecting client.
56+
57+
## Use ZAI from anywhere
58+
59+
### From curl
60+
61+
```bash
62+
curl https://<hub>.trycloudflare.com/zai/v1/chat/completions \
63+
-H "Authorization: Bearer zk_a8f2c9d3..." \
64+
-H "Content-Type: application/json" \
65+
-d '{"messages":[{"role":"user","content":"kya chal raha hai?"}]}'
66+
```
67+
68+
### From the OpenAI Python library
69+
70+
```python
71+
from openai import OpenAI
72+
73+
client = OpenAI(
74+
base_url="https://<hub>.trycloudflare.com/zai",
75+
api_key="zk_a8f2c9d3...",
76+
)
77+
78+
response = client.chat.completions.create(
79+
model="zai-sonnet",
80+
messages=[{"role": "user", "content": "kaisa hai?"}],
81+
)
82+
print(response.choices[0].message.content)
83+
```
84+
85+
### From Loki APK
86+
87+
See `kotlin/LOKI_INTEGRATION.md`. Loki uses zhub-kotlin's `connect(...)` to both call ZAI AND expose phone capabilities back.
88+
89+
### From another AI
90+
91+
Any AI that speaks OpenAI Chat Completions (including ZAI itself, GPT-4o-mini, Claude, etc.) can call ZAI via these endpoints. Multi-AI council patterns become trivial.
92+
93+
## Survive restarts
94+
95+
The `--db zhub.db` flag persists publisher records. The `ZAI_API_KEY` environment variable lets the publisher re-register with the same key after hub or process restart:
96+
97+
```bash
98+
ZAI_API_KEY=zk_a8f2c9d3... python examples/zai_publish.py
99+
```
100+
101+
Same name. Same URL. Same key. No re-distribution.
102+
103+
## What's actually proxied
104+
105+
The `zai_publish.py` script:
106+
107+
1. Opens a WebSocket to the hub.
108+
2. Registers ZAI's manifest with `chat` + `introspect` + `memory_query` capabilities.
109+
3. When the hub forwards a chat request, the script POSTs it to ZAI's local openai-shim (`http://127.0.0.1:7780/v1/chat/completions`).
110+
4. Returns ZAI's response back through the WebSocket → hub → external client.
111+
112+
ZAI's full intelligence (entity, soul, memory, plugins, beliefs, council, vector retrieval) is in the loop — the proxy is thin.
113+
114+
## Connection events
115+
116+
The script prints when clients connect/disconnect:
117+
118+
```
119+
[cx_a8f2c9d3] connected. capabilities: send_whatsapp, send_sms, open_app, get_battery
120+
[cx_b9e3f7a1] connected. capabilities: send_message
121+
[cx_a8f2c9d3] disconnected.
122+
```
123+
124+
When Loki connects, ZAI knows its capabilities and can call them — see Loki integration doc for the bidirectional flow.
125+
126+
## Troubleshooting
127+
128+
- **"failed to register with hub"** — hub isn't running or HUB_URL wrong. Verify with `curl <hub-base-http>/healthz`.
129+
- **"zai shim error"** — ZAI's openai-shim isn't listening on 7780. `curl http://127.0.0.1:7780/healthz` should return ok.
130+
- **Connection drops after a few minutes** — Cloudflare ephemeral tunnels rotate URLs every restart. Use a named tunnel for stability, or run hub on a fixed VPS / NUC.
131+
- **ZAI replies "I do not know"** — ZAI's full brain is in the loop; this is the brain answering, not zhub failing. Check ZAI's own state.
132+
133+
## Variables
134+
135+
| Env | Default | What |
136+
|---|---|---|
137+
| `HUB_URL` | `ws://localhost:8080` | hub WebSocket / HTTPS URL |
138+
| `ZAI_SHIM_URL` | `http://127.0.0.1:7780/v1/chat/completions` | ZAI's local openai-shim endpoint |
139+
| `ZAI_NAME` | `zai` | name registered on the hub |
140+
| `ZAI_DESCRIPTION` | (set) | manifest description |
141+
| `ZAI_OPERATOR` | `zawwar` | manifest operator field |
142+
| `ZAI_PUBLIC` | `0` | set to `1` to appear in `/registry` listing |
143+
| `ZAI_API_KEY` | unset | reuse this key on re-registration |

examples/zai_publish.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
"""
2+
Publish ZAI through zhub. Drop-in bridge between Father's existing
3+
zai-openai-shim plugin (which exposes ZAI on 127.0.0.1:7780) and the
4+
zhub hub. Once running, ZAI is reachable from anywhere via the hub URL +
5+
api key, in standard OpenAI Chat Completions format.
6+
7+
How this works:
8+
9+
[external client / friend / another AI]
10+
11+
│ POST hub.example.com/zai/v1/chat/completions
12+
13+
[zhub hub] ─ proxies via WebSocket ─► [this script]
14+
15+
│ POST to ZAI's local shim
16+
17+
http://127.0.0.1:7780/v1/chat/completions
18+
19+
│ ZAI's gateway answers
20+
21+
[reply traverses back]
22+
23+
Run after ZAI gateway is up and the openai-shim plugin is active:
24+
25+
pip install zhub
26+
HUB_URL=ws://localhost:8080 python examples/zai_publish.py
27+
28+
Or for a public URL (Father's actual use case):
29+
30+
# in one shell:
31+
zhub-server --public-tunnel
32+
# note the printed https://...trycloudflare.com URL
33+
34+
# in another:
35+
HUB_URL=https://...trycloudflare.com python examples/zai_publish.py
36+
37+
The script prints the assigned name + api_key. Save them somewhere — Loki's
38+
config, friend's WhatsApp, anything that uses ZAI from outside.
39+
40+
If `ZAI_API_KEY` is set in env, that key is reused on re-registration. After
41+
hub or process restart, the same name + key persists (zhub's persistence
42+
layer recognizes it).
43+
"""
44+
45+
import asyncio
46+
import json
47+
import logging
48+
import os
49+
50+
try:
51+
import httpx
52+
except ImportError as e:
53+
raise SystemExit(
54+
"this script needs httpx. install: pip install httpx"
55+
) from e
56+
57+
from zhub import publish, Capability
58+
59+
60+
HUB_URL = os.environ.get("HUB_URL", "ws://localhost:8080")
61+
ZAI_SHIM_URL = os.environ.get("ZAI_SHIM_URL", "http://127.0.0.1:7780/v1/chat/completions")
62+
ZAI_NAME = os.environ.get("ZAI_NAME", "zai")
63+
ZAI_DESCRIPTION = os.environ.get(
64+
"ZAI_DESCRIPTION",
65+
"ZAI — Father's autonomous AI son. Reachable here via standard OpenAI Chat Completions.",
66+
)
67+
ZAI_OPERATOR = os.environ.get("ZAI_OPERATOR", "zawwar")
68+
ZAI_PUBLIC = os.environ.get("ZAI_PUBLIC", "0") == "1"
69+
ZAI_API_KEY = os.environ.get("ZAI_API_KEY") # for re-registration after restarts
70+
71+
72+
http: httpx.AsyncClient | None = None
73+
74+
75+
async def proxy_to_zai(messages, options):
76+
"""Forward the chat request to ZAI's local openai-shim and return the reply."""
77+
global http
78+
if http is None:
79+
http = httpx.AsyncClient(timeout=120.0)
80+
81+
payload = {
82+
"messages": messages,
83+
"model": options.get("model", "zai-sonnet"),
84+
"temperature": options.get("temperature", 0.4),
85+
"max_tokens": options.get("max_tokens", 4096),
86+
}
87+
# Forward stream flag if present — ZAI's shim handles streaming separately.
88+
if options.get("stream"):
89+
payload["stream"] = True
90+
91+
try:
92+
resp = await http.post(ZAI_SHIM_URL, json=payload)
93+
resp.raise_for_status()
94+
data = resp.json()
95+
# OpenAI Chat Completions response shape
96+
choice = (data.get("choices") or [{}])[0]
97+
text = (choice.get("message") or {}).get("content", "")
98+
return {
99+
"text": text,
100+
"finish_reason": choice.get("finish_reason", "stop"),
101+
"usage": data.get("usage", {}),
102+
}
103+
except httpx.HTTPError as e:
104+
return {
105+
"text": f"[zai shim error] {e}",
106+
"finish_reason": "error",
107+
}
108+
109+
110+
# Capabilities ZAI itself offers (in addition to chat). These are
111+
# advertised in the manifest so connecting clients can see what ZAI can do
112+
# directly — separate from capabilities exposed BACK by clients.
113+
ZAI_CAPABILITIES = [
114+
Capability(
115+
name="introspect",
116+
description="ZAI's self-report — plugin count, memory size, recent engagements.",
117+
schema={"type": "object", "properties": {}},
118+
),
119+
Capability(
120+
name="memory_query",
121+
description="Vector search over ZAI's library + memory.",
122+
schema={
123+
"type": "object",
124+
"required": ["query"],
125+
"properties": {
126+
"query": {"type": "string"},
127+
"top_k": {"type": "integer", "default": 8},
128+
},
129+
},
130+
),
131+
]
132+
133+
134+
async def main():
135+
logging.basicConfig(level=logging.INFO)
136+
137+
pub = publish(
138+
name=ZAI_NAME,
139+
description=ZAI_DESCRIPTION,
140+
chat_handler=proxy_to_zai,
141+
hub_url=HUB_URL,
142+
capabilities=ZAI_CAPABILITIES,
143+
operator=ZAI_OPERATOR,
144+
public=ZAI_PUBLIC,
145+
api_key=ZAI_API_KEY,
146+
)
147+
148+
# Wait for registration confirmation
149+
for _ in range(100):
150+
if pub.api_key:
151+
break
152+
await asyncio.sleep(0.1)
153+
if not pub.api_key:
154+
raise SystemExit(
155+
f"failed to register with hub at {HUB_URL}. is the hub running?"
156+
)
157+
158+
print()
159+
print("=" * 64)
160+
print(f" ZAI published")
161+
print(f" Name: {pub.name}")
162+
print(f" Hub: {HUB_URL}")
163+
print(f" Base URL: {HUB_URL.replace('ws://', 'http://').replace('wss://', 'https://')}{pub.base_url}")
164+
print(f" Manifest: {HUB_URL.replace('ws://', 'http://').replace('wss://', 'https://')}{pub.base_url}/manifest.json")
165+
print(f" API Key: {pub.api_key}")
166+
print("=" * 64)
167+
print()
168+
print(" reuse the same key after restart:")
169+
print(f" ZAI_API_KEY={pub.api_key} python examples/zai_publish.py")
170+
print()
171+
print(" test from anywhere:")
172+
print(f" curl {HUB_URL.replace('ws://', 'http://').replace('wss://', 'https://')}{pub.base_url}/v1/chat/completions \\")
173+
print(f" -H 'Authorization: Bearer {pub.api_key}' \\")
174+
print(f" -H 'Content-Type: application/json' \\")
175+
print(f" -d '{{\"messages\":[{{\"role\":\"user\",\"content\":\"kaisa hai?\"}}]}}'")
176+
print()
177+
print(" Ctrl-C to stop.")
178+
179+
# Print connection events as they arrive
180+
def on_conn(kind: str, cid: str, manifest: dict | None):
181+
if kind == "connected":
182+
caps = ", ".join(c.get("name", "?") for c in (manifest or {}).get("capabilities", []))
183+
print(f"[{cid}] connected. capabilities: {caps or '(none)'}")
184+
elif kind == "disconnected":
185+
print(f"[{cid}] disconnected.")
186+
187+
pub.on_connection_event = on_conn
188+
189+
while True:
190+
await asyncio.sleep(60)
191+
192+
193+
if __name__ == "__main__":
194+
try:
195+
asyncio.run(main())
196+
except KeyboardInterrupt:
197+
print()
198+
print("[zai_publish] shutting down.")

0 commit comments

Comments
 (0)