@topy-ai/maggie 0.7.53 → 0.7.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README-zh-TW.md +28 -50
  2. package/README.md +80 -64
  3. package/bin/maggie.js +61 -9
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
  5. package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
  6. package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
  7. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
  8. package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
  9. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +1 -1
  10. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +2 -2
  11. package/bundled-skills/catalog.json +1 -1
  12. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  13. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  14. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  15. package/bundled-skills/maggie-blog/SKILL.md +15 -0
  16. package/bundled-skills/maggie-booking/SKILL.md +20 -12
  17. package/bundled-skills/maggie-content-localization/SKILL.md +26 -0
  18. package/bundled-skills/maggie-dash/SKILL.md +37 -2
  19. package/bundled-skills/maggie-deployment/SKILL.md +104 -1
  20. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  21. package/bundled-skills/maggie-design/SKILL.md +81 -0
  22. package/bundled-skills/maggie-feedback/SKILL.md +10 -2
  23. package/bundled-skills/maggie-marketplace/SKILL.md +22 -2
  24. package/bundled-skills/maggie-marketplace/references/directory-search.md +1 -1
  25. package/bundled-skills/maggie-marketplace/references/directory.mjs +2 -2
  26. package/bundled-skills/maggie-mobile-app/SKILL.md +29 -0
  27. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  28. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  29. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  30. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  31. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  32. package/bundled-skills/maggie-social-share/SKILL.md +50 -0
  33. package/bundled-tools/clis/maggie.py +63 -3
  34. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  35. package/bundled-tools/clis/maggie_booking.py +9 -8
  36. package/bundled-tools/clis/maggie_dash.py +173 -3
  37. package/bundled-tools/clis/maggie_deployment_ops.py +64 -1
  38. package/bundled-tools/clis/maggie_design.py +7 -1
  39. package/bundled-tools/clis/maggie_design_bulk.py +290 -0
  40. package/bundled-tools/clis/maggie_design_lint.py +131 -1
  41. package/bundled-tools/clis/maggie_feedback.py +41 -13
  42. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  43. package/bundled-tools/clis/maggie_live_urls.py +147 -0
  44. package/bundled-tools/clis/maggie_localization.py +3 -0
  45. package/bundled-tools/clis/maggie_localization_keys.py +151 -0
  46. package/bundled-tools/clis/maggie_media_generate.py +174 -0
  47. package/bundled-tools/clis/maggie_mobile.py +84 -1
  48. package/bundled-tools/clis/maggie_ops.py +2 -0
  49. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  50. package/bundled-tools/clis/maggie_product_release.py +49 -1
  51. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  52. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  53. package/bundled-tools/clis/maggie_social_share.py +223 -2
  54. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  55. package/bundled-tools/integrations/maggie-api-pull.md +49 -0
  56. package/package.json +1 -1
  57. package/bundled-contracts/maggiedash/execution-board.json +0 -5463
  58. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +0 -210
  59. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +0 -5463
  60. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +0 -270
  61. package/bundled-references/maggiedash-booking/PROGRESS.md +0 -279
  62. package/bundled-references/maggiedash-booking/README.md +0 -288
  63. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +0 -551
  64. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +0 -107
  65. package/bundled-references/maggiedash-booking/TASKS.md +0 -178
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env python3
2
+ """Record where API Pull deliveries went live, from the site's sitemap.
3
+
4
+ A delivered post is useful to other channels (social posts, internal links)
5
+ only once its live URL is known. The site should report it when it publishes;
6
+ this matcher fills the gaps from the sitemap with two rules:
7
+
8
+ * every post without a live URL is matched, including one already tracked
9
+ with an empty value (a slug missing from the record is read from its
10
+ delivered address);
11
+ * additive only: a record that already has a live URL keeps it, and a record
12
+ that matches nothing in this run is left as it was (never marked unmatched),
13
+ so the match can run on a schedule;
14
+ * the original page first: when translated copies share the slug
15
+ (``/blog/x`` and ``/zh-hans/blog/x``), the URL without a locale segment wins.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+ import xml.etree.ElementTree as ET
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from urllib.parse import urlsplit
27
+ from urllib.request import Request, urlopen
28
+
29
+ LOCALE_SEGMENT = re.compile(r"^[a-z]{2}(?:[-_][a-z0-9]{2,8})?$", re.I)
30
+ MAX_SITEMAPS = 50
31
+
32
+
33
+ def now() -> str:
34
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
35
+
36
+
37
+ def fetch(location: str) -> str:
38
+ if not location.startswith(("http://", "https://")):
39
+ return Path(location).read_text(encoding="utf-8")
40
+ with urlopen(Request(location, headers={"User-Agent": "MaggieLiveUrls/1.0"}), timeout=20) as response:
41
+ return response.read(20_000_000).decode("utf-8", "replace")
42
+
43
+
44
+ def sitemap_urls(location: str) -> list[str]:
45
+ """Every <loc> of a sitemap, following sitemap indexes (bounded)."""
46
+ seen: set[str] = set()
47
+ urls: list[str] = []
48
+ queue = [location]
49
+ while queue and len(seen) < MAX_SITEMAPS:
50
+ current = queue.pop(0)
51
+ if current in seen:
52
+ continue
53
+ seen.add(current)
54
+ root = ET.fromstring(fetch(current))
55
+ tag = root.tag.rsplit("}", 1)[-1]
56
+ locs = [element.text.strip() for element in root.iter() if element.tag.rsplit("}", 1)[-1] == "loc" and element.text]
57
+ if tag == "sitemapindex":
58
+ queue.extend(locs)
59
+ else:
60
+ urls.extend(locs)
61
+ return list(dict.fromkeys(urls))
62
+
63
+
64
+ def has_locale_segment(url: str) -> bool:
65
+ parts = [part for part in urlsplit(url).path.split("/") if part]
66
+ return len(parts) > 1 and bool(LOCALE_SEGMENT.match(parts[0]))
67
+
68
+
69
+ def best_match(slug: str, urls: list[str]) -> str | None:
70
+ candidates = [url for url in urls if [part for part in urlsplit(url).path.split("/") if part][-1:] == [slug]]
71
+ if not candidates:
72
+ return None
73
+ # Original before translated copies, then the shortest path.
74
+ return sorted(candidates, key=lambda url: (has_locale_segment(url), len(urlsplit(url).path), url))[0]
75
+
76
+
77
+ def slug_from(address: object) -> str:
78
+ path = urlsplit(str(address or "")).path.rstrip("/")
79
+ return path.rsplit("/", 1)[-1] if path else ""
80
+
81
+
82
+ def match(state: dict, urls: list[str]) -> dict:
83
+ records = state.get("content", {}) if isinstance(state.get("content"), dict) else {}
84
+ matched, kept, missing = [], [], []
85
+ for key, record in records.items():
86
+ if not isinstance(record, dict):
87
+ continue
88
+ # A post already tracked with an empty (or blank) URL still needs one.
89
+ if str(record.get("live_url") or "").strip():
90
+ kept.append({"remote_content_id": record.get("remote_content_id") or key, "live_url": record["live_url"], "source": record.get("live_url_source", "")})
91
+ continue
92
+ slug = str(record.get("slug") or "").strip("/") or slug_from(record.get("canonical_url"))
93
+ url = best_match(slug, urls) if slug else None
94
+ if url:
95
+ record["live_url"] = url
96
+ record["live_url_source"] = "sitemap"
97
+ record["live_url_matched_at"] = now()
98
+ matched.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug, "live_url": url})
99
+ else:
100
+ missing.append({"remote_content_id": record.get("remote_content_id") or key, "slug": slug})
101
+ return {"matched": matched, "kept": kept, "notFound": missing}
102
+
103
+
104
+ def main() -> int:
105
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
106
+ sub = parser.add_subparsers(dest="command", required=True)
107
+ run = sub.add_parser("match", help="fill missing live URLs of delivered posts from the sitemap (additive)")
108
+ run.add_argument("--project", default=".")
109
+ run.add_argument("--state", help="lifecycle state (default: .maggie/ai-cmo-lifecycle.json)")
110
+ run.add_argument("--sitemap", required=True, help="sitemap URL or file (indexes are followed)")
111
+ run.add_argument("--write", action="store_true", help="save the new live URLs into the state file")
112
+ run.add_argument("--report-body", help="write a report-state body for the newly matched posts (send with `maggie api report-state --body-file`)")
113
+ record = sub.add_parser("record", help="record a live URL the site reported when it published a delivered post")
114
+ record.add_argument("--project", default=".")
115
+ record.add_argument("--state")
116
+ record.add_argument("--content-id", required=True)
117
+ record.add_argument("--url", required=True)
118
+ args = parser.parse_args()
119
+ state_path = Path(args.state) if args.state else Path(args.project) / ".maggie" / "ai-cmo-lifecycle.json"
120
+ try:
121
+ state = json.loads(state_path.read_text(encoding="utf-8"))
122
+ if args.command == "record":
123
+ if urlsplit(args.url).scheme not in {"http", "https"}:
124
+ raise ValueError("--url must be an absolute http(s) URL")
125
+ target = state.get("content", {}).get(args.content_id)
126
+ if not isinstance(target, dict):
127
+ raise ValueError(f"no delivered post {args.content_id} in {state_path}")
128
+ # The site's own report is authoritative and replaces a sitemap guess.
129
+ target.update({"live_url": args.url, "live_url_source": "site", "live_url_matched_at": now()})
130
+ state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
131
+ print(json.dumps({"status": "recorded", "remote_content_id": args.content_id, "live_url": args.url}, indent=2))
132
+ return 0
133
+ result = match(state, sitemap_urls(args.sitemap))
134
+ if args.write:
135
+ state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
136
+ if args.report_body and result["matched"]:
137
+ body = {"items": [{"content_id": item["remote_content_id"], "url": item["live_url"], "status": "published"} for item in result["matched"]][:500]}
138
+ Path(args.report_body).write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8")
139
+ print(json.dumps({"status": "matched" if args.write else "dry-run", **{key: value for key, value in result.items()}, "counts": {key: len(value) for key, value in result.items()}}, indent=2, ensure_ascii=False))
140
+ return 0
141
+ except (OSError, ValueError, json.JSONDecodeError, ET.ParseError) as error:
142
+ print(f"maggie live-urls: {error}", file=sys.stderr)
143
+ return 1
144
+
145
+
146
+ if __name__ == "__main__":
147
+ raise SystemExit(main())
@@ -17,6 +17,7 @@ from localization_runner import generate, process_adapter
17
17
 
18
18
  sys.path.insert(0, str(Path(__file__).resolve().parent))
19
19
  import maggie_email_localization as email_localization
20
+ import maggie_localization_keys as localization_keys
20
21
 
21
22
 
22
23
  def generate_job(args: argparse.Namespace) -> int:
@@ -399,6 +400,7 @@ def main() -> int:
399
400
  for name in ("stale", "glossary"):
400
401
  command = sub.add_parser(name); command.add_argument("--project", type=Path, default=Path("."))
401
402
  email_localization.add_parsers(sub)
403
+ localization_keys.add_parsers(sub)
402
404
  args = parser.parse_args()
403
405
  try:
404
406
  if args.command == "generate": return generate_job(args)
@@ -411,6 +413,7 @@ def main() -> int:
411
413
  if args.command == "publish": return publish(args.job, args.confirm)
412
414
  if args.command == "stale": return stale_jobs(args.project)
413
415
  if args.command in ("email-matrix", "lifecycle"): return email_localization.run(args)
416
+ if args.command in ("key-check", "fragments"): return localization_keys.run(args)
414
417
  return glossary(args.project)
415
418
  except (OSError, ValueError, json.JSONDecodeError) as error:
416
419
  print(f"ERROR: {error}", file=sys.stderr); return 1
@@ -0,0 +1,151 @@
1
+ """Translation keys for generated pages: text keys are site-wide, positions are not.
2
+
3
+ A translation stored under its source text applies to every page that shows
4
+ that text. That is right for a shared header, wrong for generic phrases on
5
+ generated pages ("your practice" is a clinic on one page and a firm on
6
+ another). `key-check` reads per-page translation rows and lists the texts that
7
+ need position keys. `fragments` reads rendered HTML and lists text split
8
+ around an inline span (a styled brand name) into pieces too short to translate
9
+ on their own ("Can", "No.", "What").
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import re
16
+ from html.parser import HTMLParser
17
+ from pathlib import Path
18
+
19
+ INLINE = {"span", "strong", "em", "b", "i", "a", "mark", "small", "sup", "sub", "u"}
20
+ BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "h6", "li", "td", "th", "dt", "dd", "figcaption", "blockquote", "button", "label", "summary", "caption"}
21
+ SKIP = {"script", "style", "noscript", "template", "svg", "code", "pre"}
22
+ WORD = re.compile(r"[\w'’-]+", re.UNICODE)
23
+
24
+
25
+ def load_rows(path: Path) -> list[dict]:
26
+ value = json.loads(path.read_text(encoding="utf-8"))
27
+ rows = value.get("rows", []) if isinstance(value, dict) else value
28
+ if not isinstance(rows, list):
29
+ raise ValueError("rows must be a list of {page, position?, text, translation}")
30
+ return [row for row in rows if isinstance(row, dict)]
31
+
32
+
33
+ def key_check(rows: list[dict], locale: str | None = None) -> dict:
34
+ """Source texts whose translation differs between pages cannot share a text key."""
35
+ by_text: dict[str, dict[str, set[str]]] = {}
36
+ for row in rows:
37
+ if locale and row.get("locale") not in (None, locale):
38
+ continue
39
+ text = " ".join(str(row.get("text") or "").split())
40
+ translation = " ".join(str(row.get("translation") or "").split())
41
+ if not text or not translation:
42
+ continue
43
+ by_text.setdefault(text, {}).setdefault(translation, set()).add(str(row.get("page") or "?"))
44
+ conflicts = []
45
+ for text, translations in sorted(by_text.items()):
46
+ if len(translations) > 1:
47
+ pages = sorted({page for group in translations.values() for page in group})
48
+ conflicts.append({"text": text, "translations": {translation: sorted(group)[:5] for translation, group in translations.items()}, "pages": len(pages)})
49
+ shared = sum(1 for translations in by_text.values() if len({page for group in translations.values() for page in group}) > 1)
50
+ return {
51
+ "valid": not conflicts,
52
+ "texts": len(by_text),
53
+ "sharedAcrossPages": shared,
54
+ "needPositionKeys": len(conflicts),
55
+ "conflicts": conflicts[:200],
56
+ "advice": "Store these under per-page position keys (render the source page, then key each node by page + position with a source hash); keep text keys only as a fallback for genuinely shared text." if conflicts else "",
57
+ }
58
+
59
+
60
+ class FragmentParser(HTMLParser):
61
+ """Collect, per block element, the run of text pieces and inline spans."""
62
+
63
+ def __init__(self, brand: re.Pattern[str]) -> None:
64
+ super().__init__(convert_charrefs=True)
65
+ self.brand = brand
66
+ self.stack: list[str] = []
67
+ self.blocks: list[list[tuple[str, str]]] = [] # (kind, text): kind is "text" or "inline"
68
+ self.inline_depth = 0
69
+ self.inline_text = ""
70
+ self.skip = 0
71
+ self.findings: list[dict] = []
72
+
73
+ def handle_starttag(self, tag: str, attrs) -> None: # noqa: ANN001
74
+ if tag in SKIP:
75
+ self.skip += 1
76
+ return
77
+ if tag in BLOCK:
78
+ self.blocks.append([])
79
+ elif tag in INLINE and self.blocks:
80
+ if self.inline_depth == 0:
81
+ self.inline_text = ""
82
+ self.inline_depth += 1
83
+
84
+ def handle_endtag(self, tag: str) -> None:
85
+ if tag in SKIP:
86
+ self.skip = max(0, self.skip - 1)
87
+ return
88
+ if tag in INLINE and self.inline_depth:
89
+ self.inline_depth -= 1
90
+ if self.inline_depth == 0 and self.blocks:
91
+ self.blocks[-1].append(("inline", self.inline_text))
92
+ elif tag in BLOCK and self.blocks:
93
+ self.finish(self.blocks.pop())
94
+
95
+ def handle_data(self, data: str) -> None:
96
+ if self.skip or not self.blocks:
97
+ return
98
+ if self.inline_depth:
99
+ self.inline_text += data
100
+ elif data.strip():
101
+ self.blocks[-1].append(("text", data))
102
+
103
+ def finish(self, parts: list[tuple[str, str]]) -> None:
104
+ if not any(kind == "inline" and self.brand.search(text) for kind, text in parts):
105
+ return
106
+ sentence = " ".join(" ".join(text.split()) for _, text in parts).strip()
107
+ for index, (kind, text) in enumerate(parts):
108
+ if kind != "text":
109
+ continue
110
+ neighbours = [parts[i] for i in (index - 1, index + 1) if 0 <= i < len(parts)]
111
+ if not any(other_kind == "inline" and self.brand.search(other_text) for other_kind, other_text in neighbours):
112
+ continue
113
+ words = WORD.findall(text)
114
+ if 1 <= len(words) <= 2:
115
+ self.findings.append({"fragment": " ".join(text.split()), "words": len(words), "sentence": sentence[:200]})
116
+
117
+
118
+ def fragments(paths: list[Path], brand: str) -> dict:
119
+ pattern = re.compile(re.escape(brand), re.I)
120
+ findings = []
121
+ for path in paths:
122
+ parser = FragmentParser(pattern)
123
+ parser.feed(path.read_text(encoding="utf-8", errors="replace"))
124
+ findings.extend({"file": str(path), **item} for item in parser.findings)
125
+ return {
126
+ "valid": not findings,
127
+ "files": len(paths),
128
+ "fragments": findings[:500],
129
+ "advice": f"Translate the whole sentence around {brand!r} as one unit (keep the span as a placeholder such as {{brand}}) instead of storing these short pieces." if findings else "",
130
+ }
131
+
132
+
133
+ def add_parsers(sub: argparse._SubParsersAction) -> None:
134
+ keys = sub.add_parser("key-check", help="list source texts translated differently on different pages (they need per-page position keys)")
135
+ keys.add_argument("rows", type=Path, help='JSON rows [{"page", "position"?, "text", "translation", "locale"?}]')
136
+ keys.add_argument("--locale")
137
+ split = sub.add_parser("fragments", help="find text split around an inline brand span into pieces too short to translate")
138
+ split.add_argument("--html", type=Path, nargs="+", required=True, help="rendered HTML files or directories (searched for *.html)")
139
+ split.add_argument("--brand", required=True, help="the inline span's text, e.g. the brand name")
140
+
141
+
142
+ def run(args: argparse.Namespace) -> int:
143
+ if args.command == "key-check":
144
+ result = key_check(load_rows(args.rows), args.locale)
145
+ else:
146
+ files: list[Path] = []
147
+ for item in args.html:
148
+ files.extend(sorted(item.rglob("*.html")) if item.is_dir() else [item])
149
+ result = fragments(files, args.brand)
150
+ print(json.dumps(result, indent=2, ensure_ascii=False))
151
+ return 0 if result["valid"] else 1
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env python3
2
+ """Generate page pictures with a chosen engine, and plan a large run first.
3
+
4
+ Engines:
5
+
6
+ * ``codex`` — the local ``codex`` CLI's image tool on the signed-in ChatGPT
7
+ plan. No per-image charge, but it draws from the plan's quota and is slow
8
+ (about 75 s per picture observed). Accepts reference pictures (``--image``).
9
+ * ``gemini`` — Gemini image generation with the project's own
10
+ ``GEMINI_API_KEY`` (billed to that Google account). Faster (about 19 s at 2K
11
+ observed). No reference pictures in this tool.
12
+
13
+ ``engine-plan`` states the time and cost of each engine for a run before it
14
+ starts; ``generate`` draws one picture and never runs without ``--confirm``.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import base64
20
+ import json
21
+ import math
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ import time
28
+ from pathlib import Path
29
+ from urllib.error import HTTPError, URLError
30
+ from urllib.request import Request, urlopen
31
+
32
+ GEMINI_MODEL = "gemini-3.1-flash-image"
33
+ GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
34
+ RATIOS = ("21:9", "16:9", "3:2", "4:3", "5:4", "1:1", "4:5", "3:4", "2:3", "9:16")
35
+ # Observed seconds per picture (one worker); override with --seconds-codex / --seconds-gemini.
36
+ OBSERVED_SECONDS = {"codex": 75.0, "gemini": 19.0}
37
+
38
+
39
+ def parse_size(value: str) -> tuple[int, int]:
40
+ try:
41
+ width, height = (int(part) for part in value.lower().split("x"))
42
+ except ValueError as error:
43
+ raise ValueError("--size must look like 2400x1000") from error
44
+ if width < 64 or height < 64:
45
+ raise ValueError("--size is too small")
46
+ return width, height
47
+
48
+
49
+ def nearest_ratio(width: int, height: int) -> str:
50
+ target = width / height
51
+ return min(RATIOS, key=lambda ratio: abs(math.log((int(ratio.split(":")[0]) / int(ratio.split(":")[1])) / target)))
52
+
53
+
54
+ def image_size(width: int, height: int) -> str:
55
+ longest = max(width, height)
56
+ return "4K" if longest > 2600 else "2K" if longest > 1300 else "1K"
57
+
58
+
59
+ def engine_plan(count: int, width: int, height: int, workers: int, references: bool, seconds: dict[str, float], gemini_unit_cost: float | None, currency: str) -> dict:
60
+ engines = []
61
+ for engine in ("codex", "gemini"):
62
+ per = seconds[engine]
63
+ wall = math.ceil(count / max(1, workers)) * per
64
+ row = {"engine": engine, "secondsPerPicture": per, "workers": workers, "estimatedMinutes": round(wall / 60, 1)}
65
+ if engine == "codex":
66
+ row.update({"billing": "ChatGPT plan quota (no per-picture charge)", "quotaPictures": count, "cost": None, "referenceImages": True})
67
+ else:
68
+ row.update({"billing": "the project's GEMINI_API_KEY (its Google account)", "aspectRatio": nearest_ratio(width, height), "imageSize": image_size(width, height),
69
+ "cost": round(count * gemini_unit_cost, 2) if gemini_unit_cost is not None else None, "currency": currency if gemini_unit_cost is not None else None,
70
+ "costNote": None if gemini_unit_cost is not None else "pass --gemini-unit-cost from the current Gemini price list to state the cost", "referenceImages": False})
71
+ engines.append(row)
72
+ usable = [row for row in engines if not (references and not row["referenceImages"])]
73
+ fastest = min(usable, key=lambda row: row["estimatedMinutes"])["engine"] if usable else None
74
+ return {"schemaVersion": "maggie-media-engine-plan.v1", "pictures": count, "size": f"{width}x{height}", "needsReferenceImages": references, "engines": engines, "fastest": fastest,
75
+ "confirmBeforeRunning": True, "note": "State this plan to the person and wait for a yes before a large run."}
76
+
77
+
78
+ def gemini_generate(prompt: str, width: int, height: int, model: str, timeout: float) -> bytes:
79
+ key = os.environ.get("GEMINI_API_KEY", "")
80
+ if not key:
81
+ raise RuntimeError("--engine gemini needs GEMINI_API_KEY in the environment")
82
+ endpoint = os.environ.get("GEMINI_API_ENDPOINT") or GEMINI_ENDPOINT.format(model=model)
83
+ body = json.dumps({"contents": [{"role": "user", "parts": [{"text": prompt}]}],
84
+ "generationConfig": {"responseModalities": ["IMAGE"], "imageConfig": {"aspectRatio": nearest_ratio(width, height), "imageSize": image_size(width, height)}}}).encode()
85
+ for attempt in range(1, 5):
86
+ request = Request(endpoint, data=body, method="POST", headers={"content-type": "application/json", "x-goog-api-key": key})
87
+ status, data = 0, {}
88
+ try:
89
+ with urlopen(request, timeout=timeout) as response:
90
+ status, data = response.status, json.loads(response.read() or b"{}")
91
+ except HTTPError as error:
92
+ status = error.code
93
+ try:
94
+ data = json.loads(error.read() or b"{}")
95
+ except json.JSONDecodeError:
96
+ data = {}
97
+ except (URLError, TimeoutError, OSError) as error:
98
+ data = {"error": {"message": type(error).__name__}}
99
+ parts = (((data.get("candidates") or [{}])[0].get("content") or {}).get("parts") or [])
100
+ image = next((part["inlineData"]["data"] for part in parts if isinstance(part, dict) and part.get("inlineData", {}).get("data")), None)
101
+ if 200 <= status < 300 and image:
102
+ return base64.b64decode(image)
103
+ if attempt == 4 or (400 <= status < 500 and status != 429):
104
+ reason = (data.get("error") or {}).get("message") or ((data.get("candidates") or [{}])[0].get("finishReason")) or f"HTTP {status}"
105
+ raise RuntimeError(f"Gemini: {str(reason)[:300]}")
106
+ time.sleep(float(os.environ.get("MAGGIE_MEDIA_RETRY_SECONDS", attempt * 15)))
107
+ raise RuntimeError("Gemini: no image")
108
+
109
+
110
+ def codex_generate(prompt: str, width: int, height: int, images: list[Path], timeout: float) -> bytes:
111
+ if not shutil.which("codex"):
112
+ raise RuntimeError("--engine codex needs the codex CLI on PATH (signed in to ChatGPT)")
113
+ shape = "wide landscape (about 3:2 or wider)" if width >= height * 1.4 else "landscape" if width > height else "portrait" if width < height else "square"
114
+ with tempfile.TemporaryDirectory(prefix="maggie-media-") as work:
115
+ reference = "Use the attached image only as a reference for layout and content. " if images else ""
116
+ instruction = f"Use your image generation tool to create exactly one image, {shape}. {reference}{prompt} Save the final image into the current directory as out.png. Reply only with the saved path."
117
+ # `-i` takes several files, so `--` ends the list before the prompt.
118
+ argv = ["codex", "exec", "--skip-git-repo-check", "--sandbox", "workspace-write", *[item for path in images for item in ("-i", str(path.resolve()))], *(["--"] if images else []), instruction]
119
+ subprocess.run(argv, cwd=work, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, timeout=timeout, check=False)
120
+ out = Path(work) / "out.png"
121
+ if not out.is_file():
122
+ raise RuntimeError("codex did not save out.png")
123
+ return out.read_bytes()
124
+
125
+
126
+ def main(argv: list[str] | None = None) -> int:
127
+ parser = argparse.ArgumentParser(prog="maggie media", description=__doc__.splitlines()[0])
128
+ sub = parser.add_subparsers(dest="command", required=True)
129
+ plan = sub.add_parser("engine-plan", help="time and cost of each engine for a run, before it starts")
130
+ plan.add_argument("--count", type=int, required=True)
131
+ plan.add_argument("--size", default="1600x1200")
132
+ plan.add_argument("--workers", type=int, default=1)
133
+ plan.add_argument("--reference-images", action="store_true", help="the run redraws screenshots (codex only)")
134
+ plan.add_argument("--seconds-codex", type=float, default=OBSERVED_SECONDS["codex"])
135
+ plan.add_argument("--seconds-gemini", type=float, default=OBSERVED_SECONDS["gemini"])
136
+ plan.add_argument("--gemini-unit-cost", type=float, help="price of one picture at this size from the current Gemini price list")
137
+ plan.add_argument("--currency", default="USD")
138
+ gen = sub.add_parser("generate", help="draw one picture with the chosen engine")
139
+ gen.add_argument("--engine", choices=("codex", "gemini"), default="codex")
140
+ gen.add_argument("--prompt", required=True)
141
+ gen.add_argument("--size", default="1600x1200")
142
+ gen.add_argument("--image", type=Path, action="append", default=[], help="reference picture (codex only, repeatable)")
143
+ gen.add_argument("--output", type=Path, required=True, help="PNG path to write")
144
+ gen.add_argument("--model", default=GEMINI_MODEL)
145
+ gen.add_argument("--timeout", type=float, default=600)
146
+ gen.add_argument("--confirm", action="store_true")
147
+ args = parser.parse_args(argv)
148
+ try:
149
+ width, height = parse_size(args.size)
150
+ if args.command == "engine-plan":
151
+ print(json.dumps(engine_plan(args.count, width, height, args.workers, args.reference_images, {"codex": args.seconds_codex, "gemini": args.seconds_gemini}, args.gemini_unit_cost, args.currency), indent=2))
152
+ return 0
153
+ if not args.confirm:
154
+ print("CONFIRMATION_REQUIRED: generating a picture uses plan quota (codex) or a paid API (gemini); rerun with --confirm", file=sys.stderr)
155
+ return 2
156
+ for path in args.image:
157
+ if not path.is_file():
158
+ raise ValueError(f"--image {path} not found")
159
+ if args.engine == "gemini" and args.image:
160
+ raise ValueError("--image needs the codex engine")
161
+ started = time.monotonic()
162
+ data = gemini_generate(args.prompt, width, height, args.model, args.timeout) if args.engine == "gemini" else codex_generate(args.prompt, width, height, args.image, args.timeout)
163
+ args.output.parent.mkdir(parents=True, exist_ok=True)
164
+ args.output.write_bytes(data)
165
+ print(json.dumps({"engine": args.engine, "output": str(args.output), "bytes": len(data), "seconds": round(time.monotonic() - started, 1), "aspectRatio": nearest_ratio(width, height) if args.engine == "gemini" else None, "imageSize": image_size(width, height) if args.engine == "gemini" else None,
166
+ "next": "resize/crop to the exact size and convert to WebP before use"}, indent=2))
167
+ return 0
168
+ except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error:
169
+ print(f"BLOCKED: maggie media: {error}", file=sys.stderr)
170
+ return 1
171
+
172
+
173
+ if __name__ == "__main__":
174
+ raise SystemExit(main())
@@ -28,6 +28,16 @@ made from: ``eas.json`` and ``package.json`` usually changed since. ``plan
28
28
  ``eas fingerprint:compare``, publish the update only when it matches, and
29
29
  always put the working copies back (verified by hash), even on failure. ``ota``
30
30
  is a dry run unless ``--confirm`` is given, because the update is published.
31
+
32
+ ``ota-guard`` checks the runtime an update would target against the runtimes
33
+ of builds people have installed (a runtimes file kept with the app) and
34
+ refuses when none matches. In a pnpm monorepo the fingerprint also hashes
35
+ pnpm's virtual-store paths (``node_modules/.pnpm/<pkg>_<peer hash>``), so a
36
+ dependency change in any workspace package can move the runtime;
37
+ ``ignorePaths`` and ``fileHookTransform`` cannot help because native module
38
+ folders are added by their real path. With ``--publish --confirm`` it runs
39
+ ``eas update`` after a match and checks that the published runtime is the one
40
+ that was checked.
31
41
  """
32
42
 
33
43
  from __future__ import annotations
@@ -180,6 +190,9 @@ def preflight(project: Path, app_dir: Path | None, config_json: Path | None, pro
180
190
  package = _load(app / "package.json") if (app / "package.json").is_file() else {}
181
191
  ignore = (app / ".fingerprintignore").read_text(encoding="utf-8") if (app / ".fingerprintignore").is_file() else ""
182
192
  errors, warnings, facts = check_profile(expo, eas, package, profile, platforms, auto_submit, raw, ignore)
193
+ if is_pnpm_monorepo(app) and any(runtime_policy(expo, platform)[0] == {"policy": "fingerprint"} or runtime_policy(expo, platform)[0] == "fingerprint" for platform in platforms):
194
+ warnings.append("pnpm monorepo with a fingerprint runtime: pnpm's virtual-store paths are part of the fingerprint, so a dependency change in another workspace package can move the runtime. Publish updates through `maggie mobile ota-guard --runtimes FILE` and set an explicit runtimeVersion (plus a native-change check) at the next native build")
195
+ facts["pnpmMonorepo"] = True
183
196
  return {
184
197
  "schemaVersion": SCHEMA,
185
198
  "appDir": str(app.relative_to(project)) if app.is_relative_to(project) else ".",
@@ -304,10 +317,70 @@ def ota(app: Path, build_ref: str, build_id: str, channel: str, environment: str
304
317
  report["steps"] = steps
305
318
 
306
319
 
320
+ def is_pnpm_monorepo(app: Path) -> bool:
321
+ for folder in (app, *app.parents):
322
+ if (folder / "pnpm-workspace.yaml").is_file():
323
+ return True
324
+ if (folder / ".git").exists():
325
+ break
326
+ return False
327
+
328
+
329
+ def load_runtimes(path: Path, platform: str) -> list[dict]:
330
+ """`maggie-mobile-runtimes.v1` ({"ios": [{"build", "eas_build_id", "runtime"}], "android": [...]})."""
331
+ data = _load(path)
332
+ builds = data.get(platform) or []
333
+ if not isinstance(builds, list) or not all(isinstance(item, dict) and item.get("runtime") for item in builds):
334
+ raise ValueError(f"{path} has no `{platform}` list of {{build, runtime}} entries")
335
+ return builds
336
+
337
+
338
+ def fingerprint_command(platform: str) -> list[str]:
339
+ return shlex.split(os.environ.get("MAGGIE_MOBILE_FINGERPRINT", f"npx expo-updates fingerprint:generate --platform {platform}"))
340
+
341
+
342
+ def ota_guard(app: Path, runtimes: Path, platform: str, runtime: str | None, publish: bool, channel: str | None, environment: str, message: str | None, confirm: bool) -> dict:
343
+ builds = load_runtimes(runtimes, platform)
344
+ report: dict = {"schemaVersion": "maggie-mobile-ota-guard.v1", "platform": platform, "installedBuilds": [{"build": item.get("build"), "runtime": item["runtime"]} for item in builds], "executed": False}
345
+ if not runtime:
346
+ result = subprocess.run(fingerprint_command(platform), cwd=app, capture_output=True, text=True, check=False)
347
+ try:
348
+ runtime = str(json.loads(result.stdout[result.stdout.find("{"):])["hash"]) if result.returncode == 0 else None
349
+ except (ValueError, KeyError):
350
+ runtime = None
351
+ if not runtime:
352
+ return {**report, "status": "failed", "reason": "could not compute the fingerprint (expo-updates fingerprint:generate)"}
353
+ report["runtime"] = runtime
354
+ match = next((item for item in builds if item["runtime"] == runtime), None)
355
+ if not match:
356
+ report.update(status="failed", reason=f"no installed build has runtime {runtime}; an update published now would reach nobody",
357
+ fix=["the change needs a native build (new native module, permission, native config): build, then add its runtime to the runtimes file",
358
+ "or only install paths moved (a dependency change elsewhere in the monorepo): publish from a temporary worktree with the build's lockfile and workspace file, after a frozen install, and confirm the fingerprint equals the build's runtime",
359
+ "next native build: set an explicit runtimeVersion and keep a native-change check, so unrelated workspace changes cannot move it"])
360
+ return report
361
+ report["matchedBuild"] = match.get("build")
362
+ if not publish:
363
+ return {**report, "status": "passed"}
364
+ if not (channel and message):
365
+ raise ValueError("--publish needs an EAS Update channel on the profile and --message")
366
+ update = eas_command() + ["update", "--channel", channel, "--environment", environment, "--platform", platform, "--message", message, "--non-interactive"]
367
+ report["command"] = shlex.join(update)
368
+ if not confirm:
369
+ return {**report, "status": "dry-run", "note": "runtime matches; re-run with --confirm to publish"}
370
+ result = subprocess.run(update, cwd=app, capture_output=True, text=True, check=False)
371
+ published = re.search(r"Runtime version\s+([0-9a-f]{8,})", result.stdout or "")
372
+ report.update(executed=True, publishedRuntime=published.group(1) if published else None)
373
+ if result.returncode:
374
+ return {**report, "status": "failed", "reason": "eas update failed; see its output"}
375
+ if not published or published.group(1) != runtime:
376
+ return {**report, "status": "failed", "reason": "the published runtime differs from the checked one; check `eas update:list`"}
377
+ return {**report, "status": "published"}
378
+
379
+
307
380
  def main(argv: list[str] | None = None) -> int:
308
381
  parser = argparse.ArgumentParser(prog="maggie mobile", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
309
382
  sub = parser.add_subparsers(dest="command", required=True)
310
- for name in ("preflight", "plan", "ota"):
383
+ for name in ("preflight", "plan", "ota", "ota-guard"):
311
384
  command = sub.add_parser(name)
312
385
  command.add_argument("--project", type=Path, default=Path("."))
313
386
  command.add_argument("--app-dir", type=Path, help="Expo app folder inside a monorepo, e.g. apps/app")
@@ -319,6 +392,12 @@ def main(argv: list[str] | None = None) -> int:
319
392
  command.add_argument("--output", type=Path)
320
393
  if name in {"plan", "ota"}:
321
394
  command.add_argument("--build-ref", help="git commit or tag the installed store build was made from")
395
+ if name == "ota-guard":
396
+ command.add_argument("--runtimes", type=Path, required=True, help="maggie-mobile-runtimes.v1 JSON: installed builds' runtimes per platform")
397
+ command.add_argument("--runtime", help="use this runtime instead of computing the fingerprint")
398
+ command.add_argument("--publish", action="store_true", help="publish the update after a match")
399
+ command.add_argument("--message")
400
+ command.add_argument("--confirm", action="store_true")
322
401
  if name == "ota":
323
402
  command.add_argument("--build-id", required=True, help="EAS build id of the installed build (for fingerprint:compare)")
324
403
  command.add_argument("--message", required=True)
@@ -341,6 +420,10 @@ def main(argv: list[str] | None = None) -> int:
341
420
  report = {"schemaVersion": "maggie-mobile-release-plan.v1", "preflight": {key: report[key] for key in ("status", "errors", "warnings")}, "commands": plan_commands(args.profile, platforms, args.auto_submit, channel, args.environment, args.build_ref, drift), "costNotice": report["costNotice"], "executed": False}
342
421
  if drift is not None:
343
422
  report["buildConfig"] = [{"file": item["file"], "differsFromBuild": item["differs"]} for item in drift]
423
+ elif args.command == "ota-guard":
424
+ if args.platform == "all":
425
+ raise ValueError("--platform ios or android: runtimes differ per platform")
426
+ report = ota_guard(app, (project / args.runtimes) if not args.runtimes.is_absolute() else args.runtimes, args.platform, args.runtime, args.publish, channel, environment, args.message, args.confirm)
344
427
  elif args.command == "ota":
345
428
  if report["status"] == "failed":
346
429
  raise ValueError("preflight failed: " + "; ".join(report["errors"]))
@@ -335,6 +335,8 @@ def main() -> int:
335
335
  favicon.add_argument("--declared-url")
336
336
  favicon.add_argument("--timeout", type=int, default=15)
337
337
  favicon.set_defaults(func=command_favicon_check)
338
+ from maggie_ops_routines import add_parsers as add_routine_parsers
339
+ add_routine_parsers(sub)
338
340
  record = sub.add_parser("record", help="record an explicitly authorised operation")
339
341
  record.add_argument("operation", choices=("sync", "sitemap-match", "rewrite-queue", "rewrite-approve", "publish", "migration"))
340
342
  record.add_argument("--dry-run", action="store_true")