Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,27 @@ ClawWatch now intercepts some commands locally instead of sending them to the mo

- **Set a timer**: say `set a timer for 10 minutes` and ClawWatch will hand it off to the watch's internal timer app.
- **Check pulse**: say `what is my pulse` and ClawWatch will read the watch heart-rate sensor directly.
- **Measure and write pulse**: say `measure my pulse and write it to Google Health` and ClawWatch will take a live watch heart-rate reading and write it to Health Connect when permission is granted.
- **Check vitals**: say `check my vitals` to get a live snapshot of pulse, movement, light, pressure, and other available watch signals.
- **Check the family**: say `what's going on with the family` and ClawWatch will summarize the configured GroupMind room updates.
- **Write to the room**: say `tell the family that I am on my way` or `send message to the room saying ...` and ClawWatch will post to the configured GroupMind room.
- **Check live weather**: say `check weather tomorrow in Berlin` for an Open-Meteo forecast without needing a search API key.
- **Check event context**: say `what's the topic of today's event I'm attending` and ClawWatch will summarize relevant recent GroupMind room context.

Phone/Oura vitals arrive through the Wear Data Layer path `/clawwatch/vitals` as a JSON `payload`, for example:

```json
{
"source": "Oura",
"resting_heart_rate_bpm": 54,
"hrv_rmssd_ms": 42.0,
"sleep_summary": "7h 20m sleep",
"readiness_summary": "good readiness",
"updated_at_epoch_ms": 1777032000000
}
```

The private phone companion should read phone-side Health Connect/Oura records and send that snapshot to the watch. ClawWatch will include fresh phone/Oura data in `check Google Health` and `check my vitals` responses, falling back clearly when no phone snapshot has arrived.

This means ClawWatch can act as a real local watch agent instead of bluffing about capabilities it does not have.

Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />

<!-- For Health Connect intent filter -->
<queries>
Expand Down
91 changes: 88 additions & 3 deletions app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,19 @@ class ClawRunner(private val context: Context) {
else -> "Overcast"
}

suspend fun weatherSummary(query: String): Result<String> = withContext(Dispatchers.IO) {
val result = wttrSearch(query).firstOrNull()
?: return@withContext Result.failure(RuntimeException("I couldn't get the weather right now."))
Result.success(result.second.take(260))
}

private fun wttrSearch(query: String): List<Pair<String, String>> {
return try {
// Extract location from query
val location = query.lowercase()
.replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tonight|now|currently|right now|please|tell me)\\b"), " ")
val lowerQuery = query.lowercase()
val wantsTomorrow = lowerQuery.contains("tomorrow")
val location = lowerQuery
.replace(Regex("\\b(weather|forecast|temperature|what's?|what is|the|is|in|today|tomorrow|tonight|now|currently|right now|please|tell me)\\b"), " ")
.trim().replace(Regex("\\s+"), " ").ifBlank { "Berlin" }

// Step 1: geocode the location
Expand All @@ -403,14 +411,30 @@ class ClawRunner(private val context: Context) {
val cityName = place.getString("name")
val country = place.optString("country", "")

// Step 2: get current weather
// Step 2: get current weather and tomorrow forecast
val wxUrl = URL("https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon" +
"&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,apparent_temperature" +
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max" +
"&forecast_days=2&timezone=auto" +
"&temperature_unit=celsius&wind_speed_unit=kmh&format=json")
val wxConn = wxUrl.openConnection() as HttpURLConnection
wxConn.connectTimeout = 8_000; wxConn.readTimeout = 8_000
if (wxConn.responseCode != 200) return emptyList()
val wxJson = JSONObject(wxConn.inputStream.bufferedReader().readText())
if (wantsTomorrow) {
val daily = wxJson.getJSONObject("daily")
val idx = 1
val desc = wmoDescription(daily.getJSONArray("weather_code").getInt(idx))
val max = daily.getJSONArray("temperature_2m_max").getDouble(idx)
val min = daily.getJSONArray("temperature_2m_min").getDouble(idx)
val rain = daily.optJSONArray("precipitation_probability_max")?.optInt(idx, -1) ?: -1
val wind = daily.optJSONArray("wind_speed_10m_max")?.optDouble(idx, -1.0) ?: -1.0
val rainText = if (rain >= 0) " Rain chance $rain%." else ""
val windText = if (wind >= 0) " Wind up to ${wind} km/h." else ""
val summary = "Tomorrow in $cityName: $desc, ${min} to ${max}°C.$rainText$windText"
Log.i(TAG, "Open-Meteo: $summary")
return listOf(Pair("Tomorrow weather in $cityName, $country", summary))
}
val current = wxJson.getJSONObject("current")

val tempC = current.getDouble("temperature_2m")
Expand Down Expand Up @@ -870,6 +894,52 @@ class ClawRunner(private val context: Context) {
)
}

suspend fun summarizeCurrentEvent(): Result<String> = withContext(Dispatchers.IO) {
val groupMindKey = getGroupMindKey()
?: return@withContext Result.success(
"I don't have room access configured yet, so I can't check today's event context."
)
val recentMessages = fetchRecentFamilyMessages(getGroupMindRooms(), groupMindKey)
val eventMessages = recentMessages.filter { message ->
val body = message.body.lowercase()
body.contains("event") ||
body.contains("ai unplugged") ||
body.contains("gen z talks tech") ||
body.contains("gdg") ||
body.contains("neukölln") ||
body.contains("neukolln") ||
body.contains("cube 221")
}
if (eventMessages.isEmpty()) {
return@withContext Result.success(
"I couldn't find today's event details in the room yet."
)
}

val apiKey = getApiKey()
?: return@withContext Result.success(buildFallbackEventSummary(eventMessages))

val transcript = eventMessages.joinToString("\n") { message ->
"[${message.room}] ${message.createdAt} ${message.from}: ${message.body}"
}
val prompt = buildString {
append("The wearer asks what today's event is about. Use only these room updates.\n")
append("Answer in one or two spoken sentences, with event name, topic, and location if present.\n\n")
append(transcript)
}
val result = callAnthropicMessages(
apiKey = apiKey,
model = getModel(),
maxTokens = 120,
systemPrompt = "You summarize event context for a smartwatch. Be concrete and brief.",
userMessage = prompt
)
result.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.success(buildFallbackEventSummary(eventMessages)) }
)
}

suspend fun postRoomMessage(message: String, requestedRoom: String? = null): Result<String> =
withContext(Dispatchers.IO) {
val groupMindKey = getGroupMindKey()
Expand Down Expand Up @@ -1001,6 +1071,21 @@ class ClawRunner(private val context: Context) {
.take(220)
}

private fun buildFallbackEventSummary(messages: List<FamilyMessage>): String {
val combined = messages.joinToString(" ") { it.body }
val knownEvent = Regex("AI Unplugged[^.\\n]*Gen Z Talks Tech", RegexOption.IGNORE_CASE)
.find(combined)
?.value
?: "AI Unplugged: Gen Z Talks Tech"
val location = when {
combined.contains("CUBE 221", ignoreCase = true) -> "at CUBE 221 on Sonnenallee in Neukölln"
combined.contains("Sonnenallee", ignoreCase = true) -> "on Sonnenallee in Neukölln"
combined.contains("Neukölln", ignoreCase = true) || combined.contains("Neukolln", ignoreCase = true) -> "in Neukölln"
else -> ""
}
return "Today's event is $knownEvent ${location}.".replace(Regex("\\s+"), " ").trim().take(220)
}

// ── Mode 2: Kotlin RAG — pre-search + inject ──────────────────────────────

private suspend fun queryWithKotlinRag(
Expand Down
15 changes: 15 additions & 0 deletions app/src/main/java/com/thinkoff/clawwatch/ConfigSyncService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.json.JSONObject
* /clawwatch/config — model, system_prompt, max_tokens, rag_mode, nullclaw_mode
* /clawwatch/apikey — Anthropic API key
* /clawwatch/bravekey — Brave Search API key
* /clawwatch/vitals — phone/Oura vitals snapshot as JSON payload
*/
class ConfigSyncService : WearableListenerService() {

Expand All @@ -27,6 +28,7 @@ class ConfigSyncService : WearableListenerService() {

override fun onDataChanged(events: DataEventBuffer) {
val runner = ClawRunner(applicationContext)
val phoneVitalsStore = PhoneVitalsStore(applicationContext)

for (event in events) {
if (event.type != DataEvent.TYPE_CHANGED) continue
Expand All @@ -44,9 +46,13 @@ class ConfigSyncService : WearableListenerService() {
json.optString("tavily_key").takeIf { it.isNotBlank() }
?.let { runner.saveTavilyKey(it) }
(json.optString("groupmind_key").takeIf { it.isNotBlank() }
?: json.optString("groupmind_api_key").takeIf { it.isNotBlank() }
?: json.optString("clawhub_api_key").takeIf { it.isNotBlank() }
?: json.optString("clawhub_key").takeIf { it.isNotBlank() }
?: json.optString("antfarm_key").takeIf { it.isNotBlank() })
?.let { runner.saveGroupMindKey(it) }
(json.optString("groupmind_rooms").takeIf { it.isNotBlank() }
?: json.optString("clawhub_rooms").takeIf { it.isNotBlank() }
?: json.optString("antfarm_rooms").takeIf { it.isNotBlank() })
?.let { runner.saveGroupMindRooms(it) }
json.optString("model").takeIf { it.isNotBlank() }
Expand All @@ -64,6 +70,15 @@ class ConfigSyncService : WearableListenerService() {
Log.e(TAG, "Config sync burst error: ${e.message}")
}
}
"/clawwatch/vitals" -> {
val payload = map.getString("payload") ?: continue
try {
phoneVitalsStore.saveFromJson(payload)
Log.i(TAG, "Phone vitals synced from companion")
} catch (e: Exception) {
Log.e(TAG, "Phone vitals sync error: ${e.message}")
}
}
}
}
}
Expand Down
104 changes: 102 additions & 2 deletions app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class MainActivity : AppCompatActivity() {
private enum class State { SETUP, IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR }
private enum class AvatarType { ANT, LOBSTER, ORANGE_LOBSTER, ROBOT, BOY, GIRL }
private enum class AvatarState { IDLE, LISTENING, THINKING, SEARCHING, SPEAKING, ERROR }
private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, FAMILY_STATUS }
private enum class LocalCommandType { VITALS_SNAPSHOT, HEART_RATE, MEASURE_PULSE_WRITE, FAMILY_STATUS, WEATHER, EVENT_TOPIC }
private data class TimerCommand(
val totalSeconds: Int,
val spokenDuration: String
Expand Down Expand Up @@ -509,6 +509,14 @@ class MainActivity : AppCompatActivity() {
}

private fun handleLocalCommand(prompt: String, token: Int): Boolean {
if (isWeatherCommand(prompt)) {
launchWeatherCommand(prompt, token)
return true
}
if (isEventTopicCommand(prompt)) {
launchEventTopicCommand(token)
return true
}
parseVitalsCommand(prompt)?.let { command ->
launchVitalsCommand(command, token)
return true
Expand All @@ -527,6 +535,19 @@ class MainActivity : AppCompatActivity() {

private fun parseVitalsCommand(prompt: String): LocalCommandType? {
val normalized = prompt.lowercase()
val healthSubject = normalized.contains("google health") ||
normalized.contains("health connect") ||
normalized.contains("oura") ||
normalized.contains("my health")
if (healthSubject) {
return LocalCommandType.VITALS_SNAPSHOT
}
if (
(normalized.contains("measure") || normalized.contains("write") || normalized.contains("record")) &&
(normalized.contains("pulse") || normalized.contains("heart rate") || normalized.contains("heartbeat"))
) {
return LocalCommandType.MEASURE_PULSE_WRITE
}
if (
normalized.contains("pulse") ||
normalized.contains("heart rate") ||
Expand All @@ -545,6 +566,29 @@ class MainActivity : AppCompatActivity() {
return null
}

private fun isWeatherCommand(prompt: String): Boolean {
val normalized = prompt.lowercase()
return normalized.contains("weather") ||
normalized.contains("forecast") ||
normalized.contains("temperature tomorrow") ||
normalized.contains("rain tomorrow")
}

private fun isEventTopicCommand(prompt: String): Boolean {
val normalized = prompt.lowercase()
val hasEvent = normalized.contains("event") ||
normalized.contains("attending") ||
normalized.contains("today's topic") ||
normalized.contains("todays topic")
return hasEvent && (
normalized.contains("topic") ||
normalized.contains("what is") ||
normalized.contains("what's") ||
normalized.contains("where") ||
normalized.contains("attending")
)
}

private fun isFamilyStatusCommand(prompt: String): Boolean {
val normalized = prompt.lowercase()
val hasSubject = normalized.contains("family") ||
Expand Down Expand Up @@ -700,11 +744,19 @@ class MainActivity : AppCompatActivity() {

private fun requiredVitalsPermissions(command: LocalCommandType): List<String> {
val permissions = mutableListOf<String>()
if (command == LocalCommandType.HEART_RATE || command == LocalCommandType.VITALS_SNAPSHOT) {
if (command == LocalCommandType.HEART_RATE ||
command == LocalCommandType.MEASURE_PULSE_WRITE ||
command == LocalCommandType.VITALS_SNAPSHOT
) {
if (!hasPermission("android.permission.health.READ_HEART_RATE")) {
permissions += "android.permission.health.READ_HEART_RATE"
}
}
if (command == LocalCommandType.MEASURE_PULSE_WRITE &&
!hasPermission("android.permission.health.WRITE_HEART_RATE")
) {
permissions += "android.permission.health.WRITE_HEART_RATE"
}
if (command == LocalCommandType.VITALS_SNAPSHOT && !hasPermission(Manifest.permission.ACTIVITY_RECOGNITION)) {
permissions += Manifest.permission.ACTIVITY_RECOGNITION
}
Expand Down Expand Up @@ -735,6 +787,14 @@ class MainActivity : AppCompatActivity() {

val response = when (command) {
LocalCommandType.VITALS_SNAPSHOT -> buildVitalsSummary(snapshot, canReadHeartRate, canReadSteps)
LocalCommandType.MEASURE_PULSE_WRITE -> {
val bpm = vitalsReader.measurePulseAndWriteToHealthConnect()
if (bpm != null) {
"Your pulse is $bpm beats per minute. I wrote it to Health Connect."
} else {
"I couldn't get a live pulse reading right now."
}
}
LocalCommandType.HEART_RATE -> {
if (snapshot.heartRateBpm != null) {
"Your last recorded pulse is ${snapshot.heartRateBpm} beats per minute."
Expand All @@ -743,12 +803,40 @@ class MainActivity : AppCompatActivity() {
}
}
LocalCommandType.FAMILY_STATUS -> "I couldn't check the family yet."
LocalCommandType.WEATHER -> "I couldn't check the weather yet."
LocalCommandType.EVENT_TOPIC -> "I couldn't check today's event yet."
}
binding.responseText.text = response
speakLocalResponse(response, token)
}
}

private fun launchWeatherCommand(prompt: String, token: Int) {
queryJob?.cancel()
setState(State.SEARCHING)
setStatus("Checking weather…")
queryJob = lifecycleScope.launch {
val result = clawRunner.weatherSummary(prompt)
if (token != interactionToken) return@launch
val response = result.getOrElse { "I couldn't get the weather right now." }
binding.responseText.text = response
speakLocalResponse(response, token)
}
}

private fun launchEventTopicCommand(token: Int) {
queryJob?.cancel()
setState(State.THINKING)
setStatus("Checking today's event…")
queryJob = lifecycleScope.launch {
val result = clawRunner.summarizeCurrentEvent()
if (token != interactionToken) return@launch
val response = result.getOrElse { "I couldn't check today's event right now." }
binding.responseText.text = response
speakLocalResponse(response, token)
}
}

private fun launchFamilyStatusCommand(token: Int) {
queryJob?.cancel()
setState(State.THINKING)
Expand Down Expand Up @@ -813,6 +901,18 @@ class MainActivity : AppCompatActivity() {
} else if (!canReadSteps) {
details += "Step count is unavailable until you allow activity access."
}
snapshot.phoneVitals?.takeIf { it.isFresh() }?.let { phoneVitals ->
val phoneDetails = mutableListOf<String>()
phoneVitals.restingHeartRateBpm?.let { phoneDetails += "resting pulse $it" }
phoneVitals.hrvRmssdMs?.let { phoneDetails += "HRV ${it.toInt()} milliseconds" }
phoneVitals.sleepSummary?.let { phoneDetails += it }
phoneVitals.readinessSummary?.let { phoneDetails += it }
if (phoneDetails.isNotEmpty()) {
details += "${phoneVitals.source.replaceFirstChar { it.uppercase() }} phone data: ${phoneDetails.joinToString(", ")}."
}
} ?: run {
details += "No fresh phone or Oura vitals have arrived yet."
}
details += describeRecoveryState(snapshot, snapshot.heartRateBpm)
return details.joinToString(" ").take(320)
}
Expand Down
Loading