Implement client ConnectionState, satirical idle sayings, response cycling, and complete MVVM/Compose multiplatform scaffolding
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/src/main/kotlin/meatbag/backend/ProfileStore.kt
Normal file
58
backend/src/main/kotlin/meatbag/backend/ProfileStore.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
25
client/src/androidMain/AndroidManifest.xml
Normal file
25
client/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||
321
client/src/androidMain/kotlin/meatbag/client/MainActivity.kt
Normal file
321
client/src/androidMain/kotlin/meatbag/client/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
84
gradlew.bat
vendored
Normal 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
143
mcp_server.py
Normal 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()
|
||||
17
profiles/grumpy_wizard.json
Normal file
17
profiles/grumpy_wizard.json
Normal 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
38
project_overview.md
Normal 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.
|
||||
Reference in New Issue
Block a user