@topy-ai/maggie 0.7.54 → 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 (36) hide show
  1. package/README-zh-TW.md +22 -22
  2. package/README.md +62 -67
  3. package/bin/maggie.js +10 -2
  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-skills/catalog.json +1 -1
  10. package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
  11. package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
  12. package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
  13. package/bundled-skills/maggie-blog/SKILL.md +9 -2
  14. package/bundled-skills/maggie-booking/SKILL.md +1 -1
  15. package/bundled-skills/maggie-dash/SKILL.md +9 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +77 -1
  17. package/bundled-skills/maggie-deployment/references/vps.md +5 -0
  18. package/bundled-skills/maggie-marketplace/SKILL.md +21 -1
  19. package/bundled-skills/maggie-ops/SKILL.md +28 -0
  20. package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
  21. package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
  22. package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
  23. package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
  24. package/bundled-skills/maggie-social-share/SKILL.md +20 -0
  25. package/bundled-tools/clis/maggie.py +63 -3
  26. package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
  27. package/bundled-tools/clis/maggie_host_runtime.py +458 -0
  28. package/bundled-tools/clis/maggie_live_urls.py +11 -2
  29. package/bundled-tools/clis/maggie_ops.py +2 -0
  30. package/bundled-tools/clis/maggie_ops_routines.py +352 -0
  31. package/bundled-tools/clis/maggie_query_ideas.py +154 -0
  32. package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
  33. package/bundled-tools/clis/maggie_social_share.py +101 -1
  34. package/bundled-tools/clis/maggie_version_sources.py +93 -0
  35. package/bundled-tools/integrations/maggie-api-pull.md +28 -1
  36. package/package.json +1 -1
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
  import json
7
7
  import sys
8
- from datetime import datetime, timezone
8
+ from datetime import datetime, timedelta, timezone
9
9
  from pathlib import Path
10
10
  from urllib.error import HTTPError, URLError
11
11
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@@ -169,6 +169,96 @@ def picture_groups(value: dict) -> dict:
169
169
  return {"valid": not errors, "errors": errors, "ideas": report}
170
170
 
171
171
 
172
+ REUSE_DAYS = 30
173
+ SHARED_STATUSES = {"approved", "scheduled", "publishing", "published"}
174
+ OPEN_STATUSES = {"draft", "queued", "held", "needs_review", "pending"}
175
+
176
+
177
+ def article_key(item: dict) -> str:
178
+ """The article a post shares: its source id, else its address without tracking parameters."""
179
+ if item.get("source_content_id"):
180
+ return "id:" + str(item["source_content_id"])
181
+ url = str(item.get("canonical_url") or item.get("url") or "")
182
+ parts = urlsplit(url)
183
+ query = urlencode([(key, value) for key, value in parse_qsl(parts.query) if not key.startswith("utm_")])
184
+ return "url:" + urlunsplit((parts.scheme, parts.netloc.lower().removeprefix("www."), parts.path.rstrip("/"), query, ""))
185
+
186
+
187
+ def shared_on(history: list[dict], now: datetime, days: int = REUSE_DAYS) -> dict[str, dict[str, str]]:
188
+ """Article key -> {channel: last shared instant} within the rolling window."""
189
+ used: dict[str, dict[str, str]] = {}
190
+ for post in history:
191
+ if post.get("status", "published") not in SHARED_STATUSES:
192
+ continue
193
+ stamp = post.get("posted_at") or post.get("published_at") or post.get("scheduled_at")
194
+ if not stamp or not post.get("channel"):
195
+ continue
196
+ when = parse_instant(stamp, "history posted_at")
197
+ if (now - when).total_seconds() > days * 86400 or when > now + timedelta(days=days):
198
+ continue
199
+ channels = used.setdefault(article_key(post), {})
200
+ channel = str(post["channel"])
201
+ channels[channel] = max(channels.get(channel, ""), stamp)
202
+ return used
203
+
204
+
205
+ def sources_used_on(document: dict, now: datetime) -> dict:
206
+ """Each article with the channels it was shared to in the last 30 days (rolling)."""
207
+ used = shared_on(document.get("history") or [], now, int(document.get("reuse_days") or REUSE_DAYS))
208
+ articles = []
209
+ for article in document.get("articles") or []:
210
+ channels = used.get(article_key(article), {})
211
+ articles.append({**article, "used_on": sorted(channels), "last_shared": channels})
212
+ return {"valid": True, "reuse_days": int(document.get("reuse_days") or REUSE_DAYS), "articles": articles}
213
+
214
+
215
+ def queue_check(document: dict, now: datetime) -> dict:
216
+ """What an automatic run and "Approve all" may do with the open posts.
217
+
218
+ * reuse is per channel: an article shared on a channel in the last 30 days
219
+ (rolling) is not shared there again — other channels are free;
220
+ * every new post carries a picture;
221
+ * in auto mode a hold raised only by optional checks does not block a post;
222
+ a failing required check always does;
223
+ * "Approve all" covers every open post, held ones included.
224
+ """
225
+ mode = str(document.get("approval_mode") or "manual")
226
+ if mode not in {"auto", "manual"}:
227
+ raise ValueError("approval_mode must be auto or manual")
228
+ days = int(document.get("reuse_days") or REUSE_DAYS)
229
+ used = shared_on(document.get("history") or [], now, days)
230
+ planned: dict[tuple[str, str], str] = {}
231
+ posts, approve_all, blocked = [], [], []
232
+ for post in document.get("posts") or []:
233
+ if post.get("status", "draft") not in OPEN_STATUSES:
234
+ continue
235
+ pid, channel, key = str(post.get("id")), str(post.get("channel") or ""), article_key(post)
236
+ reasons, notes = [], []
237
+ last = used.get(key, {}).get(channel)
238
+ if last:
239
+ reasons.append(f"this article was shared on {channel} at {last}, within {days} days")
240
+ elif (channel, key) in planned:
241
+ reasons.append(f"post {planned[(channel, key)]} already shares this article on {channel}")
242
+ planned.setdefault((channel, key), pid)
243
+ if not post.get("image_url"):
244
+ reasons.append("needs a picture: every new post carries one (use the picture of its idea)")
245
+ for hold in post.get("holds") or []:
246
+ name = str(hold.get("check") or hold.get("reason") or "check")
247
+ if hold.get("required"):
248
+ reasons.append(f"required check failed: {name}")
249
+ elif mode == "auto":
250
+ notes.append(f"optional check {name} does not hold the post in auto mode")
251
+ else:
252
+ notes.append(f"held for review by optional check {name}; Approve all includes it")
253
+ held = bool(post.get("holds")) or post.get("status") in {"held", "needs_review"}
254
+ entry = {"id": pid, "channel": channel, "held": held, "blocked": bool(reasons), "reasons": reasons, "notes": notes}
255
+ if mode == "auto":
256
+ entry["auto_publish"] = not reasons
257
+ posts.append(entry)
258
+ (blocked if reasons else approve_all).append(pid)
259
+ return {"valid": not blocked, "approval_mode": mode, "reuse_days": days, "approve_all": approve_all, "blocked": blocked, "posts": posts}
260
+
261
+
172
262
  def validate(value: dict) -> list[str]:
173
263
  errors = [f"missing {key}" for key in REQUIRED if not value.get(key)]
174
264
  if value.get("status") not in {"draft", "approved", "scheduled", "publishing", "published", "failed", "cancelled"}:
@@ -220,6 +310,12 @@ def main() -> int:
220
310
  links.add_argument("input", help="JSON: a list of URLs or article objects with canonical_url, or {\"articles\": [...]}")
221
311
  links.add_argument("--cache", default=".maggie/social-link-cache.json", help="verdict cache (default: .maggie/social-link-cache.json); 'none' disables it")
222
312
  links.add_argument("--ttl-hours", type=float, default=6.0)
313
+ used_on = sub.add_parser("used-on", help="list, per article, the channels it was shared to in the last 30 days (rolling)")
314
+ used_on.add_argument("input", help='JSON: {"articles": [...], "history": [{"channel", "source_content_id"|"canonical_url", "posted_at", "status"}]}')
315
+ used_on.add_argument("--now")
316
+ qcheck = sub.add_parser("queue-check", help="decide what auto mode and Approve all may do: per-channel 30-day reuse, a picture on every post, optional holds")
317
+ qcheck.add_argument("input", help='JSON: {"approval_mode": "auto"|"manual", "posts": [...], "history": [...]}')
318
+ qcheck.add_argument("--now")
223
319
  check = sub.add_parser("validate", help="validate a draft channel contract")
224
320
  check.add_argument("input")
225
321
  adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
@@ -258,6 +354,10 @@ def main() -> int:
258
354
  result = validate_adapters(value)
259
355
  elif args.command == "connections":
260
356
  result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
357
+ elif args.command == "used-on":
358
+ result = sources_used_on(value, parse_instant(args.now, "--now") if args.now else now)
359
+ elif args.command == "queue-check":
360
+ result = queue_check(value, parse_instant(args.now, "--now") if args.now else now)
261
361
  elif args.command == "pictures":
262
362
  result = picture_groups(value if isinstance(value, dict) else {"drafts": value})
263
363
  elif args.command == "reconcile":
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Check that every version source of a package agrees, and that generated data is current.
3
+
4
+ A packager that writes its own version constant over a hand-bumped manifest
5
+ (or the reverse) ships one version in one place and another elsewhere; a site
6
+ built from generated data that was not regenerated then fails to deploy. This
7
+ check never writes a version: it reads each source and refuses on a mismatch.
8
+
9
+ Sources:
10
+ FILE a plain VERSION file (first line)
11
+ FILE:dotted.key a JSON value, e.g. package.json:version or manifest.json:meta.version
12
+ FILE~NAME a code constant NAME = "x.y.z" / NAME: "x.y.z" / export const NAME = 'x.y.z'
13
+
14
+ Generated data:
15
+ --generated OUT --command "node scripts/gen.mjs {out}"
16
+ runs the generator into a temporary file ({out}) and fails when OUT differs.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import re
23
+ import shlex
24
+ import subprocess
25
+ import sys
26
+ import tempfile
27
+ from pathlib import Path
28
+
29
+
30
+ def read_source(root: Path, spec: str) -> str:
31
+ if "~" in spec:
32
+ name, constant = spec.split("~", 1)
33
+ text = (root / name).read_text(encoding="utf-8")
34
+ match = re.search(rf"\b{re.escape(constant)}\b\s*[:=]\s*['\"]([^'\"]+)['\"]", text)
35
+ if not match:
36
+ raise ValueError(f"{spec}: constant {constant} not found")
37
+ return match.group(1)
38
+ if ":" in spec and not re.match(r"^[A-Za-z]:[\\/]", spec):
39
+ name, dotted = spec.split(":", 1)
40
+ value: object = json.loads((root / name).read_text(encoding="utf-8"))
41
+ for key in dotted.split("."):
42
+ if not isinstance(value, dict) or key not in value:
43
+ raise ValueError(f"{spec}: key {dotted} not found")
44
+ value = value[key]
45
+ return str(value)
46
+ return (root / spec).read_text(encoding="utf-8").splitlines()[0].strip()
47
+
48
+
49
+ def check_generated(root: Path, output: str, command: str) -> dict:
50
+ with tempfile.TemporaryDirectory() as directory:
51
+ fresh = Path(directory) / Path(output).name
52
+ argv = [part.replace("{out}", str(fresh)) for part in shlex.split(command)]
53
+ completed = subprocess.run(argv, cwd=root, capture_output=True, text=True, check=False)
54
+ if completed.returncode:
55
+ return {"file": output, "current": False, "error": f"generator exited {completed.returncode}: {(completed.stderr or completed.stdout).strip()[-300:]}"}
56
+ if not fresh.is_file():
57
+ return {"file": output, "current": False, "error": "generator did not write {out}"}
58
+ current = (root / output).is_file() and (root / output).read_bytes() == fresh.read_bytes()
59
+ return {"file": output, "current": current, **({} if current else {"error": "out of date: regenerate and commit it before deploying"})}
60
+
61
+
62
+ def main() -> int:
63
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter, epilog="\n".join(__doc__.splitlines()[2:]))
64
+ parser.add_argument("command", choices=["check"])
65
+ parser.add_argument("--project", default=".")
66
+ parser.add_argument("--source", action="append", default=[], required=True, help="a version source (repeat; the first is the one to follow)")
67
+ parser.add_argument("--generated", action="append", default=[], help="generated file that must be current")
68
+ parser.add_argument("--generator", action="append", default=[], help="command that writes the matching --generated file to {out}")
69
+ args = parser.parse_args()
70
+ root = Path(args.project).resolve()
71
+ errors: list[str] = []
72
+ versions: dict[str, str | None] = {}
73
+ for spec in args.source:
74
+ try:
75
+ versions[spec] = read_source(root, spec)
76
+ except (OSError, ValueError, json.JSONDecodeError, IndexError) as error:
77
+ versions[spec] = None
78
+ errors.append(str(error))
79
+ found = {value for value in versions.values() if value}
80
+ if len(found) > 1:
81
+ first = versions[args.source[0]]
82
+ errors.extend(f"{spec} is {value}; {args.source[0]} is {first}" for spec, value in versions.items() if value and value != first)
83
+ if len(args.generated) != len(args.generator):
84
+ errors.append("give one --generator per --generated file")
85
+ generated = [check_generated(root, output, command) for output, command in zip(args.generated, args.generator)]
86
+ errors.extend(f"{item['file']}: {item['error']}" for item in generated if not item["current"])
87
+ result = {"valid": not errors, "version": versions.get(args.source[0]) if not errors else None, "sources": versions, "generated": generated, "errors": errors}
88
+ print(json.dumps(result, indent=2))
89
+ return 0 if result["valid"] else 1
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
@@ -89,13 +89,40 @@ back to an older stored URL, which after a platform move answers 404. So:
89
89
  `maggie blog live-urls match --sitemap https://example.com/sitemap.xml --write --report-body .maggie/live-urls-report.json`,
90
90
  then send that body with `maggie api report-state --body-file … --execute`.
91
91
 
92
- Matching is **additive**: a post that already has a live URL keeps it, and a
92
+ Every post without a live URL is a candidate, including one already tracked
93
+ with an empty value; when the record has no slug, the last path segment of its
94
+ delivered address is used. Matching is **additive**: a post that already has a live URL keeps it, and a
93
95
  post this run did not find is left as it was — never marked unmatched — so the
94
96
  match can run daily without retiring earlier matches. When translated copies
95
97
  share the slug (`/blog/x`, `/zh-hans/blog/x`), the URL **without a locale
96
98
  segment** wins. The same two rules apply to the platform's own sitemap
97
99
  matching for API Pull destinations.
98
100
 
101
+ ## Rewrites on pull sites
102
+
103
+ A pull site receives rewrites the same way it receives posts: `GET /updates`
104
+ on the same schedule as `/posts` (one rewrite per call, charged like a post;
105
+ the answer is `{delivered, updates: [...]}`). Without this path a rewrite
106
+ requested for a pulled post never reaches the site. Each rewrite is applied to
107
+ the post it is for, never stored as a new post:
108
+
109
+ 1. Find the post by `source_content_id` (the id `/posts` delivered it under),
110
+ else by its slug or address, previous slugs included.
111
+ 2. Replace only the body. Title, slug, status, topics and the live URL stay as
112
+ the site has them.
113
+ 3. Keep the replaced body (and its checksum) so the rewrite can be undone.
114
+ 4. A rewrite with `publication_status: "draft"` is held for review, and one
115
+ with no matching post here is kept as `unmatched`; neither is applied.
116
+ 5. Translations of a rewritten post are stale: queue them again.
117
+ 6. Report the outcome with `POST /content-tracking/report-state`.
118
+
119
+ `maggie api lifecycle --execute --allow-quota --pull-updates` does steps 1, 2,
120
+ 4 locally: the rewrite lands on the original record in
121
+ `.maggie/ai-cmo-lifecycle.json` (`rewrite_pending`, a `rewrites` history with
122
+ the previous checksum), held ones are listed without changing the body, and
123
+ unmatched ones go to `rewrites_unmatched`. A later `/posts` re-delivery keeps
124
+ the record's live URL and rewrite history.
125
+
99
126
  ## Project context synchronization
100
127
 
101
128
  `GET /project-context` returns sanitized, read-only context scoped by the API
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.54",
3
+ "version": "0.7.55",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",