Skip to content

Commit 21c1796

Browse files
Zawwarsami16claude
andcommitted
phase 0.3: zhub-kotlin — JVM/Android client library
connect-side library for Loki + any JVM client. wire-protocol-compatible with the Python core. drop-in for Loki's existing Compose + foreground- service architecture. components: - kotlin/src/main/kotlin/com/zawwar/zhub/Manifest.kt Manifest + Capability data classes via kotlinx-serialization. Same shape as Python — round-trips through json on either side. - kotlin/src/main/kotlin/com/zawwar/zhub/Protocol.kt Envelope + helpers (registerConnectionEnvelope, chatRequestEnvelope, invokeResultEnvelope). UUID-based request ids. - kotlin/src/main/kotlin/com/zawwar/zhub/Errors.kt ZhubException hierarchy — Auth, Connection, Manifest, Capability, Hub. - kotlin/src/main/kotlin/com/zawwar/zhub/Connection.kt ZhubConnection class + connect() top-level. OkHttp WebSocket. ConcurrentHashMap for pending requests, CompletableDeferred for await semantics. SharedFlow< ConnectionEvent> for lifecycle observation. CapabilityHandler is suspend fun (JsonObject) -> JsonObject — fits async natively. - kotlin/src/test/kotlin/com/zawwar/zhub/ProtocolTest.kt Envelope + manifest + chat-request + invoke-result round-trips. - kotlin/build.gradle.kts Kotlin 2.0, JVM 17 target, kotlinx-{serialization,coroutines}, OkHttp. maven-publish set up — `./gradlew jar` produces a flat artifact. - kotlin/settings.gradle.kts: zhub-kotlin module name. - kotlin/README.md Loki integration sketch — register from ForegroundService, expose send_whatsapp/sms/open_app/speak_tts/get_battery handlers, AI invokes through hub. Same usage shape as the Python connect side. with this: - Loki APK can drop kotlin/ as a Gradle subproject (or jar dependency) - ZAI publishes via Python zhub.publish() - Loki connects via zhub-kotlin, exposes phone capabilities - Father, from Telegram (or any chat), asks ZAI to do phone things - ZAI invokes Loki's capabilities via the hub - end-to-end killer scenario unlocked Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 89e0a33 commit 21c1796

8 files changed

Lines changed: 585 additions & 0 deletions

File tree

kotlin/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# zhub-kotlin
2+
3+
Kotlin/JVM client library for [zhub](https://github.com/Zawwarsami16/zhub). Drop-in for Android (Loki) and any JVM project that needs to **connect** to a published AI and expose capabilities back to it bidirectionally.
4+
5+
This is the connect-side mirror of the Python `zhub.connect()` API. Same wire protocol, same envelope schema, same hub server.
6+
7+
## Add to your project
8+
9+
In your Android / JVM module's `build.gradle.kts`:
10+
11+
```kotlin
12+
dependencies {
13+
// Until published to Maven Central, add as a Gradle subproject.
14+
// From the zhub repo: kotlin/build.gradle.kts
15+
implementation(project(":zhub"))
16+
17+
// Required transitive deps (already in Loki):
18+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
19+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
20+
implementation("com.squareup.okhttp3:okhttp:4.12.0")
21+
}
22+
```
23+
24+
## Usage
25+
26+
```kotlin
27+
import com.zawwar.zhub.connect
28+
import com.zawwar.zhub.CapabilityHandler
29+
import kotlinx.serialization.json.JsonObject
30+
import kotlinx.serialization.json.JsonPrimitive
31+
import kotlinx.serialization.json.buildJsonObject
32+
import kotlinx.coroutines.runBlocking
33+
34+
val sendWhatsApp: CapabilityHandler = { args ->
35+
// Your real implementation — talk to phone bridge, etc.
36+
buildJsonObject {
37+
put("delivered", JsonPrimitive(true))
38+
put("to", args["to"] ?: JsonPrimitive("?"))
39+
}
40+
}
41+
42+
val whatsappSchema = buildJsonObject {
43+
put("type", JsonPrimitive("object"))
44+
put("required", kotlinx.serialization.json.JsonArray(listOf(JsonPrimitive("to"), JsonPrimitive("message"))))
45+
put("properties", buildJsonObject {
46+
put("to", buildJsonObject { put("type", JsonPrimitive("string")) })
47+
put("message", buildJsonObject { put("type", JsonPrimitive("string")) })
48+
})
49+
}
50+
51+
fun main() = runBlocking {
52+
val conn = connect(
53+
aiName = "zai",
54+
apiKey = "zk_a8f2c9d3...",
55+
hubUrl = "https://hub.example.com",
56+
description = "Loki — Father's phone bridge",
57+
operator = "zawwar",
58+
capabilities = mapOf(
59+
"send_whatsapp" to (whatsappSchema to sendWhatsApp),
60+
),
61+
)
62+
63+
// Talk to the AI from this client.
64+
val reply = conn.chat(messages = listOf(
65+
mapOf("role" to "user", "content" to "kya chal raha hai?"),
66+
))
67+
println("ZAI: $reply")
68+
69+
// The AI can now invoke `send_whatsapp` through the hub at any time.
70+
// We just keep the connection alive — the WebSocket handles dispatch.
71+
}
72+
```
73+
74+
## Loki integration sketch
75+
76+
Inside Loki's existing Compose app, register the connection in your `ForegroundService` (so the WebSocket survives across activity recreations):
77+
78+
```kotlin
79+
class LokiZhubBridgeService : Service() {
80+
private var conn: ZhubConnection? = null
81+
82+
override fun onCreate() {
83+
super.onCreate()
84+
val cfg = readZaiEndpointConfig(this) // your existing settings store
85+
conn = connect(
86+
aiName = cfg.aiName,
87+
apiKey = cfg.apiKey,
88+
hubUrl = cfg.hubUrl,
89+
capabilities = mapOf(
90+
"send_whatsapp" to (whatsappSchema to ::handleWhatsApp),
91+
"send_sms" to (smsSchema to ::handleSms),
92+
"open_app" to (openAppSchema to ::handleOpenApp),
93+
"speak_tts" to (ttsSchema to ::handleTts),
94+
"get_battery" to (batterySchema to ::handleBattery),
95+
),
96+
)
97+
}
98+
99+
override fun onDestroy() {
100+
conn?.close()
101+
super.onDestroy()
102+
}
103+
}
104+
```
105+
106+
The handlers reuse Loki's existing tool-dispatch — just adapt them to take `JsonObject` args and return `JsonObject` results.
107+
108+
## Build + test
109+
110+
```bash
111+
cd kotlin
112+
./gradlew test
113+
./gradlew jar
114+
```
115+
116+
The output jar at `kotlin/build/libs/zhub-0.1.0.jar` can be dropped into any JVM project as a flat dependency.
117+
118+
## Status
119+
120+
Phase 0.3 — published with the Python core. Wire-protocol-compatible. Tests for envelope + manifest serialization. Real WebSocket lifecycle. No publish() side yet (an AI typically runs Python; the Kotlin lib is connect-only by design — but the symmetry is one weekend of work if you want a Kotlin AI to publish itself).

kotlin/build.gradle.kts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
plugins {
2+
kotlin("jvm") version "2.0.0"
3+
kotlin("plugin.serialization") version "2.0.0"
4+
`java-library`
5+
`maven-publish`
6+
}
7+
8+
group = "com.zawwar"
9+
version = "0.1.0"
10+
11+
java {
12+
sourceCompatibility = JavaVersion.VERSION_17
13+
targetCompatibility = JavaVersion.VERSION_17
14+
}
15+
16+
kotlin {
17+
jvmToolchain(17)
18+
}
19+
20+
repositories {
21+
mavenCentral()
22+
}
23+
24+
dependencies {
25+
// Coroutines for async handlers + WS dispatch
26+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
27+
// JSON wire format
28+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
29+
// OkHttp for WebSocket transport
30+
implementation("com.squareup.okhttp3:okhttp:4.12.0")
31+
32+
testImplementation(kotlin("test"))
33+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
34+
}
35+
36+
tasks.test {
37+
useJUnitPlatform()
38+
}
39+
40+
publishing {
41+
publications {
42+
create<MavenPublication>("maven") {
43+
from(components["java"])
44+
artifactId = "zhub"
45+
}
46+
}
47+
}

kotlin/settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rootProject.name = "zhub-kotlin"
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package com.zawwar.zhub
2+
3+
import kotlinx.coroutines.*
4+
import kotlinx.coroutines.flow.MutableSharedFlow
5+
import kotlinx.coroutines.flow.SharedFlow
6+
import kotlinx.coroutines.flow.asSharedFlow
7+
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.JsonPrimitive
9+
import kotlinx.serialization.json.buildJsonObject
10+
import kotlinx.serialization.json.jsonPrimitive
11+
import okhttp3.*
12+
import java.util.concurrent.ConcurrentHashMap
13+
import java.util.concurrent.TimeUnit
14+
15+
/**
16+
* Capability handler. Receives a JSON-shaped args object, returns a JSON-shaped result.
17+
* Suspending so the implementation can await native APIs (e.g., Termux IPC, intent
18+
* dispatch on Android, network calls, etc).
19+
*/
20+
typealias CapabilityHandler = suspend (JsonObject) -> JsonObject
21+
22+
/**
23+
* Bidirectional zhub connection from a client (Loki, Telegram bot, web chat, ...)
24+
* to a published AI. Connects to `<hubUrl>/ws/connect`, registers the client's
25+
* capability manifest, then:
26+
* - Allows the client to call `chat(...)` on the AI through the hub.
27+
* - Listens for invoke-request envelopes and dispatches them to registered handlers.
28+
*
29+
* Lifecycle:
30+
* val conn = ZhubConnection.connect(...)
31+
* conn.events.collect { ... } // optional: observe lifecycle
32+
* val response = conn.chat(messages) // talk to the AI
33+
* conn.close() // graceful shutdown
34+
*/
35+
class ZhubConnection private constructor(
36+
val aiName: String,
37+
val apiKey: String,
38+
val hubUrl: String,
39+
val clientManifest: Manifest,
40+
private val capabilities: Map<String, Pair<JsonObject, CapabilityHandler>>,
41+
private val scope: CoroutineScope,
42+
) {
43+
private val client = OkHttpClient.Builder()
44+
.pingInterval(20, TimeUnit.SECONDS)
45+
.readTimeout(0, TimeUnit.MILLISECONDS)
46+
.build()
47+
48+
private var webSocket: WebSocket? = null
49+
private val pending = ConcurrentHashMap<String, CompletableDeferred<JsonObject>>()
50+
private val _events = MutableSharedFlow<ConnectionEvent>(replay = 0, extraBufferCapacity = 64)
51+
val events: SharedFlow<ConnectionEvent> = _events.asSharedFlow()
52+
53+
private fun start() {
54+
val wsUrl = toWsUrl(hubUrl, "/ws/connect")
55+
val request = Request.Builder().url(wsUrl).build()
56+
webSocket = client.newWebSocket(request, object : WebSocketListener() {
57+
override fun onOpen(ws: WebSocket, response: Response) {
58+
ws.send(registerConnectionEnvelope(aiName, apiKey, clientManifest).toJson())
59+
scope.launch { _events.emit(ConnectionEvent.Opened) }
60+
}
61+
62+
override fun onMessage(ws: WebSocket, text: String) {
63+
scope.launch { handleMessage(ws, text) }
64+
}
65+
66+
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) {
67+
scope.launch { _events.emit(ConnectionEvent.Failed(t)) }
68+
}
69+
70+
override fun onClosing(ws: WebSocket, code: Int, reason: String) {
71+
ws.close(code, reason)
72+
}
73+
74+
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
75+
scope.launch { _events.emit(ConnectionEvent.Closed(code, reason)) }
76+
}
77+
})
78+
}
79+
80+
private suspend fun handleMessage(ws: WebSocket, text: String) {
81+
val env = try {
82+
Envelope.fromJson(text)
83+
} catch (t: Throwable) {
84+
_events.emit(ConnectionEvent.MalformedPayload(text, t))
85+
return
86+
}
87+
when (env.type) {
88+
"registered" -> _events.emit(ConnectionEvent.Registered)
89+
"chat-response" -> {
90+
pending.remove(env.request_id)?.complete(env.payload)
91+
}
92+
"invoke-request" -> {
93+
val capability = env.payload["capability"]?.jsonPrimitive?.content ?: return
94+
val args = env.payload["args"] as? JsonObject ?: JsonObject(emptyMap())
95+
val handler = capabilities[capability]?.second
96+
if (handler == null) {
97+
ws.send(invokeResultEnvelope(env.request_id, false, error = "capability '$capability' not exposed").toJson())
98+
return
99+
}
100+
try {
101+
val result = handler(args)
102+
ws.send(invokeResultEnvelope(env.request_id, true, result = result).toJson())
103+
} catch (t: Throwable) {
104+
ws.send(invokeResultEnvelope(env.request_id, false, error = t.message ?: t.javaClass.simpleName).toJson())
105+
}
106+
}
107+
"error" -> {
108+
_events.emit(ConnectionEvent.HubError(env.payload.toString()))
109+
val code = env.payload["code"]?.jsonPrimitive?.content
110+
if (code == "register_failed") {
111+
pending.values.forEach { it.completeExceptionally(AuthException(env.payload.toString())) }
112+
pending.clear()
113+
}
114+
}
115+
"ping" -> {
116+
ws.send(Envelope(type = "pong", request_id = env.request_id).toJson())
117+
}
118+
}
119+
}
120+
121+
/** Send a chat request to the AI through the hub. Suspending. Times out at [timeoutMs]. */
122+
suspend fun chat(
123+
messages: List<Map<String, String>>,
124+
model: String = "default",
125+
temperature: Double = 0.4,
126+
maxTokens: Int = 4096,
127+
timeoutMs: Long = 60_000,
128+
): String = withTimeout(timeoutMs) {
129+
val ws = webSocket ?: throw ZhubConnectionException("not connected")
130+
val env = chatRequestEnvelope(messages, model, temperature, maxTokens)
131+
val deferred = CompletableDeferred<JsonObject>()
132+
pending[env.request_id] = deferred
133+
try {
134+
ws.send(env.toJson())
135+
val payload = deferred.await()
136+
payload["text"]?.jsonPrimitive?.content ?: ""
137+
} finally {
138+
pending.remove(env.request_id)
139+
}
140+
}
141+
142+
fun close() {
143+
webSocket?.close(1000, "client closing")
144+
client.dispatcher.executorService.shutdown()
145+
}
146+
147+
sealed class ConnectionEvent {
148+
object Opened : ConnectionEvent()
149+
object Registered : ConnectionEvent()
150+
data class Closed(val code: Int, val reason: String) : ConnectionEvent()
151+
data class Failed(val cause: Throwable) : ConnectionEvent()
152+
data class HubError(val payload: String) : ConnectionEvent()
153+
data class MalformedPayload(val raw: String, val cause: Throwable) : ConnectionEvent()
154+
}
155+
156+
companion object {
157+
/**
158+
* Connect a client to a published AI and expose capabilities back to it.
159+
*
160+
* @param aiName The AI's registered name on the hub.
161+
* @param apiKey The bearer key returned to the publisher at register time.
162+
* @param hubUrl Hub URL (http://, https://, ws://, or wss://).
163+
* @param description Human-readable description of this client.
164+
* @param operator Who runs this client.
165+
* @param capabilities Map of capabilityName -> (jsonSchemaForArgs, handler).
166+
* @param scope Coroutine scope. Defaults to Dispatchers.Default + a SupervisorJob.
167+
*/
168+
fun connect(
169+
aiName: String,
170+
apiKey: String,
171+
hubUrl: String = "ws://localhost:8080",
172+
description: String = "",
173+
operator: String = "",
174+
capabilities: Map<String, Pair<JsonObject, CapabilityHandler>>,
175+
scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
176+
): ZhubConnection {
177+
val capList = capabilities.map { (name, pair) ->
178+
Capability(name = name, description = "", schema = pair.first)
179+
}
180+
val manifest = Manifest(
181+
name = "$aiName-client",
182+
description = description.ifEmpty { "client of $aiName" },
183+
operator = operator,
184+
capabilities = capList,
185+
)
186+
val conn = ZhubConnection(
187+
aiName = aiName,
188+
apiKey = apiKey,
189+
hubUrl = hubUrl,
190+
clientManifest = manifest,
191+
capabilities = capabilities,
192+
scope = scope,
193+
)
194+
conn.start()
195+
return conn
196+
}
197+
198+
private fun toWsUrl(input: String, path: String): String {
199+
val replaced = when {
200+
input.startsWith("https://") -> "wss://" + input.removePrefix("https://")
201+
input.startsWith("http://") -> "ws://" + input.removePrefix("http://")
202+
else -> input
203+
}
204+
return if (replaced.endsWith("/")) replaced + path.removePrefix("/") else replaced + path
205+
}
206+
}
207+
}
208+
209+
/** Convenience top-level for parity with Python's `from zhub import connect`. */
210+
fun connect(
211+
aiName: String,
212+
apiKey: String,
213+
hubUrl: String = "ws://localhost:8080",
214+
description: String = "",
215+
operator: String = "",
216+
capabilities: Map<String, Pair<JsonObject, CapabilityHandler>>,
217+
scope: CoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()),
218+
): ZhubConnection = ZhubConnection.connect(aiName, apiKey, hubUrl, description, operator, capabilities, scope)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.zawwar.zhub
2+
3+
open class ZhubException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
4+
class AuthException(message: String) : ZhubException(message)
5+
class ZhubConnectionException(message: String, cause: Throwable? = null) : ZhubException(message, cause)
6+
class ManifestException(message: String) : ZhubException(message)
7+
class CapabilityException(message: String) : ZhubException(message)
8+
class HubException(message: String) : ZhubException(message)

0 commit comments

Comments
 (0)