@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.
- package/README-zh-TW.md +22 -22
- package/README.md +62 -67
- package/bin/maggie.js +10 -2
- package/bundled-contracts/maggie-assistant-tools/booking-receptionist-example-v1.json +18 -2
- package/bundled-contracts/maggie-assistant-tools/report-v1.schema.json +43 -0
- package/bundled-contracts/maggie-assistant-tools/tool-registry-example-v1.json +249 -11
- package/bundled-contracts/maggie-assistant-tools/tool-registry-v1.schema.json +175 -0
- package/bundled-contracts/maggie-ops/agent-routines-v1.schema.json +48 -0
- package/bundled-skills/catalog.json +1 -1
- package/bundled-skills/maggie-assistant-tools/SKILL.md +34 -1
- package/bundled-skills/maggie-assistant-tools/references/report-inbox.md +45 -0
- package/bundled-skills/maggie-assistant-tools/references/unattended-agents.md +70 -0
- package/bundled-skills/maggie-blog/SKILL.md +9 -2
- package/bundled-skills/maggie-booking/SKILL.md +1 -1
- package/bundled-skills/maggie-dash/SKILL.md +9 -0
- package/bundled-skills/maggie-deployment/SKILL.md +77 -1
- package/bundled-skills/maggie-deployment/references/vps.md +5 -0
- package/bundled-skills/maggie-marketplace/SKILL.md +21 -1
- package/bundled-skills/maggie-ops/SKILL.md +28 -0
- package/bundled-skills/maggie-ops/references/agents-repository.md +108 -0
- package/bundled-skills/maggie-ops/references/ops-autopilot.md +65 -0
- package/bundled-skills/maggie-ops/references/routine-mode.md +92 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +9 -1
- package/bundled-skills/maggie-social-share/SKILL.md +20 -0
- package/bundled-tools/clis/maggie.py +63 -3
- package/bundled-tools/clis/maggie_assistant_tools.py +225 -1
- package/bundled-tools/clis/maggie_host_runtime.py +458 -0
- package/bundled-tools/clis/maggie_live_urls.py +11 -2
- package/bundled-tools/clis/maggie_ops.py +2 -0
- package/bundled-tools/clis/maggie_ops_routines.py +352 -0
- package/bundled-tools/clis/maggie_query_ideas.py +154 -0
- package/bundled-tools/clis/maggie_skill_catalog.py +16 -2
- package/bundled-tools/clis/maggie_social_share.py +101 -1
- package/bundled-tools/clis/maggie_version_sources.py +93 -0
- package/bundled-tools/integrations/maggie-api-pull.md +28 -1
- 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
|
-
|
|
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
|