From 3910b1ba5050d6fec5606929e07a3111f513d7a3 Mon Sep 17 00:00:00 2001 From: Melody Gui <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:14:15 -0500 Subject: [PATCH 01/12] Add proactive weather main.py Implement weather capability to fetch and summarize weather data based on user location. Signed-off-by: Melody Gui <122416115+melodygui@users.noreply.github.com> --- community/proactive_weather/main.py | 174 ++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 community/proactive_weather/main.py diff --git a/community/proactive_weather/main.py b/community/proactive_weather/main.py new file mode 100644 index 00000000..2109e7a3 --- /dev/null +++ b/community/proactive_weather/main.py @@ -0,0 +1,174 @@ +import json +import requests +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +GEOCODE_URL = "https://nominatim.openstreetmap.org/search" +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" +LOCATION_KEY = "weather_user_location" +WEATHER_JSON = "weather_data.json" +WEATHER_MD = "local_weather.md" + +SUMMARIZE_PROMPT = """ +Here is current weather data: {weather_data}. +Summarize in 2-3 sentences, voice-friendly. Only mention what affects the user's day: +temperature, precipitation, wind if notable, and anything severe. +Do not read out every field. Keep it natural and conversational. +""" + +WEATHER_MD_PROMPT = """ +Here is current weather data: {weather_data} for {location}. +Write a concise markdown summary (under 200 words) for a voice assistant's background context. +Use bullet points under a ## header. Include: current conditions, today's high/low, +precipitation outlook, and any active severe conditions. Write current state only, not history. +""" + + +class WeatherCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # {{register_capability}} + + def parse_location(self, raw: str) -> str: + result = self.capability_worker.text_to_text_response( + f"Extract ONLY the city name from this text: '{raw}'. " + "Reply with the city name only — no other words, no punctuation, no explanation. " + "Examples: 'Los Angeles' or 'London' or 'Chicago, IL'. " + "If there is absolutely no city mentioned, reply with exactly: NONE" + ) + result = result.strip() + if result.upper() == "NONE" or not result: + return "" + return result + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + async def run(self): + # self.capability_worker.delete_key(LOCATION_KEY) # temporary line to clear persistent storage from prev session + try: + # confirm location is persistently saved + trigger = await self.capability_worker.wait_for_complete_transcription() + saved = self.capability_worker.get_single_key(LOCATION_KEY) + self.worker.editor_logging_handler.info(f"[Weather] get_single_key returned: {saved}") + + # Check if we have a saved location + saved = self.capability_worker.get_single_key(LOCATION_KEY) + saved_value = saved.get("value") if saved else None + if saved_value and saved_value.get("city"): + location = saved_value["city"] + await self.capability_worker.speak(f"Checking the weather in {location}.") + else: + # Try to extract location from the trigger phrase first + extracted = self.parse_location(trigger) + if extracted: + location = extracted + self.capability_worker.create_key(LOCATION_KEY, {"city": location}) + await self.capability_worker.speak(f"Checking the weather in {location}.") + else: + # Nothing in trigger, ask explicitly + raw = await self.capability_worker.run_io_loop( + "Which city would you like the weather for?" + ) + location = self.parse_location(raw) + if not location: + await self.capability_worker.speak("I didn't catch that. Try again later.") + self.capability_worker.resume_normal_flow() + return + self.capability_worker.create_key(LOCATION_KEY, {"city": location}) + self.worker.editor_logging_handler.info(f"[Weather] Saved location: {location}") # confirm location is saved + await self.capability_worker.speak(f"Got it, checking {location}.") + + # Geocode + lat, lon = self.geocode(location) + if lat is None: + await self.capability_worker.speak( + "I couldn't find that location. Try a different city name." + ) + self.capability_worker.resume_normal_flow() + return + + # Fetch weather + weather_data = self.fetch_weather(lat, lon) + if not weather_data: + await self.capability_worker.speak( + "Sorry, I couldn't get the weather right now." + ) + self.capability_worker.resume_normal_flow() + return + + # Speak a concise summary + summary = self.capability_worker.text_to_text_response( + SUMMARIZE_PROMPT.format(weather_data=json.dumps(weather_data)) + ) + await self.capability_worker.speak(summary) + + # Write local_weather.md for the Memory Watcher + await self.write_weather_md(location, weather_data) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Error: {e}") + await self.capability_worker.speak("Sorry, something went wrong.") + finally: + self.capability_worker.resume_normal_flow() + + def geocode(self, location: str): + try: + resp = requests.get( + GEOCODE_URL, + params={"q": location, "format": "json", "limit": 1}, + headers={"User-Agent": "OpenHome-Weather-Ability"}, + timeout=10, + ) + data = resp.json() + if data: + return float(data[0]["lat"]), float(data[0]["lon"]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Geocode failed: {e}") + return None, None + + def fetch_weather(self, lat: float, lon: float) -> dict: + try: + resp = requests.get( + WEATHER_URL, + params={ + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weathercode,windspeed_10m,precipitation", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode", + "temperature_unit": "fahrenheit", + "forecast_days": 1, + "timezone": "auto", + }, + timeout=10, + ) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Fetch failed: {e}") + return None + + + async def write_weather_md(self, location: str, weather_data: dict): + """Write local_weather.md for Memory Watcher injection into Personality prompt.""" + try: + content = self.capability_worker.text_to_text_response( + WEATHER_MD_PROMPT.format( + weather_data=json.dumps(weather_data), + location=location + ) + ) + + # required write pattern for context files + exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) + await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) + + self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") From b3941f38680a486620a6ef3dd69c2819fb142af6 Mon Sep 17 00:00:00 2001 From: Melody Gui <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:15:09 -0500 Subject: [PATCH 02/12] add Proactive Weather background.py Signed-off-by: Melody Gui <122416115+melodygui@users.noreply.github.com> --- community/proactive_weather/background.py | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 community/proactive_weather/background.py diff --git a/community/proactive_weather/background.py b/community/proactive_weather/background.py new file mode 100644 index 00000000..7ce91da6 --- /dev/null +++ b/community/proactive_weather/background.py @@ -0,0 +1,197 @@ +import json +import requests +from time import time +from src.agent.capability import MatchingCapability +from src.main import AgentWorker +from src.agent.capability_worker import CapabilityWorker + +GEOCODE_URL = "https://nominatim.openstreetmap.org/search" +WEATHER_URL = "https://api.open-meteo.com/v1/forecast" +LOCATION_KEY = "weather_user_location" +WEATHER_JSON = "weather_data.json" +WEATHER_MD = "local_weather.md" +ALERTS_JSON = "weather_alerts_state.json" +POLL_INTERVAL = 600.0 # 10 minutes + +SEVERE_CODES = { + 55, 56, 57, + 65, 66, 67, + 75, 77, + 82, + 85, 86, + 95, 96, 99, +} + +WMO_DESCRIPTIONS = { + 55: "heavy drizzle", 56: "light freezing drizzle", 57: "dense freezing drizzle", + 65: "heavy rain", 66: "light freezing rain", 67: "heavy freezing rain", + 75: "heavy snowfall", 77: "snow grains", + 82: "violent rain showers", + 85: "heavy snow showers", 86: "heavy snow showers", + 95: "thunderstorm", + 96: "thunderstorm with slight hail", 99: "thunderstorm with heavy hail", +} + +ALERT_PROMPT = """ +Severe weather detected: {description} (WMO code {code}). +Current conditions: {current}. +Write a short 1-2 sentence calm but urgent voice alert to warn the user. +""" + +WEATHER_MD_PROMPT = """ +Here is current weather data: {weather_data} for {location}. +Write a concise markdown summary (under 200 words) for a voice assistant's background context. +Use bullet points under a ## header. Include: current conditions, today's high/low, +precipitation outlook, and any active severe conditions. Write current state only, not history. +""" + + +class WeatheralertCapabilityBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + #{{register capability}} + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.worker = worker + self.background_daemon_mode = background_daemon_mode + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.watch_weather()) + + async def watch_weather(self): + self.worker.editor_logging_handler.info(f"[WeatherDaemon] Started at {time()}") + + # Clear stale files from previous session before first cycle + await self.clear_stale_files() + + while True: + try: + saved = self.capability_worker.get_single_key(LOCATION_KEY) + saved_value = saved.get("value") if saved else None + if saved_value and saved_value.get("city"): + lat, lon = self.geocode(saved_value["city"]) + if lat is not None: + weather_data = self.fetch_weather(lat, lon) + if weather_data: + await self.check_for_alerts(weather_data) + await self.write_weather_md(saved["city"], weather_data) + else: + self.worker.editor_logging_handler.info( + "[WeatherDaemon] No saved location yet, skipping poll." + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Loop error: {e}") + + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + async def clear_stale_files(self): + """Clear .md and alerts state from previous session on startup.""" + for filename in [WEATHER_MD, ALERTS_JSON]: + try: + exists = await self.capability_worker.check_if_file_exists(filename, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(filename, in_ability_directory=False) + self.worker.editor_logging_handler.info( + f"[WeatherDaemon] Cleared stale file: {filename}" + ) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[WeatherDaemon] Failed to clear {filename}: {e}" + ) + + def geocode(self, location: str): + try: + resp = requests.get( + GEOCODE_URL, + params={"q": location, "format": "json", "limit": 1}, + headers={"User-Agent": "OpenHome-Weather-Ability"}, + timeout=10, + ) + data = resp.json() + if data: + return float(data[0]["lat"]), float(data[0]["lon"]) + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Geocode failed: {e}") + return None, None + + def fetch_weather(self, lat: float, lon: float) -> dict: + try: + resp = requests.get( + WEATHER_URL, + params={ + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,weathercode,windspeed_10m,precipitation", + "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weathercode", + "temperature_unit": "fahrenheit", + "forecast_days": 1, + "timezone": "auto", + }, + timeout=10, + ) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Fetch failed: {e}") + return None + + async def check_for_alerts(self, weather_data: dict): + try: + current = weather_data.get("current", {}) + code = current.get("weathercode") + if code not in SEVERE_CODES: + return + + # Load dedup state + fired_codes = [] + exists = await self.capability_worker.check_if_file_exists(ALERTS_JSON, False) + if exists: + raw = await self.capability_worker.read_file(ALERTS_JSON, False) + try: + fired_codes = json.loads(raw).get("fired_codes", []) + except Exception: + fired_codes = [] + + if code in fired_codes: + return + + # Generate and speak alert + description = WMO_DESCRIPTIONS.get(code, "severe weather") + alert_text = self.capability_worker.text_to_text_response( + ALERT_PROMPT.format( + code=code, + description=description, + current=json.dumps(current), + ) + ) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(alert_text) + + # Save fired code to dedup state + fired_codes.append(code) + await self.capability_worker.write_file(ALERTS_JSON, json.dumps({"fired_codes": fired_codes}), False) + self.worker.editor_logging_handler.info(f"[WeatherDaemon] Fired alert for code {code}") + except Exception as e: + self.worker.editor_logging_handler.error(f"[WeatherDaemon] Alert error: {e}") + + async def write_weather_md(self, location: str, weather_data: dict): + """Write local_weather.md for Memory Watcher injection into Personality prompt.""" + try: + content = self.capability_worker.text_to_text_response( + WEATHER_MD_PROMPT.format( + weather_data=json.dumps(weather_data), + location=location + ) + ) + + # required write pattern for context files + exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) + if exists: + await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) + await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) + + self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") + + except Exception as e: + self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") From 49dae30c695a7339c8c65929a7620d31b34de837 Mon Sep 17 00:00:00 2001 From: Melody Gui <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:17:40 -0500 Subject: [PATCH 03/12] Add README for Proactive Weather ability Added a README for the Proactive Weather ability, detailing installation and usage instructions. Signed-off-by: Melody Gui <122416115+melodygui@users.noreply.github.com> --- community/proactive_weather/README.md | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 community/proactive_weather/README.md diff --git a/community/proactive_weather/README.md b/community/proactive_weather/README.md new file mode 100644 index 00000000..de97d510 --- /dev/null +++ b/community/proactive_weather/README.md @@ -0,0 +1,28 @@ +# Proactive Weather + +A weather ability for OpenHome with two components: a reactive flow for on-demand +weather queries, and a background daemon that monitors conditions and proactively +alerts you to severe weather. + +No API key required. Uses Open-Meteo (weather data) and Nominatim (geocoding), +both free with no authentication needed. + +## Installation + +1. Upload the ability folder to OpenHome +2. Set trigger words in the dashboard (e.g. "what's the weather", "weather") +3. Select **Skill** as the ability category + +## Usage + +**Reactive:** Say a trigger phrase to get current conditions and today's forecast. +On first use the ability asks for your city and saves it for future sessions. + +**Proactive:** The background daemon starts automatically on session connect. If +severe weather is detected (thunderstorms, heavy rain, hail, etc.), it interrupts +the conversation with an alert. Each alert type fires once per session. + +**Personality awareness:** After fetching weather, `local_weather.md` is written +to persistent storage. The Memory Watcher picks it up within ~60-90 seconds and +injects it into the Personality's system prompt so it can naturally reference +weather conditions without being asked. From 89790db6cc6a9855cb2d97b941eadc7d9e77a73a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 00:45:09 +0000 Subject: [PATCH 04/12] style: auto-format Python files with autoflake + autopep8 --- community/proactive_weather/background.py | 12 ++++++------ community/proactive_weather/main.py | 19 +++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/community/proactive_weather/background.py b/community/proactive_weather/background.py index 7ce91da6..94f7b5b2 100644 --- a/community/proactive_weather/background.py +++ b/community/proactive_weather/background.py @@ -52,7 +52,7 @@ class WeatheralertCapabilityBackground(MatchingCapability): background_daemon_mode: bool = False # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker, background_daemon_mode: bool): self.worker = worker @@ -71,12 +71,12 @@ async def watch_weather(self): saved = self.capability_worker.get_single_key(LOCATION_KEY) saved_value = saved.get("value") if saved else None if saved_value and saved_value.get("city"): - lat, lon = self.geocode(saved_value["city"]) + lat, lon = self.geocode(saved_value["city"]) if lat is not None: weather_data = self.fetch_weather(lat, lon) if weather_data: await self.check_for_alerts(weather_data) - await self.write_weather_md(saved["city"], weather_data) + await self.write_weather_md(saved["city"], weather_data) else: self.worker.editor_logging_handler.info( "[WeatherDaemon] No saved location yet, skipping poll." @@ -185,13 +185,13 @@ async def write_weather_md(self, location: str, weather_data: dict): ) ) - # required write pattern for context files + # required write pattern for context files exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) if exists: await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") - + except Exception as e: - self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") + self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") diff --git a/community/proactive_weather/main.py b/community/proactive_weather/main.py index 2109e7a3..3c379bfa 100644 --- a/community/proactive_weather/main.py +++ b/community/proactive_weather/main.py @@ -55,13 +55,13 @@ async def run(self): trigger = await self.capability_worker.wait_for_complete_transcription() saved = self.capability_worker.get_single_key(LOCATION_KEY) self.worker.editor_logging_handler.info(f"[Weather] get_single_key returned: {saved}") - + # Check if we have a saved location saved = self.capability_worker.get_single_key(LOCATION_KEY) saved_value = saved.get("value") if saved else None if saved_value and saved_value.get("city"): location = saved_value["city"] - await self.capability_worker.speak(f"Checking the weather in {location}.") + await self.capability_worker.speak(f"Checking the weather in {location}.") else: # Try to extract location from the trigger phrase first extracted = self.parse_location(trigger) @@ -80,8 +80,8 @@ async def run(self): self.capability_worker.resume_normal_flow() return self.capability_worker.create_key(LOCATION_KEY, {"city": location}) - self.worker.editor_logging_handler.info(f"[Weather] Saved location: {location}") # confirm location is saved - await self.capability_worker.speak(f"Got it, checking {location}.") + self.worker.editor_logging_handler.info(f"[Weather] Saved location: {location}") # confirm location is saved + await self.capability_worker.speak(f"Got it, checking {location}.") # Geocode lat, lon = self.geocode(location) @@ -107,7 +107,7 @@ async def run(self): ) await self.capability_worker.speak(summary) - # Write local_weather.md for the Memory Watcher + # Write local_weather.md for the Memory Watcher await self.write_weather_md(location, weather_data) except Exception as e: @@ -150,8 +150,7 @@ def fetch_weather(self, lat: float, lon: float) -> dict: except Exception as e: self.worker.editor_logging_handler.error(f"[Weather] Fetch failed: {e}") return None - - + async def write_weather_md(self, location: str, weather_data: dict): """Write local_weather.md for Memory Watcher injection into Personality prompt.""" try: @@ -162,13 +161,13 @@ async def write_weather_md(self, location: str, weather_data: dict): ) ) - # required write pattern for context files + # required write pattern for context files exists = await self.capability_worker.check_if_file_exists(WEATHER_MD, in_ability_directory=False) if exists: await self.capability_worker.delete_file(WEATHER_MD, in_ability_directory=False) await self.capability_worker.write_file(WEATHER_MD, content, in_ability_directory=False) - self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") - + self.worker.editor_logging_handler.info("[Weather] Wrote local_weather.md") + except Exception as e: self.worker.editor_logging_handler.error(f"[Weather] Failed to write md: {e}") From a5527c5eecc5a21ffafd1ddd46d412969fa720b2 Mon Sep 17 00:00:00 2001 From: Melody <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:11:43 -0700 Subject: [PATCH 05/12] Rename folder to proactive-weather --- community/{proactive_weather => proactive-weather}/README.md | 0 community/{proactive_weather => proactive-weather}/background.py | 0 community/{proactive_weather => proactive-weather}/main.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename community/{proactive_weather => proactive-weather}/README.md (100%) rename community/{proactive_weather => proactive-weather}/background.py (100%) rename community/{proactive_weather => proactive-weather}/main.py (100%) diff --git a/community/proactive_weather/README.md b/community/proactive-weather/README.md similarity index 100% rename from community/proactive_weather/README.md rename to community/proactive-weather/README.md diff --git a/community/proactive_weather/background.py b/community/proactive-weather/background.py similarity index 100% rename from community/proactive_weather/background.py rename to community/proactive-weather/background.py diff --git a/community/proactive_weather/main.py b/community/proactive-weather/main.py similarity index 100% rename from community/proactive_weather/main.py rename to community/proactive-weather/main.py From d3165a64bb9d0b89e3b678973b93dd0b2bcad4da Mon Sep 17 00:00:00 2001 From: Melody Gui <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:13:40 -0500 Subject: [PATCH 06/12] Add __init__.py Signed-off-by: Melody Gui <122416115+melodygui@users.noreply.github.com> --- community/proactive-weather/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 community/proactive-weather/__init__.py diff --git a/community/proactive-weather/__init__.py b/community/proactive-weather/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/community/proactive-weather/__init__.py @@ -0,0 +1 @@ + From ad16ede634cad32d55381d261b9f3e7f1c4309c7 Mon Sep 17 00:00:00 2001 From: Melody Gui <122416115+melodygui@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:15:25 -0500 Subject: [PATCH 07/12] Fix Flake8 Errors Signed-off-by: Melody Gui <122416115+melodygui@users.noreply.github.com> --- community/proactive-weather/main.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/community/proactive-weather/main.py b/community/proactive-weather/main.py index 3c379bfa..6631df6a 100644 --- a/community/proactive-weather/main.py +++ b/community/proactive-weather/main.py @@ -20,7 +20,7 @@ WEATHER_MD_PROMPT = """ Here is current weather data: {weather_data} for {location}. Write a concise markdown summary (under 200 words) for a voice assistant's background context. -Use bullet points under a ## header. Include: current conditions, today's high/low, +Use bullet points under a ## header. Include: current conditions, today's high/low, precipitation outlook, and any active severe conditions. Write current state only, not history. """ @@ -48,8 +48,7 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) - async def run(self): - # self.capability_worker.delete_key(LOCATION_KEY) # temporary line to clear persistent storage from prev session + async def run(self): try: # confirm location is persistently saved trigger = await self.capability_worker.wait_for_complete_transcription() From 53171a533c525388498d3034050a86a51a138fca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 30 Mar 2026 01:15:37 +0000 Subject: [PATCH 08/12] style: auto-format Python files with autoflake + autopep8 --- community/proactive-weather/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/proactive-weather/main.py b/community/proactive-weather/main.py index 6631df6a..d3bf8267 100644 --- a/community/proactive-weather/main.py +++ b/community/proactive-weather/main.py @@ -48,7 +48,7 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.worker.session_tasks.create(self.run()) - async def run(self): + async def run(self): try: # confirm location is persistently saved trigger = await self.capability_worker.wait_for_complete_transcription() From c0b1588a0d1c304d63a04ef12dcf3f7ef8d42fe3 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Tue, 31 Mar 2026 16:02:02 +0500 Subject: [PATCH 09/12] Implement city update handling and location parsing Added methods to handle city updates and location parsing for weather checks. Signed-off-by: Uzair Ullah --- community/proactive-weather/main.py | 108 ++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/community/proactive-weather/main.py b/community/proactive-weather/main.py index d3bf8267..c8c0628d 100644 --- a/community/proactive-weather/main.py +++ b/community/proactive-weather/main.py @@ -32,6 +32,7 @@ class WeatherCapability(MatchingCapability): # {{register_capability}} def parse_location(self, raw: str) -> str: + """Extract city name from user text using LLM.""" result = self.capability_worker.text_to_text_response( f"Extract ONLY the city name from this text: '{raw}'. " "Reply with the city name only — no other words, no punctuation, no explanation. " @@ -43,6 +44,34 @@ def parse_location(self, raw: str) -> str: return "" return result + def is_update_city_intent(self, text: str) -> bool: + """Check if user wants to change their saved city.""" + result = self.capability_worker.text_to_text_response( + f"Does this text mean the user wants to change, update, or switch their weather city/location? " + f"Examples that mean YES: 'change my city', 'update my location', 'switch to London', " + f"'set my city to Paris', 'use a different city'. " + f"Examples that mean NO: 'what's the weather', 'will it rain', 'weather in Tokyo'. " + f'Text: "{text}"\nReply YES or NO only.' + ) + return result.strip().upper().startswith("Y") + + def get_saved_location(self) -> str: + """Get saved city from KV storage, or empty string.""" + saved = self.capability_worker.get_single_key(LOCATION_KEY) + saved_value = saved.get("value") if saved else None + if saved_value and saved_value.get("city"): + return saved_value["city"] + return "" + + def save_location(self, city: str): + """Save city to KV storage using create-or-update pattern.""" + existing = self.capability_worker.get_single_key(LOCATION_KEY) + if existing and existing.get("value"): + self.capability_worker.update_key(LOCATION_KEY, {"city": city}) + else: + self.capability_worker.create_key(LOCATION_KEY, {"city": city}) + self.worker.editor_logging_handler.info(f"[Weather] Saved location: {city}") + def call(self, worker: AgentWorker): self.worker = worker self.capability_worker = CapabilityWorker(self.worker) @@ -50,37 +79,47 @@ def call(self, worker: AgentWorker): async def run(self): try: - # confirm location is persistently saved trigger = await self.capability_worker.wait_for_complete_transcription() - saved = self.capability_worker.get_single_key(LOCATION_KEY) - self.worker.editor_logging_handler.info(f"[Weather] get_single_key returned: {saved}") - - # Check if we have a saved location - saved = self.capability_worker.get_single_key(LOCATION_KEY) - saved_value = saved.get("value") if saved else None - if saved_value and saved_value.get("city"): - location = saved_value["city"] + self.worker.editor_logging_handler.info(f"[Weather] Trigger: {trigger}") + + # Check if user wants to update their city + if trigger and self.is_update_city_intent(trigger): + await self.handle_update_city(trigger) + return + + # Check for saved location + location = self.get_saved_location() + + if location: + # Check if trigger mentions a different city (one-time override) + extracted = self.parse_location(trigger) if trigger else "" + if extracted and extracted.lower() != location.lower(): + # User asked about a specific city — use it but don't overwrite saved + location = extracted await self.capability_worker.speak(f"Checking the weather in {location}.") else: - # Try to extract location from the trigger phrase first - extracted = self.parse_location(trigger) + # No saved location — try to extract from trigger + extracted = self.parse_location(trigger) if trigger else "" if extracted: location = extracted - self.capability_worker.create_key(LOCATION_KEY, {"city": location}) - await self.capability_worker.speak(f"Checking the weather in {location}.") else: - # Nothing in trigger, ask explicitly raw = await self.capability_worker.run_io_loop( "Which city would you like the weather for?" ) location = self.parse_location(raw) if not location: await self.capability_worker.speak("I didn't catch that. Try again later.") - self.capability_worker.resume_normal_flow() return - self.capability_worker.create_key(LOCATION_KEY, {"city": location}) - self.worker.editor_logging_handler.info(f"[Weather] Saved location: {location}") # confirm location is saved - await self.capability_worker.speak(f"Got it, checking {location}.") + + # Ask if they want to save this city + save = await self.capability_worker.run_confirmation_loop( + f"Would you like me to remember {location} for future weather checks?" + ) + if save: + self.save_location(location) + await self.capability_worker.speak(f"Saved. Checking {location}.") + else: + await self.capability_worker.speak(f"No problem. Checking {location}.") # Geocode lat, lon = self.geocode(location) @@ -88,7 +127,6 @@ async def run(self): await self.capability_worker.speak( "I couldn't find that location. Try a different city name." ) - self.capability_worker.resume_normal_flow() return # Fetch weather @@ -97,7 +135,6 @@ async def run(self): await self.capability_worker.speak( "Sorry, I couldn't get the weather right now." ) - self.capability_worker.resume_normal_flow() return # Speak a concise summary @@ -115,6 +152,37 @@ async def run(self): finally: self.capability_worker.resume_normal_flow() + async def handle_update_city(self, trigger: str): + """Handle user intent to change their saved city.""" + current = self.get_saved_location() + + # Try to extract new city from trigger (e.g. "change my city to London") + new_city = self.parse_location(trigger) + + if not new_city: + raw = await self.capability_worker.run_io_loop( + "Which city would you like to switch to?" + ) + new_city = self.parse_location(raw) + if not new_city: + await self.capability_worker.speak("I didn't catch that.") + return + + if current: + confirm = await self.capability_worker.run_confirmation_loop( + f"Change your weather city from {current} to {new_city}?" + ) + else: + confirm = await self.capability_worker.run_confirmation_loop( + f"Save {new_city} as your weather city?" + ) + + if confirm: + self.save_location(new_city) + await self.capability_worker.speak(f"Done. {new_city} is your weather city now.") + else: + await self.capability_worker.speak("Okay, no changes.") + def geocode(self, location: str): try: resp = requests.get( From 760469783007da9ed38b42bf79484340988af52c Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Tue, 31 Mar 2026 16:02:38 +0500 Subject: [PATCH 10/12] Update background.py Signed-off-by: Uzair Ullah --- community/proactive-weather/background.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/proactive-weather/background.py b/community/proactive-weather/background.py index 94f7b5b2..118a0dbc 100644 --- a/community/proactive-weather/background.py +++ b/community/proactive-weather/background.py @@ -76,7 +76,7 @@ async def watch_weather(self): weather_data = self.fetch_weather(lat, lon) if weather_data: await self.check_for_alerts(weather_data) - await self.write_weather_md(saved["city"], weather_data) + await self.write_weather_md(saved_value["city"], weather_data) else: self.worker.editor_logging_handler.info( "[WeatherDaemon] No saved location yet, skipping poll." From ef4eab8764dd7755b288b13266859f5b9379cee4 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Tue, 31 Mar 2026 16:16:28 +0500 Subject: [PATCH 11/12] Use asyncio for geocoding and weather fetching Refactor weather fetching and geocoding to use asyncio for better performance. Signed-off-by: Uzair Ullah --- community/proactive-weather/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/community/proactive-weather/main.py b/community/proactive-weather/main.py index c8c0628d..36e5795d 100644 --- a/community/proactive-weather/main.py +++ b/community/proactive-weather/main.py @@ -1,3 +1,4 @@ +import asyncio import json import requests from src.agent.capability import MatchingCapability @@ -15,6 +16,7 @@ Summarize in 2-3 sentences, voice-friendly. Only mention what affects the user's day: temperature, precipitation, wind if notable, and anything severe. Do not read out every field. Keep it natural and conversational. +Plain spoken English only. No markdown, no bullet points, no lists, no formatting. """ WEATHER_MD_PROMPT = """ @@ -122,7 +124,7 @@ async def run(self): await self.capability_worker.speak(f"No problem. Checking {location}.") # Geocode - lat, lon = self.geocode(location) + lat, lon = await asyncio.to_thread(self.geocode, location) if lat is None: await self.capability_worker.speak( "I couldn't find that location. Try a different city name." @@ -130,7 +132,7 @@ async def run(self): return # Fetch weather - weather_data = self.fetch_weather(lat, lon) + weather_data = await asyncio.to_thread(self.fetch_weather, lat, lon) if not weather_data: await self.capability_worker.speak( "Sorry, I couldn't get the weather right now." From 1bdde35e4cf19befe5f0a8e15908fad45fea1f10 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Tue, 31 Mar 2026 16:17:05 +0500 Subject: [PATCH 12/12] Update background.py Signed-off-by: Uzair Ullah --- community/proactive-weather/background.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/community/proactive-weather/background.py b/community/proactive-weather/background.py index 118a0dbc..c45648a7 100644 --- a/community/proactive-weather/background.py +++ b/community/proactive-weather/background.py @@ -33,9 +33,10 @@ } ALERT_PROMPT = """ -Severe weather detected: {description} (WMO code {code}). +Severe weather detected: {description}. Current conditions: {current}. Write a short 1-2 sentence calm but urgent voice alert to warn the user. +Plain spoken English only. No markdown, no lists, no formatting, no codes or numbers the user wouldn't understand. """ WEATHER_MD_PROMPT = """ @@ -160,7 +161,6 @@ async def check_for_alerts(self, weather_data: dict): description = WMO_DESCRIPTIONS.get(code, "severe weather") alert_text = self.capability_worker.text_to_text_response( ALERT_PROMPT.format( - code=code, description=description, current=json.dumps(current), ) @@ -168,8 +168,10 @@ async def check_for_alerts(self, weather_data: dict): await self.capability_worker.send_interrupt_signal() await self.capability_worker.speak(alert_text) - # Save fired code to dedup state + # Save fired code to dedup state (delete-before-write) fired_codes.append(code) + if await self.capability_worker.check_if_file_exists(ALERTS_JSON, False): + await self.capability_worker.delete_file(ALERTS_JSON, False) await self.capability_worker.write_file(ALERTS_JSON, json.dumps({"fired_codes": fired_codes}), False) self.worker.editor_logging_handler.info(f"[WeatherDaemon] Fired alert for code {code}") except Exception as e: