diff --git a/_skill-index.md b/_skill-index.md index ee1e439..dcfb002 100644 --- a/_skill-index.md +++ b/_skill-index.md @@ -47,6 +47,7 @@ description: Master index of all skills in your robot assistant system. Your ass | "read this email," "review this email," "open this gmail," "check my gmail" | **gmail-browser** | | "make a query," "show all pages tagged," "build a filter," "list tasks in," "show recent notes," "create a silverbullet view," "make a live widget" | **silverbullet-query** | | "clipboard history," "from maccy," "what was on my clipboard," "get the last link I copied" | **clipboard-history** | +| "log food," "add a food item," "what did I eat," "show my food log," "track macros," "food items," "daily food" | **food-tracking** | --- @@ -250,6 +251,12 @@ description: Master index of all skills in your robot assistant system. Your ass **File:** `skills/silverbullet-query/SKILL.md` **Dependencies:** SilverBullet running with Space Lua enabled +### Food Tracking +**Purpose:** Manage food items (catalog with nutrition info) and daily food log entries in MongoDB. Add new foods with macros, search the catalog, log meals and supplements, review eating history. +**Triggers:** "log food," "add a food item," "what did I eat," "show my food log," "track macros," "food items," "daily food" +**File:** `skills/food-tracking/SKILL.md` +**Dependencies:** Python with `pymongo`, `uv` CLI, scripts at `skills/food-tracking/scripts/` + ### Clipboard History **Purpose:** Retrieve items from clipboard history using Maccy on macOS. Query the Maccy SQLite database to find recently copied text, URLs, or search for specific content. macOS only. **Triggers:** "clipboard history," "from maccy," "what was on my clipboard," "get the last link I copied" diff --git a/food-tracking/SKILL.md b/food-tracking/SKILL.md new file mode 100644 index 0000000..095f808 --- /dev/null +++ b/food-tracking/SKILL.md @@ -0,0 +1,100 @@ +# Food Tracking + +Manage food items and daily food log entries in MongoDB (`lists.food_items`, `lists.food_log`). + +## Scripts + +Both scripts are standalone Python CLIs. Run with `uv run --with pymongo`: + +### food_items.py — `lists.food_items` + +Reference item catalog: what foods exist, their nutrition, serving sizes, etc. + +```bash +# Path relative to skill directory +SCRIPT="skills/food-tracking/scripts/food_items.py" + +# Add a new food item +uv run --with pymongo "$SCRIPT" add \ + --name "Kefir Oats" \ + --type food \ + --serving "1.5 cups" \ + --description "Whole milk kefir with old-fashioned rolled oats" \ + --nutrition '{"calories": 228, "protein_g": 12.1, "fat_g": 7.0, "carbs_g": 30.1, "fiber_g": 3.5, "sugar_g": 6.3}' + +# Add a medicine +uv run --with pymongo "$SCRIPT" add \ + --name "Excedrin Migraine" \ + --type medicine \ + --max-dosage "2 caplets" + +# Add with arbitrary extra fields via JSON +uv run --with pymongo "$SCRIPT" add \ + --name "Chicken Breast" \ + --type food \ + --serving "4 oz" \ + --nutrition '{"calories": 187, "protein_g": 35, "fat_g": 4, "carbs_g": 0}' \ + --extra '{"recipe_notes": "grilled, no oil"}' + +# Get all items +uv run --with pymongo "$SCRIPT" get + +# Filter by type +uv run --with pymongo "$SCRIPT" get --type food + +# Search by name +uv run --with pymongo "$SCRIPT" get --name "kefir" + +# Update an item by name +uv run --with pymongo "$SCRIPT" update --name "Kefir Oats" --set-serving "2 cups" + +# Update an item by _id +uv run --with pymongo "$SCRIPT" update --id "6a11b0c3..." --set-nutrition '{"calories": 300, "protein_g": 15, "fat_g": 9, "carbs_g": 40}' + +# Unset a field +uv run --with pymongo "$SCRIPT" update --name "Excedrin Migraine" --unset max_dosage + +# Merge arbitrary fields +uv run --with pymongo "$SCRIPT" update --name "Chicken Breast" --set-extra '{"tags": ["dinner", "high-protein"]}' +``` + +### food_log.py — `lists.food_log` + +Daily consumption log: what was eaten, when, and how much. + +```bash +SCRIPT="skills/food-tracking/scripts/food_log.py" + +# Log a food entry +uv run --with pymongo "$SCRIPT" add --name "Kefir Oats" --type food --amount 1 --unit "serving" + +# Log a medicine +uv run --with pymongo "$SCRIPT" add --name "Excedrin Migraine" --type medicine --amount 1 --unit "caplet" + +# Log with extra fields +uv run --with pymongo "$SCRIPT" add --name "Coffee" --type food --amount 2 --unit "cups" --extra '{"caffeine_mg": 200}' + +# Get today's log +uv run --with pymongo "$SCRIPT" get --today + +# Get last 7 days +uv run --with pymongo "$SCRIPT" get --days 7 + +# Filter by name or type +uv run --with pymongo "$SCRIPT" get --name "Excedrin" +uv run --with pymongo "$SCRIPT" get --type medicine + +# Update an entry (requires --id from a previous get) +uv run --with pymongo "$SCRIPT" update --id "6a11a937..." --set-amount 2 --set-unit "caplets" + +# Unset a field +uv run --with pymongo "$SCRIPT" update --id "6a11a937..." --unset unit +``` + +## Workflow Notes + +- When logging food, the assistant should run `food_items.py get --name ` first to confirm the item exists and show its nutrition info. +- For new foods not in the catalog, add the item first, then log it. +- Always present nutrition info back to the user when logging food (pull from `food_items` so the user sees the macros). +- When searching the internet for nutrition facts, use USDA FoodData Central as the source, then store the result as a food item. +- The `--extra` flag on both scripts accepts arbitrary JSON, useful for fields not covered by named flags (e.g., recipe details, tags, notes). diff --git a/food-tracking/scripts/food_items.py b/food-tracking/scripts/food_items.py new file mode 100644 index 0000000..c97d172 --- /dev/null +++ b/food-tracking/scripts/food_items.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""CLI for managing food items in MongoDB lists.food_items.""" + +import argparse +import json +import sys +from datetime import datetime, timezone +from pymongo import MongoClient + +CONN = "mongodb://root:3wwfoUjyk2E2zWELXFlLuHqfw1ALlOp4pb2H5Vq3TImbMIHL2h1u8Jej2mjzCPl@docdb.connorrhodes.com:35563/?tls=true&tlsAllowInvalidCertificates=true" + +def get_collection(): + client = MongoClient(CONN) + return client["lists"]["food_items"], client + +def cmd_add(args): + coll, client = get_collection() + doc = {"name": args.name} + + if args.type: + doc["type"] = args.type + if args.serving: + doc["serving"] = args.serving + if args.description: + doc["description"] = args.description + if args.max_dosage: + doc["max_dosage"] = args.max_dosage + if args.nutrition: + try: + doc["nutrition"] = json.loads(args.nutrition) + except json.JSONDecodeError: + print(f"Error: --nutrition must be valid JSON. Got: {args.nutrition}", file=sys.stderr) + sys.exit(1) + if args.extra: + try: + extra = json.loads(args.extra) + doc.update(extra) + except json.JSONDecodeError: + print(f"Error: --extra must be valid JSON. Got: {args.extra}", file=sys.stderr) + sys.exit(1) + + result = coll.insert_one(doc) + out = coll.find_one({"_id": result.inserted_id}) + out["_id"] = str(out["_id"]) + print(json.dumps(out, indent=2, default=str)) + client.close() + +def cmd_get(args): + coll, client = get_collection() + query = {} + if args.name: + query["name"] = {"$regex": args.name, "$options": "i"} + if args.type: + query["type"] = {"$regex": args.type, "$options": "i"} + + cursor = coll.find(query).sort("name", 1).limit(args.limit if args.limit else 50) + docs = [] + for d in cursor: + d["_id"] = str(d["_id"]) + docs.append(d) + + if not docs: + print("No items found.") + else: + print(json.dumps(docs, indent=2, default=str)) + client.close() + +def cmd_update(args): + coll, client = get_collection() + + # Build filter + if args.id: + from bson import ObjectId + query = {"_id": ObjectId(args.id)} + elif args.name: + query = {"name": args.name} + else: + print("Error: provide --id or --name to identify the item", file=sys.stderr) + sys.exit(1) + + # Build update + update_fields = {} + if args.set_name: + update_fields["name"] = args.set_name + if args.set_type: + update_fields["type"] = args.set_type + if args.set_serving: + update_fields["serving"] = args.set_serving + if args.set_description: + update_fields["description"] = args.set_description + if args.set_max_dosage: + update_fields["max_dosage"] = args.set_max_dosage + if args.set_nutrition: + try: + update_fields["nutrition"] = json.loads(args.set_nutrition) + except json.JSONDecodeError: + print(f"Error: --set-nutrition must be valid JSON", file=sys.stderr) + sys.exit(1) + if args.set_extra: + try: + extra = json.loads(args.set_extra) + update_fields.update(extra) + except json.JSONDecodeError: + print(f"Error: --set-extra must be valid JSON", file=sys.stderr) + sys.exit(1) + if args.unset: + for field in args.unset: + update_fields[field] = "" + + if not update_fields: + print("Error: nothing to update", file=sys.stderr) + sys.exit(1) + + set_doc = {"$set": {k: v for k, v in update_fields.items()}} + unset_doc = {"$unset": {k: v for k, v in update_fields.items() if v == ""}} + + update = {} + if set_doc["$set"]: + update["$set"] = set_doc["$set"] + if unset_doc["$unset"]: + update["$unset"] = unset_doc["$unset"] + + result = coll.update_one(query, update) + print(f"Matched: {result.matched_count}, Modified: {result.modified_count}") + + if result.matched_count: + updated = coll.find_one(query) + updated["_id"] = str(updated["_id"]) + print(json.dumps(updated, indent=2, default=str)) + else: + print("No matching item found.") + client.close() + +def main(): + parser = argparse.ArgumentParser(description="Manage food items in lists.food_items") + sub = parser.add_subparsers(dest="command", required=True) + + # add + p_add = sub.add_parser("add", help="Add a new food item") + p_add.add_argument("--name", required=True) + p_add.add_argument("--type") + p_add.add_argument("--serving") + p_add.add_argument("--description") + p_add.add_argument("--max-dosage") + p_add.add_argument("--nutrition", help='JSON string, e.g. \'{"calories": 228, "protein_g": 12}\'') + p_add.add_argument("--extra", help="JSON string with additional fields to merge into the document") + + # get + p_get = sub.add_parser("get", help="Get food items") + p_get.add_argument("--name", help="Filter by name (regex)") + p_get.add_argument("--type", help="Filter by type (regex)") + p_get.add_argument("--limit", type=int, help="Max items to return") + + # update + p_upd = sub.add_parser("update", help="Update an existing food item") + p_upd.add_argument("--id", help="ObjectId to update") + p_upd.add_argument("--name", help="Name to match (if no --id)") + p_upd.add_argument("--set-name", dest="set_name") + p_upd.add_argument("--set-type", dest="set_type") + p_upd.add_argument("--set-serving", dest="set_serving") + p_upd.add_argument("--set-description", dest="set_description") + p_upd.add_argument("--set-max-dosage", dest="set_max_dosage") + p_upd.add_argument("--set-nutrition", dest="set_nutrition", help="JSON string to replace nutrition") + p_upd.add_argument("--set-extra", dest="set_extra", help="JSON string with additional fields to set") + p_upd.add_argument("--unset", nargs="*", help="Field names to remove") + + args = parser.parse_args() + {"add": cmd_add, "get": cmd_get, "update": cmd_update}[args.command](args) + +if __name__ == "__main__": + main() diff --git a/food-tracking/scripts/food_log.py b/food-tracking/scripts/food_log.py new file mode 100644 index 0000000..309ea56 --- /dev/null +++ b/food-tracking/scripts/food_log.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""CLI for managing food log entries in MongoDB lists.food_log.""" + +import argparse +import json +import sys +from datetime import datetime, timezone, timedelta +from pymongo import MongoClient + +CONN = "mongodb://root:3wwfoUjyk2E2zWELXFlLuHqfw1ALlOp4pb2H5Vq3TImbMIHL2h1u8Jej2mjzCPl@docdb.connorrhodes.com:35563/?tls=true&tlsAllowInvalidCertificates=true" + +def get_collection(): + client = MongoClient(CONN) + return client["lists"]["food_log"], client + +def cmd_add(args): + coll, client = get_collection() + doc = {"timestamp": datetime.now(timezone.utc)} + + if args.name: + doc["name"] = args.name + if args.type: + doc["type"] = args.type + if args.amount: + doc["amount"] = args.amount + if args.unit: + doc["unit"] = args.unit + if args.extra: + try: + extra = json.loads(args.extra) + doc.update(extra) + except json.JSONDecodeError: + print(f"Error: --extra must be valid JSON. Got: {args.extra}", file=sys.stderr) + sys.exit(1) + + if "name" not in doc: + print("Error: --name is required", file=sys.stderr) + sys.exit(1) + + result = coll.insert_one(doc) + out = coll.find_one({"_id": result.inserted_id}) + out["_id"] = str(out["_id"]) + print(json.dumps(out, indent=2, default=str)) + client.close() + +def cmd_get(args): + coll, client = get_collection() + query = {} + + if args.name: + query["name"] = {"$regex": args.name, "$options": "i"} + if args.type: + query["type"] = {"$regex": args.type, "$options": "i"} + if args.today: + today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + query["timestamp"] = {"$gte": today_start} + if args.days: + cutoff = datetime.now(timezone.utc) - timedelta(days=args.days) + query["timestamp"] = {"$gte": cutoff} + + cursor = coll.find(query).sort("timestamp", -1).limit(args.limit if args.limit else 50) + docs = [] + for d in cursor: + d["_id"] = str(d["_id"]) + docs.append(d) + + if not docs: + print("No entries found.") + else: + print(json.dumps(docs, indent=2, default=str)) + client.close() + +def cmd_update(args): + coll, client = get_collection() + + from bson import ObjectId + + if not args.id: + print("Error: --id is required for update", file=sys.stderr) + sys.exit(1) + + query = {"_id": ObjectId(args.id)} + + update_fields = {} + if args.set_name: + update_fields["name"] = args.set_name + if args.set_type: + update_fields["type"] = args.set_type + if args.set_amount is not None: + update_fields["amount"] = args.set_amount + if args.set_unit: + update_fields["unit"] = args.set_unit + if args.set_extra: + try: + extra = json.loads(args.set_extra) + update_fields.update(extra) + except json.JSONDecodeError: + print(f"Error: --set-extra must be valid JSON", file=sys.stderr) + sys.exit(1) + if args.unset: + for field in args.unset: + update_fields[field] = "" + + if not update_fields: + print("Error: nothing to update", file=sys.stderr) + sys.exit(1) + + set_doc = {"$set": {k: v for k, v in update_fields.items()}} + unset_doc = {"$unset": {k: v for k, v in update_fields.items() if v == ""}} + + update = {} + if set_doc["$set"]: + update["$set"] = set_doc["$set"] + if unset_doc["$unset"]: + update["$unset"] = unset_doc["$unset"] + + result = coll.update_one(query, update) + print(f"Matched: {result.matched_count}, Modified: {result.modified_count}") + + if result.matched_count: + updated = coll.find_one(query) + updated["_id"] = str(updated["_id"]) + print(json.dumps(updated, indent=2, default=str)) + else: + print("No matching entry found.") + client.close() + +def main(): + parser = argparse.ArgumentParser(description="Manage food log entries in lists.food_log") + sub = parser.add_subparsers(dest="command", required=True) + + # add + p_add = sub.add_parser("add", help="Add a food log entry") + p_add.add_argument("--name", required=True) + p_add.add_argument("--type") + p_add.add_argument("--amount", type=float) + p_add.add_argument("--unit") + p_add.add_argument("--extra", help="JSON string with additional fields") + + # get + p_get = sub.add_parser("get", help="Get food log entries") + p_get.add_argument("--name", help="Filter by name (regex)") + p_get.add_argument("--type", help="Filter by type (regex)") + p_get.add_argument("--today", action="store_true", help="Only today's entries") + p_get.add_argument("--days", type=int, help="Entries from last N days") + p_get.add_argument("--limit", type=int, help="Max entries to return") + + # update + p_upd = sub.add_parser("update", help="Update a food log entry") + p_upd.add_argument("--id", required=True, help="ObjectId to update") + p_upd.add_argument("--set-name", dest="set_name") + p_upd.add_argument("--set-type", dest="set_type") + p_upd.add_argument("--set-amount", dest="set_amount", type=float) + p_upd.add_argument("--set-unit", dest="set_unit") + p_upd.add_argument("--set-extra", dest="set_extra", help="JSON string with additional fields to set") + p_upd.add_argument("--unset", nargs="*", help="Field names to remove") + + args = parser.parse_args() + {"add": cmd_add, "get": cmd_get, "update": cmd_update}[args.command](args) + +if __name__ == "__main__": + main()