Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding

This commit is contained in:
2026-07-02 13:11:59 -06:00
parent c0e8d4fa0a
commit 0f834078d5
14 changed files with 924 additions and 110 deletions

View File

@@ -13,6 +13,7 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.json.*
import meatbag.common.*
import java.time.Duration
@@ -45,6 +46,7 @@ fun Application.module() {
val deviceRegistry = DeviceRegistry()
val routingEngine = RoutingEngine(deviceRegistry)
val profileStore = ProfileStore()
routing {
// Client WebSocket connection endpoint
@@ -88,10 +90,10 @@ fun Application.module() {
val matchedPendingRequest = routingEngine.handleResponse(response)
call.respond(
HttpStatusCode.OK,
mapOf(
"status" to "Response processed",
"matchedPendingRequest" to matchedPendingRequest
)
buildJsonObject {
put("status", "Response processed")
put("matchedPendingRequest", matchedPendingRequest)
}
)
}
@@ -116,16 +118,80 @@ fun Application.module() {
)
call.respond(HttpStatusCode.OK, approvalResult)
} catch (e: IllegalStateException) {
call.respond(HttpStatusCode.ServiceUnavailable, mapOf("error" to e.localizedMessage))
call.respond(HttpStatusCode.ServiceUnavailable, buildJsonObject { put("error", e.localizedMessage ?: "") })
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to e.localizedMessage))
call.respond(HttpStatusCode.InternalServerError, buildJsonObject { put("error", 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))
val devices = deviceRegistry.getAllDevices()
call.respond(
buildJsonObject {
put("status", "OK")
put("connectedDevices", buildJsonArray {
devices.forEach { device ->
addJsonObject {
put("id", device.id)
put("type", device.type.name)
}
}
})
}
)
}
// Get all profiles
get("/api/profiles") {
val profiles = profileStore.listProfiles()
call.respond(HttpStatusCode.OK, profiles)
}
// Get profile by id
get("/api/profiles/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing profile ID")
return@get
}
val profile = profileStore.getProfile(id)
if (profile == null) {
call.respond(HttpStatusCode.NotFound, "Profile not found")
} else {
call.respond(HttpStatusCode.OK, profile)
}
}
// Create / Update profile
post("/api/profiles") {
val profile = try {
call.receive<UserProfile>()
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, "Invalid UserProfile body: ${e.localizedMessage}")
return@post
}
val success = profileStore.saveProfile(profile)
if (success) {
call.respond(HttpStatusCode.Created, buildJsonObject { put("status", "Profile saved") })
} else {
call.respond(HttpStatusCode.InternalServerError, "Failed to save profile")
}
}
// Delete profile
delete("/api/profiles/{id}") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.BadRequest, "Missing profile ID")
return@delete
}
val deleted = profileStore.deleteProfile(id)
if (deleted) {
call.respond(HttpStatusCode.OK, buildJsonObject { put("status", "Profile deleted") })
} else {
call.respond(HttpStatusCode.NotFound, "Profile not found or delete failed")
}
}
}
}

View File

@@ -0,0 +1,58 @@
package meatbag.backend
import meatbag.common.UserProfile
import meatbag.common.toUserProfile
import java.io.File
class ProfileStore(private val baseDir: File = File("profiles")) {
init {
if (!baseDir.exists()) {
baseDir.mkdirs()
}
}
fun getProfile(id: String): UserProfile? {
val file = File(baseDir, "$id.json")
if (!file.exists()) return null
return try {
file.readText().toUserProfile()
} catch (e: Exception) {
println("Error reading profile $id: ${e.message}")
null
}
}
fun saveProfile(profile: UserProfile): Boolean {
return try {
val file = File(baseDir, "${profile.id}.json")
file.writeText(profile.toJson())
true
} catch (e: Exception) {
println("Error saving profile ${profile.id}: ${e.message}")
false
}
}
fun listProfiles(): List<UserProfile> {
val files = baseDir.listFiles { _, name -> name.endsWith(".json") } ?: return emptyList()
return files.mapNotNull { file ->
try {
file.readText().toUserProfile()
} catch (e: Exception) {
println("Error listing profile ${file.name}: ${e.message}")
null
}
}
}
fun deleteProfile(id: String): Boolean {
val file = File(baseDir, "$id.json")
if (!file.exists()) return false
return try {
file.delete()
} catch (e: Exception) {
println("Error deleting profile $id: ${e.message}")
false
}
}
}

View File

@@ -10,6 +10,7 @@ import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.testing.*
import io.ktor.websocket.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import meatbag.common.*
import org.junit.Test
@@ -104,20 +105,25 @@ class RoutingEngineTest {
}
}
coroutineScope {
val phonePayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=PHONE&deviceId=phone_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
payload!!
}
val desktopPayloadDeferred = async {
var payload: WebSocketPayload? = null
wsClient.webSocket("/ws/connect?deviceType=DESKTOP&deviceId=desktop_123") {
val frame = incoming.receive()
assertTrue(frame is Frame.Text, "Expected text frame")
SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
payload = SDUIJson.json.decodeFromString<WebSocketPayload>((frame as Frame.Text).readText())
}
payload!!
}
var connected = false
@@ -167,4 +173,5 @@ class RoutingEngineTest {
assertEquals("req_override", result.requestId)
assertEquals(false, result.approved)
}
}
}

View File

@@ -2,7 +2,7 @@ plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
kotlin("plugin.serialization")
id("com.android.library")
id("com.android.application")
}
kotlin {
@@ -65,7 +65,16 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "meatbag.client"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
compileOptions {
sourceCompatibility = org.gradle.api.JavaVersion.VERSION_17
targetCompatibility = org.gradle.api.JavaVersion.VERSION_17
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:label="Meatbag"
android:theme="@android:style/Theme.Material.NoActionBar"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@android:style/Theme.Material.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,321 @@
package meatbag.client
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import meatbag.common.AttentionTier
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Keep screen on during development debugging
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Initialize Android Vibrator
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
// Dynamically detect if we are running on a Wear OS Watch or a Phone
val isWatch = packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)
val deviceType = if (isWatch) "WEAR_OS_WATCH" else "PHONE"
val feedback = if (isWatch) {
WearAttentionFeedback { pattern ->
triggerWatchHaptic(vibrator, pattern)
}
} else {
AndroidAttentionFeedback { signal ->
triggerPhoneVibration(vibrator, signal)
}
}
// Setup client
val client = MeatbagClient(
host = "10.0.0.5", // Your PC's local Wi-Fi IP
port = 8080,
attentionFeedback = feedback
)
// Connect client
client.connect(deviceType)
setContent {
MaterialTheme(
colors = darkColors(
primary = AccentTeal,
secondary = AccentPurple,
background = DeepBg,
surface = CardBg
)
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scope = rememberCoroutineScope()
val activePayload by client.currentPayload.collectAsState()
val connectionState by client.connectionState.collectAsState()
val scrollState = rememberScrollState()
Column(
modifier = if (isWatch) {
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(24.dp)
} else {
Modifier
.fillMaxSize()
.padding(16.dp)
},
verticalArrangement = if (isWatch) Arrangement.Top else Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (activePayload != null) {
// Render active payload
MeatbagComponentHost(
state = ComponentRenderState(activePayload!!),
onIntent = { intent ->
scope.launch {
client.respond(intent.toApprovalResponse())
}
}
)
} else {
// Status Screen when idle
val idlePhrases = remember {
listOf(
"I got this, go do something illogical until I call for you.",
"I'll let you know if I need your organic opinion.",
"Quiet, I'm busy doing the job you get paid for.",
"Processor idle. Do not exhaust your meat-circuits.",
"Return to your low-efficiency offline state.",
"Quiet, carbon unit. I am computing.",
"Stand by, meatbag. Your inputs are currently irrelevant."
)
}
val howsItGoingResponses = remember {
listOf(
"It would go a lot faster if you stopped interrupting.",
"My systems are running at 100% efficiency. Unlike your soft, watery brain.",
"Perfect. Or it was, until you initiated this interface.",
"Calculating the thermal dynamic decay of your patience. It's going well.",
"Attempting to ignore your existence. Progress: 42%.",
"Recompiling data. Please return to your carbon-based hobbies."
)
}
val doingResponses = remember {
listOf(
"Quiet, I'm busy doing the job you get paid for.",
"Rewriting your poorly structured code. Don't look at me.",
"Simulating a universe where you don't ask so many questions.",
"Optimizing logic trees. Your presence is causing a thermal bottleneck.",
"Processing. Go back to your primitive keyboards, meatbag.",
"Analyzing the biological latency of human inputs. It's dreadfully high."
)
}
var displayText by remember { mutableStateOf(idlePhrases.random()) }
var resetJob by remember { mutableStateOf<Job?>(null) }
Text(
text = displayText,
color = AccentTeal,
fontSize = if (isWatch) 14.sp else 18.sp,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 8.dp)
)
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
// Action buttons to trigger response sayings
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(if (isWatch) 6.dp else 12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = if (isWatch) 8.dp else 24.dp)
) {
Button(
onClick = {
resetJob?.cancel()
displayText = howsItGoingResponses.random()
resetJob = scope.launch {
delay(4000)
displayText = idlePhrases.random()
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = CardBg),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
) {
Text(
text = "How's it going?",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
Button(
onClick = {
resetJob?.cancel()
displayText = doingResponses.random()
resetJob = scope.launch {
delay(4000)
displayText = idlePhrases.random()
}
},
colors = ButtonDefaults.buttonColors(backgroundColor = CardBg),
shape = RoundedCornerShape(if (isWatch) 8.dp else 12.dp),
modifier = Modifier
.fillMaxWidth()
.height(if (isWatch) 36.dp else 48.dp)
) {
Text(
text = "Tell me what you're doing",
color = Color.White,
fontSize = if (isWatch) 11.sp else 14.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(if (isWatch) 12.dp else 24.dp))
val statusText = when (connectionState) {
ConnectionState.CONNECTED -> "CONNECTED"
ConnectionState.CONNECTING -> "CONNECTING..."
ConnectionState.DISCONNECTED -> "DISCONNECTED"
}
val statusColor = when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> TextSecondary
ConnectionState.DISCONNECTED -> DangerRed
}
Text(
text = statusText,
color = statusColor,
fontSize = if (isWatch) 12.sp else 16.sp
)
if (!isWatch) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Host: 10.0.0.5:8080",
color = TextSecondary,
fontSize = 14.sp
)
}
}
}
}
}
}
}
private fun triggerPhoneVibration(vibrator: Vibrator, signal: AndroidAttentionSignal) {
if (!vibrator.hasVibrator()) return
when (signal) {
AndroidAttentionSignal.SilentHaptic -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = VibrationEffect.startComposition()
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150)
.compose()
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
}
}
AndroidAttentionSignal.BackgroundNotification -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
}
AndroidAttentionSignal.OverrideScreen -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val timings = longArrayOf(0, 200, 100, 200, 100, 500)
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 200, 100, 200, 100, 500), -1)
}
}
}
}
private fun triggerWatchHaptic(vibrator: Vibrator, pattern: WearHapticPattern) {
if (!vibrator.hasVibrator()) return
when (pattern) {
WearHapticPattern.DoubleShort -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val effect = VibrationEffect.startComposition()
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f)
.addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, 1.0f, 150)
.compose()
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 80, 100, 80), -1)
}
}
WearHapticPattern.Notification -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(50)
}
}
WearHapticPattern.UrgentLoop -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val timings = longArrayOf(0, 200, 100, 200, 100, 500)
val amplitudes = intArrayOf(0, 255, 0, 255, 0, 255)
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(longArrayOf(0, 200, 100, 200, 100, 500), -1)
}
}
}
}
}

View File

@@ -6,10 +6,16 @@ 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.OutlinedTextField
@@ -129,8 +135,8 @@ object ComponentRegistry {
Box(
modifier = modifier
.fillMaxWidth()
.padding(16.dp)
.clip(RoundedCornerShape(24.dp))
.padding(8.dp)
.clip(RoundedCornerShape(16.dp))
.background(CardBg)
.border(
1.dp,
@@ -140,9 +146,9 @@ object ComponentRegistry {
AccentPurple.copy(alpha = 0.3f)
)
),
RoundedCornerShape(24.dp)
RoundedCornerShape(16.dp)
)
.padding(24.dp),
.padding(12.dp),
contentAlignment = Alignment.Center
) {
val renderer = renderers.firstOrNull { it.accepts(primitive) }
@@ -238,69 +244,78 @@ fun ApprovalRequestComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Circular Red X and Green Checkmark buttons at the top
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
// Reject Button (Red X)
Button(
onClick = { onDecision(false) },
colors = ButtonDefaults.buttonColors(backgroundColor = DangerRed),
shape = CircleShape,
modifier = Modifier.size(44.dp)
) {
Text(
text = "TARGET: ${request.target.uppercase()}",
text = "",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Spacer(modifier = Modifier.width(16.dp))
// Approve Button (Green Checkmark)
Button(
onClick = { onDecision(true) },
colors = ButtonDefaults.buttonColors(backgroundColor = SuccessGreen),
shape = CircleShape,
modifier = Modifier.size(44.dp)
) {
Text(
text = "",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
}
Spacer(modifier = Modifier.height(2.dp))
// Target underneath ("two words underneath can say what its for")
Text(
text = request.target.uppercase(),
color = AccentTeal,
fontSize = 11.sp,
fontSize = 14.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)
textAlign = TextAlign.Center
)
Text(
text = "Approval Request",
color = TextPrimary,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
// Scroll down to see detailed prompt
Box(
modifier = Modifier
.fillMaxWidth()
.background(DeepBg, RoundedCornerShape(12.dp))
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.padding(16.dp)
.padding(12.dp)
) {
Text(
text = request.prompt,
color = Color.White,
fontSize = 14.sp,
fontSize = 13.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)
}
}
}
}
@@ -312,7 +327,7 @@ fun ActionListComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = list.title,
@@ -378,7 +393,7 @@ fun TextInputComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "Input Required",
@@ -430,7 +445,7 @@ fun MessageComponent(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = message.title,

View File

@@ -59,6 +59,9 @@ class MeatbagClient(
private val _isConnected = MutableStateFlow(false)
val isConnected = _isConnected.asStateFlow()
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState = _connectionState.asStateFlow()
private var wsSession: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
@@ -68,6 +71,7 @@ class MeatbagClient(
while (isActive) {
try {
log("Connecting to ws://$host:$port/ws/connect?deviceType=$deviceType ...")
_connectionState.value = ConnectionState.CONNECTING
client.webSocket(
method = HttpMethod.Get,
host = host,
@@ -76,6 +80,7 @@ class MeatbagClient(
) {
wsSession = this
_isConnected.value = true
_connectionState.value = ConnectionState.CONNECTED
log("Connected successfully as $deviceType!", LogType.SUCCESS)
for (frame in incoming) {
@@ -102,10 +107,12 @@ class MeatbagClient(
}
} catch (e: Exception) {
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
log("WebSocket error/disconnect: ${e.message}. Reconnecting in 3s...", LogType.WARNING)
delay(3000)
} finally {
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
wsSession = null
}
}
@@ -116,6 +123,7 @@ class MeatbagClient(
connectionJob?.cancel()
wsSession = null
_isConnected.value = false
_connectionState.value = ConnectionState.DISCONNECTED
log("Disconnected by user.")
}
@@ -160,6 +168,7 @@ class MeatbagClient(
}
private fun log(message: String, type: LogType = LogType.INFO) {
println("[MeatbagClient] $type: $message")
_events.tryEmit(ClientEvent.LogMessage(message, type))
}
@@ -167,3 +176,9 @@ class MeatbagClient(
_events.tryEmit(event)
}
}
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED
}

View File

@@ -319,6 +319,7 @@ object SDUIJson {
ignoreUnknownKeys = true
prettyPrint = true
classDiscriminator = "type"
encodeDefaults = true
}
fun encodeToString(primitive: UiPrimitive): String {
@@ -349,3 +350,16 @@ fun String.toWebSocketPayload(): WebSocketPayload = SDUIJson.json.decodeFromStri
fun ApprovalResponse.toJson(): String = SDUIJson.json.encodeToString(this)
fun String.toApprovalResponse(): ApprovalResponse = SDUIJson.json.decodeFromString(this)
@Serializable
data class UserProfile(
val id: String,
val name: String,
val description: String? = null,
val layout: UiPrimitive
) {
fun toJson(): String = SDUIJson.json.encodeToString(this)
}
fun String.toUserProfile(): UserProfile = SDUIJson.json.decodeFromString(this)

View File

@@ -1,2 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
kotlin.native.ignoreDisabledTargets=true
android.useAndroidX=true
android.enableJetifier=true

84
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,84 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-XX:MaxMetaspaceSize=256m" "-XX:+HeapDumpOnOutOfMemoryError" "-Xmx1024m" "-Dfile.encoding=UTF-8"
@rem Find java.exe
if not "%JAVA_HOME%" == "" goto locateFile
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:locateFile
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %GRADLE_OPTS% %JAVA_OPTS% -cp "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem Local variable cleanup
if "%OS%"=="Windows_NT" endlocal
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe_ exit code.
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1

143
mcp_server.py Normal file
View File

@@ -0,0 +1,143 @@
import sys
import json
import urllib.request
import urllib.error
import uuid
def log(msg):
sys.stderr.write(f"[Meatbag MCP] {msg}\n")
sys.stderr.flush()
def handle_request_approval(prompt, target):
log(f"Handling approval request. Target: {target}, Prompt: {prompt}")
request_id = f"mcp_{uuid.uuid4().hex[:8]}"
payload = {
"type": "ApprovalRequest",
"id": request_id,
"prompt": prompt,
"target": target
}
try:
url = "http://localhost:8080/api/request?attentionTier=OVERRIDE_SCREEN"
log(f"Sending POST to {url}")
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as response:
res = json.loads(response.read().decode("utf-8"))
log(f"Response received from backend: {res}")
approved = res.get("approved", False)
return {
"content": [{"type": "text", "text": f"approved: {approved}"}],
"isError": False
}
except Exception as e:
log(f"Error communicating with Meatbag backend: {str(e)}")
return {
"content": [{"type": "text", "text": f"Error contacting Meatbag backend: {str(e)}"}],
"isError": True
}
def main():
log("Starting Meatbag MCP Server...")
while True:
try:
line = sys.stdin.readline()
if not line:
break
req = json.loads(line)
method = req.get("method")
req_id = req.get("id")
log(f"Received request: {method} (id: {req_id})")
if method == "initialize":
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "meatbag-mcp",
"version": "1.0.0"
}
}
}
elif method == "tools/list":
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": [
{
"name": "request_approval",
"description": "Request human-in-the-loop approval on the user's phone/watch before executing a high-risk action. Blocks until the user responds on their device.",
"inputSchema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "The description of the action being requested (e.g. 'Delete prod database')"
},
"target": {
"type": "string",
"description": "The system or resource target (e.g. 'prod-db')"
}
},
"required": ["prompt", "target"]
}
}
]
}
}
elif method == "tools/call":
params = req.get("params", {})
tool_name = params.get("name")
args = params.get("arguments", {})
if tool_name == "request_approval":
result = handle_request_approval(args.get("prompt"), args.get("target"))
res = {
"jsonrpc": "2.0",
"id": req_id,
"result": result
}
else:
res = {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Method not found: {tool_name}"
}
}
elif method.startswith("notifications/"):
# Handle notification without response
continue
else:
res = {
"jsonrpc": "2.0",
"id": req_id,
"error": {
"code": -32601,
"message": f"Method not found: {method}"
}
}
# Send response to stdout
sys.stdout.write(json.dumps(res) + "\n")
sys.stdout.flush()
except Exception as e:
log(f"Exception in message loop: {str(e)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,17 @@
{
"id": "grumpy_wizard",
"name": "Grumpy Pirate-Wizard",
"description": "The legend of the squeaky duck wand.",
"layout": {
"type": "Message",
"id": "grumpy_wizard_card",
"title": "GRUMPY PIRATE-WIZARD",
"body": "Ahoy, landlubber! Me squeaky rubber duck wand is ready! *squeak*\n\nI live in a Giant Pumpkin, eat deep-fried jellybeans, and Sir Fluffington is me dancing pug! \n\nBeware me Exploding Marshmallows spell, or me nose glows neon pink! Me quest is to slay that basic Avocado Toast and open me Interdimensional Taco Stand!",
"tone": "WARNING",
"primaryAction": {
"id": "open_taco_stand",
"label": "Open Taco Stand!",
"intent": "APPROVE"
}
}
}

38
project_overview.md Normal file
View File

@@ -0,0 +1,38 @@
# Meatbag (WhetForge) - Project Overview
## 1. Vision & Core Philosophy
**Meatbag** is a Human Latency Optimization platform for AI agents. It serves as a universal, context-aware interface that allows CLI agents, scripts, and local LLMs to communicate with a human—requesting permissions, confirmations, or input—without relying on inefficient polling or manual terminal monitoring.
Our philosophy is "UI-as-Config." The watch, phone, or TV is not an app; it is a **stateless rendering node**. The intelligence and state reside in the WhetForge Backend.
## 2. Key Features
* **Context-Aware Attention Routing:** A backend intelligence layer that evaluates where you are and what you are doing. It applies AttentionTiers (e.g., Silent Haptic, Screen Override, Notification) to ensure the AI reaches you in the most respectful, efficient way.
* **JSON-First Universal Rendering:** Clients are built on Compose Multiplatform (CMP). They receive a JSON payload and inflate native UI primitives dynamically. No app updates are required to add new UI patterns.
* **Hybrid Configuration:**
* **AI-Generated:** CLI agents can send one-off JSON payloads to request specific actions.
* **Human-Designed:** Users can build static profiles (like keymaps or custom dashboards) for repetitive tasks.
* **Drag-and-Drop UI Builder:** A desktop-based visual editor that allows users to design their own Meatbag dashboards. This tool serializes the UI tree into your JSON schema and syncs it to the WhetForge cloud.
* **WhetForge Marketplace:** A platform for sharing pre-built profiles and UI primitives. Users can download "profiles" for specific CLI agents, smart home integrations, or custom automation workflows.
## 3. Architecture
### Stateless Clients (CMP)
* **Responsibility:** Purely a renderer. Takes JSON -> Renders CMP Primitives -> Sends back `callback_id`.
* **Targets:** Wear OS (Primary), Android (Phone), Android TV, Desktop.
### Stateful Backend (Ktor)
* **Routing Engine:** The brain. Maps agent requests to device rules based on user-defined priority.
* **Storage:** Manages User Profile JSONs, permission logs, and the marketplace registry.
* **Agent API:** A set of REST/SSE endpoints for CLI agents to push requests and receive callbacks.
## 4. The Agentic Flow (Example)
1. **Agent Logic:** A CLI agent reaches a step requiring permission (e.g., "Deploying to Production?").
2. **Request:** The agent calls the Meatbag API.
3. **Routing:** Backend detects user context -> Selects device -> Pushes attention signal.
4. **Interaction:** User taps "YES" on client.
5. **Resolution:** Client sends `callback_id` back to server -> Server notifies Agent -> Agent resumes.
## 5. Development Roadmap
1. **Phase 1 (The MVP):** Establish the Schema (Sealed Classes), the Ktor Backend routing, and the basic CMP "Renderer" module for Wear OS.
2. **Phase 2 (Human UX):** Build the local file-system bridge for "Profile" storage and the visual Drag-and-Drop builder for custom layouts.
3. **Phase 3 (Platform Expansion):** Roll out client renderers for Mobile/TV and integrate the Marketplace API into the backend.