@topy-ai/maggie 0.7.52 → 0.7.53

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 (57) hide show
  1. package/README-zh-TW.md +48 -2
  2. package/README.md +66 -54
  3. package/bin/maggie.js +18 -7
  4. package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +161 -0
  5. package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +268 -20
  6. package/bundled-contracts/maggie-deployment/bridge-host-example-v1.json +49 -7
  7. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +181 -13
  8. package/bundled-contracts/maggiedash/booking-rules-v1.json +1699 -0
  9. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +398 -39
  10. package/bundled-references/maggiedash-booking/PRD.md +22 -11
  11. package/bundled-references/maggiedash-booking/RULES.md +167 -0
  12. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +42 -4
  13. package/bundled-references/provider-adapter-contract.md +16 -3
  14. package/bundled-skills/catalog.json +1 -1
  15. package/bundled-skills/maggie-assistant-tools/SKILL.md +54 -3
  16. package/bundled-skills/maggie-assistant-tools/references/approvals-and-audit.md +27 -0
  17. package/bundled-skills/maggie-assistant-tools/references/booking-receptionist.md +45 -0
  18. package/bundled-skills/maggie-assistant-tools/references/bridge-host.md +54 -0
  19. package/bundled-skills/maggie-auth-reference/SKILL.md +18 -0
  20. package/bundled-skills/maggie-booking/SKILL.md +47 -3
  21. package/bundled-skills/maggie-deployment/SKILL.md +21 -0
  22. package/bundled-skills/maggie-deployment/references/cloudflare.md +19 -0
  23. package/bundled-skills/maggie-deployment/references/vps.md +92 -0
  24. package/bundled-skills/maggie-design/SKILL.md +17 -3
  25. package/bundled-skills/maggie-design/references/app-ui-patterns.md +79 -0
  26. package/bundled-skills/maggie-feedback/SKILL.md +17 -0
  27. package/bundled-skills/maggie-marketplace/SKILL.md +12 -0
  28. package/bundled-skills/maggie-marketplace/references/directory-search.md +51 -0
  29. package/bundled-skills/maggie-marketplace/references/directory.mjs +112 -0
  30. package/bundled-skills/maggie-mobile-app/SKILL.md +36 -6
  31. package/bundled-skills/maggie-mobile-app/references/payments-and-push.md +78 -0
  32. package/bundled-skills/maggie-qa-workflow/SKILL.md +60 -0
  33. package/bundled-skills/maggie-seo-geo/SKILL.md +33 -0
  34. package/bundled-skills/maggie-service-booking/SKILL.md +28 -3
  35. package/bundled-skills/maggie-social-share/SKILL.md +26 -4
  36. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +28 -0
  37. package/bundled-tools/clis/maggie_assistant_tools.py +84 -3
  38. package/bundled-tools/clis/maggie_booking.py +15 -4
  39. package/bundled-tools/clis/maggie_booking_connect_qa.py +136 -0
  40. package/bundled-tools/clis/maggie_booking_rules.py +476 -0
  41. package/bundled-tools/clis/maggie_bridge_host.py +77 -1
  42. package/bundled-tools/clis/maggie_deployment_ops.py +461 -0
  43. package/bundled-tools/clis/maggie_design.py +9 -2
  44. package/bundled-tools/clis/maggie_design_lint.py +75 -3
  45. package/bundled-tools/clis/maggie_feedback.py +97 -15
  46. package/bundled-tools/clis/maggie_mobile.py +136 -4
  47. package/bundled-tools/clis/maggie_qa_app_preflight.py +122 -4
  48. package/bundled-tools/clis/maggie_qa_fit.py +217 -0
  49. package/bundled-tools/clis/maggie_qa_workflow.py +5 -0
  50. package/bundled-tools/clis/maggie_schema_complexity.py +84 -0
  51. package/bundled-tools/clis/maggie_seo_routes.py +154 -0
  52. package/bundled-tools/clis/maggie_service_migration.py +272 -0
  53. package/bundled-tools/clis/maggie_social_queue.py +2 -0
  54. package/bundled-tools/clis/maggie_social_share.py +52 -6
  55. package/bundled-tools/runtime/assistant_approval.py +98 -0
  56. package/package.json +1 -1
  57. package/references/provider-adapter-contract.md +16 -3
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env python3
2
+ """Dry run of moving a business's clients, future appointments and gift cards (``maggie service migrate-check``).
3
+
4
+ Switching a real business from Fresha (or any system with CSV exports) needs
5
+ more than the service catalogue: clients with consent, future appointments
6
+ and gift-card balances. This command reads the exports and writes nothing:
7
+
8
+ - recognises columns by synonyms (export headers vary by report and locale)
9
+ and lists anything it could not map, so the owner can fix the mapping;
10
+ - clients: de-duplicates by e-mail, then by phone normalised to E.164 for the
11
+ market; keeps marketing consent only when the export says opted in;
12
+ - appointments: keeps only future, not-cancelled rows; converts local times
13
+ with the **venue's** zone; matches services and staff by normalised name;
14
+ reports clashes (one team member, overlapping times) instead of forcing
15
+ them;
16
+ - gift cards: keeps code + remaining balance (+ expiry) in their own
17
+ namespace (``IMPORT:<code>``) so they can never collide with new codes;
18
+ - returns counts, a sample, row errors and the import plan with an undo key.
19
+
20
+ Values (names, e-mails, phones, codes) never appear in the report: only
21
+ counts, column names, row numbers and unmatched service/staff names.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import csv
28
+ import hashlib
29
+ import json
30
+ import re
31
+ import sys
32
+ from datetime import datetime, timedelta, timezone
33
+ from pathlib import Path
34
+ from zoneinfo import ZoneInfo
35
+
36
+ SYNONYMS = {
37
+ "first_name": ["first name", "firstname", "given name"],
38
+ "last_name": ["last name", "lastname", "surname", "family name"],
39
+ "name": ["client", "client name", "customer", "customer name", "full name", "name"],
40
+ "email": ["email", "e-mail", "email address", "client email"],
41
+ "phone": ["mobile number", "mobile", "phone", "phone number", "telephone", "client phone"],
42
+ "marketing": ["marketing consent", "accepts marketing", "marketing opt-in", "email marketing", "subscribed", "marketing notifications"],
43
+ "notes": ["notes", "client notes", "note"],
44
+ "external_id": ["client id", "customer id", "id", "ref #", "reference"],
45
+ "date": ["appointment date", "date", "scheduled date"],
46
+ "time": ["start time", "time", "scheduled time"],
47
+ "start": ["start", "start date time", "scheduled at", "appointment start"],
48
+ "service": ["service", "service name", "treatment"],
49
+ "staff": ["team member", "staff", "staff member", "employee", "therapist"],
50
+ "duration": ["duration", "duration (min)", "duration minutes"],
51
+ "price": ["price", "amount", "total", "sale price"],
52
+ "status": ["status", "appointment status"],
53
+ "code": ["code", "voucher code", "gift card code", "card code"],
54
+ "balance": ["balance", "remaining balance", "remaining", "value remaining"],
55
+ "expiry": ["expiry", "expires", "expiry date", "expiration date"],
56
+ }
57
+ YES = {"yes", "y", "true", "1", "opted in", "subscribed", "accepted"}
58
+ CANCELLED = re.compile(r"cancel|no.?show|deleted", re.I)
59
+ COUNTRY_CODES = {"uk": "44", "us": "1"}
60
+
61
+
62
+ def norm(value: str) -> str:
63
+ return re.sub(r"[^a-z0-9]+", " ", (value or "").lower()).strip()
64
+
65
+
66
+ def map_columns(headers: list[str], wanted: list[str]) -> tuple[dict[str, str], list[str]]:
67
+ mapping: dict[str, str] = {}
68
+ for header in headers:
69
+ key = norm(header)
70
+ for field in wanted:
71
+ if field not in mapping and key in {norm(s) for s in SYNONYMS[field]}:
72
+ mapping[field] = header
73
+ break
74
+ unmapped = [h for h in headers if h not in mapping.values()]
75
+ return mapping, unmapped
76
+
77
+
78
+ def e164(phone: str, market: str) -> str | None:
79
+ digits = re.sub(r"\D", "", phone or "")
80
+ if not digits:
81
+ return None
82
+ if (phone or "").strip().startswith("+"):
83
+ return "+" + digits
84
+ code = COUNTRY_CODES[market]
85
+ if digits.startswith("00"):
86
+ return "+" + digits[2:]
87
+ if market == "uk" and digits.startswith("0"):
88
+ return "+44" + digits[1:]
89
+ if market == "us" and len(digits) == 10:
90
+ return "+1" + digits
91
+ return "+" + (digits if digits.startswith(code) else code + digits)
92
+
93
+
94
+ def money(value: str) -> int | None:
95
+ text = re.sub(r"[^\d.,-]", "", value or "").replace(",", "")
96
+ if not text:
97
+ return None
98
+ try:
99
+ return int(round(float(text) * 100))
100
+ except ValueError:
101
+ return None
102
+
103
+
104
+ def parse_local(row: dict, mapping: dict[str, str], zone: ZoneInfo) -> datetime | None:
105
+ if "start" in mapping and row.get(mapping["start"]):
106
+ text = row[mapping["start"]].strip()
107
+ elif "date" in mapping and "time" in mapping:
108
+ text = f"{row.get(mapping['date'], '').strip()} {row.get(mapping['time'], '').strip()}"
109
+ else:
110
+ return None
111
+ for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%d/%m/%Y %H:%M", "%m/%d/%Y %H:%M", "%d %b %Y %H:%M", "%Y-%m-%d %I:%M %p", "%d/%m/%Y %I:%M %p", "%m/%d/%Y %I:%M %p"):
112
+ try:
113
+ return datetime.strptime(text, fmt).replace(tzinfo=zone)
114
+ except ValueError:
115
+ continue
116
+ return None
117
+
118
+
119
+ def read_csv(path: Path) -> tuple[list[str], list[dict]]:
120
+ with path.open(encoding="utf-8-sig", newline="") as handle:
121
+ reader = csv.DictReader(handle)
122
+ return list(reader.fieldnames or []), list(reader)
123
+
124
+
125
+ def migrate_check(clients: Path | None, appointments: Path | None, gift_cards: Path | None, timezone_name: str, market: str, now: datetime,
126
+ services: list[str], staff: list[str], date_order: str = "auto") -> dict:
127
+ zone = ZoneInfo(timezone_name)
128
+ report: dict = {"schemaVersion": "maggie-service-migration-dry-run.v1", "writes": False, "timezone": timezone_name, "market": market}
129
+ errors: list[str] = []
130
+ warnings: list[str] = []
131
+ digest = hashlib.sha256()
132
+ if clients:
133
+ headers, rows = read_csv(clients)
134
+ digest.update(clients.read_bytes())
135
+ mapping, unmapped = map_columns(headers, ["first_name", "last_name", "name", "email", "phone", "marketing", "notes", "external_id"])
136
+ if not ({"email", "phone"} & set(mapping)):
137
+ errors.append("clients: no e-mail or phone column recognised; map one before importing")
138
+ seen_email, seen_phone, dupes, consented, kept, row_errors = set(), set(), 0, 0, 0, []
139
+ for index, row in enumerate(rows, start=2):
140
+ email = (row.get(mapping.get("email", ""), "") or "").strip().lower()
141
+ phone = e164(row.get(mapping.get("phone", ""), ""), market) if "phone" in mapping else None
142
+ if not email and not phone and not (row.get(mapping.get("name", ""), "") or row.get(mapping.get("first_name", ""), "")):
143
+ row_errors.append(f"row {index}: empty")
144
+ continue
145
+ if (email and email in seen_email) or (not email and phone and phone in seen_phone):
146
+ dupes += 1
147
+ continue
148
+ if email:
149
+ seen_email.add(email)
150
+ if phone:
151
+ seen_phone.add(phone)
152
+ kept += 1
153
+ if "marketing" in mapping and (row.get(mapping["marketing"], "") or "").strip().lower() in YES:
154
+ consented += 1
155
+ report["clients"] = {"rows": len(rows), "toImport": kept, "duplicatesMerged": dupes, "marketingConsent": consented, "columns": mapping, "unmappedColumns": unmapped, "rowErrors": row_errors[:20]}
156
+ if "marketing" not in mapping:
157
+ warnings.append("clients: no marketing-consent column; imported clients get no marketing (consent is never assumed)")
158
+ if appointments:
159
+ headers, rows = read_csv(appointments)
160
+ digest.update(appointments.read_bytes())
161
+ mapping, unmapped = map_columns(headers, ["start", "date", "time", "service", "staff", "duration", "price", "status", "email", "phone", "name", "external_id"])
162
+ if not ("start" in mapping or {"date", "time"} <= set(mapping)) or "service" not in mapping:
163
+ errors.append("appointments: need a start (or date + time) and a service column")
164
+ service_keys = {norm(name): name for name in services}
165
+ staff_keys = {norm(name): name for name in staff}
166
+ future, past, cancelled, unmatched_services, unmatched_staff, bad_rows = [], 0, 0, set(), set(), []
167
+ for index, row in enumerate(rows, start=2):
168
+ if "status" in mapping and CANCELLED.search(row.get(mapping["status"], "") or ""):
169
+ cancelled += 1
170
+ continue
171
+ start = parse_local(row, mapping, zone)
172
+ if start is None:
173
+ bad_rows.append(f"row {index}: unreadable date/time")
174
+ continue
175
+ if start.astimezone(timezone.utc) <= now:
176
+ past += 1
177
+ continue
178
+ duration = int(re.sub(r"\D", "", row.get(mapping.get("duration", ""), "") or "") or 60)
179
+ service = row.get(mapping["service"], "")
180
+ person = row.get(mapping.get("staff", ""), "") if "staff" in mapping else ""
181
+ if services and norm(service) not in service_keys:
182
+ unmatched_services.add(service)
183
+ if person and staff and norm(person) not in staff_keys:
184
+ unmatched_staff.add(person)
185
+ future.append({"row": index, "startUtc": start.astimezone(timezone.utc), "end": start.astimezone(timezone.utc) + timedelta(minutes=duration), "staff": norm(person)})
186
+ clashes = []
187
+ by_staff: dict[str, list[dict]] = {}
188
+ for item in sorted(future, key=lambda a: a["startUtc"]):
189
+ if not item["staff"]:
190
+ continue
191
+ for other in by_staff.get(item["staff"], []):
192
+ if other["end"] > item["startUtc"]:
193
+ clashes.append(f"rows {other['row']} and {item['row']}: same team member at overlapping times")
194
+ by_staff.setdefault(item["staff"], []).append(item)
195
+ report["appointments"] = {"rows": len(rows), "future": len(future), "pastSkipped": past, "cancelledSkipped": cancelled, "clashes": clashes[:20], "unmatchedServices": sorted(unmatched_services)[:20],
196
+ "unmatchedStaff": sorted(unmatched_staff)[:20], "rowErrors": bad_rows[:20], "columns": mapping, "unmappedColumns": unmapped,
197
+ "firstUtc": _iso(min(a["startUtc"] for a in future)) if future else None}
198
+ if unmatched_services:
199
+ warnings.append(f"appointments: {len(unmatched_services)} service name(s) match no imported service; map them or create them first")
200
+ if clashes:
201
+ warnings.append(f"appointments: {len(clashes)} clash(es) are reported, not forced; resolve them with the business")
202
+ if gift_cards:
203
+ headers, rows = read_csv(gift_cards)
204
+ digest.update(gift_cards.read_bytes())
205
+ mapping, unmapped = map_columns(headers, ["code", "balance", "expiry"])
206
+ if not {"code", "balance"} <= set(mapping):
207
+ errors.append("gift cards: need code and balance columns")
208
+ codes, total, zero, bad = set(), 0, 0, []
209
+ for index, row in enumerate(rows, start=2):
210
+ code = (row.get(mapping.get("code", ""), "") or "").strip().upper()
211
+ balance = money(row.get(mapping.get("balance", ""), ""))
212
+ if not code or balance is None:
213
+ bad.append(f"row {index}: missing code or balance")
214
+ continue
215
+ if balance <= 0:
216
+ zero += 1
217
+ continue
218
+ if code in codes:
219
+ bad.append(f"row {index}: duplicate code")
220
+ continue
221
+ codes.add(code)
222
+ total += balance
223
+ report["giftCards"] = {"rows": len(rows), "toImport": len(codes), "zeroBalanceSkipped": zero, "totalBalanceMinor": total, "namespace": "IMPORT:<code>", "rowErrors": bad[:20], "columns": mapping, "unmappedColumns": unmapped}
224
+ report["undoKey"] = "import-" + digest.hexdigest()[:16]
225
+ report["plan"] = [
226
+ "create the import record (undo key) before writing",
227
+ "clients: insert or merge by e-mail then phone; marketing consent only when opted in",
228
+ "appointments: create bookings with source=import in the venue time zone; clashes stay unimported",
229
+ "gift cards: issue codes in the IMPORT: namespace with their balance and expiry in a paused 'Imported gift cards' voucher",
230
+ "undo deletes the import's bookings and the clients/team it created when unused, and voids untouched imported codes",
231
+ "send the optional \"we've moved\" e-mail once, only to imported clients with marketing consent, while online booking is live",
232
+ ]
233
+ report["errors"], report["warnings"] = errors, warnings
234
+ report["status"] = "failed" if errors else ("warning" if warnings else "ready")
235
+ return report
236
+
237
+
238
+ def _iso(value: datetime) -> str:
239
+ return value.strftime("%Y-%m-%dT%H:%M:%SZ")
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ parser = argparse.ArgumentParser(prog="maggie service migrate-check", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
244
+ parser.add_argument("--project", type=Path, default=Path("."))
245
+ parser.add_argument("--clients", type=Path)
246
+ parser.add_argument("--appointments", type=Path)
247
+ parser.add_argument("--gift-cards", type=Path)
248
+ parser.add_argument("--timezone", required=True, help="the venue's IANA zone, e.g. Europe/London")
249
+ parser.add_argument("--market", choices=sorted(COUNTRY_CODES), default="uk")
250
+ parser.add_argument("--service", action="append", default=[], help="service name already in the catalogue (repeat)")
251
+ parser.add_argument("--staff", action="append", default=[], help="team member name (repeat)")
252
+ parser.add_argument("--now", help="ISO instant (default: now)")
253
+ parser.add_argument("--output", type=Path)
254
+ args = parser.parse_args(argv)
255
+ if not (args.clients or args.appointments or args.gift_cards):
256
+ parser.error("pass at least one of --clients, --appointments, --gift-cards")
257
+ now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else datetime.now(timezone.utc)
258
+ try:
259
+ report = migrate_check(args.clients, args.appointments, args.gift_cards, args.timezone, args.market, now, args.service, args.staff)
260
+ except (OSError, ValueError, csv.Error) as error:
261
+ print(f"BLOCKED: maggie service migrate-check: {error}", file=sys.stderr)
262
+ return 2
263
+ text = json.dumps(report, indent=2, ensure_ascii=False, default=str)
264
+ if args.output:
265
+ args.output.parent.mkdir(parents=True, exist_ok=True)
266
+ args.output.write_text(text + "\n", encoding="utf-8")
267
+ print(text)
268
+ return 1 if report["status"] == "failed" else 0
269
+
270
+
271
+ if __name__ == "__main__":
272
+ raise SystemExit(main())
@@ -318,6 +318,8 @@ def check_campaigns(document: dict) -> dict:
318
318
  errors.append(f"campaign {cid} has invalid status {status}")
319
319
  continue
320
320
  blockers = campaign_readiness(campaign)
321
+ if campaign.get("plan_status") == "failed" and not campaign.get("ideas"):
322
+ errors.append(f"campaign {cid} was created only to be planned and the plan failed: delete it (or create it only after the plan succeeds) so retries do not pile up empty drafts")
321
323
  if status == "started" and blockers:
322
324
  errors.append(f"campaign {cid} is started but not startable: {'; '.join(blockers)}")
323
325
  report.append({"id": cid, "status": status, "can_start": not blockers, "blockers": blockers,
@@ -15,6 +15,18 @@ from maggie_social_queue import check_campaigns, connection_health, parse_instan
15
15
 
16
16
  LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
17
17
  REQUIRED = ("source_content_id", "title", "canonical_url")
18
+ PUBLISH_MODES = ("image", "link_preview")
19
+ LIVE_STATUSES = {"approved", "scheduled", "publishing", "published"}
20
+
21
+
22
+ def host_of(url: str) -> str:
23
+ return (urlsplit(str(url or "")).hostname or "").lower().removeprefix("www.")
24
+
25
+
26
+ def on_workspace_domain(url: str, domain: str) -> bool:
27
+ """True when the URL's host is the workspace domain or one of its subdomains."""
28
+ host, domain = host_of(url), str(domain or "").lower().strip().removeprefix("www.").rstrip(".")
29
+ return bool(host and domain) and (host == domain or host.endswith("." + domain))
18
30
 
19
31
 
20
32
  def read(path: Path) -> dict:
@@ -31,21 +43,31 @@ def tracked_url(url: str, channel: str) -> str:
31
43
  return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
32
44
 
33
45
 
34
- def plan(content: dict, channels: list[str]) -> dict:
46
+ def plan(content: dict, channels: list[str], workspace_domain: str | None = None) -> dict:
35
47
  errors = [f"missing {key}" for key in REQUIRED if not content.get(key)]
36
48
  invalid = [channel for channel in channels if channel not in LIMITS]
37
49
  if invalid:
38
50
  errors.append("unsupported channels: " + ", ".join(invalid))
51
+ domain = workspace_domain or content.get("workspace_domain")
52
+ if not domain:
53
+ errors.append("workspace_domain is required: posts may only share articles published on the workspace's own site")
54
+ elif content.get("canonical_url") and not on_workspace_domain(content["canonical_url"], domain):
55
+ errors.append(f"canonical_url is on {host_of(content['canonical_url'])}, not {domain}: pick an article published on this workspace's own domain")
39
56
  if errors:
40
57
  raise ValueError("; ".join(errors))
58
+ image = content.get("image_url")
41
59
  return {
42
60
  "schema_version": "1.0",
43
61
  "status": "draft",
44
62
  "source_content_id": content["source_content_id"],
45
63
  "title": content["title"],
46
64
  "canonical_url": content["canonical_url"],
65
+ "workspace_domain": domain,
47
66
  "cta": content.get("cta"),
48
- "channels": [{"name": channel, "character_limit": LIMITS[channel], "tracked_url": tracked_url(content["canonical_url"], channel), "copy": None, "status": "needs-copy"} for channel in channels],
67
+ "image_url": image,
68
+ # A post with its own image goes out as an image post with the link in the text;
69
+ # link_preview is the default only without an image, or when the person switches it.
70
+ "channels": [{"name": channel, "character_limit": LIMITS[channel], "tracked_url": tracked_url(content["canonical_url"], channel), "publish_mode": "image" if image else "link_preview", "copy": None, "status": "needs-copy"} for channel in channels],
49
71
  "approval_required": True,
50
72
  "publish_requires": ["approved", "active provider connection", "explicit execute flag"],
51
73
  }
@@ -57,12 +79,30 @@ def validate(value: dict) -> list[str]:
57
79
  errors.append("invalid status")
58
80
  if value.get("status") == "published" and not value.get("provider_post_id"):
59
81
  errors.append("published draft requires provider_post_id")
82
+ domain = value.get("workspace_domain")
83
+ if domain and value.get("canonical_url") and not on_workspace_domain(value["canonical_url"], domain):
84
+ errors.append(f"canonical_url is on {host_of(value['canonical_url'])}, not the workspace domain {domain}")
85
+ elif not domain and value.get("status") in LIVE_STATUSES:
86
+ errors.append("workspace_domain is required before approval: the source must be published on the workspace's own domain")
60
87
  for channel in value.get("channels", []):
61
88
  name = channel.get("name")
62
89
  if name not in LIMITS:
63
90
  errors.append(f"unsupported channel: {name}")
64
- if channel.get("copy") and len(channel["copy"]) > LIMITS.get(name, 0):
65
- errors.append(f"copy exceeds {name} limit")
91
+ mode = channel.get("publish_mode")
92
+ if mode is not None and mode not in PUBLISH_MODES:
93
+ errors.append(f"{name}: publish_mode must be image or link_preview")
94
+ has_image = bool(channel.get("image_url") or value.get("image_url"))
95
+ if has_image and mode is None:
96
+ errors.append(f"{name}: the post has an image, so publish_mode must be set (image by default)")
97
+ if has_image and mode == "link_preview" and channel.get("publish_mode_source") != "user":
98
+ errors.append(f"{name}: an attached image would not be published as a link preview; use publish_mode image, or record publish_mode_source user when the person chose the preview")
99
+ if mode == "image" and not has_image:
100
+ errors.append(f"{name}: publish_mode image needs an image")
101
+ copy = channel.get("copy")
102
+ if copy and mode == "image" and channel.get("tracked_url") and channel["tracked_url"] not in copy and value.get("canonical_url", "") not in copy:
103
+ errors.append(f"{name}: an image post carries the link in its text; add the tracked URL to the copy")
104
+ if copy and len(copy) > LIMITS.get(name, 0):
105
+ errors.append(f"copy exceeds {name} limit" + (" (the link counts)" if mode == "image" else ""))
66
106
  return errors
67
107
 
68
108
 
@@ -72,6 +112,7 @@ def main() -> int:
72
112
  create = sub.add_parser("plan", help="create a draft channel contract from one content object")
73
113
  create.add_argument("input")
74
114
  create.add_argument("--channels", default="linkedin,facebook")
115
+ create.add_argument("--workspace-domain", help="the workspace's own site domain; the source must be published there")
75
116
  create.add_argument("--output")
76
117
  check = sub.add_parser("validate", help="validate a draft channel contract")
77
118
  check.add_argument("input")
@@ -89,11 +130,16 @@ def main() -> int:
89
130
  queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
90
131
  queue.add_argument("--slots", type=int, default=14)
91
132
  queue.add_argument("--output")
133
+ schema = sub.add_parser("schema-check", help="estimate a structured-output JSON schema's complexity before a model rejects it")
134
+ schema.add_argument("input")
92
135
  args = parser.parse_args()
93
136
  try:
94
137
  value = read(Path(args.input))
95
138
  now = datetime.now(timezone.utc)
96
- if args.command == "adapters":
139
+ if args.command == "schema-check":
140
+ from maggie_schema_complexity import check_schema
141
+ result = check_schema(value)
142
+ elif args.command == "adapters":
97
143
  result = validate_adapters(value)
98
144
  elif args.command == "connections":
99
145
  result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
@@ -110,7 +156,7 @@ def main() -> int:
110
156
  return 0
111
157
  else:
112
158
  if args.command == "plan":
113
- value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()])
159
+ value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()], args.workspace_domain)
114
160
  if args.output:
115
161
  Path(args.output).parent.mkdir(parents=True, exist_ok=True)
116
162
  Path(args.output).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
@@ -77,6 +77,8 @@ def verify(secret: bytes, token: str, identity: str, scope: str, calls: list[dic
77
77
  if not hmac.compare_digest(hmac.new(secret, body, hashlib.sha256).digest(), signature):
78
78
  raise ApprovalError("approval token signature does not match")
79
79
  approval = json.loads(body)
80
+ if approval.get("kind") == "standing":
81
+ raise ApprovalError("a standing approval only covers workflow runs; use check_standing")
80
82
  if approval.get("expires", 0) < (now if now is not None else time.time()):
81
83
  raise ApprovalError("approval expired; ask again")
82
84
  if not hmac.compare_digest(approval.get("principal", ""), principal_hash(identity)):
@@ -109,3 +111,99 @@ class TurnStore:
109
111
  if not token:
110
112
  raise ApprovalError("no pending approval for this turn (already used or never proposed)")
111
113
  return token
114
+
115
+
116
+ # --- Standing approvals -------------------------------------------------------
117
+ #
118
+ # Scheduled and event-driven workflows ("when someone cancels, offer the slot to
119
+ # the waitlist") cannot ask for a card every time. A standing approval is a
120
+ # signed grant bound to one workflow, its tools, per-tool limits and an expiry.
121
+ # Every run inside the limits is audited with channel "workflow" and the grant
122
+ # id; a call outside the limits raises OutsideStandingApproval and the caller
123
+ # falls back to a normal approval card. Revocation and daily usage live on the
124
+ # server (StandingStore models them).
125
+
126
+ MAX_STANDING_DAYS = 90
127
+
128
+
129
+ class OutsideStandingApproval(ApprovalError):
130
+ """The call is not covered by the grant: ask with a normal approval card."""
131
+
132
+
133
+ def issue_standing(secret: bytes, identity: str, scope: str, workflow: str, grants: list[dict], days: int = 30, now: float | None = None) -> tuple[str, dict]:
134
+ if not workflow or not grants:
135
+ raise ApprovalError("a standing approval needs a workflow and at least one tool grant")
136
+ if not 1 <= days <= MAX_STANDING_DAYS:
137
+ raise ApprovalError(f"a standing approval lasts 1-{MAX_STANDING_DAYS} days; renew it instead of making it permanent")
138
+ for grant in grants:
139
+ if not grant.get("name") or not isinstance(grant.get("maxPerDay"), int) or grant["maxPerDay"] < 1:
140
+ raise ApprovalError("every tool grant needs a name and maxPerDay")
141
+ approval = {
142
+ "kind": "standing",
143
+ "id": secrets.token_hex(16),
144
+ "workflow": workflow,
145
+ "grants": [{key: grant[key] for key in ("name", "maxPerDay", "maxPerRun", "fixedArgs", "maxAmountMinor") if key in grant} for grant in grants],
146
+ "principal": principal_hash(identity),
147
+ "scope": scope,
148
+ "expires": int((now if now is not None else time.time()) + days * 86400),
149
+ }
150
+ body = canonical(approval).encode("utf-8")
151
+ return _b64(body) + "." + _b64(hmac.new(secret, body, hashlib.sha256).digest()), approval
152
+
153
+
154
+ def _decode(secret: bytes, token: str) -> dict:
155
+ try:
156
+ body_text, signature_text = token.split(".", 1)
157
+ body, signature = _unb64(body_text), _unb64(signature_text)
158
+ except (ValueError, TypeError) as error:
159
+ raise ApprovalError("approval token is malformed") from error
160
+ if not hmac.compare_digest(hmac.new(secret, body, hashlib.sha256).digest(), signature):
161
+ raise ApprovalError("approval token signature does not match")
162
+ return json.loads(body)
163
+
164
+
165
+ def check_standing(secret: bytes, token: str, identity: str, scope: str, workflow: str, call: dict, used_today: int, run_calls: int = 0, revoked: set[str] | None = None, now: float | None = None) -> dict:
166
+ """Allow one workflow call under a standing approval, or raise (OutsideStandingApproval = ask with a card)."""
167
+ approval = _decode(secret, token)
168
+ if approval.get("kind") != "standing":
169
+ raise ApprovalError("not a standing approval")
170
+ if approval["id"] in (revoked or set()):
171
+ raise OutsideStandingApproval("this standing approval was revoked; ask again")
172
+ if approval.get("expires", 0) < (now if now is not None else time.time()):
173
+ raise OutsideStandingApproval("this standing approval expired; ask the owner to renew it")
174
+ if not hmac.compare_digest(approval.get("principal", ""), principal_hash(identity)) or approval.get("scope") != scope:
175
+ raise ApprovalError("standing approval belongs to another owner or workspace")
176
+ if approval.get("workflow") != workflow:
177
+ raise OutsideStandingApproval("the standing approval covers another workflow")
178
+ grant = next((g for g in approval.get("grants", []) if g.get("name") == call.get("name")), None)
179
+ if grant is None:
180
+ raise OutsideStandingApproval(f"{call.get('name')} is not part of this workflow's standing approval")
181
+ if used_today >= grant["maxPerDay"]:
182
+ raise OutsideStandingApproval(f"daily limit of {grant['maxPerDay']} reached for {call['name']}")
183
+ if grant.get("maxPerRun") and run_calls >= grant["maxPerRun"]:
184
+ raise OutsideStandingApproval(f"limit of {grant['maxPerRun']} per run reached for {call['name']}")
185
+ args = call.get("args", {})
186
+ for key, value in (grant.get("fixedArgs") or {}).items():
187
+ if args.get(key) != value:
188
+ raise OutsideStandingApproval(f"{call['name']}: {key} differs from the approved value")
189
+ if grant.get("maxAmountMinor") is not None and int(args.get("amountMinor", 0)) > grant["maxAmountMinor"]:
190
+ raise OutsideStandingApproval(f"{call['name']}: amount above the approved maximum")
191
+ return {"approvalId": approval["id"], "workflow": workflow, "auditChannel": "workflow", "idempotencyKey": idempotency_key(approval["id"], call)}
192
+
193
+
194
+ class StandingStore:
195
+ """Server-side model: revocations and per-day usage per grant and tool."""
196
+
197
+ def __init__(self) -> None:
198
+ self.revoked: set[str] = set()
199
+ self.usage: dict[tuple[str, str, str], int] = {}
200
+
201
+ def revoke(self, approval_id: str) -> None:
202
+ self.revoked.add(approval_id)
203
+
204
+ def run(self, secret: bytes, token: str, identity: str, scope: str, workflow: str, call: dict, day: str, run_calls: int = 0, now: float | None = None) -> dict:
205
+ approval_id = _decode(secret, token).get("id", "")
206
+ key = (approval_id, call.get("name", ""), day)
207
+ allowed = check_standing(secret, token, identity, scope, workflow, call, self.usage.get(key, 0), run_calls, self.revoked, now)
208
+ self.usage[key] = self.usage.get(key, 0) + 1
209
+ return allowed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.52",
3
+ "version": "0.7.53",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -173,9 +173,22 @@ rules from a redacted JSON manifest; see the Social Share skill for shapes.
173
173
 
174
174
  ## Structured model output
175
175
 
176
- Keep structured-output schemas small. Gemini rejected a JSON schema whose enum
177
- had 125 values with `400 INVALID_ARGUMENT`, and the caller silently fell back
178
- for every request. For a large vocabulary (styles, categories, catalogue IDs):
176
+ Keep structured-output schemas small. The limit is **schema complexity**, not
177
+ vocabulary size: roughly enum values × repeated array items × nesting.
178
+ Gemini rejected a JSON schema whose enum had 125 values with
179
+ `400 INVALID_ARGUMENT`, and the caller silently fell back for every request.
180
+ It also rejected a plan schema with a 5-6 value `template_key` enum on each
181
+ of up to 20 array items (`maxItems: 20`) — immediately and with no detail —
182
+ while 4 values or `maxItems: 10` passed. So:
183
+
184
+ - never put an `enum` inside repeated array items; describe the allowed
185
+ values in the prompt or the field `description` and validate server-side;
186
+ - keep `maxItems` small on arrays of objects, and always set it;
187
+ - run `maggie tool maggie_social_share.py schema-check schema.json`
188
+ (`tools/clis/maggie_schema_complexity.py`) before a release; it fails on
189
+ the patterns above and reports an estimated complexity score.
190
+
191
+ For a large vocabulary (styles, categories, catalogue IDs):
179
192
 
180
193
  1. ask for a free string (optionally with a short list of examples in the
181
194
  prompt) instead of a large `enum`;