diff --git a/SKILL.md b/SKILL.md
index 55afed8..85670f7 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -71,6 +71,9 @@ Schema:
>=85% of that meal's protein โ e.g. a bowl of 10 sausages. A balanced plate where the lead
protein is capped but other proteins + veg contribute is NOT overload (a normal 4-egg meal
passes). So spread across 3+ different proteins (plus veg, grain, dairy) so the plate is varied.
+ **CALORIE CAP: each meal must stay at or under ~1000 kcal** (est., excluding unlogged cooking
+ fat) โ run `scripts/calc_meal.py` on the meal's items to check. If a capped+balanced meal
+ would exceed 1000 kcal, trim starch/cheese/halloumi/lentil portions (not protein) until it fits.
**If capped + balanced meals still can't reach ~165 g/day with current stock, DO NOT fake it**
โ produce the best balanced meal you can, state the realistic total, and list the top 2โ3
things to buy (eggs, whey, chicken, tuna) to close the gap. Never suggest "10 sausages" or any
diff --git a/references/email-template.md b/references/email-template.md
index 39c4dd9..0430b89 100644
--- a/references/email-template.md
+++ b/references/email-template.md
@@ -20,12 +20,14 @@ Copy this scaffold, fill the meal sections, and save to `/tmp/meal_today.html`
๐ฝ Lunch โ Meal Name
Ingredients: 3 eggs, 100g spinach, 1 tbsp olive oil
+
Protein: ~24 g ยท Est. kcal: ~310
Method: 1) โฆ 2) โฆ 3) โฆ
๐ฝ Dinner โ Meal Name
Ingredients: โฆ
+
Protein: ~40 g ยท Est. kcal: ~520
Method: 1) โฆ 2) โฆ
diff --git a/references/meal-history.md b/references/meal-history.md
index 1440b12..ddf56f6 100644
--- a/references/meal-history.md
+++ b/references/meal-history.md
@@ -11,7 +11,9 @@ job must record them into `meal-history.json` (same skill dir) so the user can l
"weekday": "Thursday",
"meal": "lunch" | "dinner",
"name": "Human-readable meal name",
- "items": [ { "name": "Name as in inventory.json", "qty": 1, "unit": "count" }, ... ]
+ "items": [ { "name": "Name as in inventory.json", "qty": 1, "unit": "count" }, ... ],
+ "kcal": 720,
+ "protein_g": 64
}
```
diff --git a/scripts/calc_meal.py b/scripts/calc_meal.py
new file mode 100644
index 0000000..df5ea00
--- /dev/null
+++ b/scripts/calc_meal.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""
+Estimate protein (g), calories (kcal), fibre (g) and saturated fat (g) for a meal
+from its items, and check the RATIONAL PORTIONS caps (kcal cap + per-item protein
+max_per_meal). Reads references/calories.md-style numbers inline (kept in sync with
+that file). Usage:
+ python3 calc_meal.py '[{"name":"Eggs","qty":4,"unit":"count"}, ...]'
+Prints JSON: {protein_g, kcal, fibre_g, satfat_g, over_kcal, over_item}.
+Exit non-zero if over_kcal (so it can gate sends in the daily job).
+"""
+import json, sys, os
+
+SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# kcal per logged portion. For g/ml-unit items the value is PER GRAM (ร qty).
+# For count/can/portion items the value is the whole item. Keep in sync with
+# references/calories.md (which shows dairy per 100g for humans โ divide by 100 here).
+KCAL = {
+ "Eggs": 70, "Chicken breast": 165, "Tinned tuna in veg oil": 190,
+ "Tinned tuna in spring water": 130, "Tinned green lentils": 230,
+ "Greek yogurt (0%)": 0.5, "Cottage cheese": 0.95, "Cheese": 4.0, "Halloumi": 3.2,
+ "Whey protein powder": 4, "Milk (semi)": 0.5,
+ "Dried red lentils": 3.5, "Baked beans": 200, "Chickpeas": 180, "Tofu (firm)": 1.4,
+ "Edamame (frozen)": 1.2, "Wholemeal pasta": 350, "Microwave rice": 220,
+ "Scottish oats": 3.7,
+ "Broccoli": 35, "Carrots": 0.41, "Courgette": 20, "Sweet potatoes": 130,
+ "Aubergine": 40, "Red cabbage": 0.04, "Cauliflower": 30, "Leeks": 55, "Mushrooms": 0.23,
+ "Lettuce": 15, "Salad tomatoes": 15, "Orange bell pepper": 30, "Small white onions": 15,
+ "Frozen mixed veg": 0.73, "Peeled plum tomatoes": 90,
+ "Cumberland sausages": 55, "Lamb mince": 2.5,
+}
+PROTEIN_G = {
+ "Eggs": 6, "Chicken breast": 33, "Tinned tuna in veg oil": 25,
+ "Tinned tuna in spring water": 25, "Tinned green lentils": 18,
+ "Greek yogurt (0%)": 0.10, "Cottage cheese": 0.12, "Cheese": 0.25, "Halloumi": 0.19,
+ "Whey protein powder": 0.8, "Milk (semi)": 3.4,
+ "Dried red lentils": 0.25, "Baked beans": 18, "Chickpeas": 8, "Tofu (firm)": 13,
+ "Edamame (frozen)": 11, "Wholemeal pasta": 7, "Microwave rice": 3, "Scottish oats": 11,
+ "Broccoli": 3, "Carrots": 0.01, "Courgette": 1, "Sweet potatoes": 2, "Aubergine": 1,
+ "Red cabbage": 0.01, "Cauliflower": 3, "Leeks": 1, "Mushrooms": 0.03, "Lettuce": 1,
+ "Salad tomatoes": 1, "Orange bell pepper": 1, "Small white onions": 1,
+ "Frozen mixed veg": 0.03, "Peeled plum tomatoes": 2,
+ "Cumberland sausages": 11, "Lamb mince": 18,
+}
+FIBRE_G = {
+ "Dried red lentils": 0.11, "Baked beans": 14, "Chickpeas": 14, "Tinned green lentils": 14,
+ "Tofu (firm)": 0.004, "Edamame (frozen)": 0.1, "Wholemeal pasta": 5, "Scottish oats": 0.1,
+ "Broccoli": 3, "Carrots": 0.03, "Courgette": 1, "Sweet potatoes": 4, "Aubergine": 3,
+ "Red cabbage": 0.04, "Cauliflower": 3, "Leeks": 3, "Mushrooms": 0.02, "Lettuce": 1,
+ "Salad tomatoes": 1, "Orange bell pepper": 2, "Small white onions": 1,
+ "Frozen mixed veg": 0.04, "Peeled plum tomatoes": 2, "Cumberland sausages": 0, "Lamb mince": 0,
+}
+SATFAT_G = {
+ "Eggs": 6, "Cheese": 0.21, "Halloumi": 0.26, "Cumberland sausages": 18, "Lamb mince": 9,
+ "Tinned tuna in veg oil": 3, "Chicken breast": 1, "Whey protein powder": 0,
+}
+KCAL_CAP = 1000
+
+GRAMS_PER_UNIT = {
+ "g": 1, "ml": 1, "kg": 1000, "count": 1, "can": 1,
+ "small block": 120, "block": 120, "serving": 1, "portion": 1,
+ "pack": 1, "jar": 1, "tub": 1, "bulb": 1, "bottle": 1, "loaf": 1,
+}
+
+
+def estimate(items, inv):
+ tot = {"protein_g": 0.0, "kcal": 0.0, "fibre_g": 0.0, "satfat_g": 0.0}
+ over_kcal = False
+ over_item = []
+ for it in items:
+ name, qty, unit = it["name"], it["qty"], it.get("unit", "count")
+ # kcal
+ kp = KCAL.get(name, 0)
+ k = kp * (qty if unit not in ("g", "ml") else qty)
+ # protein
+ pp = PROTEIN_G.get(name, 0)
+ p = pp * (qty if unit not in ("g", "ml") else qty)
+ # fibre
+ fp = FIBRE_G.get(name, 0)
+ f = fp * (qty if unit not in ("g", "ml") else qty)
+ # satfat
+ sp = SATFAT_G.get(name, 0)
+ s = sp * (qty if unit not in ("g", "ml") else qty)
+ tot["kcal"] += k; tot["protein_g"] += p; tot["fibre_g"] += f; tot["satfat_g"] += s
+ # per-item max_per_meal (protein) check โ convert both sides to grams
+ inv_item = next((i for i in inv["items"] if i["name"].lower() == name.lower()), None)
+ cap = inv_item.get("max_per_meal") if inv_item else None
+ if cap is not None:
+ cap_unit = inv_item.get("unit", unit)
+ used_g = qty * GRAMS_PER_UNIT.get(unit, 1)
+ cap_g = cap * GRAMS_PER_UNIT.get(cap_unit, 1)
+ if used_g > cap_g + 1e-6:
+ over_item.append(f"{name} {qty}{unit} > cap {cap}{cap_unit}")
+ if tot["kcal"] > KCAL_CAP + 1e-6:
+ over_kcal = True
+ for key in tot:
+ tot[key] = round(tot[key], 1)
+ return {**tot, "over_kcal": over_kcal, "over_item": over_item}
+
+
+def main():
+ items = json.loads(sys.argv[1]) if len(sys.argv) > 1 else []
+ inv = json.load(open(os.path.join(SKILL_DIR, "inventory.json")))
+ res = estimate(items, inv)
+ print(json.dumps(res))
+ if res["over_kcal"] or res["over_item"]:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/verify_portions.py b/scripts/verify_portions.py
index 4a522e8..115b4dc 100644
--- a/scripts/verify_portions.py
+++ b/scripts/verify_portions.py
@@ -121,6 +121,19 @@ def main():
f"{it['name']} {it['qty']} is >=70% of its cap and supplies "
f"{pg:.0f}/{total:.0f} g ({pg/total*100:.0f}%) of meal protein "
f"โ not a balanced plate")
+ # 3) calorie cap check (uses calc_meal helper if available)
+ try:
+ import importlib.util
+ spec = importlib.util.spec_from_file_location(
+ "calc_meal", os.path.join(SKILL_DIR, "scripts", "calc_meal.py"))
+ cm = importlib.util.module_from_spec(spec); spec.loader.exec_module(cm)
+ res = cm.estimate(meal_items, inv)
+ if res["over_kcal"]:
+ violations.append(
+ f"KCAL CAP {e['date']} {e['meal']} '{e['name']}': "
+ f"~{res['kcal']:.0f} kcal exceeds 1000 kcal/meal")
+ except Exception:
+ pass
if violations:
print("FAIL: rational-portion violations:")
for v in violations: