From 08bcb73c93e1126e91f953086c317c45e882763a Mon Sep 17 00:00:00 2001 From: Tati Date: Sun, 30 Aug 2026 01:57:29 -0400 Subject: [PATCH 1/3] fix(pantry-pro): harden inventory load/save and item matching Refuse saves when inventory failed to load so a bad read cannot wipe stock. Merge only on exact or singular/plural names; keep fuzzy match for lookups. Read user_profile.md for recipe suggestions (diet/household), never write it. Co-authored-by: Cursor --- community/pantry-pro/README.md | 2 +- community/pantry-pro/background.py | 39 ++++-- community/pantry-pro/main.py | 195 +++++++++++++++++++++-------- 3 files changed, 171 insertions(+), 65 deletions(-) diff --git a/community/pantry-pro/README.md b/community/pantry-pro/README.md index 78b51b7d..f080a997 100644 --- a/community/pantry-pro/README.md +++ b/community/pantry-pro/README.md @@ -83,7 +83,7 @@ Background, later that session: 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. +5. Recipe search hits TheMealDB using your soonest-to-expire ingredient, then compares the ingredient list to stock. The LLM fallback also reads `user_profile.md` (diet, household size) — read-only, never written. 6. Say **done** to hand control back. The background daemon keeps watching expiry dates for the rest of the session. ### Background daemon diff --git a/community/pantry-pro/background.py b/community/pantry-pro/background.py index 70841827..e0cde7d6 100644 --- a/community/pantry-pro/background.py +++ b/community/pantry-pro/background.py @@ -75,27 +75,38 @@ def _threshold_for(self, days: int) -> int: return threshold return -1 - async def _load(self) -> dict: + async def _load(self): + # returns (data, load_ok). missing file is ok; a failed read is not. 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 + exists = await self.capability_worker.check_if_file_exists(STORAGE_FILE, False) + if not exists: + return _empty_data(), True + raw = await self.capability_worker.read_file(STORAGE_FILE, False) + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("inventory is not a json object") + parsed.setdefault("items", []) + parsed.setdefault("shopping", []) + return parsed, True except Exception as e: self.worker.editor_logging_handler.error(f"[PantryProBG] load failed: {e}") - return _empty_data() + return None, False - async def _save(self, data: dict): + async def _save(self, data: dict, *, load_ok: bool) -> bool: + if not load_ok: + self.worker.editor_logging_handler.error( + "[PantryProBG] save refused: inventory was not loaded cleanly" + ) + return False try: await self.capability_worker.delete_file(STORAGE_FILE, False) await self.capability_worker.write_file( STORAGE_FILE, json.dumps(data), False ) + return True except Exception as e: self.worker.editor_logging_handler.error(f"[PantryProBG] save failed: {e}") + return False def _alert_line(self, item: dict, days: int) -> str: name = item.get("name", "food") @@ -115,7 +126,11 @@ async def watch_loop(self): await self.worker.session_tasks.sleep(POLL_INTERVAL) continue - data = await self._load() + data, load_ok = await self._load() + if not load_ok: + await self.worker.session_tasks.sleep(POLL_INTERVAL) + continue + items = data.get("items") or [] if not items: await self.worker.session_tasks.sleep(POLL_INTERVAL) @@ -139,7 +154,7 @@ async def watch_loop(self): changed = True if changed: - await self._save(data) + await self._save(data, load_ok=True) if not nudge_items: await self.worker.session_tasks.sleep(POLL_INTERVAL) diff --git a/community/pantry-pro/main.py b/community/pantry-pro/main.py index 305654e4..440369e7 100644 --- a/community/pantry-pro/main.py +++ b/community/pantry-pro/main.py @@ -65,9 +65,12 @@ 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. +respect diet and household size from the user profile when present +(for example vegetarian, vegan, gluten-free, or cooking for a family). return ONLY JSON: {{"meals":[{{"name":"","uses":["item"]}}]}} inventory: {inventory} expiring soon: {expiring} +user profile: {profile} """ INGREDIENT_MAP = { @@ -120,6 +123,27 @@ def _norm(name: str) -> str: return re.sub(r"[^a-z0-9]+", " ", (name or "").lower()).strip() +def _singularize(name: str) -> str: + # light singularization for eggs/egg, tomatoes/tomato, boxes/box + n = _norm(name) + if len(n) > 4 and n.endswith("ies"): + return n[:-3] + "y" + if len(n) > 4 and n.endswith(("oes", "ses", "xes", "ches", "shes")): + return n[:-2] + if len(n) > 2 and n.endswith("s") and not n.endswith("ss"): + return n[:-1] + return n + + +def _names_match_strict(a: str, b: str) -> bool: + na, nb = _norm(a), _norm(b) + if not na or not nb: + return False + if na == nb: + return True + return _singularize(na) == _singularize(nb) + + def _parse_json(raw: str) -> dict: clean = raw.replace("```json", "").replace("```", "").strip() try: @@ -156,6 +180,7 @@ class PantryProCapability(MatchingCapability): capability_worker: CapabilityWorker = None data: dict = None pending: dict = None + load_ok: bool = False # do not change following tag of register capability # {{register capability}} @@ -169,6 +194,7 @@ def call(self, worker: AgentWorker): self.capability_worker = CapabilityWorker(self.worker) self.data = _empty_data() self.pending = None + self.load_ok = False self.worker.session_tasks.create(self.run()) def _today(self): @@ -186,28 +212,65 @@ def _err(self, msg: str): # storage - async def _load(self): + async def _load(self) -> bool: + # true = safe to save later. missing file is ok; a failed read is not. 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 + exists = await self.capability_worker.check_if_file_exists(STORAGE_FILE, False) + if not exists: + self.data = _empty_data() + self.load_ok = True + return True + + raw = await self.capability_worker.read_file(STORAGE_FILE, False) + parsed = json.loads(raw) + if not isinstance(parsed, dict): + raise ValueError("inventory is not a json object") + parsed.setdefault("items", []) + parsed.setdefault("shopping", []) + self.data = parsed + self.load_ok = True + return True except Exception as e: self._err(f"load failed: {e}") - self.data = _empty_data() - - async def _save(self): + self.data = _empty_data() + self.load_ok = False + return False + + async def _save(self) -> bool: + if not self.load_ok: + self._err("save refused: inventory was not loaded cleanly") + return False try: await self.capability_worker.delete_file(STORAGE_FILE, False) await self.capability_worker.write_file( STORAGE_FILE, json.dumps(self.data), False ) + return True except Exception as e: self._err(f"save failed: {e}") + return False + + async def _persist(self) -> str: + # save after a mutation; return a short spoken warning on failure + if await self._save(): + return "" + return " I couldn't save that right now, so it may not stick." + + async def _read_user_profile(self) -> str: + # read-only — never write user_profile.md (platform-owned) + try: + if not await self.capability_worker.check_if_file_exists( + "user_profile.md", False + ): + return "" + raw = await self.capability_worker.read_file("user_profile.md", False) + text = (raw or "").strip() + if len(text) > 1200: + text = text[:1200] + return text + except Exception as e: + self._err(f"user_profile read skipped: {e}") + return "" # inventory helpers @@ -220,24 +283,39 @@ def _days_until(self, expires: str) -> int: except Exception: return 9999 - def _find_item(self, name: str, location: str = ""): + def _find_item(self, name: str, location: str = "", *, fuzzy: bool = False): + # merge/add: strict exact or singular/plural only. + # lookups (used/remove/update): fuzzy allowed, longest name wins. n = _norm(name) if not n: return None - matches = [] + + candidates = [] for item in self.data.get("items", []): + if location and item.get("location") and item.get("location") != location: + continue + candidates.append(item) + + for item in candidates: + if _norm(item.get("name", "")) == n: + return item + + for item in candidates: + if _names_match_strict(item.get("name", ""), n): + return item + + if not fuzzy: + return None + + fuzzy_hits = [] + for item in candidates: 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: + if n in iname or iname in n: + fuzzy_hits.append(item) + if not fuzzy_hits: 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] + fuzzy_hits.sort(key=lambda i: len(_norm(i.get("name", ""))), reverse=True) + return fuzzy_hits[0] def _expiring(self, within: int = 5) -> list: due = [] @@ -355,7 +433,7 @@ def _upsert_item(self, spec: dict) -> str: return name def _remove_item(self, name: str, location: str = "") -> dict: - item = self._find_item(name, location) + item = self._find_item(name, location, fuzzy=True) if not item: return {} self.data["items"] = [ @@ -442,13 +520,15 @@ async def _lookup_meal(self, meal_id: str) -> dict: self._err(f"mealdb lookup failed: {e}") return {} - def _llm_recipes(self) -> list: + async 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)] + profile = await self._read_user_profile() raw = self.capability_worker.text_to_text_response( RECIPE_FALLBACK_PROMPT.format( inventory=_join_and(items) or "empty", expiring=_join_and(expiring) or "none", + profile=profile or "none", ), system_prompt="return only valid json. no markdown.", ) @@ -534,13 +614,13 @@ async def _handle_add(self, specs: list) -> str: 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() + warn = await self._persist() 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 + return msg + warn async def _handle_used(self, specs: list) -> str: removed = [] @@ -550,9 +630,13 @@ async def _handle_used(self, specs: list) -> str: removed.append(item.get("name")) if not removed: return "I couldn't find that in your pantry." - await self._save() + warn = await self._persist() self.pending = {"type": "shop_used", "names": removed} - return f"Removed {_join_and(removed)}. Add {_join_and(removed)} to the shopping list?" + return ( + f"Removed {_join_and(removed)}. " + f"Add {_join_and(removed)} to the shopping list?" + + warn + ) async def _handle_remove(self, specs: list) -> str: removed = [] @@ -563,18 +647,20 @@ async def _handle_remove(self, specs: list) -> str: removed.append(item.get("name")) else: missing.append(_norm(spec.get("name", ""))) - await self._save() + warn = await self._persist() if removed else "" 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." + return (" ".join(parts) or "I didn't catch what to remove.") + warn async def _handle_update(self, specs: list) -> str: updated = [] for spec in specs: - item = self._find_item(spec.get("name", ""), spec.get("location") or "") + item = self._find_item( + spec.get("name", ""), spec.get("location") or "", fuzzy=True + ) if not item: continue if spec.get("qty"): @@ -591,8 +677,8 @@ async def _handle_update(self, specs: list) -> str: 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)}." + warn = await self._persist() + return f"Updated {_join_and(updated)}." + warn async def _handle_recipes(self) -> str: items = self.data.get("items") or [] @@ -616,7 +702,7 @@ async def _handle_recipes(self) -> str: break if not meals: - meals = self._llm_recipes() + meals = await self._llm_recipes() if not meals: stock = self._headline_stock() @@ -637,24 +723,24 @@ 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() + warn = await self._persist() if added else "" 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?" + return (" ".join(parts) or "What should I add to the shopping list?") + warn async def _handle_shop_build(self, specs: list) -> str: - # if they named ingredients, add those; else restock expired + empty staples from used list + # if they named ingredients, add those; else restock expired items onto the 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() + warn = await self._persist() if added else "" if added: - return f"Added expired items: {_join_and(added)}. {_shop_tail(shopping)}" + return f"Added expired items: {_join_and(added)}. {_shop_tail(shopping)}" + warn 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." @@ -704,8 +790,8 @@ async def _dispatch(self, result: dict) -> str: ) if confirmed: self.data["shopping"] = [] - await self._save() - return "Shopping list cleared." + warn = await self._persist() + return "Shopping list cleared." + warn return "Okay, keeping the list." if intent == "shop_build": return await self._handle_shop_build(specs) @@ -744,22 +830,21 @@ async def _handle_pending(self, user_input: str) -> str: 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) + item = self._find_item(name, fuzzy=True) if item: item["expires"] = date - await self._save() + warn = await self._persist() self.pending = None label = _join_and(names) - return f"Set {label} to expire {date}. Anything else?" + return f"Set {label} to expire {date}. Anything else?" + warn 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." + warn = await self._persist() + return f"Added {_join_and(added or names)} to the shopping list." + warn if self._is_no(user_input): self.pending = None return "Okay, leaving the shopping list as is." @@ -818,8 +903,8 @@ async def _handle_pending(self, user_input: str) -> str: 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." + warn = await self._persist() + return f"Added {_join_and(added or names)} to the shopping list." + warn if self._is_no(user_input): self.pending = None return "Okay, I won't add them." @@ -864,7 +949,13 @@ def _is_generic_trigger(self, text: str) -> bool: async def run(self): try: - await self._load() + if not await self._load(): + await self.capability_worker.speak( + "I couldn't load your pantry safely, so I won't change anything " + "this session. Try again in a moment." + ) + return + trigger = self._trigger_text() self._log(f"started. trigger={trigger!r} items={len(self.data.get('items') or [])}") From d860ae87b778f7cace03a480deae21a441277180 Mon Sep 17 00:00:00 2001 From: Tati Date: Sun, 30 Aug 2026 22:38:53 -0400 Subject: [PATCH 2/3] fix(pantry-pro): full inventory readout and per-item expiry dates Read the whole list when asked instead of truncating. Ask and apply expiry dates one item at a time for perishables, and answer when a specific item expires. Co-authored-by: Cursor --- community/pantry-pro/main.py | 248 +++++++++++++++++++++++++++++++---- 1 file changed, 223 insertions(+), 25 deletions(-) diff --git a/community/pantry-pro/main.py b/community/pantry-pro/main.py index 440369e7..e68a6afa 100644 --- a/community/pantry-pro/main.py +++ b/community/pantry-pro/main.py @@ -31,16 +31,26 @@ YES_WORDS = ("yes", "yeah", "yep", "sure", "ok", "okay", "please", "do it", "yup") +FULL_LIST_PHRASES = ( + "whole list", "full list", "entire list", "complete list", + "all of them", "all of it", "all items", "everything", + "the rest", "what's left", "whats left", "read them all", + "list them all", "every item", "don't truncate", "dont truncate", + "instead of", "no more", "not four more", "not 4 more", +) + 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"}} +{{"intent":"","items":[{{"name":"","qty":1,"unit":"","location":"","expires":""}}],"location_filter":"all","full_list":false}} 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 +- expiring — what's going bad soon across the pantry (no specific item) +- item_date — ask when a specific item expires / what date is on it + (e.g. "when does the ground beef expire", "what's the date on the milk") - recipes — meal ideas from current stock - shop_add — put items on the shopping list - shop_read — hear the shopping list @@ -57,8 +67,12 @@ - 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 +- full_list is true when the user wants the complete inventory read aloud + (whole list, full list, all items, everything, the rest, stop saying N more) +- for item_date, put the named item in items - for list/expiring/recipes/exit/unknown, items may be empty - split multiples: "milk and eggs" → two items +- "when does X expire" / "expiry date for X" / "tell me the date that X expires" → item_date, not expiring user said: "{input}" """ @@ -164,6 +178,18 @@ def _join_and(parts: list) -> str: return ", ".join(parts[:-1]) + f", and {parts[-1]}" +def _wants_full_list(text: str) -> bool: + lower = (text or "").lower() + if any(p in lower for p in FULL_LIST_PHRASES): + return True + # "give me all of them" / "read all eight" + if "all" in lower.split() and any( + w in lower for w in ("list", "item", "items", "them", "stock", "pantry", "fridge") + ): + return True + return False + + def _format_days(days: int) -> str: if days < 0: n = abs(days) @@ -382,10 +408,49 @@ def classify(self, user_input: str) -> dict: result.setdefault("intent", "unknown") result.setdefault("items", []) result.setdefault("location_filter", "all") + result.setdefault("full_list", False) if not isinstance(result["items"], list): result["items"] = [] return result + def _looks_like_item_date(self, text: str) -> bool: + lower = (text or "").lower() + asks_when = any( + p in lower + for p in ( + "when does", "when do", "what date", "the date", + "expiry", "expiration", "best by", "use by", + ) + ) + mentions_expire = any( + p in lower for p in ("expire", "expires", "expiry", "expiration", "date") + ) + return asks_when and mentions_expire + + def _refine_result(self, result: dict, user_input: str) -> dict: + # fix common misroutes before dispatch + intent = (result.get("intent") or "unknown").lower() + if intent == "list" and _wants_full_list(user_input): + result["full_list"] = True + + if self._looks_like_item_date(user_input) and intent in ( + "expiring", "unknown", "list", "tips", + ): + result["intent"] = "item_date" + if not any((s.get("name") or "").strip() for s in (result.get("items") or [])): + # match a stocked item named in the utterance + lower = user_input.lower() + hits = [] + for item in self.data.get("items") or []: + name = _norm(item.get("name", "")) + if name and name in lower: + hits.append(name) + if hits: + # longest name wins (ground beef over beef) + hits.sort(key=len, reverse=True) + result["items"] = [{"name": hits[0], "qty": 1, "unit": "", "location": "", "expires": ""}] + return result + # mutations def _upsert_item(self, spec: dict) -> str: @@ -543,7 +608,7 @@ async def _llm_recipes(self) -> list: # speak helpers - def _list_speech(self, location_filter: str = "all") -> str: + def _list_speech(self, location_filter: str = "all", *, full: bool = False) -> 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] @@ -559,19 +624,96 @@ def _list_speech(self, location_filter: str = "all") -> str: if location_filter in by_loc: names = by_loc[location_filter] - extra = f" and {len(names) - 5} more" if len(names) > 5 else "" + if full or len(names) <= 5: + return f"In the {location_filter}: {_join_and(names)}." shown = names[:5] - return f"In the {location_filter}: {_join_and(shown)}{extra}." + rest = len(names) - 5 + return ( + f"In the {location_filter}: {_join_and(shown)}. " + f"Plus {rest} more — want the whole list?" + ) - chunks = [] total = len(items) + if full: + chunks = [] + for loc in ("fridge", "pantry", "freezer"): + names = by_loc.get(loc) or [] + if names: + chunks.append(f"{loc}: {_join_and(names)}") + return f"{total} items. " + ". ".join(chunks) + "." + + chunks = [] + truncated = False 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) + "." + if len(names) > 4: + truncated = True + chunks.append(f"{loc} has {_join_and(names[:4])}") + else: + chunks.append(f"{loc} has {_join_and(names)}") + speech = f"{total} items. " + ". ".join(chunks) + "." + if truncated: + speech += " Want the whole list?" + return speech + + def _spoken_date(self, expires: str) -> str: + try: + exp = datetime.strptime(expires[:10], "%Y-%m-%d").date() + return f"{exp.strftime('%B')} {exp.day}, {exp.year}" + except Exception: + return expires + + def _should_ask_expiry(self, name: str, location: str) -> bool: + # ask dates for fridge/freezer and obvious perishables; skip dry goods + loc = (location or "").lower() + n = _norm(name) + if loc in ("fridge", "freezer"): + return True + perishable = ( + "milk", "cream", "yogurt", "cheese", "butter", "egg", + "beef", "chicken", "pork", "turkey", "fish", "salmon", "shrimp", + "meat", "leftover", "deli", "ham", "bacon", "sausage", + "spinach", "lettuce", "berries", "strawberry", "tofu", + ) + shelf_stable = ( + "pasta", "spaghetti", "rice", "bean", "sauce", "flour", "sugar", + "oil", "vinegar", "cereal", "oat", "spice", "salt", "pepper", + "can", "canned", "broth", "stock", "honey", "peanut butter", + ) + if any(p in n for p in perishable): + return True + if any(s in n for s in shelf_stable): + return False + return False + + def _item_expiry_speech(self, specs: list) -> str: + parts = [] + for spec in specs: + name = (spec.get("name") or "").strip() + if not name: + continue + item = self._find_item(name, fuzzy=True) + if not item: + parts.append(f"I don't have {_norm(name)} tracked.") + continue + label = item.get("name", name) + expires = item.get("expires") or "" + if not expires: + parts.append(f"No expiry date set for {label}.") + continue + days = self._days_until(expires) + spoken = self._spoken_date(expires) + if days < 0: + parts.append(f"{label} expired on {spoken}.") + elif days == 0: + parts.append(f"{label} expires today — {spoken}.") + else: + parts.append( + f"{label} expires {spoken} — that's {_format_days(days)}." + ) + return " ".join(parts) or "Which item's expiry date do you want?" def _expiring_speech(self) -> str: due = self._expiring(5) @@ -610,16 +752,23 @@ async def _handle_add(self, specs: list) -> str: continue added.append(name) item = self._find_item(name, (spec.get("location") or "")) - if item and not item.get("expires"): + if not item or item.get("expires"): + continue + loc = item.get("location") or (spec.get("location") or "") + if self._should_ask_expiry(name, loc): needs_date.append(name) if not added: return "I didn't catch what to add. Try 'add milk to the fridge, expires Friday'." warn = await self._persist() 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." + # ask one item at a time — never apply one date to the whole batch + self.pending = { + "type": "expiry", + "name": needs_date[0], + "remaining": needs_date[1:], + } + msg += f" When does the {needs_date[0]} expire? Say a date, or skip." return msg + warn async def _handle_used(self, specs: list) -> str: @@ -773,8 +922,17 @@ async def _dispatch(self, result: dict) -> str: if intent == "remove": return await self._handle_remove(specs) if intent == "list": - return self._list_speech(loc) + full = bool(result.get("full_list")) + speech = self._list_speech(loc, full=full) + if not full and "want the whole list" in speech.lower(): + self.pending = {"type": "list_full", "location_filter": loc} + return speech + if intent == "item_date": + return self._item_expiry_speech(specs) if intent == "expiring": + # "when does the milk expire" sometimes lands here with an item name + if any((s.get("name") or "").strip() for s in specs): + return self._item_expiry_speech(specs) return self._expiring_speech() if intent == "recipes": return await self._handle_recipes() @@ -816,27 +974,64 @@ async def _handle_pending(self, user_input: str) -> str: ptype = pending.get("type") + if ptype == "list_full": + loc = pending.get("location_filter") or "all" + if self._is_yes(user_input) or _wants_full_list(user_input): + self.pending = None + return self._list_speech(loc, full=True) + if self._is_no(user_input): + self.pending = None + return "Okay." + self.pending = None + return "" + if ptype == "expiry": - names = pending.get("names") or [] + name = pending.get("name") or "" + remaining = list(pending.get("remaining") or []) + if self._is_yes(user_input) or "skip" in user_input.lower(): + if remaining: + self.pending = { + "type": "expiry", + "name": remaining[0], + "remaining": remaining[1:], + } + return ( + f"Okay, no date for {name}. " + f"When does the {remaining[0]} expire? Say a date, or skip." + ) 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.", + f"from: '{user_input}'. " + "If only a month and year are given, use the first day of that month. " + "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, fuzzy=True) - if item: - item["expires"] = date + + item = self._find_item(name, fuzzy=True) + if item: + item["expires"] = date warn = await self._persist() + spoken = self._spoken_date(date) + if remaining: + self.pending = { + "type": "expiry", + "name": remaining[0], + "remaining": remaining[1:], + } + return ( + f"Set {name} to expire {spoken}. " + f"When does the {remaining[0]} expire? Say a date, or skip." + + warn + ) self.pending = None - label = _join_and(names) - return f"Set {label} to expire {date}. Anything else?" + warn + return f"Set {name} to expire {spoken}. Anything else?" + warn if ptype == "shop_used": names = pending.get("names") or [] @@ -961,7 +1156,7 @@ async def run(self): handled_up_front = False if trigger and not self._is_generic_trigger(trigger) and not self._is_exit(trigger): - result = self.classify(trigger) + result = self._refine_result(self.classify(trigger), trigger) intent = (result.get("intent") or "unknown").lower() if intent not in ("unknown", "exit", ""): reply = await self._dispatch(result) @@ -1008,8 +1203,11 @@ async def run(self): await self.capability_worker.speak(self._signoff()) break - result = self.classify(user_input) - self._log(f"intent={result.get('intent')} items={result.get('items')}") + result = self._refine_result(self.classify(user_input), user_input) + self._log( + f"intent={result.get('intent')} items={result.get('items')} " + f"full_list={result.get('full_list')}" + ) reply = await self._dispatch(result) if reply == "__exit__": await self.capability_worker.speak(self._signoff()) From a8eafa35b18d484f33d64f6f9e7c96be567b7cd9 Mon Sep 17 00:00:00 2001 From: Uzair Ullah Date: Mon, 31 Aug 2026 18:01:36 +0500 Subject: [PATCH 3/3] Remove does_match() HOTWORDS duplication does_match() hardcoded a HOTWORDS tuple that duplicated whatever gets configured as this ability's dashboard trigger words -- a second source of truth that can drift from the first with nothing to notice if it does. No other ability in the repo overrides does_match() for this reason; invocation is left entirely to the dashboard. Unlike a couple of other abilities reviewed this same pass, HOTWORDS had no second job here (e.g. no in-conversation restart check reusing it), so removing it needed nothing else adjusted. Verified the three fixes this PR actually addresses are unaffected: strict add-time matching still keeps "almond milk" distinct from "milk", fuzzy lookup still resolves "eggs" to a stocked "egg", and the new item_date / full_list logic added in this PR still runs correctly. File still imports cleanly with no does_match anywhere in the class. Separately, and NOT changed here: _search_meals/_lookup_meal call session_tasks.get_async(), a method name that appears nowhere in the SDK reference docs (only .create() and .sleep() are documented there) and that no other ability in this repo uses -- everything else calls session_tasks.get() or plain requests.get(). That code isn't touched by this PR's diff at all; it's inherited from #368. Flagging it rather than guessing at a fix, since getting a live SDK method name wrong by assumption risks trading a real bug for a different one. --- community/pantry-pro/main.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/community/pantry-pro/main.py b/community/pantry-pro/main.py index e68a6afa..abd1e7c7 100644 --- a/community/pantry-pro/main.py +++ b/community/pantry-pro/main.py @@ -15,18 +15,6 @@ 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") @@ -211,10 +199,6 @@ class PantryProCapability(MatchingCapability): # 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)