diff --git a/community/pantry-pro/.gitignore b/community/pantry-pro/.gitignore new file mode 100644 index 00000000..eb941887 --- /dev/null +++ b/community/pantry-pro/.gitignore @@ -0,0 +1 @@ +pantrypro_inventory.json diff --git a/community/pantry-pro/.openhome.json b/community/pantry-pro/.openhome.json new file mode 100644 index 00000000..5925b9c5 --- /dev/null +++ b/community/pantry-pro/.openhome.json @@ -0,0 +1,6 @@ +{ + "name": "pantrypro", + "capability_id": null, + "category": "skill", + "description": "Voice-guided pantry assistant. Tracks pantry and fridge inventory, suggests recipes from what you have (prioritizing items about to expire), alerts before food goes bad, and builds a shopping list from gaps." +} diff --git a/community/pantry-pro/README.md b/community/pantry-pro/README.md new file mode 100644 index 00000000..78b51b7d --- /dev/null +++ b/community/pantry-pro/README.md @@ -0,0 +1,140 @@ +# PantryPro — Voice-Guided Pantry Assistant + +![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square) +![Status](https://img.shields.io/badge/Status-Stage%201-blue?style=flat-square) + +A voice-first pantry assistant for OpenHome. Track what's in the pantry and fridge, get meal ideas from what you already have (oldest food first), hear alerts before things go bad, and build a shopping list from the gaps. + +**Reduce waste. Save money.** + +--- + +## What it does + +| Feature | What you say | What happens | +| --- | --- | --- | +| **Track inventory** | "add milk to the fridge, expires Friday" | Logs item, location, quantity, and best-by date | +| **Smart suggestions** | "what can I cook?" | Matches stock to real recipes (TheMealDB), prioritizing food that's about to expire | +| **Expiration alerts** | (automatic) | Background daemon speaks up when items expire today, tomorrow, or within 3 days | +| **Shopping made easy** | "we're out of pasta" | Removes it from stock and offers to put it on the grocery list; missing recipe ingredients can be added the same way | + +No API key required. Recipes use the free [TheMealDB](https://www.themealdb.com) API, with an LLM fallback if the network is down. + +--- + +## Suggested trigger words + +Dashboard triggers must be **at least 4 letters**. Recommended: + +`pantry`, `pantry pro`, `what's in the fridge`, `what's in the pantry`, `what's expiring`, `what can I cook`, `recipe ideas`, `food inventory`, `grocery run` + +In-session phrases (after PantryPro is already running): + +| Phrase | What it does | +| --- | --- | +| `"add pasta and canned beans"` | Add items (asks for a date if you skip one) | +| `"check the fridge"` / `"what's in stock"` | Read back inventory | +| `"what's going bad"` | Items expiring within 5 days | +| `"I used the last of the milk"` | Remove + offer to restock on the shopping list | +| `"what can I cook"` | 3 meal ideas from current stock | +| `"what's on my shopping list"` | Read the list | +| `"clear the shopping list"` | Confirm, then wipe it | +| `"done"` | Save and return to the Agent | + +--- + +## Example conversation + +**User:** “Pantry pro.” + +**PantryPro:** “You have pasta, tomato sauce, and canned beans. Want recipe ideas?” + +**User:** “Yes.” + +**PantryPro:** “One sec, matching what you've got to some meals.” + +**PantryPro:** “Using tomato sauce before it goes. I can do 1, Pasta and Beans. 2, Tomato and Herb Pasta. 3, Bean chilli. Pick a number, or say skip.” + +**User:** “1.” + +**PantryPro:** “Pasta and Beans. You're missing onion and garlic. Add those to the shopping list?” + +**User:** “Yes.” + +**PantryPro:** “Added onion and garlic to the shopping list.” + +**User:** “We're out of milk.” + +**PantryPro:** “Removed milk. Add milk to the shopping list?” + +**User:** “Yes. Done.” + +**PantryPro:** “Saved. 3 items in stock, 3 on the shopping list.” + +Background, later that session: + +**PantryPro:** “Heads up — yogurt in the fridge expires tomorrow. Want a recipe that uses it? Say pantry pro.” + +--- + +## How it works + +1. Trigger with `pantry` (or a specific ask like “what's expiring”). +2. Inventory loads from persistent storage (`pantrypro_inventory.json`). +3. A specific ask is handled immediately (quick mode). A bare “pantry pro” greets with what's on hand and offers recipes. +4. Natural speech is classified by the LLM — add, used-up, list, recipes, shopping, tips. +5. Recipe search hits TheMealDB using your soonest-to-expire ingredient, then compares the ingredient list to stock. +6. Say **done** to hand control back. The background daemon keeps watching expiry dates for the rest of the session. + +### Background daemon + +Runs while the Agent session is alive. Checks every 5 minutes (90-second startup grace so it doesn't talk over boot). + +| Days to expiry | What happens | +| --- | --- | +| 3 days | First heads-up (once per day) | +| 1 day / today | Daily urgent alert | +| Already expired | Daily reminder until you remove it | + +Alerts are grouped: *“Urgent — 2 items need using: milk expires today and spinach expires tomorrow.”* + +--- + +## Setup + +1. Install the ability and set dashboard triggers (see above). +2. No API keys or extra config. +3. Talk to it. First run starts empty — log a few items to get recipe ideas and alerts. + +--- + +## Project layout + +``` +community/pantry-pro/ +├── README.md +├── .openhome.json +├── main.py # voice skill +├── background.py # expiry alerts +└── __init__.py +``` + +Runtime (user storage, not shipped): `pantrypro_inventory.json` + +--- + +## Related + +Nearby kitchen abilities — PantryPro is the persistent *stock + expiry* layer, not a duplicate of these: + +- [`community/grocery-list-manager`](../grocery-list-manager/) — shopping list only +- [`community/mealmate-ability`](../mealmate-ability/) — recipe search; you list ingredients each time +- [`community/smart-sous-chef`](../smart-sous-chef/) — hands-free cook-along +- [`community/recipe-coach`](../recipe-coach/) — LLM-generated walkthroughs +- [`community/food-water-log`](../food-water-log/) — what you *ate*, not what's on the shelf + +--- + +## Status + +Stage 1 is live-testable: add/remove stock → expiry dates → recipe ideas from inventory → shopping gaps → background alerts. Cook-along steps stay in Mealmate / Smart Sous Chef / Recipe Coach. diff --git a/community/pantry-pro/__init__.py b/community/pantry-pro/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/pantry-pro/background.py b/community/pantry-pro/background.py new file mode 100644 index 00000000..70841827 --- /dev/null +++ b/community/pantry-pro/background.py @@ -0,0 +1,171 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +import json + +STORAGE_FILE = "pantrypro_inventory.json" +POLL_INTERVAL = 300.0 +STARTUP_GRACE = 90 +# tightest matching window first: expired, tomorrow, 3 days out +NUDGE_THRESHOLDS = [0, 1, 3] + + +def _empty_data() -> dict: + return {"items": [], "shopping": []} + + +def _join_and(parts: list) -> str: + parts = [p for p in parts if p] + if not parts: + return "" + if len(parts) == 1: + return parts[0] + if len(parts) == 2: + return f"{parts[0]} and {parts[1]}" + return ", ".join(parts[:-1]) + f", and {parts[-1]}" + + +def _format_days(days: int) -> str: + if days < 0: + return "already expired" + if days == 0: + return "expires today" + if days == 1: + return "expires tomorrow" + return f"expires in {days} days" + + +class PantryProBackground(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.worker) + self.worker.session_tasks.create(self.watch_loop()) + + def _today(self): + try: + tz = ZoneInfo(self.capability_worker.get_timezone()) + return datetime.now(tz).date() + except Exception: + return datetime.now().date() + + def _days_until(self, expires: str) -> int: + if not expires: + return 9999 + try: + exp = datetime.strptime(expires[:10], "%Y-%m-%d").date() + return (exp - self._today()).days + except Exception: + return 9999 + + def _threshold_for(self, days: int) -> int: + for threshold in NUDGE_THRESHOLDS: + if days <= threshold: + return threshold + return -1 + + async def _load(self) -> dict: + try: + if await self.capability_worker.check_if_file_exists(STORAGE_FILE, False): + raw = await self.capability_worker.read_file(STORAGE_FILE, False) + parsed = json.loads(raw) + if isinstance(parsed, dict): + parsed.setdefault("items", []) + parsed.setdefault("shopping", []) + return parsed + except Exception as e: + self.worker.editor_logging_handler.error(f"[PantryProBG] load failed: {e}") + return _empty_data() + + async def _save(self, data: dict): + try: + await self.capability_worker.delete_file(STORAGE_FILE, False) + await self.capability_worker.write_file( + STORAGE_FILE, json.dumps(data), False + ) + except Exception as e: + self.worker.editor_logging_handler.error(f"[PantryProBG] save failed: {e}") + + def _alert_line(self, item: dict, days: int) -> str: + name = item.get("name", "food") + loc = item.get("location") or "" + where = f" in the {loc}" if loc else "" + return f"{name}{where} {_format_days(days)}" + + async def watch_loop(self): + self.capability_worker.resume_normal_flow() + self.worker.editor_logging_handler.info("[PantryProBG] daemon started") + started_at = datetime.now().timestamp() + + while True: + try: + daemon_age = datetime.now().timestamp() - started_at + if daemon_age <= STARTUP_GRACE: + await self.worker.session_tasks.sleep(POLL_INTERVAL) + continue + + data = await self._load() + items = data.get("items") or [] + if not items: + await self.worker.session_tasks.sleep(POLL_INTERVAL) + continue + + today = self._today().isoformat() + nudge_items = [] + changed = False + + for item in items: + days = self._days_until(item.get("expires", "")) + threshold = self._threshold_for(days) + if threshold == -1: + continue + + last_date = item.get("last_nudge_date", "") + if last_date != today: + nudge_items.append((item, days)) + item["last_nudge_date"] = today + item["last_nudge_threshold"] = threshold + changed = True + + if changed: + await self._save(data) + + if not nudge_items: + await self.worker.session_tasks.sleep(POLL_INTERVAL) + continue + + urgent = any(d <= 1 for _, d in nudge_items) + prefix = "Urgent" if urgent else "Heads up" + lines = [self._alert_line(item, days) for item, days in nudge_items[:3]] + if len(nudge_items) == 1: + msg = f"{prefix} — {lines[0]}. Want a recipe that uses it? Say pantry pro." + else: + extra = f" Plus {len(nudge_items) - 3} more." if len(nudge_items) > 3 else "" + msg = ( + f"{prefix} — {len(nudge_items)} items need using: " + f"{_join_and(lines)}.{extra} Say pantry pro for recipe ideas." + ) + + self.worker.editor_logging_handler.info( + f"[PantryProBG] alerting: {[i.get('name') for i, _ in nudge_items]}" + ) + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(msg) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[PantryProBG] loop error: {e}") + await self.worker.session_tasks.sleep(60.0) + continue + + await self.worker.session_tasks.sleep(POLL_INTERVAL) diff --git a/community/pantry-pro/main.py b/community/pantry-pro/main.py new file mode 100644 index 00000000..305654e4 --- /dev/null +++ b/community/pantry-pro/main.py @@ -0,0 +1,948 @@ +import json +import re +import uuid +from datetime import datetime +from zoneinfo import ZoneInfo + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# pantrypro — voice-guided pantry assistant +# persist inventory, suggest meals from stock, flag expiry, build shopping lists + +STORAGE_FILE = "pantrypro_inventory.json" +MEALDB = "https://www.themealdb.com/api/json/v1/1" +API_TIMEOUT = 10 + +HOTWORDS = ( + "pantry", "pantry pro", "pantrypro", "pantry assistant", + "what's in the fridge", "whats in the fridge", + "what's in my fridge", "whats in my fridge", + "what's in the pantry", "whats in the pantry", + "check the fridge", "check the pantry", + "food inventory", "what's expiring", "whats expiring", + "expiring soon", "use it up", "what can i cook", + "what can I cook", "recipe ideas", "grocery run", + "shopping list", "add to the pantry", "add to the fridge", +) + +CANCEL_PHRASES = ("never mind", "cancel", "forget it", "skip") + +YES_WORDS = ("yes", "yeah", "yep", "sure", "ok", "okay", "please", "do it", "yup") + +INTENT_PROMPT = """Classify this pantry command. Today is {today}. +Return ONLY JSON in this exact shape: +{{"intent":"","items":[{{"name":"","qty":1,"unit":"","location":"","expires":""}}],"location_filter":"all"}} + +intents: +- add — putting food into the pantry, fridge, or freezer +- used — finished / threw out / used the last of something (restock later) +- remove — stop tracking an item without restocking +- list — hear what's in stock +- expiring — what's going bad soon +- recipes — meal ideas from current stock +- shop_add — put items on the shopping list +- shop_read — hear the shopping list +- shop_clear — empty the shopping list +- shop_build — generate a grocery list from gaps / a planned meal +- update — change quantity or expiry +- tips — waste-saving tips +- exit — done / stop +- unknown — not a pantry command + +rules: +- extract item names as short lowercase grocery words (milk, not "the milk we bought") +- location is pantry, fridge, freezer, or empty +- expires is YYYY-MM-DD if a date can be inferred, else empty +- qty is a number (default 1). unit is optional (cans, gallons, leftovers) +- location_filter is pantry, fridge, freezer, or all +- for list/expiring/recipes/exit/unknown, items may be empty +- split multiples: "milk and eggs" → two items + +user said: "{input}" +""" + +RECIPE_FALLBACK_PROMPT = """You are a concise home cook. Given this inventory, suggest 3 simple meals. +prioritize items that expire soon. each meal should mostly use what's on hand. +return ONLY JSON: {{"meals":[{{"name":"","uses":["item"]}}]}} +inventory: {inventory} +expiring soon: {expiring} +""" + +INGREDIENT_MAP = { + "pasta": "spaghetti", + "spaghetti": "spaghetti", + "tomato sauce": "tomato", + "pasta sauce": "tomato", + "canned beans": "kidney beans", + "beans": "kidney beans", + "black beans": "black beans", + "chickpeas": "chickpeas", + "garbanzo": "chickpeas", + "milk": "milk", + "eggs": "egg", + "egg": "egg", + "chicken": "chicken", + "rice": "rice", + "onion": "onion", + "garlic": "garlic", + "butter": "butter", + "cheese": "cheese", + "beef": "beef", + "ground beef": "beef", + "tomato": "tomato", + "tomatoes": "tomato", + "bread": "bread", + "potato": "potato", + "potatoes": "potato", + "spinach": "spinach", + "lettuce": "lettuce", + "yogurt": "yogurt", + "tuna": "tuna", + "salmon": "salmon", + "flour": "flour", + "sugar": "sugar", + "oats": "oats", + "peanut butter": "peanut butter", +} + + +def _empty_data() -> dict: + return {"items": [], "shopping": []} + + +def _item_id() -> str: + return f"itm_{uuid.uuid4().hex[:8]}" + + +def _norm(name: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip() + + +def _parse_json(raw: str) -> dict: + clean = raw.replace("```json", "").replace("```", "").strip() + try: + data = json.loads(clean) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _join_and(parts: list) -> str: + parts = [p for p in parts if p] + if not parts: + return "" + if len(parts) == 1: + return parts[0] + if len(parts) == 2: + return f"{parts[0]} and {parts[1]}" + return ", ".join(parts[:-1]) + f", and {parts[-1]}" + + +def _format_days(days: int) -> str: + if days < 0: + n = abs(days) + return "yesterday" if n == 1 else f"{n} days ago" + if days == 0: + return "today" + if days == 1: + return "tomorrow" + return f"in {days} days" + + +class PantryProCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + data: dict = None + pending: dict = None + + # do not change following tag of register capability + # {{register capability}} + + def does_match(self, text: str) -> bool: + t = (text or "").lower() + return any(hw in t for hw in HOTWORDS) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.data = _empty_data() + self.pending = None + self.worker.session_tasks.create(self.run()) + + def _today(self): + try: + tz = ZoneInfo(self.capability_worker.get_timezone()) + return datetime.now(tz).date() + except Exception: + return datetime.now().date() + + def _log(self, msg: str): + self.worker.editor_logging_handler.info(f"[PantryPro] {msg}") + + def _err(self, msg: str): + self.worker.editor_logging_handler.error(f"[PantryPro] {msg}") + + # storage + + async def _load(self): + try: + if await self.capability_worker.check_if_file_exists(STORAGE_FILE, False): + raw = await self.capability_worker.read_file(STORAGE_FILE, False) + parsed = json.loads(raw) + if isinstance(parsed, dict): + parsed.setdefault("items", []) + parsed.setdefault("shopping", []) + self.data = parsed + return + except Exception as e: + self._err(f"load failed: {e}") + self.data = _empty_data() + + async def _save(self): + try: + await self.capability_worker.delete_file(STORAGE_FILE, False) + await self.capability_worker.write_file( + STORAGE_FILE, json.dumps(self.data), False + ) + except Exception as e: + self._err(f"save failed: {e}") + + # inventory helpers + + def _days_until(self, expires: str) -> int: + if not expires: + return 9999 + try: + exp = datetime.strptime(expires[:10], "%Y-%m-%d").date() + return (exp - self._today()).days + except Exception: + return 9999 + + def _find_item(self, name: str, location: str = ""): + n = _norm(name) + if not n: + return None + matches = [] + for item in self.data.get("items", []): + iname = _norm(item.get("name", "")) + if n == iname or n in iname or iname in n: + if location and item.get("location") and item.get("location") != location: + continue + matches.append(item) + if not matches: + return None + if location: + loc_hits = [m for m in matches if m.get("location") == location] + if loc_hits: + return loc_hits[0] + return matches[0] + + def _expiring(self, within: int = 5) -> list: + due = [] + for item in self.data.get("items", []): + days = self._days_until(item.get("expires", "")) + if days <= within: + due.append((item, days)) + due.sort(key=lambda pair: pair[1]) + return due + + def _headline_stock(self, limit: int = 3) -> str: + items = self.data.get("items", []) + if not items: + return "" + ranked = sorted( + items, + key=lambda i: self._days_until(i.get("expires", "")), + ) + names = [i["name"] for i in ranked[:limit]] + return _join_and(names) + + def _is_exit(self, text: str) -> bool: + lower = (text or "").lower().strip() + if any( + p in lower + for p in ( + "that's all", "thats all", "nothing else", + "i'm good", "im good", "all done", "all good", "no thanks", + ) + ): + return True + tokens = set(lower.split()) + return bool(tokens & {"stop", "exit", "quit", "done", "bye", "goodbye"}) and len(tokens) <= 2 + + def _is_cancel(self, text: str) -> bool: + lower = (text or "").lower() + return any(p in lower for p in CANCEL_PHRASES) + + def _is_yes(self, text: str) -> bool: + lower = (text or "").lower().strip() + return lower in YES_WORDS or lower.startswith("yes") + + def _is_no(self, text: str) -> bool: + lower = (text or "").lower().strip() + return lower in ("no", "nope", "nah", "not now", "later") or lower.startswith("no ") + + def _trigger_text(self) -> str: + history = self.capability_worker.get_full_message_history() or [] + for msg in reversed(history): + if msg.get("role") == "user": + return (msg.get("content") or "").strip() + return "" + + def classify(self, user_input: str) -> dict: + prompt = INTENT_PROMPT.format(today=self._today().isoformat(), input=user_input) + raw = self.capability_worker.text_to_text_response( + prompt, + system_prompt="return only valid json. no markdown.", + ) + result = _parse_json(raw) + if not result: + self._err(f"intent parse failed: {raw[:200]}") + return {"intent": "unknown", "items": [], "location_filter": "all"} + result.setdefault("intent", "unknown") + result.setdefault("items", []) + result.setdefault("location_filter", "all") + if not isinstance(result["items"], list): + result["items"] = [] + return result + + # mutations + + def _upsert_item(self, spec: dict) -> str: + name = _norm(spec.get("name", "")) + if not name: + return "" + location = (spec.get("location") or "").lower().strip() + if location not in ("pantry", "fridge", "freezer"): + location = "" + qty = spec.get("qty") or 1 + try: + qty = int(qty) + except (TypeError, ValueError): + qty = 1 + unit = (spec.get("unit") or "").strip().lower() + expires = (spec.get("expires") or "").strip() + if expires and not re.match(r"^\d{4}-\d{2}-\d{2}$", expires): + expires = "" + + existing = self._find_item(name, location) + if existing: + try: + extra = int(qty) + except (TypeError, ValueError): + extra = 1 + existing["qty"] = int(existing.get("qty") or 1) + max(1, extra) + if unit: + existing["unit"] = unit + if location: + existing["location"] = location + if expires: + existing["expires"] = expires + return existing["name"] + + item = { + "id": _item_id(), + "name": name, + "qty": max(1, qty), + "unit": unit, + "location": location or "pantry", + "expires": expires, + "added": self._today().isoformat(), + } + self.data.setdefault("items", []).append(item) + return name + + def _remove_item(self, name: str, location: str = "") -> dict: + item = self._find_item(name, location) + if not item: + return {} + self.data["items"] = [ + i for i in self.data.get("items", []) if i.get("id") != item.get("id") + ] + return item + + def _shop_add(self, names: list) -> list: + added = [] + shopping = self.data.setdefault("shopping", []) + for name in names: + n = _norm(name) + if not n: + continue + if n not in shopping: + shopping.append(n) + added.append(n) + return added + + # recipes + + def _mealdb_ingredient(self, name: str) -> str: + n = _norm(name) + if n in INGREDIENT_MAP: + return INGREDIENT_MAP[n] + for key, val in INGREDIENT_MAP.items(): + if key in n or n in key: + return val + # last token often works ("canned tomato" → tomato) + parts = n.split() + return parts[-1] if parts else n + + def _parse_meal_ingredients(self, meal: dict) -> list: + out = [] + for i in range(1, 21): + ing = _norm(meal.get(f"strIngredient{i}", "")) + if ing: + out.append(ing) + return out + + def _missing_for(self, ingredients: list) -> list: + stock = [_norm(i.get("name", "")) for i in self.data.get("items", [])] + missing = [] + skip = { + "salt", "pepper", "water", "oil", "olive oil", "vegetable oil", + "sugar", "flour", "garlic", "onion", + } + for ing in ingredients: + if ing in skip: + continue + if any(ing == s or ing in s or s in ing for s in stock): + continue + missing.append(ing) + return missing[:8] + + async def _search_meals(self, ingredient: str) -> list: + url = f"{MEALDB}/filter.php" + try: + r = await self.worker.session_tasks.get_async( + url, params={"i": ingredient}, timeout=API_TIMEOUT + ) + if r.status_code != 200: + self._err(f"mealdb filter status {r.status_code}") + return [] + data = r.json() + return (data.get("meals") or [])[:6] + except Exception as e: + self._err(f"mealdb filter failed: {e}") + return [] + + async def _lookup_meal(self, meal_id: str) -> dict: + url = f"{MEALDB}/lookup.php" + try: + r = await self.worker.session_tasks.get_async( + url, params={"i": meal_id}, timeout=API_TIMEOUT + ) + if r.status_code != 200: + self._err(f"mealdb lookup status {r.status_code}") + return {} + data = r.json() + meals = data.get("meals") or [] + return meals[0] if meals else {} + except Exception as e: + self._err(f"mealdb lookup failed: {e}") + return {} + + def _llm_recipes(self) -> list: + items = [i.get("name", "") for i in self.data.get("items", [])] + expiring = [i["name"] for i, _ in self._expiring(5)] + raw = self.capability_worker.text_to_text_response( + RECIPE_FALLBACK_PROMPT.format( + inventory=_join_and(items) or "empty", + expiring=_join_and(expiring) or "none", + ), + system_prompt="return only valid json. no markdown.", + ) + parsed = _parse_json(raw) + meals = parsed.get("meals") if isinstance(parsed, dict) else [] + out = [] + if isinstance(meals, list): + for m in meals[:3]: + if isinstance(m, dict) and m.get("name"): + out.append({"strMeal": m["name"], "idMeal": "", "uses": m.get("uses") or []}) + return out + + # speak helpers + + def _list_speech(self, location_filter: str = "all") -> str: + items = self.data.get("items", []) + if location_filter in ("pantry", "fridge", "freezer"): + items = [i for i in items if i.get("location") == location_filter] + if not items: + if location_filter == "all": + return "Nothing tracked yet. Tell me what's in the pantry or fridge." + return f"Nothing in the {location_filter} yet." + + by_loc = {"fridge": [], "pantry": [], "freezer": []} + for item in items: + loc = item.get("location") or "pantry" + by_loc.setdefault(loc, []).append(item.get("name", "item")) + + if location_filter in by_loc: + names = by_loc[location_filter] + extra = f" and {len(names) - 5} more" if len(names) > 5 else "" + shown = names[:5] + return f"In the {location_filter}: {_join_and(shown)}{extra}." + + chunks = [] + total = len(items) + for loc in ("fridge", "pantry", "freezer"): + names = by_loc.get(loc) or [] + if not names: + continue + extra = f" and {len(names) - 4} more" if len(names) > 4 else "" + chunks.append(f"{loc} has {_join_and(names[:4])}{extra}") + return f"{total} items. " + ". ".join(chunks) + "." + + def _expiring_speech(self) -> str: + due = self._expiring(5) + if not due: + return "Nothing is close to expiring. Nice work." + parts = [] + for item, days in due[:4]: + name = item.get("name", "item") + loc = item.get("location") or "" + where = f" in the {loc}" if loc else "" + if days < 0: + parts.append(f"{name}{where} already went bad {_format_days(days)}") + elif days == 0: + parts.append(f"{name}{where} expires today") + else: + parts.append(f"{name}{where} expires {_format_days(days)}") + extra = f" Plus {len(due) - 4} more." if len(due) > 4 else "" + return _join_and(parts).capitalize() + "." + extra + + def _shop_speech(self) -> str: + shopping = self.data.get("shopping") or [] + if not shopping: + return "Your shopping list is empty." + if len(shopping) == 1: + return f"One item: {shopping[0]}." + return f"{len(shopping)} items: {_join_and(shopping)}." + + # handlers + + async def _handle_add(self, specs: list) -> str: + added = [] + needs_date = [] + for spec in specs: + name = self._upsert_item(spec) + if not name: + continue + added.append(name) + item = self._find_item(name, (spec.get("location") or "")) + if item and not item.get("expires"): + needs_date.append(name) + if not added: + return "I didn't catch what to add. Try 'add milk to the fridge, expires Friday'." + await self._save() + msg = f"Added {_join_and(added)}." + if needs_date: + self.pending = {"type": "expiry", "names": needs_date} + first = needs_date[0] + msg += f" When does the {first} expire? Say a date, or skip." + return msg + + async def _handle_used(self, specs: list) -> str: + removed = [] + for spec in specs: + item = self._remove_item(spec.get("name", ""), spec.get("location") or "") + if item: + removed.append(item.get("name")) + if not removed: + return "I couldn't find that in your pantry." + await self._save() + self.pending = {"type": "shop_used", "names": removed} + return f"Removed {_join_and(removed)}. Add {_join_and(removed)} to the shopping list?" + + async def _handle_remove(self, specs: list) -> str: + removed = [] + missing = [] + for spec in specs: + item = self._remove_item(spec.get("name", ""), spec.get("location") or "") + if item: + removed.append(item.get("name")) + else: + missing.append(_norm(spec.get("name", ""))) + await self._save() + parts = [] + if removed: + parts.append(f"Stopped tracking {_join_and(removed)}.") + if missing: + parts.append(f"Couldn't find {_join_and([m for m in missing if m])}.") + return " ".join(parts) or "I didn't catch what to remove." + + async def _handle_update(self, specs: list) -> str: + updated = [] + for spec in specs: + item = self._find_item(spec.get("name", ""), spec.get("location") or "") + if not item: + continue + if spec.get("qty"): + try: + item["qty"] = max(1, int(spec["qty"])) + except (TypeError, ValueError): + pass + if spec.get("unit"): + item["unit"] = spec["unit"] + if spec.get("location") in ("pantry", "fridge", "freezer"): + item["location"] = spec["location"] + if spec.get("expires"): + item["expires"] = spec["expires"] + updated.append(item["name"]) + if not updated: + return "I couldn't find that item to update." + await self._save() + return f"Updated {_join_and(updated)}." + + async def _handle_recipes(self) -> str: + items = self.data.get("items") or [] + if not items: + return "Add a few ingredients first, then I can suggest meals." + + await self.capability_worker.speak("One sec, matching what you've got to some meals.") + + due = self._expiring(5) + search_from = [i for i, _ in due] + items + meals = [] + used_ing = "" + for src in search_from[:4]: + ing = self._mealdb_ingredient(src.get("name", "")) + if not ing: + continue + found = await self._search_meals(ing) + if found: + meals = found + used_ing = ing + break + + if not meals: + meals = self._llm_recipes() + + if not meals: + stock = self._headline_stock() + return f"I couldn't find a match. You have {stock}. Want to add more items?" + + show = meals[:3] + self.pending = {"type": "recipe_pick", "meals": show} + names = [m.get("strMeal", "a meal") for m in show] + lead = "" + if due: + lead = f"Using {due[0][0].get('name')} before it goes. " + elif used_ing: + lead = f"Based on {used_ing}. " + numbered = ". ".join(f"{i + 1}, {n}" for i, n in enumerate(names)) + return f"{lead}I can do {numbered}. Pick a number, or say skip." + + async def _handle_shop_add(self, specs: list) -> str: + names = [_norm(s.get("name", "")) for s in specs] + added = self._shop_add(names) + skipped = [n for n in names if n and n not in added] + await self._save() + parts = [] + if added: + parts.append(f"Put {_join_and(added)} on the shopping list.") + if skipped: + parts.append(f"{_join_and(skipped)} already listed.") + return " ".join(parts) or "What should I add to the shopping list?" + + async def _handle_shop_build(self, specs: list) -> str: + # if they named ingredients, add those; else restock expired + empty staples from used list + if specs and any(s.get("name") for s in specs): + return await self._handle_shop_add(specs) + expired = [i.get("name") for i, d in self._expiring(0) if d < 0] + added = self._shop_add(expired) + shopping = self.data.get("shopping") or [] + await self._save() + if added: + return f"Added expired items: {_join_and(added)}. {_shop_tail(shopping)}" + if shopping: + return self._shop_speech() + return "List is empty. Name items to buy, or pick a recipe and I'll add what's missing." + + async def _handle_tips(self) -> str: + due = self._expiring(5) + stock = [i.get("name") for i in self.data.get("items", [])] + if not stock: + return "Once you log a few items, I can give use-it-up tips." + prompt = ( + "Give one short spoken tip (2 sentences max) to reduce food waste. " + f"Stock: {_join_and(stock)}. " + f"Expiring: {_join_and([i['name'] for i, _ in due]) or 'none'}." + ) + tip = self.capability_worker.text_to_text_response( + prompt, + system_prompt="you are pantrypro. be warm and brief. no markdown.", + ) + return (tip or "Cook the oldest items first, and freeze leftovers the same day.").strip() + + async def _dispatch(self, result: dict) -> str: + intent = (result.get("intent") or "unknown").lower() + specs = result.get("items") or [] + loc = (result.get("location_filter") or "all").lower() + + if intent == "add": + return await self._handle_add(specs) + if intent == "used": + return await self._handle_used(specs) + if intent == "remove": + return await self._handle_remove(specs) + if intent == "list": + return self._list_speech(loc) + if intent == "expiring": + return self._expiring_speech() + if intent == "recipes": + return await self._handle_recipes() + if intent == "shop_add": + return await self._handle_shop_add(specs) + if intent == "shop_read": + return self._shop_speech() + if intent == "shop_clear": + if not self.data.get("shopping"): + return "The shopping list is already empty." + confirmed = await self.capability_worker.run_confirmation_loop( + f"Clear all {len(self.data['shopping'])} shopping items?" + ) + if confirmed: + self.data["shopping"] = [] + await self._save() + return "Shopping list cleared." + return "Okay, keeping the list." + if intent == "shop_build": + return await self._handle_shop_build(specs) + if intent == "update": + return await self._handle_update(specs) + if intent == "tips": + return await self._handle_tips() + if intent == "exit": + return "__exit__" + return ( + "I can add food, check what's expiring, suggest meals, or build a shopping list. " + "What do you need?" + ) + + async def _handle_pending(self, user_input: str) -> str: + pending = self.pending + if not pending: + return "" + if self._is_cancel(user_input): + self.pending = None + return "Okay, skipped." + + ptype = pending.get("type") + + if ptype == "expiry": + names = pending.get("names") or [] + if self._is_yes(user_input) or "skip" in user_input.lower(): + self.pending = None + return "Got it, no date. Anything else?" + raw = self.capability_worker.text_to_text_response( + f"Today is {self._today().isoformat()}. Extract an expiry date as YYYY-MM-DD " + f"from: '{user_input}'. Return ONLY the date or UNKNOWN.", + system_prompt="return only a date or UNKNOWN.", + ) + date = (raw or "").strip()[:10] + if not re.match(r"^\d{4}-\d{2}-\d{2}$", date): + return "I didn't catch the date. Try 'next Friday' or say skip." + for name in names: + item = self._find_item(name) + if item: + item["expires"] = date + await self._save() + self.pending = None + label = _join_and(names) + return f"Set {label} to expire {date}. Anything else?" + + if ptype == "shop_used": + names = pending.get("names") or [] + lower = user_input.lower() + if self._is_yes(user_input): + self.pending = None + added = self._shop_add(names) + await self._save() + return f"Added {_join_and(added or names)} to the shopping list." + if self._is_no(user_input): + self.pending = None + return "Okay, leaving the shopping list as is." + self.pending = None + return "" + + if ptype == "recipes_offer": + if self._is_yes(user_input) or "recipe" in user_input.lower(): + self.pending = None + return await self._handle_recipes() + if self._is_no(user_input): + self.pending = None + return "Okay. Add items, check expiry, or say done." + self.pending = None + return "" + + if ptype == "recipe_pick": + meals = pending.get("meals") or [] + lower = user_input.lower().strip() + pick = None + if lower in ("1", "one", "first"): + pick = meals[0] if meals else None + elif lower in ("2", "two", "second") and len(meals) > 1: + pick = meals[1] + elif lower in ("3", "three", "third") and len(meals) > 2: + pick = meals[2] + else: + for m in meals: + if _norm(m.get("strMeal", "")) in _norm(user_input) or _norm(user_input) in _norm(m.get("strMeal", "")): + pick = m + break + if not pick: + return "Say 1, 2, or 3, or skip." + + meal_id = pick.get("idMeal") or "" + title = pick.get("strMeal", "that meal") + missing = [] + if meal_id: + detail = await self._lookup_meal(meal_id) + ings = self._parse_meal_ingredients(detail) if detail else [] + missing = self._missing_for(ings) + elif pick.get("uses"): + missing = self._missing_for([_norm(u) for u in pick["uses"]]) + + self.pending = None + if missing: + self.pending = {"type": "shop_missing", "names": missing, "meal": title} + return ( + f"{title}. You're missing {_join_and(missing[:5])}. " + "Add those to the shopping list?" + ) + return f"{title}. You already have what you need. Want another idea?" + + if ptype == "shop_missing": + names = pending.get("names") or [] + if self._is_yes(user_input): + self.pending = None + added = self._shop_add(names) + await self._save() + return f"Added {_join_and(added or names)} to the shopping list." + if self._is_no(user_input): + self.pending = None + return "Okay, I won't add them." + self.pending = None + return "" + + self.pending = None + return "" + + def _signoff(self) -> str: + n = len(self.data.get("items") or []) + shop = len(self.data.get("shopping") or []) + if n and shop: + return f"Saved. {n} items in stock, {shop} on the shopping list." + if n: + return f"Saved. {n} items in stock. See you next time." + return "Okay. Come back when you have groceries to log." + + async def _greet(self, trigger: str) -> str: + items = self.data.get("items") or [] + if not items: + return ( + "PantryPro here. Tell me what's in the pantry or fridge, " + "like pasta, tomato sauce, and canned beans." + ) + due = self._expiring(3) + stock = self._headline_stock() + if due: + names = _join_and([i.get("name") for i, _ in due[:3]]) + self.pending = {"type": "recipes_offer"} + return f"Welcome back. {names} should be used soon. Want recipe ideas?" + self.pending = {"type": "recipes_offer"} + return f"You have {stock}. Want recipe ideas?" + + def _is_generic_trigger(self, text: str) -> bool: + t = _norm(text) + generic = { + "pantry", "pantry pro", "pantrypro", "pantry assistant", + "open pantry", "food inventory", + } + return t in generic or not t + + async def run(self): + try: + await self._load() + trigger = self._trigger_text() + self._log(f"started. trigger={trigger!r} items={len(self.data.get('items') or [])}") + + handled_up_front = False + if trigger and not self._is_generic_trigger(trigger) and not self._is_exit(trigger): + result = self.classify(trigger) + intent = (result.get("intent") or "unknown").lower() + if intent not in ("unknown", "exit", ""): + reply = await self._dispatch(result) + if reply == "__exit__": + await self.capability_worker.speak(self._signoff()) + return + await self.capability_worker.speak(reply) + handled_up_front = True + if not self.pending: + await self.capability_worker.speak("Anything else for the pantry?") + + if not handled_up_front: + await self.capability_worker.speak(await self._greet(trigger)) + + idle_count = 0 + while True: + try: + user_input = await self.capability_worker.user_response() + + if not user_input: + idle_count += 1 + if idle_count >= 2: + await self.capability_worker.speak( + "Still here if you need the pantry. Otherwise I'll sign off." + ) + follow = await self.capability_worker.user_response() + if not follow or self._is_exit(follow): + await self.capability_worker.speak(self._signoff()) + break + user_input = follow + idle_count = 0 + else: + continue + + idle_count = 0 + + if self.pending: + pending_reply = await self._handle_pending(user_input) + if pending_reply: + await self.capability_worker.speak(pending_reply) + continue + + if self._is_exit(user_input): + await self.capability_worker.speak(self._signoff()) + break + + result = self.classify(user_input) + self._log(f"intent={result.get('intent')} items={result.get('items')}") + reply = await self._dispatch(result) + if reply == "__exit__": + await self.capability_worker.speak(self._signoff()) + break + await self.capability_worker.speak(reply) + + except Exception as e: + self._err(f"turn error: {e}") + await self.capability_worker.speak( + "Something glitched. Try that again?" + ) + continue + + except Exception as e: + self._err(f"run error: {e}") + try: + await self.capability_worker.speak("PantryPro hit a snag. Back to the agent.") + except Exception: + pass + finally: + self.capability_worker.resume_normal_flow() + + +def _shop_tail(shopping: list) -> str: + if not shopping: + return "Shopping list is still empty." + return f"List now has {_join_and(shopping)}."