commit 8b4cb8eace8647bfea7e82a83c9bb2f2ce15887b Author: bill Date: Tue Jun 30 15:12:58 2026 -0600 Initialize Meatbag SDUI platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57439ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +build/ +**/build/ + +.idea/ +*.iml + +.DS_Store + +local.properties +*.log diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..168f5b4 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + kotlin("jvm") + id("io.ktor.plugin") +} + +group = "meatbag" +version = "1.0-SNAPSHOT" + +application { + mainClass.set("meatbag.backend.ApplicationKt") +} + +dependencies { + implementation(project(":common")) + implementation("io.ktor:ktor-server-core-jvm:2.3.11") + implementation("io.ktor:ktor-server-netty-jvm:2.3.11") + implementation("io.ktor:ktor-server-websockets-jvm:2.3.11") + implementation("io.ktor:ktor-server-content-negotiation-jvm:2.3.11") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.11") + implementation("io.ktor:ktor-server-cors-jvm:2.3.11") + implementation("io.ktor:ktor-server-call-logging-jvm:2.3.11") + implementation("ch.qos.logback:logback-classic:1.4.14") + + // Test dependencies including Ktor client plugins for testApplication + testImplementation("io.ktor:ktor-server-tests-jvm:2.3.11") + testImplementation("io.ktor:ktor-client-content-negotiation-jvm:2.3.11") + testImplementation("io.ktor:ktor-client-websockets-jvm:2.3.11") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23") +} diff --git a/backend/src/main/kotlin/meatbag/backend/Application.kt b/backend/src/main/kotlin/meatbag/backend/Application.kt new file mode 100644 index 0000000..af2a7c4 --- /dev/null +++ b/backend/src/main/kotlin/meatbag/backend/Application.kt @@ -0,0 +1,145 @@ +package meatbag.backend + +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.callloging.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.plugins.cors.routing.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.websocket.* +import io.ktor.websocket.* +import meatbag.common.* +import java.time.Duration + +fun main() { + embeddedServer(Netty, port = 8080, host = "0.0.0.0", module = Application::module).start(wait = true) +} + +fun Application.module() { + install(CallLogging) + + install(CORS) { + anyHost() + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Get) + } + + install(WebSockets) { + pingPeriod = Duration.ofSeconds(15) + timeout = Duration.ofSeconds(15) + maxFrameSize = Long.MAX_VALUE + masking = false + } + + install(ContentNegotiation) { + json(SDUIJson.json) + } + + val deviceRegistry = DeviceRegistry() + val routingEngine = RoutingEngine(deviceRegistry) + + routing { + // Client WebSocket connection endpoint + webSocket("/ws/connect") { + val deviceId = call.parameters["deviceId"] ?: java.util.UUID.randomUUID().toString() + val deviceTypeStr = call.parameters["deviceType"] ?: "DESKTOP" + val deviceType = try { + DeviceType.valueOf(deviceTypeStr.uppercase()) + } catch (e: IllegalArgumentException) { + DeviceType.DESKTOP + } + + val device = ActiveDevice(deviceId, deviceType, this) + deviceRegistry.register(device) + println("[WebSocket] Device registered: ID=$deviceId, Type=$deviceType") + + try { + for (frame in incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + println("[WebSocket] Message from device $deviceId: $text") + } + } + } catch (e: Exception) { + println("[WebSocket] Connection error or close for device $deviceId: ${e.localizedMessage}") + } finally { + deviceRegistry.unregister(deviceId) + println("[WebSocket] Device unregistered: ID=$deviceId") + } + } + + // Endpoint for receiving responses from clients + post("/api/respond") { + val response = try { + call.receive() + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, "Invalid Response body structure: ${e.localizedMessage}") + return@post + } + + val matchedPendingRequest = routingEngine.handleResponse(response) + call.respond( + HttpStatusCode.OK, + mapOf( + "status" to "Response processed", + "matchedPendingRequest" to matchedPendingRequest + ) + ) + } + + // Endpoint for receiving requests from agents + post("/api/request") { + val rawJson = call.receiveText() + val uiPrimitive = try { + SDUIJson.decodeFromString(rawJson) + } catch (e: Exception) { + call.respond(HttpStatusCode.BadRequest, "Invalid UI Primitive format: ${e.localizedMessage}") + return@post + } + + try { + val approvalResult = routingEngine.routeRequest( + AgentRequestContext( + uiPrimitive = uiPrimitive, + urgent = call.request.queryParameters["urgent"]?.toBoolean() ?: false, + requestedAttention = parseAttentionTier(call.request.queryParameters["attentionTier"]), + preferredDeviceTypes = parseDeviceTypes(call.request.queryParameters["deviceTypes"]) + ) + ) + call.respond(HttpStatusCode.OK, approvalResult) + } catch (e: IllegalStateException) { + call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage)) + } catch (e: Exception) { + call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage)) + } + } + + // Diagnostic / status endpoint + get("/api/status") { + val devices = deviceRegistry.getAllDevices().map { mapOf("id" to it.id, "type" to it.type.name) } + call.respond(mapOf("status" to "OK", "connectedDevices" to devices)) + } + } +} + +private fun parseAttentionTier(value: String?): AttentionTier { + return value + ?.let { runCatching { AttentionTier.valueOf(it.uppercase()) }.getOrNull() } + ?: AttentionTier.BACKGROUND_NOTIFICATION +} + +private fun parseDeviceTypes(value: String?): List { + return value + ?.split(",") + ?.mapNotNull { rawType -> runCatching { DeviceType.valueOf(rawType.trim().uppercase()) }.getOrNull() } + ?.distinct() + ?: emptyList() +} diff --git a/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt b/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt new file mode 100644 index 0000000..c3e6c7f --- /dev/null +++ b/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt @@ -0,0 +1,31 @@ +package meatbag.backend + +import java.util.concurrent.ConcurrentHashMap + +class DeviceRegistry { + private val devices = ConcurrentHashMap() + + fun register(device: ActiveDevice) { + devices[device.id] = device + } + + fun unregister(deviceId: String) { + devices.remove(deviceId) + } + + fun getDevice(deviceId: String): ActiveDevice? { + return devices[deviceId] + } + + fun getAllDevices(): List { + return devices.values.toList() + } + + fun getDevicesByType(deviceTypes: Set): List { + if (deviceTypes.isEmpty()) return emptyList() + + return devices.values + .filter { it.type in deviceTypes } + .sortedWith(compareBy { it.type.ordinal }.thenBy { it.id }) + } +} diff --git a/backend/src/main/kotlin/meatbag/backend/Models.kt b/backend/src/main/kotlin/meatbag/backend/Models.kt new file mode 100644 index 0000000..c076839 --- /dev/null +++ b/backend/src/main/kotlin/meatbag/backend/Models.kt @@ -0,0 +1,53 @@ +package meatbag.backend + +import io.ktor.websocket.* +import meatbag.common.AttentionTier +import meatbag.common.UiPrimitive + +enum class DeviceType { + WEAR_OS_WATCH, + PHONE, + DESKTOP +} + +data class ActiveDevice( + val id: String, + val type: DeviceType, + val session: WebSocketSession +) + +data class AgentRequestContext( + val uiPrimitive: UiPrimitive, + val requestedAttention: AttentionTier = AttentionTier.BACKGROUND_NOTIFICATION, + val urgent: Boolean = false, + val preferredDeviceTypes: List = emptyList() +) + +data class UserRuleSet( + val id: String = "mock-default", + val criticalKeywords: Set = setOf( + "rm -rf", + "delete", + "destroy", + "kill", + "urgent", + "critical", + "sudo", + "permission", + "allow" + ), + val watchFirstForSilentHaptic: Boolean = true, + val fanOutOverrideScreen: Boolean = true +) + +data class RoutingDecision( + val attentionTier: AttentionTier, + val deviceTypes: List, + val fanOut: Boolean = false +) + +data class DeliveryResult( + val requestId: String, + val attentionTier: AttentionTier, + val deviceIds: List +) diff --git a/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt b/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt new file mode 100644 index 0000000..2a3cf78 --- /dev/null +++ b/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt @@ -0,0 +1,235 @@ +package meatbag.backend + +import io.ktor.websocket.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.encodeToString +import meatbag.common.* +import java.util.concurrent.ConcurrentHashMap + +interface AgentCallback { + suspend fun onHumanResponse(response: ApprovalResponse) +} + +class RecordingAgentCallback : AgentCallback { + private val responses = ConcurrentHashMap() + + override suspend fun onHumanResponse(response: ApprovalResponse) { + responses[response.requestId] = response + } + + fun getResponse(requestId: String): ApprovalResponse? = responses[requestId] +} + +class RoutingEngine( + private val deviceRegistry: DeviceRegistry, + private val userRuleSet: UserRuleSet = UserRuleSet(), + private val agentCallback: AgentCallback = RecordingAgentCallback() +) { + // Tracks pending requests: requestId -> CompletableDeferred + private val pendingRequests = ConcurrentHashMap>() + + suspend fun routeRequest( + uiPrimitive: UiPrimitive, + isUrgent: Boolean, + requestedAttention: AttentionTier + ): ApprovalResponse { + return routeRequest( + AgentRequestContext( + uiPrimitive = uiPrimitive, + requestedAttention = requestedAttention, + urgent = isUrgent + ) + ) + } + + suspend fun routeRequest(context: AgentRequestContext): ApprovalResponse { + val activeDevices = deviceRegistry.getAllDevices() + if (activeDevices.isEmpty()) { + throw IllegalStateException("No active client devices connected") + } + + val decision = evaluateRules(context) + val targetDevices = selectDevices(decision, activeDevices) + + val deferred = CompletableDeferred() + pendingRequests[context.uiPrimitive.id] = deferred + + println( + "Routing request ${context.uiPrimitive.id} to devices " + + targetDevices.joinToString { "${it.id} (${it.type})" } + + " with tier ${decision.attentionTier}" + ) + + try { + targetDevices.forEach { device -> + device.session.send(Frame.Text(payloadJson(context, decision, device))) + } + + return deferred.await() + } catch (e: Exception) { + pendingRequests.remove(context.uiPrimitive.id) + throw e + } + } + + suspend fun deliverRequest(context: AgentRequestContext): DeliveryResult { + val activeDevices = deviceRegistry.getAllDevices() + if (activeDevices.isEmpty()) { + throw IllegalStateException("No active client devices connected") + } + + val decision = evaluateRules(context) + val targetDevices = selectDevices(decision, activeDevices) + targetDevices.forEach { device -> + device.session.send(Frame.Text(payloadJson(context, decision, device))) + } + + return DeliveryResult( + requestId = context.uiPrimitive.id, + attentionTier = decision.attentionTier, + deviceIds = targetDevices.map { it.id } + ) + } + + fun evaluateRules(context: AgentRequestContext): RoutingDecision { + if (context.preferredDeviceTypes.isNotEmpty()) { + return RoutingDecision( + attentionTier = context.requestedAttention, + deviceTypes = context.preferredDeviceTypes, + fanOut = context.preferredDeviceTypes.size > 1 + ) + } + + val promptRequiresAttention = (context.uiPrimitive as? ApprovalRequest) + ?.prompt + ?.lowercase() + ?.let { prompt -> userRuleSet.criticalKeywords.any(prompt::contains) } + ?: false + + val attentionTier = when { + context.requestedAttention == AttentionTier.OVERRIDE_SCREEN -> AttentionTier.OVERRIDE_SCREEN + context.urgent || promptRequiresAttention -> AttentionTier.SILENT_HAPTIC + else -> context.requestedAttention + } + + val deviceTypes = when (attentionTier) { + AttentionTier.SILENT_HAPTIC -> { + if (userRuleSet.watchFirstForSilentHaptic) { + listOf(DeviceType.WEAR_OS_WATCH, DeviceType.PHONE, DeviceType.DESKTOP) + } else { + listOf(DeviceType.PHONE, DeviceType.WEAR_OS_WATCH, DeviceType.DESKTOP) + } + } + AttentionTier.OVERRIDE_SCREEN -> { + if (userRuleSet.fanOutOverrideScreen) { + listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH) + } else { + listOf(DeviceType.PHONE, DeviceType.DESKTOP) + } + } + AttentionTier.BACKGROUND_NOTIFICATION -> listOf(DeviceType.PHONE, DeviceType.DESKTOP, DeviceType.WEAR_OS_WATCH) + } + + return RoutingDecision( + attentionTier = attentionTier, + deviceTypes = deviceTypes, + fanOut = attentionTier == AttentionTier.OVERRIDE_SCREEN && userRuleSet.fanOutOverrideScreen + ) + } + + suspend fun handleResponse(response: ApprovalResponse): Boolean { + val deferred = pendingRequests.remove(response.requestId) + if (deferred != null) { + agentCallback.onHumanResponse(response) + deferred.complete(response) + println("Completed request ${response.requestId} with approved=${response.approved}") + return true + } else { + println("No pending request found for ID ${response.requestId}") + return false + } + } + + private fun selectDevices(decision: RoutingDecision, activeDevices: List): List { + val orderedMatches = decision.deviceTypes.flatMap { desiredType -> + activeDevices.filter { it.type == desiredType }.sortedBy { it.id } + } + + return when { + orderedMatches.isNotEmpty() && decision.fanOut -> orderedMatches + orderedMatches.isNotEmpty() -> listOf(orderedMatches.first()) + else -> listOf(activeDevices.sortedWith(compareBy { it.type.ordinal }.thenBy { it.id }).first()) + } + } + + private fun payloadJson( + context: AgentRequestContext, + decision: RoutingDecision, + device: ActiveDevice + ): String { + return SDUIJson.json.encodeToString( + WebSocketPayload( + attentionTier = decision.attentionTier, + uiPrimitive = context.uiPrimitive, + request = RequestContext( + requestId = context.uiPrimitive.id, + source = RequestSource( + system = "agent", + reason = if (context.urgent) "urgent" else null + ) + ), + deviceContext = device.toDeviceContext(), + attentionPolicy = AttentionPolicy( + tier = decision.attentionTier, + hapticPattern = decision.attentionTier.toHapticPattern(), + presentation = decision.toPresentationMode() + ) + ) + ) + } + + private fun ActiveDevice.toDeviceContext(): DeviceContext { + return when (type) { + DeviceType.WEAR_OS_WATCH -> DeviceContext( + deviceId = id, + deviceClass = DeviceClass.WEARABLE, + platform = DevicePlatform.WEAR_OS, + capabilities = setOf(DeviceCapability.HAPTICS, DeviceCapability.NOTIFICATIONS) + ) + DeviceType.PHONE -> DeviceContext( + deviceId = id, + deviceClass = DeviceClass.PHONE, + platform = DevicePlatform.ANDROID, + capabilities = setOf( + DeviceCapability.HAPTICS, + DeviceCapability.NOTIFICATIONS, + DeviceCapability.FULL_SCREEN_INTENT, + DeviceCapability.TEXT_INPUT + ) + ) + DeviceType.DESKTOP -> DeviceContext( + deviceId = id, + deviceClass = DeviceClass.DESKTOP, + platform = DevicePlatform.DESKTOP, + capabilities = setOf(DeviceCapability.NOTIFICATIONS, DeviceCapability.TEXT_INPUT) + ) + } + } + + private fun AttentionTier.toHapticPattern(): HapticPattern { + return when (this) { + AttentionTier.SILENT_HAPTIC -> HapticPattern.DOUBLE_SHORT + AttentionTier.BACKGROUND_NOTIFICATION -> HapticPattern.SHORT + AttentionTier.OVERRIDE_SCREEN -> HapticPattern.URGENT_LOOP + } + } + + private fun RoutingDecision.toPresentationMode(): PresentationMode { + return when { + fanOut -> PresentationMode.ALL_DEVICES + deviceTypes.firstOrNull() == DeviceType.WEAR_OS_WATCH -> PresentationMode.WATCH_ONLY + deviceTypes.firstOrNull() == DeviceType.PHONE -> PresentationMode.PHONE_ONLY + else -> PresentationMode.AUTO + } + } +} diff --git a/backend/src/main/resources/logback.xml b/backend/src/main/resources/logback.xml new file mode 100644 index 0000000..23e0f4b --- /dev/null +++ b/backend/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt b/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt new file mode 100644 index 0000000..7630bdc --- /dev/null +++ b/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt @@ -0,0 +1,170 @@ +package meatbag.backend + +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.testing.* +import io.ktor.websocket.* +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import meatbag.common.* +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RoutingEngineTest { + + @Test + fun testCompleteAgentRoutingFlow() = testApplication { + // Configure Ktor backend module + application { + module() + } + + // Client for WebSocket connection + val wsClient = createClient { + install(WebSockets) + install(ContentNegotiation) { + json(SDUIJson.json) + } + } + + // Client for HTTP API calls + val httpClient = createClient { + install(ContentNegotiation) { + json(SDUIJson.json) + } + } + + // Run the client WebSocket session and agent HTTP request concurrently + wsClient.webSocket("/ws/connect?deviceType=WEAR_OS_WATCH&deviceId=watch_123") { + val agentResponseDeferred = async { + val approvalReq = ApprovalRequest( + id = "req_999", + prompt = "Urgent: Allow system update?", + target = "system-agent" + ) + // Post approval request to backend polymorphically + httpClient.post("/api/request") { + contentType(ContentType.Application.Json) + setBody(approvalReq) + } + } + + // Client receives the websocket notification frame + val frame = incoming.receive() + assertTrue(frame is Frame.Text, "Expected text frame") + val textFrame = frame as Frame.Text + val payloadJson = textFrame.readText() + + // Decode payload using common module's serializer + val payload = SDUIJson.json.decodeFromString(payloadJson) + // Due to "Urgent" in prompt, it should resolve to SILENT_HAPTIC and target watch_123 + assertEquals(AttentionTier.SILENT_HAPTIC, payload.attentionTier) + assertEquals("req_999", payload.uiPrimitive.id) + assertTrue(payload.uiPrimitive is ApprovalRequest) + assertEquals("Urgent: Allow system update?", (payload.uiPrimitive as ApprovalRequest).prompt) + + // Simulate user clicking Yes on their watch by sending response to /api/respond + val respondResponse = httpClient.post("/api/respond") { + contentType(ContentType.Application.Json) + setBody(ApprovalResponse(requestId = "req_999", approved = true)) + } + assertEquals(HttpStatusCode.OK, respondResponse.status) + + // Await agent response and verify the callback output + val agentResponse = agentResponseDeferred.await() + assertEquals(HttpStatusCode.OK, agentResponse.status) + val result = agentResponse.body() + assertEquals("req_999", result.requestId) + assertEquals(true, result.approved) + } + } + + @Test + fun testOverrideScreenFansOutToPhoneAndDesktop() = testApplication { + application { + module() + } + + val wsClient = createClient { + install(WebSockets) + install(ContentNegotiation) { + json(SDUIJson.json) + } + } + + val httpClient = createClient { + install(ContentNegotiation) { + json(SDUIJson.json) + } + } + + val phonePayloadDeferred = async { + wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") { + val frame = incoming.receive() + assertTrue(frame is Frame.Text, "Expected text frame") + SDUIJson.json.decodeFromString((frame as Frame.Text).readText()) + } + } + + val desktopPayloadDeferred = async { + wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") { + val frame = incoming.receive() + assertTrue(frame is Frame.Text, "Expected text frame") + SDUIJson.json.decodeFromString((frame as Frame.Text).readText()) + } + } + + var connected = false + var attempts = 0 + while (!connected && attempts < 20) { + val response = httpClient.get("/api/status") + val body = response.bodyAsText() + if (body.contains("phone_123") && body.contains("desktop_123")) { + connected = true + break + } + attempts += 1 + delay(25) + } + assertTrue(connected, "Expected both devices to connect before routing") + + val agentResponseDeferred = async { + httpClient.post("/api/request?attentionTier=OVERRIDE_SCREEN") { + contentType(ContentType.Application.Json) + setBody( + ApprovalRequest( + id = "req_override", + prompt = "Approve remote screen takeover?", + target = "system-agent" + ) + ) + } + } + + val phonePayload = phonePayloadDeferred.await() + val desktopPayload = desktopPayloadDeferred.await() + + assertEquals(AttentionTier.OVERRIDE_SCREEN, phonePayload.attentionTier) + assertEquals(AttentionTier.OVERRIDE_SCREEN, desktopPayload.attentionTier) + assertEquals("req_override", phonePayload.uiPrimitive.id) + assertEquals("req_override", desktopPayload.uiPrimitive.id) + + val respondResponse = httpClient.post("/api/respond") { + contentType(ContentType.Application.Json) + setBody(ApprovalResponse(requestId = "req_override", approved = false)) + } + assertEquals(HttpStatusCode.OK, respondResponse.status) + + val agentResponse = agentResponseDeferred.await() + assertEquals(HttpStatusCode.OK, agentResponse.status) + val result = agentResponse.body() + assertEquals("req_override", result.requestId) + assertEquals(false, result.approved) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7f02d00 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + // Note: Use matching versions of Kotlin and Compose Multiplatform + kotlin("multiplatform") version "1.9.23" apply false + kotlin("jvm") version "1.9.23" apply false + kotlin("plugin.serialization") version "1.9.23" apply false + id("org.jetbrains.compose") version "1.6.11" apply false + id("io.ktor.plugin") version "2.3.11" apply false + id("com.android.library") version "8.2.2" apply false +} diff --git a/client/build.gradle.kts b/client/build.gradle.kts new file mode 100644 index 0000000..4ca3118 --- /dev/null +++ b/client/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") + kotlin("plugin.serialization") + id("com.android.library") +} + +kotlin { + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + jvm("desktop") { + compilations.all { + kotlinOptions { + jvmTarget = "17" + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":common")) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + + // Ktor client dependencies for WebSocket connection + implementation("io.ktor:ktor-client-core:2.3.11") + implementation("io.ktor:ktor-client-websockets:2.3.11") + implementation("io.ktor:ktor-client-cio:2.3.11") + implementation("io.ktor:ktor-client-content-negotiation:2.3.11") + implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11") + } + } + + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } + + val wearMain by creating { + dependsOn(commonMain) + } + + val androidMain by getting { + dependsOn(wearMain) + dependencies { + implementation("androidx.activity:activity-compose:1.8.2") + } + } + } +} + +android { + namespace = "meatbag.client" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } +} + +compose.desktop { + application { + mainClass = "meatbag.client.MainKt" + nativeDistributions { + targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg) + packageName = "meatbag-client" + packageVersion = "1.0.0" + } + } +} diff --git a/client/src/androidMain/kotlin/meatbag/client/AndroidAttentionFeedback.kt b/client/src/androidMain/kotlin/meatbag/client/AndroidAttentionFeedback.kt new file mode 100644 index 0000000..581cfe5 --- /dev/null +++ b/client/src/androidMain/kotlin/meatbag/client/AndroidAttentionFeedback.kt @@ -0,0 +1,25 @@ +package meatbag.client + +import meatbag.common.AttentionTier + +class AndroidAttentionFeedback( + private val notify: suspend (AndroidAttentionSignal) -> Unit +) : AttentionFeedback { + override suspend fun onAttentionTier(tier: AttentionTier) { + notify(tier.toAndroidAttentionSignal()) + } +} + +enum class AndroidAttentionSignal { + SilentHaptic, + BackgroundNotification, + OverrideScreen +} + +fun AttentionTier.toAndroidAttentionSignal(): AndroidAttentionSignal { + return when (this) { + AttentionTier.SILENT_HAPTIC -> AndroidAttentionSignal.SilentHaptic + AttentionTier.BACKGROUND_NOTIFICATION -> AndroidAttentionSignal.BackgroundNotification + AttentionTier.OVERRIDE_SCREEN -> AndroidAttentionSignal.OverrideScreen + } +} diff --git a/client/src/commonMain/kotlin/meatbag/client/AttentionFeedback.kt b/client/src/commonMain/kotlin/meatbag/client/AttentionFeedback.kt new file mode 100644 index 0000000..be0b873 --- /dev/null +++ b/client/src/commonMain/kotlin/meatbag/client/AttentionFeedback.kt @@ -0,0 +1,11 @@ +package meatbag.client + +import meatbag.common.AttentionTier + +interface AttentionFeedback { + suspend fun onAttentionTier(tier: AttentionTier) +} + +object NoOpAttentionFeedback : AttentionFeedback { + override suspend fun onAttentionTier(tier: AttentionTier) = Unit +} diff --git a/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt b/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt new file mode 100644 index 0000000..c0d48f7 --- /dev/null +++ b/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt @@ -0,0 +1,476 @@ +package meatbag.client + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import meatbag.common.ActionList +import meatbag.common.ApprovalResponse +import meatbag.common.ApprovalRequest +import meatbag.common.Message +import meatbag.common.TextInput +import meatbag.common.UiPrimitive +import meatbag.common.WebSocketPayload + +val DeepBg = Color(0xFF0F111A) +val CardBg = Color(0xFF1E2235) +val AccentTeal = Color(0xFF00F2FE) +val AccentPurple = Color(0xFF9B51E0) +val DangerRed = Color(0xFFFF4D4D) +val SuccessGreen = Color(0xFF27AE60) +val TextPrimary = Color(0xFFFFFFFF) +val TextSecondary = Color(0xFF8E92B2) + +data class ComponentRenderState( + val payload: WebSocketPayload +) + +sealed interface ComponentIntent { + val requestId: String + + data class ApprovalDecision( + override val requestId: String, + val approved: Boolean + ) : ComponentIntent + + data class ActionSelected( + override val requestId: String, + val actionId: String + ) : ComponentIntent + + data class TextSubmitted( + override val requestId: String, + val text: String + ) : ComponentIntent + + data class Acknowledged( + override val requestId: String, + val actionId: String? = null + ) : ComponentIntent +} + +fun ComponentIntent.toApprovalResponse(): ApprovalResponse { + return when (this) { + is ComponentIntent.Acknowledged -> ApprovalResponse( + requestId = requestId, + approved = true, + actionId = actionId + ) + is ComponentIntent.ActionSelected -> ApprovalResponse( + requestId = requestId, + approved = true, + actionId = actionId + ) + is ComponentIntent.ApprovalDecision -> ApprovalResponse( + requestId = requestId, + approved = approved + ) + is ComponentIntent.TextSubmitted -> ApprovalResponse( + requestId = requestId, + approved = true, + text = text + ) + } +} + +@Composable +fun MeatbagComponentHost( + state: ComponentRenderState, + onIntent: (ComponentIntent) -> Unit, + modifier: Modifier = Modifier +) { + ComponentRegistry.Render( + primitive = state.payload.uiPrimitive, + onIntent = onIntent, + modifier = modifier + ) +} + +object ComponentRegistry { + private val renderers = listOf( + ApprovalRequestRenderer, + ActionListRenderer, + TextInputRenderer, + MessageRenderer + ) + + @Composable + fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit, + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(24.dp)) + .background(CardBg) + .border( + 1.dp, + Brush.linearGradient( + listOf( + AccentTeal.copy(alpha = 0.3f), + AccentPurple.copy(alpha = 0.3f) + ) + ), + RoundedCornerShape(24.dp) + ) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + val renderer = renderers.firstOrNull { it.accepts(primitive) } + ?: error("No renderer registered for primitive id=${primitive.id}") + renderer.Render(primitive, onIntent) + } + } +} + +private interface PrimitiveRenderer { + fun accepts(primitive: UiPrimitive): Boolean + + @Composable + fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit + ) +} + +private object ApprovalRequestRenderer : PrimitiveRenderer { + override fun accepts(primitive: UiPrimitive): Boolean = primitive is ApprovalRequest + + @Composable + override fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit + ) { + ApprovalRequestComponent( + request = primitive as ApprovalRequest, + onDecision = { approved -> + onIntent(ComponentIntent.ApprovalDecision(primitive.id, approved)) + } + ) + } +} + +private object ActionListRenderer : PrimitiveRenderer { + override fun accepts(primitive: UiPrimitive): Boolean = primitive is ActionList + + @Composable + override fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit + ) { + ActionListComponent( + list = primitive as ActionList, + onActionSelected = { actionId -> + onIntent(ComponentIntent.ActionSelected(primitive.id, actionId)) + } + ) + } +} + +private object TextInputRenderer : PrimitiveRenderer { + override fun accepts(primitive: UiPrimitive): Boolean = primitive is TextInput + + @Composable + override fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit + ) { + TextInputComponent( + input = primitive as TextInput, + onSubmit = { text -> + onIntent(ComponentIntent.TextSubmitted(primitive.id, text)) + } + ) + } +} + +private object MessageRenderer : PrimitiveRenderer { + override fun accepts(primitive: UiPrimitive): Boolean = primitive is Message + + @Composable + override fun Render( + primitive: UiPrimitive, + onIntent: (ComponentIntent) -> Unit + ) { + MessageComponent( + message = primitive as Message, + onAcknowledge = { actionId -> + onIntent(ComponentIntent.Acknowledged(primitive.id, actionId)) + } + ) + } +} + +@Composable +fun ApprovalRequestComponent( + request: ApprovalRequest, + onDecision: (approved: Boolean) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "TARGET: ${request.target.uppercase()}", + color = AccentTeal, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .background(AccentTeal.copy(alpha = 0.1f), RoundedCornerShape(8.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + + Text( + text = "Approval Request", + color = TextPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(DeepBg, RoundedCornerShape(12.dp)) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp)) + .padding(16.dp) + ) { + Text( + text = request.prompt, + color = Color.White, + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Button( + onClick = { onDecision(false) }, + colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .weight(1f) + .height(48.dp) + ) { + Text("Reject", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + + Button( + onClick = { onDecision(true) }, + colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .weight(1f) + .height(48.dp) + ) { + Text("Approve", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + } + } +} + +@Composable +fun ActionListComponent( + list: ActionList, + onActionSelected: (actionId: String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = list.title, + color = TextPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + list.actions.forEach { actionItem -> + Button( + onClick = { onActionSelected(actionItem.id) }, + colors = ButtonDefaults.buttonColors(backgroundColor = DeepBg), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .border( + 1.dp, + Brush.linearGradient( + listOf( + AccentTeal.copy(alpha = 0.5f), + AccentPurple.copy(alpha = 0.5f) + ) + ), + RoundedCornerShape(12.dp) + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = actionItem.label, + color = TextPrimary, + fontSize = 15.sp, + fontWeight = FontWeight.Medium + ) + Text( + text = ">", + color = AccentTeal, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +fun TextInputComponent( + input: TextInput, + onSubmit: (text: String) -> Unit +) { + var textVal by remember(input.id) { mutableStateOf("") } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Input Required", + color = TextPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + + Text( + text = input.prompt, + color = TextSecondary, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + + OutlinedTextField( + value = textVal, + onValueChange = { textVal = it }, + placeholder = { Text(input.placeholder, color = TextSecondary) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = TextPrimary, + focusedBorderColor = AccentTeal, + unfocusedBorderColor = TextSecondary.copy(alpha = 0.5f), + cursorColor = AccentTeal + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth() + ) + + Button( + onClick = { onSubmit(textVal) }, + colors = ButtonDefaults.buttonColors(backgroundColor = AccentPurple), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = textVal.isNotBlank() + ) { + Text("Submit", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + } +} + +@Composable +fun MessageComponent( + message: Message, + onAcknowledge: (actionId: String?) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = message.title, + color = TextPrimary, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .background(DeepBg, RoundedCornerShape(12.dp)) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp)) + .padding(16.dp) + ) { + Text( + text = message.body, + color = TextSecondary, + fontSize = 14.sp, + textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() + ) + } + + Button( + onClick = { onAcknowledge(message.primaryAction?.id) }, + colors = ButtonDefaults.buttonColors(backgroundColor = AccentTeal), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + enabled = message.primaryAction?.enabled ?: true + ) { + Text( + text = message.primaryAction?.label ?: "Acknowledge", + color = DeepBg, + fontWeight = FontWeight.Bold, + fontSize = 15.sp + ) + } + } +} diff --git a/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt new file mode 100644 index 0000000..05beab9 --- /dev/null +++ b/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt @@ -0,0 +1,169 @@ +package meatbag.client + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.websocket.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.websocket.readText +import io.ktor.serialization.kotlinx.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.Json +import meatbag.common.AttentionTier +import meatbag.common.WebSocketPayload +import meatbag.common.ApprovalResponse +import meatbag.common.toWebSocketPayload +import meatbag.common.toJson + +sealed interface ClientEvent { + data class HapticTrigger(val tier: AttentionTier, val message: String) : ClientEvent + data class LogMessage(val text: String, val type: LogType = LogType.INFO) : ClientEvent +} + +enum class LogType { + INFO, SUCCESS, WARNING, ERROR, HAPTIC +} + +class MeatbagClient( + private val host: String = "localhost", + private val port: Int = 8080, + private val attentionFeedback: AttentionFeedback = NoOpAttentionFeedback +) { + private val jsonConfig = Json { + ignoreUnknownKeys = true + classDiscriminator = "type" + } + + private val client = HttpClient(CIO) { + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(jsonConfig) + } + install(ContentNegotiation) { + json(jsonConfig) + } + } + + private val _currentPayload = MutableStateFlow(null) + val currentPayload = _currentPayload.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 100) + val events = _events.asSharedFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected = _isConnected.asStateFlow() + + private var wsSession: DefaultClientWebSocketSession? = null + private var connectionJob: Job? = null + + fun connect(deviceType: String) { + connectionJob?.cancel() + connectionJob = CoroutineScope(Dispatchers.Default).launch { + while (isActive) { + try { + log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...") + client.webSocket( + method = HttpMethod.Get, + host = host, + port = port, + path = "/ws/connect?deviceType=$deviceType" + ) { + wsSession = this + _isConnected.value = true + log("Connected successfully as $deviceType!", LogType.SUCCESS) + + for (frame in incoming) { + when (frame) { + is io.ktor.websocket.Frame.Text -> { + val text = frame.readText() + log("Received WebSocket frame: $text") + try { + val payload = text.toWebSocketPayload() + log("Parsed payload: primitive=${payload.uiPrimitive.id}, tier=${payload.attentionTier}", LogType.SUCCESS) + + // Trigger haptics, alerts and audio simulations + handleAttentionTier(payload.attentionTier) + + // Update local state + _currentPayload.value = payload + } catch (e: Exception) { + log("Failed to parse WebSocketPayload: ${e.message}", LogType.ERROR) + } + } + else -> {} + } + } + } + } catch (e: Exception) { + _isConnected.value = false + log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING) + delay(3000) + } finally { + _isConnected.value = false + wsSession = null + } + } + } + } + + fun disconnect() { + connectionJob?.cancel() + wsSession = null + _isConnected.value = false + log("Disconnected by user.") + } + + private suspend fun handleAttentionTier(tier: AttentionTier) { + attentionFeedback.onAttentionTier(tier) + val message = when (tier) { + AttentionTier.SILENT_HAPTIC -> { + "[VIBRATION] Double short haptic pulses triggered silently on Wear OS." + } + AttentionTier.BACKGROUND_NOTIFICATION -> { + "[SOUND/BANNER] Play standard notification chime + display overlay banner." + } + AttentionTier.OVERRIDE_SCREEN -> { + "[OVERRIDE] Play high-priority alert loop + lock display and takeover interface." + } + } + _events.emit(ClientEvent.HapticTrigger(tier, message)) + log("Attention Action: $message", LogType.HAPTIC) + } + + suspend fun respond(response: ApprovalResponse): Boolean { + return try { + log("Sending response to POST http://$host:$port/api/respond : ${response.toJson()}") + val httpResponse = client.post("http://$host:$port/api/respond") { + contentType(ContentType.Application.Json) + setBody(response) + } + if (httpResponse.status.isSuccess()) { + log("Response submitted successfully! Server returned 200 OK", LogType.SUCCESS) + if (_currentPayload.value?.uiPrimitive?.id == response.requestId) { + _currentPayload.value = null + } + true + } else { + log("Server rejected response: ${httpResponse.status} - ${httpResponse.bodyAsText()}", LogType.ERROR) + false + } + } catch (e: Exception) { + log("Error sending response: ${e.message}", LogType.ERROR) + false + } + } + + private fun log(message: String, type: LogType = LogType.INFO) { + _events.tryEmit(ClientEvent.LogMessage(message, type)) + } + + fun injectMockEvent(event: ClientEvent) { + _events.tryEmit(event) + } +} diff --git a/client/src/desktopMain/kotlin/meatbag/client/DesktopAttentionFeedback.kt b/client/src/desktopMain/kotlin/meatbag/client/DesktopAttentionFeedback.kt new file mode 100644 index 0000000..5946e8f --- /dev/null +++ b/client/src/desktopMain/kotlin/meatbag/client/DesktopAttentionFeedback.kt @@ -0,0 +1,7 @@ +package meatbag.client + +import meatbag.common.AttentionTier + +class DesktopAttentionFeedback : AttentionFeedback { + override suspend fun onAttentionTier(tier: AttentionTier) = Unit +} diff --git a/client/src/desktopMain/kotlin/meatbag/client/Main.kt b/client/src/desktopMain/kotlin/meatbag/client/Main.kt new file mode 100644 index 0000000..575bc93 --- /dev/null +++ b/client/src/desktopMain/kotlin/meatbag/client/Main.kt @@ -0,0 +1,658 @@ +package meatbag.client + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import meatbag.common.* +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +// Theme Palette (Dark Cyberpunk / Glassmorphism) +val SpaceDark = Color(0xFF090A0F) +val SurfaceDark = Color(0xFF131520) +val ConsoleDark = Color(0xFF07080D) +val AccentCyan = Color(0xFF00E5FF) +val AccentPurpleGlow = Color(0xFFBD00FF) +val ActiveGreen = Color(0xFF00FF66) +val AlertOrange = Color(0xFFFF9900) + +data class LogEntry( + val timestamp: String, + val text: String, + val type: LogType +) + +private fun handleComponentIntent( + intent: ComponentIntent, + localMockPayload: WebSocketPayload?, + client: MeatbagClient, + onMockResolved: () -> Unit, + scope: CoroutineScope +) { + val response = intent.toApprovalResponse() + scope.launch { + if (localMockPayload?.uiPrimitive?.id == response.requestId) { + client.injectMockEvent( + ClientEvent.LogMessage( + "Mock response triggered: ${response.toJson()}", + LogType.SUCCESS + ) + ) + onMockResolved() + } else { + client.respond(response) + } + } +} + +fun main() = application { + val windowState = rememberWindowState(width = 1280.dp, height = 800.dp) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Meatbag SDUI Client Simulator" + ) { + MaterialTheme( + colors = darkColors( + primary = AccentTeal, + secondary = AccentPurple, + background = SpaceDark, + surface = SurfaceDark + ) + ) { + SimulatorApp() + } + } +} + +@Composable +fun SimulatorApp() { + var client by remember { mutableStateOf(MeatbagClient(attentionFeedback = DesktopAttentionFeedback())) } + val scope = rememberCoroutineScope() + + val networkPayload by client.currentPayload.collectAsState() + var localMockPayload by remember { mutableStateOf(null) } + val activePayload = localMockPayload ?: networkPayload + + val isConnected by client.isConnected.collectAsState() + var deviceType by remember { mutableStateOf("WEAR_OS_WATCH") } + var hostInput by remember { mutableStateOf("localhost") } + var portInput by remember { mutableStateOf("8080") } + + val logs = remember { mutableStateListOf() } + val lazyListState = rememberLazyListState() + + var bannerMessage by remember { mutableStateOf(null) } + var bannerTier by remember { mutableStateOf(null) } + var showBanner by remember { mutableStateOf(false) } + + val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss") + + // Listen to client events + LaunchedEffect(client) { + client.events.collectLatest { event -> + val timestamp = LocalTime.now().format(timeFormatter) + when (event) { + is ClientEvent.LogMessage -> { + logs.add(LogEntry(timestamp, event.text, event.type)) + // Auto scroll to bottom + if (logs.size > 0) { + lazyListState.animateScrollToItem(logs.size - 1) + } + } + is ClientEvent.HapticTrigger -> { + // Show visual banner in simulator + bannerTier = event.tier + bannerMessage = event.message + showBanner = true + // Auto hide banner unless it is OVERRIDE_SCREEN + if (event.tier != AttentionTier.OVERRIDE_SCREEN) { + scope.launch { + kotlinx.coroutines.delay(4000) + showBanner = false + } + } + } + } + } + } + + // Reset banner when payload is resolved/dismissed + LaunchedEffect(activePayload) { + if (activePayload == null) { + showBanner = false + bannerMessage = null + bannerTier = null + } + } + + Row( + modifier = Modifier + .fillMaxSize() + .background(SpaceDark) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + // COLUMN 1: CONTROL PANEL & MOCKS + Column( + modifier = Modifier + .width(320.dp) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Connection Card + Card( + backgroundColor = SurfaceDark, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Connection Settings", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + + OutlinedTextField( + value = hostInput, + onValueChange = { hostInput = it }, + label = { Text("Host", color = TextSecondary) }, + colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White), + modifier = Modifier.fillMaxWidth() + ) + + OutlinedTextField( + value = portInput, + onValueChange = { portInput = it }, + label = { Text("Port", color = TextSecondary) }, + colors = TextFieldDefaults.outlinedTextFieldColors(textColor = Color.White), + modifier = Modifier.fillMaxWidth() + ) + + // Device Selector + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Simulated Device Profile", color = TextSecondary, fontSize = 12.sp) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + val profiles = listOf("WEAR_OS_WATCH", "PHONE", "DESKTOP") + profiles.forEach { profile -> + val isSelected = deviceType == profile + Button( + onClick = { + deviceType = profile + if (isConnected) { + client.disconnect() + client.connect(profile) + } + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = if (isSelected) AccentTeal else SpaceDark + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.weight(1f).height(36.dp), + contentPadding = PaddingValues(2.dp) + ) { + Text( + profile.replace("WEAR_OS_", ""), + color = if (isSelected) DeepBg else Color.White, + fontSize = 10.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + // Connection Action + Button( + onClick = { + if (isConnected) { + client.disconnect() + } else { + client.disconnect() + client = MeatbagClient( + host = hostInput, + port = portInput.toIntOrNull() ?: 8080, + attentionFeedback = DesktopAttentionFeedback() + ) + client.connect(deviceType) + } + }, + colors = ButtonDefaults.buttonColors( + backgroundColor = if (isConnected) DangerRed else SuccessGreen + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (isConnected) ActiveGreen else Color.Gray) + ) + Text( + if (isConnected) "Disconnect" else "Connect WebSocket", + color = Color.White, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + // Mock Data Injector Card + Card( + backgroundColor = SurfaceDark, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)), + modifier = Modifier.weight(1f) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Local Ping-Pong Mocking", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + Text( + "Trigger mock payloads to test layout and events without Ktor server running.", + color = TextSecondary, + fontSize = 12.sp + ) + + Button( + onClick = { + localMockPayload = WebSocketPayload( + attentionTier = AttentionTier.SILENT_HAPTIC, + uiPrimitive = ApprovalRequest( + id = "mock_approval_1", + prompt = "Allow agent to execute 'git push --force origin main'?", + target = "deploy-bot" + ) + ) + client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ApprovalRequest with SILENT_HAPTIC", LogType.INFO)) + client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.SILENT_HAPTIC, "[VIBRATION] Double short haptic pulses triggered silently on Wear OS.")) + }, + colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + ) { + Text("Mock Approval (Silent Haptic)", color = AccentTeal, fontSize = 12.sp) + } + + Button( + onClick = { + localMockPayload = WebSocketPayload( + attentionTier = AttentionTier.BACKGROUND_NOTIFICATION, + uiPrimitive = ActionList( + id = "mock_action_2", + title = "Select Environment to Deploy", + actions = listOf( + ActionItem("staging", "Staging Environment (UAT)"), + ActionItem("prod", "Production (US-EAST-1)"), + ActionItem("sandbox", "Sandbox Playpen") + ) + ) + ) + client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: ActionList with BACKGROUND_NOTIFICATION", LogType.INFO)) + client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.BACKGROUND_NOTIFICATION, "[SOUND/BANNER] Play standard notification chime + display overlay banner.")) + }, + colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + ) { + Text("Mock ActionList (Banner Alert)", color = AccentTeal, fontSize = 12.sp) + } + + Button( + onClick = { + localMockPayload = WebSocketPayload( + attentionTier = AttentionTier.OVERRIDE_SCREEN, + uiPrimitive = TextInput( + id = "mock_input_3", + prompt = "CRITICAL: Database Migration has failed. Type 'CONTINUE' to bypass or 'ABORT' to rollback.", + placeholder = "Type command here..." + ) + ) + client.injectMockEvent(ClientEvent.LogMessage("Mock Triggered: TextInput with OVERRIDE_SCREEN", LogType.INFO)) + client.injectMockEvent(ClientEvent.HapticTrigger(AttentionTier.OVERRIDE_SCREEN, "[OVERRIDE] Play high-priority alert loop + lock display and takeover interface.")) + }, + colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().height(44.dp).border(1.dp, AccentTeal.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + ) { + Text("Mock TextInput (Override)", color = AccentTeal, fontSize = 12.sp) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (activePayload != null) { + Button( + onClick = { + localMockPayload = null + client.injectMockEvent(ClientEvent.LogMessage("Mock state cleared.", LogType.INFO)) + }, + colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed.copy(alpha = 0.2f)), + shape = RoundedCornerShape(10.dp), + modifier = Modifier.fillMaxWidth().height(40.dp).border(1.dp, DangerRed, RoundedCornerShape(10.dp)) + ) { + Text("Clear Active UI", color = DangerRed, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } + + // COLUMN 2: SIMULATOR CANVAS (CENTER) + Column( + modifier = Modifier + .weight(1.5f) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Visual Attention Banner (Overlay effect) + if (showBanner && bannerMessage != null) { + val bannerColor = when (bannerTier) { + AttentionTier.SILENT_HAPTIC -> AccentTeal + AttentionTier.BACKGROUND_NOTIFICATION -> AlertOrange + AttentionTier.OVERRIDE_SCREEN -> DangerRed + else -> AccentTeal + } + + Card( + backgroundColor = bannerColor, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = when (bannerTier) { + AttentionTier.SILENT_HAPTIC -> Icons.Default.Notifications + AttentionTier.BACKGROUND_NOTIFICATION -> Icons.Default.Info + AttentionTier.OVERRIDE_SCREEN -> Icons.Default.Warning + else -> Icons.Default.Notifications + }, + contentDescription = "Alert", + tint = Color.White + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "ATTENTION TARGET: ${bannerTier?.name}", + color = Color.White, + fontWeight = FontWeight.ExtraBold, + fontSize = 11.sp + ) + Text( + text = bannerMessage ?: "", + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Medium + ) + } + if (bannerTier != AttentionTier.OVERRIDE_SCREEN) { + IconButton(onClick = { showBanner = false }) { + Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) + } + } + } + } + } + + Text( + text = "SIMULATOR DEVICE: $deviceType", + color = TextSecondary, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Device frame rendering + if (deviceType == "WEAR_OS_WATCH") { + // Round watch frame + Box( + modifier = Modifier + .size(360.dp) + .clip(CircleShape) + .background(Color.Black) + .border(6.dp, Color(0xFF3A3E52), CircleShape) + .border(10.dp, Brush.sweepGradient(listOf(AccentTeal, AccentPurple, AccentTeal)), CircleShape) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + if (activePayload != null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + MeatbagComponentHost( + state = ComponentRenderState(activePayload!!), + onIntent = { intent -> + handleComponentIntent( + intent = intent, + localMockPayload = localMockPayload, + client = client, + onMockResolved = { localMockPayload = null }, + scope = scope + ) + } + ) + } + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Watch Idle", + color = TextSecondary, + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) + Text( + "Awaiting payload...", + color = TextSecondary.copy(alpha = 0.6f), + fontSize = 11.sp + ) + } + } + } + } else { + // Phone / Desktop rectangular frame + val frameWidth = if (deviceType == "PHONE") 340.dp else 460.dp + val frameHeight = if (deviceType == "PHONE") 580.dp else 360.dp + + Box( + modifier = Modifier + .size(width = frameWidth, height = frameHeight) + .clip(RoundedCornerShape(24.dp)) + .background(SpaceDark) + .border(6.dp, Color(0xFF2C2F3E), RoundedCornerShape(24.dp)) + .border(1.dp, AccentPurple, RoundedCornerShape(24.dp)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + if (activePayload != null) { + MeatbagComponentHost( + state = ComponentRenderState(activePayload!!), + onIntent = { intent -> + handleComponentIntent( + intent = intent, + localMockPayload = localMockPayload, + client = client, + onMockResolved = { localMockPayload = null }, + scope = scope + ) + } + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Refresh, contentDescription = "Idle", tint = TextSecondary, modifier = Modifier.size(36.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + "$deviceType Idle", + color = TextSecondary, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + Text( + "Waiting for Ktor WebSocket payload", + color = TextSecondary.copy(alpha = 0.5f), + fontSize = 12.sp + ) + } + } + } + } + } + + // COLUMN 3: EVENT LOGS + Card( + backgroundColor = SurfaceDark, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, Color.White.copy(alpha = 0.05f)), + modifier = Modifier + .width(420.dp) + .fillMaxHeight() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Event Console Logs", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp + ) + + Button( + onClick = { logs.clear() }, + colors = ButtonDefaults.buttonColors(backgroundColor = SpaceDark), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.height(30.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) + ) { + Text("Clear", color = TextSecondary, fontSize = 11.sp) + } + } + + // Logs list view + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ConsoleDark) + .border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(logs) { log -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = "[${log.timestamp}]", + color = TextSecondary, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + + val typeText = when (log.type) { + LogType.INFO -> "INFO" + LogType.SUCCESS -> "SUCCESS" + LogType.WARNING -> "WARN" + LogType.ERROR -> "ERROR" + LogType.HAPTIC -> "HAPTIC" + } + + val badgeColor = when (log.type) { + LogType.INFO -> TextSecondary + LogType.SUCCESS -> ActiveGreen + LogType.WARNING -> AlertOrange + LogType.ERROR -> DangerRed + LogType.HAPTIC -> AccentPurple + } + + Text( + text = "$typeText:", + color = badgeColor, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + modifier = Modifier.width(60.dp) + ) + + Text( + text = log.text, + color = Color.White, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } + } + } +} diff --git a/client/src/wearMain/kotlin/meatbag/client/WearAttentionFeedback.kt b/client/src/wearMain/kotlin/meatbag/client/WearAttentionFeedback.kt new file mode 100644 index 0000000..7bc861b --- /dev/null +++ b/client/src/wearMain/kotlin/meatbag/client/WearAttentionFeedback.kt @@ -0,0 +1,25 @@ +package meatbag.client + +import meatbag.common.AttentionTier + +class WearAttentionFeedback( + private val trigger: suspend (WearHapticPattern) -> Unit +) : AttentionFeedback { + override suspend fun onAttentionTier(tier: AttentionTier) { + trigger(tier.toWearHapticPattern()) + } +} + +enum class WearHapticPattern { + DoubleShort, + Notification, + UrgentLoop +} + +fun AttentionTier.toWearHapticPattern(): WearHapticPattern { + return when (this) { + AttentionTier.SILENT_HAPTIC -> WearHapticPattern.DoubleShort + AttentionTier.BACKGROUND_NOTIFICATION -> WearHapticPattern.Notification + AttentionTier.OVERRIDE_SCREEN -> WearHapticPattern.UrgentLoop + } +} diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..d1cb305 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +kotlin { + jvm() // Targets the JVM. Additional targets (android, ios, etc.) can be configured later. + + sourceSets { + val commonMain by getting { + dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0") + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} diff --git a/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt b/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt new file mode 100644 index 0000000..2a42cf2 --- /dev/null +++ b/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt @@ -0,0 +1,351 @@ +package meatbag.common + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +sealed class UiPrimitive { + abstract val id: String +} + +@Serializable +@SerialName("ApprovalRequest") +data class ApprovalRequest( + override val id: String, + val prompt: String, + val target: String, + val title: String = "Approval required", + val body: String? = null, + val approveAction: ActionItem = ActionItem( + id = "approve", + label = "Approve", + intent = ActionIntent.APPROVE, + ), + val rejectAction: ActionItem = ActionItem( + id = "reject", + label = "Reject", + intent = ActionIntent.REJECT, + style = ActionStyle.DESTRUCTIVE, + ), + val timeoutMs: Long? = null, + val callback: CallbackRequest? = null, +) : UiPrimitive() + +@Serializable +@SerialName("ActionList") +data class ActionList( + override val id: String, + val title: String, + val actions: List, + val subtitle: String? = null, + val selectionMode: SelectionMode = SelectionMode.SINGLE, +) : UiPrimitive() + +@Serializable +@SerialName("TextInput") +data class TextInput( + override val id: String, + val prompt: String, + val placeholder: String, + val title: String = prompt, + val submitAction: ActionItem = ActionItem( + id = "submit", + label = "Submit", + intent = ActionIntent.SUBMIT, + ), + val callback: CallbackRequest? = null, +) : UiPrimitive() + +@Serializable +@SerialName("Message") +data class Message( + override val id: String, + val title: String, + val body: String, + val tone: MessageTone = MessageTone.INFO, + val primaryAction: ActionItem? = null, +) : UiPrimitive() + +@Serializable +data class ActionItem( + val id: String, + val label: String, + val intent: ActionIntent = ActionIntent.CUSTOM, + val style: ActionStyle = ActionStyle.DEFAULT, + val callback: CallbackRequest? = null, + val enabled: Boolean = true, +) + +@Serializable +enum class ActionIntent { + @SerialName("APPROVE") + APPROVE, + + @SerialName("REJECT") + REJECT, + + @SerialName("ACKNOWLEDGE") + ACKNOWLEDGE, + + @SerialName("SUBMIT") + SUBMIT, + + @SerialName("CUSTOM") + CUSTOM, +} + +@Serializable +enum class ActionStyle { + @SerialName("DEFAULT") + DEFAULT, + + @SerialName("PRIMARY") + PRIMARY, + + @SerialName("DESTRUCTIVE") + DESTRUCTIVE, +} + +@Serializable +enum class SelectionMode { + @SerialName("SINGLE") + SINGLE, + + @SerialName("MULTIPLE") + MULTIPLE, +} + +@Serializable +enum class MessageTone { + @SerialName("INFO") + INFO, + + @SerialName("SUCCESS") + SUCCESS, + + @SerialName("WARNING") + WARNING, + + @SerialName("ERROR") + ERROR, +} + +@Serializable +enum class AttentionTier { + @SerialName("SILENT_HAPTIC") + SILENT_HAPTIC, + + @SerialName("BACKGROUND_NOTIFICATION") + BACKGROUND_NOTIFICATION, + + @SerialName("OVERRIDE_SCREEN") + OVERRIDE_SCREEN, +} + +@Serializable +data class AttentionPolicy( + val tier: AttentionTier, + val hapticPattern: HapticPattern? = null, + val presentation: PresentationMode = PresentationMode.AUTO, + val expiresAtEpochMs: Long? = null, +) + +@Serializable +enum class HapticPattern { + @SerialName("NONE") + NONE, + + @SerialName("SHORT") + SHORT, + + @SerialName("DOUBLE_SHORT") + DOUBLE_SHORT, + + @SerialName("URGENT_LOOP") + URGENT_LOOP, +} + +@Serializable +enum class PresentationMode { + @SerialName("AUTO") + AUTO, + + @SerialName("WATCH_ONLY") + WATCH_ONLY, + + @SerialName("PHONE_ONLY") + PHONE_ONLY, + + @SerialName("ALL_DEVICES") + ALL_DEVICES, +} + +@Serializable +data class RequestContext( + val requestId: String, + val source: RequestSource, + val correlationId: String? = null, + val createdAtEpochMs: Long? = null, + val callback: CallbackRequest? = null, +) + +@Serializable +data class RequestSource( + val system: String, + val actor: String? = null, + val reason: String? = null, +) + +@Serializable +data class DeviceContext( + val deviceId: String, + val deviceClass: DeviceClass, + val platform: DevicePlatform, + val capabilities: Set = emptySet(), + val locale: String? = null, + val timezone: String? = null, +) + +@Serializable +enum class DeviceClass { + @SerialName("PHONE") + PHONE, + + @SerialName("WEARABLE") + WEARABLE, + + @SerialName("DESKTOP") + DESKTOP, + + @SerialName("TABLET") + TABLET, +} + +@Serializable +enum class DevicePlatform { + @SerialName("ANDROID") + ANDROID, + + @SerialName("WEAR_OS") + WEAR_OS, + + @SerialName("IOS") + IOS, + + @SerialName("DESKTOP") + DESKTOP, + + @SerialName("WEB") + WEB, +} + +@Serializable +enum class DeviceCapability { + @SerialName("HAPTICS") + HAPTICS, + + @SerialName("NOTIFICATIONS") + NOTIFICATIONS, + + @SerialName("FULL_SCREEN_INTENT") + FULL_SCREEN_INTENT, + + @SerialName("TEXT_INPUT") + TEXT_INPUT, +} + +@Serializable +data class CallbackRequest( + val id: String, + val target: CallbackTarget, + val method: CallbackMethod = CallbackMethod.POST, +) + +@Serializable +sealed class CallbackTarget { + @Serializable + @SerialName("Http") + data class Http( + val url: String, + val headers: Map = emptyMap(), + ) : CallbackTarget() + + @Serializable + @SerialName("WebSocket") + data class WebSocket( + val channel: String, + ) : CallbackTarget() + + @Serializable + @SerialName("Local") + data class Local( + val handler: String, + ) : CallbackTarget() +} + +@Serializable +enum class CallbackMethod { + @SerialName("POST") + POST, + + @SerialName("PUT") + PUT, +} + +@Serializable +data class WebSocketPayload( + val attentionTier: AttentionTier, + val uiPrimitive: UiPrimitive, + val request: RequestContext? = null, + val deviceContext: DeviceContext? = null, + val attentionPolicy: AttentionPolicy = AttentionPolicy(attentionTier), +) + +@Serializable +data class ApprovalResponse( + val requestId: String, + val approved: Boolean, + val actionId: String? = null, + val text: String? = null, + val callbackId: String? = null, + val deviceId: String? = null, +) + +object SDUIJson { + val json = Json { + ignoreUnknownKeys = true + prettyPrint = true + classDiscriminator = "type" + } + + fun encodeToString(primitive: UiPrimitive): String { + return json.encodeToString(primitive) + } + + fun decodeFromString(jsonString: String): UiPrimitive { + return json.decodeFromString(jsonString) + } + + fun encodeAttentionTier(tier: AttentionTier): String { + return json.encodeToString(tier) + } + + fun decodeAttentionTier(jsonString: String): AttentionTier { + return json.decodeFromString(jsonString) + } +} + +fun UiPrimitive.toJson(): String = SDUIJson.encodeToString(this) +fun String.toUiPrimitive(): UiPrimitive = SDUIJson.decodeFromString(this) + +fun AttentionTier.toJson(): String = SDUIJson.encodeAttentionTier(this) +fun String.toAttentionTier(): AttentionTier = SDUIJson.decodeAttentionTier(this) + +fun WebSocketPayload.toJson(): String = SDUIJson.json.encodeToString(this) +fun String.toWebSocketPayload(): WebSocketPayload = SDUIJson.json.decodeFromString(this) + +fun ApprovalResponse.toJson(): String = SDUIJson.json.encodeToString(this) +fun String.toApprovalResponse(): ApprovalResponse = SDUIJson.json.decodeFromString(this) diff --git a/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt b/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt new file mode 100644 index 0000000..880e20f --- /dev/null +++ b/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt @@ -0,0 +1,145 @@ +package meatbag.common + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class UiPrimitiveTest { + + @Test + fun testApprovalRequestSerialization() { + val approval = ApprovalRequest( + id = "req-1", + prompt = "Grant admin access?", + target = "prod-db", + callback = CallbackRequest( + id = "cb-1", + target = CallbackTarget.Http("https://backend.test/approval"), + ), + ) + val json = approval.toJson() + assertTrue(json.contains("\"type\": \"ApprovalRequest\"")) + assertTrue(json.contains("\"id\": \"req-1\"")) + assertTrue(json.contains("\"prompt\": \"Grant admin access?\"")) + assertTrue(json.contains("\"target\": \"prod-db\"")) + assertTrue(json.contains("\"intent\": \"APPROVE\"")) + assertTrue(json.contains("\"type\": \"Http\"")) + + val decoded = json.toUiPrimitive() + assertTrue(decoded is ApprovalRequest) + assertEquals(approval.id, decoded.id) + assertEquals(approval.prompt, decoded.prompt) + assertEquals(approval.target, decoded.target) + assertEquals(ActionIntent.APPROVE, decoded.approveAction.intent) + assertTrue(decoded.callback?.target is CallbackTarget.Http) + } + + @Test + fun testActionListSerialization() { + val actionList = ActionList( + id = "act-1", + title = "Choose Action", + actions = listOf( + ActionItem("1", "Acknowledge"), + ActionItem("2", "Reject") + ) + ) + val json = actionList.toJson() + assertTrue(json.contains("\"type\": \"ActionList\"")) + assertTrue(json.contains("\"title\": \"Choose Action\"")) + + val decoded = json.toUiPrimitive() + assertTrue(decoded is ActionList) + assertEquals(2, decoded.actions.size) + assertEquals("Acknowledge", decoded.actions[0].label) + assertEquals(ActionIntent.CUSTOM, decoded.actions[0].intent) + assertEquals(SelectionMode.SINGLE, decoded.selectionMode) + } + + @Test + fun testTextInputSerialization() { + val textInput = TextInput( + id = "txt-1", + prompt = "Enter passcode", + placeholder = "1234" + ) + val json = textInput.toJson() + assertTrue(json.contains("\"type\": \"TextInput\"")) + + val decoded = json.toUiPrimitive() + assertTrue(decoded is TextInput) + assertEquals("1234", decoded.placeholder) + } + + @Test + fun testAttentionTierSerialization() { + val tier = AttentionTier.SILENT_HAPTIC + val json = tier.toJson() + assertEquals("\"SILENT_HAPTIC\"", json.trim()) + + val decoded = json.toAttentionTier() + assertEquals(tier, decoded) + } + + @Test + fun testWebSocketPayloadSerialization() { + val payload = WebSocketPayload( + attentionTier = AttentionTier.OVERRIDE_SCREEN, + uiPrimitive = Message( + id = "msg-1", + title = "Manual intervention", + body = "A deployment requires attention.", + tone = MessageTone.WARNING, + ), + request = RequestContext( + requestId = "req-2", + source = RequestSource(system = "deployments", actor = "agent-7"), + callback = CallbackRequest( + id = "cb-2", + target = CallbackTarget.WebSocket(channel = "approvals"), + ), + ), + deviceContext = DeviceContext( + deviceId = "watch-1", + deviceClass = DeviceClass.WEARABLE, + platform = DevicePlatform.WEAR_OS, + capabilities = setOf( + DeviceCapability.HAPTICS, + DeviceCapability.FULL_SCREEN_INTENT, + ), + ), + attentionPolicy = AttentionPolicy( + tier = AttentionTier.OVERRIDE_SCREEN, + hapticPattern = HapticPattern.URGENT_LOOP, + presentation = PresentationMode.ALL_DEVICES, + ), + ) + + val json = payload.toJson() + assertTrue(json.contains("\"type\": \"Message\"")) + assertTrue(json.contains("\"type\": \"WebSocket\"")) + assertTrue(json.contains("\"tier\": \"OVERRIDE_SCREEN\"")) + assertTrue(json.contains("\"deviceClass\": \"WEARABLE\"")) + + val decoded = json.toWebSocketPayload() + assertEquals(AttentionTier.OVERRIDE_SCREEN, decoded.attentionTier) + assertTrue(decoded.uiPrimitive is Message) + assertEquals("req-2", decoded.request?.requestId) + assertEquals(DevicePlatform.WEAR_OS, decoded.deviceContext?.platform) + assertEquals(HapticPattern.URGENT_LOOP, decoded.attentionPolicy.hapticPattern) + } + + @Test + fun testApprovalResponseSerialization() { + val response = ApprovalResponse( + requestId = "req-3", + approved = true, + actionId = "approve", + callbackId = "cb-3", + deviceId = "phone-1", + ) + + val decoded = response.toJson().toApprovalResponse() + assertEquals(response, decoded) + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..57bc3be --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.native.ignoreDisabledTargets=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..ca78468 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] +kotlin = "1.9.23" +agp = "8.2.2" +compose-multiplatform = "1.6.1" +kotlinx-serialization = "1.6.3" +androidx-lifecycle = "2.7.0" +androidx-savedstate = "1.2.1" +androidx-activity = "1.8.2" +wear-compose = "1.3.1" +play-services-wearable = "18.1.0" + +[libraries] +kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } +compose-gradlePlugin = { module = "org.jetbrains.compose:compose-gradle-plugin", version.ref = "compose-multiplatform" } + +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +# Android Lifecycle IME dependencies +androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-savedstate = { module = "androidx.savedstate:savedstate-ktx", version.ref = "androidx-savedstate" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } + +# Wear OS dependencies +wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" } +wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" } +play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a80b22c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/handoffs/session_handoff.md b/handoffs/session_handoff.md new file mode 100644 index 0000000..6fbba75 --- /dev/null +++ b/handoffs/session_handoff.md @@ -0,0 +1,63 @@ +# Project Meatbag: Session Handoff + +**Date**: June 27, 2026 +**Ecosystem**: WhetForge +**Goal**: Scaffolding and Implementing the MVP for **Meatbag**, a multiplatform "Human Latency Optimization Tool" for server-driven UI (SDUI). + +--- + +## 1. What We Did This Session +We initiated the project from scratch, structured it as a Kotlin Multiplatform Gradle project, and developed a stateful routing backend and a stateless Compose Multiplatform client. The session focused on achieving a successful end-to-end ping-pong loop: +1. An agent submits an `ApprovalRequest` via HTTP POST to the backend. +2. The backend determines the target device and attention tier based on user context rules, then suspends the request. +3. The backend notifies the target device via WebSockets. +4. The client simulates haptics (or alerts) based on the `AttentionTier`, parses the JSON UI primitive, and inflates the UI dynamically. +5. The human taps "Yes" / interacts, sending a response to the backend. +6. The backend resumes the suspended agent request and returns the response. + +--- + +## 2. Core Project Architecture +The project directory is structured as a multi-module Kotlin project: + +* **`:common` (Kotlin Multiplatform)** + * [UiPrimitive.kt](file:///Users/blindmouse/meatbag/common/src/commonMain/kotlin/meatbag/common/UiPrimitive.kt): Declares the sealed hierarchy representing JSON UI elements (`UiPrimitive` with sub-types `ApprovalRequest`, `ActionList`, `TextInput`), the `AttentionTier` enum (`SILENT_HAPTIC`, `BACKGROUND_NOTIFICATION`, `OVERRIDE_SCREEN`), and common payload envelopes (`WebSocketPayload`, `ApprovalResponse`). Includes polymorphic serialization helpers. + * [UiPrimitiveTest.kt](file:///Users/blindmouse/meatbag/common/src/commonTest/kotlin/meatbag/common/UiPrimitiveTest.kt): Verifies correct serialization and parsing with class discriminators. + +* **`:backend` (Ktor/JVM)** + * [Application.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/Application.kt): Scaffolds Ktor Netty, sets up CORS/WebSockets/ContentNegotiation plugins, exposes client-facing WebSocket (`/ws/connect`), response handler (`/api/respond`), agent request (`/api/request`), and diagnostic endpoints. Includes context-aware urgency auto-detection. + * [DeviceRegistry.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/DeviceRegistry.kt): Registers connected active client devices dynamically. + * [RoutingEngine.kt](file:///Users/blindmouse/meatbag/backend/src/main/kotlin/meatbag/backend/RoutingEngine.kt): Implements attention-based routing (e.g. routes urgent/silent haptics to Wear OS if active, otherwise falls back to phone/desktop) and holds coroutine suspend contexts via `CompletableDeferred` pending user callbacks. + * [RoutingEngineTest.kt](file:///Users/blindmouse/meatbag/backend/src/test/kotlin/meatbag/backend/RoutingEngineTest.kt): Fully verifies WebSocket connections, payload transmission, attention tier routing, and callback resume resolution under Ktor's `testApplication` engine. + +* **`:client` (Compose Multiplatform / Desktop target)** + * [ComponentRegistry.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/ComponentRegistry.kt): dynamic Compose renderer converting `UiPrimitive` polymorphically into premium, dark cyberpunk UI components using Unidirectional Data Flow (UDF) callbacks. + * [MeatbagClient.kt](file:///Users/blindmouse/meatbag/client/src/commonMain/kotlin/meatbag/client/MeatbagClient.kt): Manages WebSocket connections and POST responses. Decodes payloads and triggers simulated attention effects (vibrations, sound chimes, or display takeover overlays). + * [Main.kt](file:///Users/blindmouse/meatbag/client/src/desktopMain/kotlin/meatbag/client/Main.kt): Cyberpunk glassmorphic client simulator. Displays connection logs and simulates haptic profiles (Smartwatch vs. Phone vs. Desktop) and injects local offline mock payloads for isolated UI debugging. + +--- + +## 3. Configuration & Dependency Fixes Applied +During compilation and verification, several compatibility fixes were successfully applied: +1. **Plugin Portal Registration**: Configured `gradlePluginPortal()` in `settings.gradle.kts` to allow resolution of the Ktor and Kotlin JVM plugins. +2. **Kotlin Compiler Version Alignment**: Managed Kotlin compiler plugin versions centrally in the root `build.gradle.kts` and applied them cleanly inside sub-modules to prevent classloader duplicate classpath conflicts. +3. **Obsolete Property Removal**: Removed the obsolete `kotlin.mpp.enableGranularSourceSetsMetadata=true` from `gradle.properties` to fix Kotlin Gradle compiler configuration errors. +4. **Client Test Classpath**: Added Ktor Client content negotiation and websocket JVM targets to `:backend` test dependencies so that Ktor's test client works flawlessly during JUnit runs. +5. **Client Simulator Fixes**: + * Fixed window size dimensions to use `Dp` units (`1280.dp`, `800.dp`). + * Resolved Ktor 2.x `Frame.Text` read extension function resolution by importing `io.ktor.websocket.readText`. + * Refactored client `log` helpers to be non-suspending (using flow `tryEmit` instead of `emit`), and introduced a public `injectMockEvent` helper to safely feed mock events from the simulator UI without coroutine thread blocks. + +--- + +## 4. Current Status +* **Compilation Status**: `BUILD SUCCESSFUL` for all modules and targets. +* **Testing Status**: Common serialization and backend integration routing tests pass successfully. +* **Active Subagents**: Cleaned up and terminated all background subagent processes. + +--- + +## 5. Next Steps +1. **Develop Android/Wear OS Platform Code**: Implement physical haptic motor triggers (`Vibrator` and `VibratorManager`) in `wearMain` and push banner notifications in `androidMain`. +2. **Persistence Layer**: Implement a database (e.g., SQLite via SQLDelight or PostgreSQL) to persist user rule-sets and past agent requests. +3. **Authentication**: Secure endpoints using API keys for CLI agents and OAuth/Device Tokens for clients. diff --git a/research/sdui_and_haptics_research.md b/research/sdui_and_haptics_research.md new file mode 100644 index 0000000..3536590 --- /dev/null +++ b/research/sdui_and_haptics_research.md @@ -0,0 +1,79 @@ +# Meatbag Research: SDUI Inflation, Wear OS Haptics, and Server Push + +This document summarizes architectural research for the Meatbag platform, focusing on performance, latency, and platform-specific haptic patterns. + +--- + +## 1. Server-Driven UI (SDUI) in Compose Multiplatform + +### The Reflection-Free Pattern +To meet performance constraints and avoid runtime performance degradation on lightweight clients (such as Wear OS watches), Meatbag implements a strictly typed, reflection-free Component Registry. + +```kotlin +@Composable +fun ComponentRegistry(primitive: UiPrimitive, onAction: (ApprovalResponse) -> Unit) { + when (primitive) { + is ApprovalRequest -> ApprovalRequestCard(primitive, onAction) + is ActionList -> ActionListContainer(primitive, onAction) + is TextInput -> TextInputField(primitive, onAction) + } +} +``` + +* **Advantage**: Compile-time safety. Polymorphic serialization (via `kotlinx.serialization` with custom class discriminators like `"type"`) ensures payloads map directly to Kotlin data types without dynamic inspection. +* **State Management**: Adheres to Unidirectional Data Flow (UDF). Primitives are read-only configuration blueprints; interactive widgets emit state updates upward through explicit event callbacks (`onAction`), keeping clients entirely stateless. + +--- + +## 2. Wear OS Haptic Feedback APIs + +To minimize human response times, we utilize precise physical notifications based on the resolved `AttentionTier`. + +### Android / Wear OS API Mapping +For triggering haptics on physical Wear OS smartwatches, the system integrates with Android's hardware `Vibrator` API. + +#### 1. Predefined Effects (Android SDK 29+) +Modern Wear OS devices support the `VibrationEffect` API, which yields crisp, hardware-optimized clicks rather than fuzzy motor rumbling. +* **`AttentionTier.SILENT_HAPTIC`**: Double short pulses to signal prompt urgency without making sound. + ```kotlin + val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + val effect = VibrationEffect.startComposition() + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f) + .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150) // 150ms delay + .compose() + vibrator.vibrate(effect) + ``` +* **`AttentionTier.BACKGROUND_NOTIFICATION`**: A light tick effect. + ```kotlin + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + ``` +* **`AttentionTier.OVERRIDE_SCREEN`**: Repetitive pulsing alert composition. + ```kotlin + val timings = longArrayOf(0, 200, 100, 200, 100, 500) // delay, vibe, pause, vibe... + val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255) + vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1)) + ``` + +#### 2. Legacy Fallbacks (Android SDK < 29) +* For older Wear OS versions, fallback to deprecated pattern arrays: + ```kotlin + vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1) + ``` + +--- + +## 3. Server Push Architecture: WebSockets vs. SSE + +Smartwatches are subject to battery constraints and intermittent connectivity. The push transport layer must be responsive yet lightweight. + +| Feature | WebSockets | Server-Sent Events (SSE) | +| :--- | :--- | :--- | +| **Protocol** | Bi-directional, custom framing | Uni-directional (Server -> Client), HTTP/1.1 or HTTP/2 | +| **Connection Overhead** | Low (post-handshake) | Extremely low (standard HTTP connection) | +| **Battery Impact** | Higher (constant connection maintenance) | Lower (leverages HTTP client connection pooling) | +| **Firewall Traversal** | Can be blocked by strict proxy rules | Standard HTTP, easily passes through proxies | +| **Meatbag MVP Selection** | **WebSockets** (Allows immediate client confirmation frames and bi-directional pings) | **Candidate for mobile clients** (Fallback for phone targets via standard APNS / FCM) | + +### Recommended Hybrid Strategy +1. **Wear OS Smartwatches**: Utilize persistent WebSockets with optimized ping-pong intervals (e.g. 30 seconds) when the app is active in the foreground. +2. **Mobile Phones (iOS & Android)**: Fall back to native push notification systems (FCM/APNS) to wake up the client, which then opens a short-lived WebSocket or POST connection to render the SDUI primitive. diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b967581 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "meatbag" +include(":common") +include(":backend") +include(":client")