@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
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from urllib.error import HTTPError, URLError
|
|
17
|
-
from urllib.parse import quote
|
|
17
|
+
from urllib.parse import quote, urlsplit
|
|
18
18
|
from urllib.request import Request, urlopen
|
|
19
19
|
|
|
20
20
|
|
|
@@ -478,13 +478,73 @@ def command_api_lifecycle(args: argparse.Namespace) -> int:
|
|
|
478
478
|
def record(step: str, value: object):
|
|
479
479
|
state["steps"][step] = {"status":"passed","completed_at":utc_now(),"summary":response_summary(value)}
|
|
480
480
|
if step in {"posts","updates"}:
|
|
481
|
-
items =
|
|
481
|
+
items = delivered_items(value, step)
|
|
482
|
+
if step == "updates":
|
|
483
|
+
state["steps"][step]["summary"]["item_count"] = len(items)
|
|
482
484
|
for item in items:
|
|
483
485
|
if not isinstance(item, dict): continue
|
|
484
486
|
remote_id = item.get("content_id") or item.get("contentId") or item.get("id") or item.get("post_id") or item.get("postId")
|
|
485
487
|
if not remote_id: continue
|
|
486
488
|
body = item.get("content") or item.get("body") or item.get("html") or item.get("content_markdown") or item.get("content_html") or ""
|
|
487
|
-
|
|
489
|
+
fields = {"version":item.get("version") or item.get("version_id") or item.get("updated_at") or item.get("updatedAt"),"content":body,"content_markdown":item.get("content_markdown") or item.get("contentMarkdown"),"content_html":item.get("content_html") or item.get("contentHtml"),"content_portable_text":item.get("content_portable_text") or item.get("portable_text") or item.get("contentPortableText"),"sha256":hashlib.sha256(json.dumps(item,sort_keys=True,ensure_ascii=False).encode()).hexdigest(),"source_step":step,"pulled_at":utc_now()}
|
|
490
|
+
if step == "updates":
|
|
491
|
+
apply_rewrite(item, str(remote_id), fields)
|
|
492
|
+
continue
|
|
493
|
+
previous = state["content"].get(str(remote_id), {})
|
|
494
|
+
record_value = {"remote_content_id":str(remote_id),"slug":item.get("slug"),"canonical_url":item.get("canonical_url") or item.get("canonicalUrl") or item.get("url"),"title":item.get("title"),**fields,"status":item.get("status") or "pulled"}
|
|
495
|
+
# A re-delivered post keeps where it went live and its rewrite history.
|
|
496
|
+
for key in ("live_url","live_url_source","live_url_matched_at","rewrites","previous_slugs"):
|
|
497
|
+
if key in previous: record_value[key] = previous[key]
|
|
498
|
+
state["content"][str(remote_id)] = record_value
|
|
499
|
+
|
|
500
|
+
def find_rewrite_target(item: dict, remote_id: str) -> str | None:
|
|
501
|
+
"""The delivered post a rewrite is for: by the id /posts delivered it
|
|
502
|
+
under, else by its slug or address (previous slugs too)."""
|
|
503
|
+
source = item.get("source_content_id") or item.get("sourceContentId") or item.get("original_content_id")
|
|
504
|
+
for key in (source, remote_id):
|
|
505
|
+
if key is not None and str(key) in state["content"]:
|
|
506
|
+
return str(key)
|
|
507
|
+
slugs = {str(item.get("slug") or "").strip("/")}
|
|
508
|
+
address = str(item.get("canonical_url") or item.get("canonicalUrl") or item.get("url") or "")
|
|
509
|
+
if address:
|
|
510
|
+
slugs.add(urlsplit(address).path.rstrip("/").rsplit("/", 1)[-1])
|
|
511
|
+
slugs.discard("")
|
|
512
|
+
for key, existing in state["content"].items():
|
|
513
|
+
if not isinstance(existing, dict): continue
|
|
514
|
+
known = {str(existing.get("slug") or "").strip("/"), *map(str, existing.get("previous_slugs") or [])}
|
|
515
|
+
if existing.get("live_url"):
|
|
516
|
+
known.add(urlsplit(str(existing["live_url"])).path.rstrip("/").rsplit("/", 1)[-1])
|
|
517
|
+
if slugs & known:
|
|
518
|
+
return key
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
def apply_rewrite(item: dict, remote_id: str, fields: dict) -> None:
|
|
522
|
+
target = find_rewrite_target(item, remote_id)
|
|
523
|
+
held = str(item.get("publication_status") or "").lower() == "draft"
|
|
524
|
+
entry = {"rewrite_content_id": remote_id, "version": fields["version"], "change_reason": str(item.get("change_reason") or "")[:500], "sha256": fields["sha256"], "pulled_at": fields["pulled_at"]}
|
|
525
|
+
if target is None or not str(fields["content"]).strip():
|
|
526
|
+
# Never create a new post from a rewrite: keep it for review instead.
|
|
527
|
+
state.setdefault("rewrites_unmatched", []).append({**entry, "slug": item.get("slug"), "status": "unmatched"})
|
|
528
|
+
return
|
|
529
|
+
record_value = state["content"][target]
|
|
530
|
+
entry["status"] = "held" if held else "pending-apply"
|
|
531
|
+
if not held:
|
|
532
|
+
# Only the body changes; title, slug, status and the live URL stay.
|
|
533
|
+
entry["previous_sha256"] = record_value.get("sha256")
|
|
534
|
+
record_value.update({key: fields[key] for key in ("version","content","content_markdown","content_html","content_portable_text","sha256","pulled_at")})
|
|
535
|
+
record_value["rewrite_pending"] = True
|
|
536
|
+
record_value.setdefault("rewrites", []).append(entry)
|
|
537
|
+
|
|
538
|
+
def delivered_items(value: object, step: str) -> list:
|
|
539
|
+
"""/posts and /updates answer {items: [...]}, {posts: [...]} or {updates: [...]}."""
|
|
540
|
+
if isinstance(value, list):
|
|
541
|
+
return value
|
|
542
|
+
if not isinstance(value, dict):
|
|
543
|
+
return []
|
|
544
|
+
for key in ("items", step, "posts", "updates"):
|
|
545
|
+
if isinstance(value.get(key), list):
|
|
546
|
+
return value[key]
|
|
547
|
+
return []
|
|
488
548
|
|
|
489
549
|
def record_read(step: str, value: object):
|
|
490
550
|
state["steps"][step] = {"status":"passed","completed_at":utc_now(),"summary":response_summary(value)}
|
|
@@ -6,7 +6,8 @@ server and ChatGPT/Claude apps. The product exports its registry as JSON
|
|
|
6
6
|
(``maggie-assistant-tool-registry.v1``); these commands never import product
|
|
7
7
|
code or call a model.
|
|
8
8
|
|
|
9
|
-
- ``registry-validate``: names, risk levels, gates, scopes, channels,
|
|
9
|
+
- ``registry-validate``: names, risk levels, gates, scopes, channels, clipped
|
|
10
|
+
lists, the report inbox, unattended-caller caps and sign-in defaults, and the
|
|
10
11
|
user-facing copy (label, detail, confirm sentence) kept separate from the
|
|
11
12
|
model-facing description. User copy may not contain snake_case tool names,
|
|
12
13
|
"the user", or "(from some_tool)" references.
|
|
@@ -17,6 +18,10 @@ code or call a model.
|
|
|
17
18
|
- ``audit-check``: every write in a recorded session has one audit entry, with
|
|
18
19
|
a readable summary (never a bare UUID) and the right scope (account-level
|
|
19
20
|
writes are not filed under the open site).
|
|
21
|
+
- ``list-check``: a recorded list result marks every item whose declared
|
|
22
|
+
field was shortened (``clipped``) and carries the id its get tool needs.
|
|
23
|
+
- ``report-check``: a report an agent is about to save to the report inbox
|
|
24
|
+
(title, one-line summary, Markdown body) fits the limits and holds no secret.
|
|
20
25
|
- ``approval-selftest``: exercises the reference signed one-time approval
|
|
21
26
|
token and standing workflow approvals (``tools/runtime/assistant_approval.py``).
|
|
22
27
|
"""
|
|
@@ -187,10 +192,222 @@ def validate_registry(registry: dict) -> dict:
|
|
|
187
192
|
errors.append("visitorPolicy.factsOnly must be true: answers come from the business's own facts, never general knowledge about prices or health")
|
|
188
193
|
if policy.get("cardDetails") != "never":
|
|
189
194
|
errors.append("visitorPolicy.cardDetails must be never: the chat never asks for or accepts card numbers")
|
|
195
|
+
by_name = {str(t.get("name")): t for t in tools if isinstance(t, dict)}
|
|
196
|
+
check_clips(by_name, errors, warnings)
|
|
197
|
+
check_unattended(registry, by_name, servers, errors, warnings)
|
|
198
|
+
check_report_inbox(registry, by_name, errors)
|
|
190
199
|
public_tools = [t.get("name") for t in tools if isinstance(t, dict) and any(servers.get(s, {}).get("auth") == "none" for s in t.get("mcpServers", []) or [])]
|
|
191
200
|
return {"schemaVersion": "maggie-assistant-registry-validation.v1", "passed": not errors, "errors": errors, "warnings": warnings, "counts": {"tools": len(tools), **counts, "cards": sum(1 for t in tools if isinstance(t, dict) and t.get("card")), "publicMcpTools": len(public_tools)}}
|
|
192
201
|
|
|
193
202
|
|
|
203
|
+
CLIP_WORDS_RE = re.compile(r"\b(?:clipped|shortened|truncated|cut to|first \d+ characters|~?\d+-character)\b", re.I)
|
|
204
|
+
WRITE_RISKS = ("write-reversible", "write-spends", "write-public")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_clips(by_name: dict[str, dict], errors: list[str], warnings: list[str]) -> None:
|
|
208
|
+
"""A list tool that shortens long fields says so per item and names a get tool with the full text.
|
|
209
|
+
|
|
210
|
+
An agent checking what will be published must read the full text; a list that
|
|
211
|
+
silently clips makes it report "cut off mid-sentence" for text that is whole.
|
|
212
|
+
"""
|
|
213
|
+
for name, tool in by_name.items():
|
|
214
|
+
clips = tool.get("clips")
|
|
215
|
+
if clips is None:
|
|
216
|
+
if risk_of(tool) == "read" and CLIP_WORDS_RE.search(str(tool.get("description", ""))):
|
|
217
|
+
warnings.append(f"{name}: the description says results are shortened; declare clips (fields, maxChars, getTool) so each item is marked and the full text has a get tool")
|
|
218
|
+
continue
|
|
219
|
+
if not isinstance(clips, dict):
|
|
220
|
+
errors.append(f"{name}: clips must be an object with fields, maxChars and getTool"); continue
|
|
221
|
+
if risk_of(tool) != "read":
|
|
222
|
+
errors.append(f"{name}: clips belongs to a read (list) tool")
|
|
223
|
+
fields = clips.get("fields")
|
|
224
|
+
if not isinstance(fields, list) or not fields or not all(isinstance(f, str) and f for f in fields):
|
|
225
|
+
errors.append(f"{name}: clips.fields must name the shortened fields")
|
|
226
|
+
if not isinstance(clips.get("maxChars"), int) or clips["maxChars"] < 1:
|
|
227
|
+
errors.append(f"{name}: clips.maxChars must be a positive integer")
|
|
228
|
+
get_name = str(clips.get("getTool", ""))
|
|
229
|
+
get_tool = by_name.get(get_name)
|
|
230
|
+
if get_tool is None:
|
|
231
|
+
errors.append(f"{name}: clips.getTool {get_name or '<missing>'!r} is not in the registry; a clipped list needs a get tool that returns the full text"); continue
|
|
232
|
+
if risk_of(get_tool) != "read":
|
|
233
|
+
errors.append(f"{name}: clips.getTool {get_name} must be a read tool")
|
|
234
|
+
if get_tool.get("scope") != tool.get("scope"):
|
|
235
|
+
errors.append(f"{name}: clips.getTool {get_name} must have the same scope as the list")
|
|
236
|
+
missing = [c for c in tool.get("channels") or [] if c not in (get_tool.get("channels") or [])]
|
|
237
|
+
if missing:
|
|
238
|
+
errors.append(f"{name}: clips.getTool {get_name} is not on {', '.join(missing)}; every caller of the list must be able to read the full text")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def oauth_full_set(by_name: dict[str, dict], server: str | None) -> set[str]:
|
|
242
|
+
"""The tools a signed-in caller's role can reach on MCP (the server filters by role at call time)."""
|
|
243
|
+
names = set()
|
|
244
|
+
for name, tool in by_name.items():
|
|
245
|
+
if risk_of(tool) == "never" or "mcp" not in (tool.get("channels") or []):
|
|
246
|
+
continue
|
|
247
|
+
if server and tool.get("mcpServers") and server not in tool["mcpServers"]:
|
|
248
|
+
continue
|
|
249
|
+
names.add(name)
|
|
250
|
+
return names
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check_unattended(registry: dict, by_name: dict[str, dict], servers: dict, errors: list[str], warnings: list[str]) -> None:
|
|
254
|
+
"""Limits for callers no person watches: agent keys and sign-in (OAuth) grants used by routines.
|
|
255
|
+
|
|
256
|
+
A prompt is not a limit. Every write such a caller can reach needs a daily cap
|
|
257
|
+
that the server counts; the OAuth default tool set is the role's whole set (a
|
|
258
|
+
narrower default needs a reason); an agent key cannot confirm previews.
|
|
259
|
+
"""
|
|
260
|
+
policy = registry.get("unattended")
|
|
261
|
+
oauth_servers = [name for name, server in servers.items() if server.get("auth") == "oauth"]
|
|
262
|
+
if policy is None:
|
|
263
|
+
if oauth_servers and any(risk_of(by_name[n]) in WRITE_RISKS for n in oauth_full_set(by_name, oauth_servers[0])):
|
|
264
|
+
warnings.append("a signed-in MCP server exposes writes but registry.unattended is missing; declare server-enforced daily caps for agent keys and OAuth grants")
|
|
265
|
+
return
|
|
266
|
+
if not isinstance(policy, dict):
|
|
267
|
+
errors.append("unattended must be an object"); return
|
|
268
|
+
if policy.get("enforcedBy") != "server":
|
|
269
|
+
errors.append("unattended.enforcedBy must be server: caps kept only in a routine's prompt are not limits")
|
|
270
|
+
keys = policy.get("agentKeys")
|
|
271
|
+
if keys is not None:
|
|
272
|
+
if not isinstance(keys, dict):
|
|
273
|
+
errors.append("unattended.agentKeys must be an object")
|
|
274
|
+
else:
|
|
275
|
+
if keys.get("canConfirmPreviews") is not False:
|
|
276
|
+
errors.append("unattended.agentKeys.canConfirmPreviews must be false: an agent key runs only the writes its standing permission covers")
|
|
277
|
+
if keys.get("refusalReturnsReason") is not True:
|
|
278
|
+
errors.append("unattended.agentKeys.refusalReturnsReason must be true: a refused write says why (e.g. today's limit is used) instead of returning a preview nobody can confirm")
|
|
279
|
+
standing = keys.get("standingTools") if isinstance(keys.get("standingTools"), dict) else {}
|
|
280
|
+
for name, cap in standing.items():
|
|
281
|
+
tool = by_name.get(name)
|
|
282
|
+
if tool is None:
|
|
283
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} is not in the registry"); continue
|
|
284
|
+
if risk_of(tool) not in ("write-reversible", "write-spends"):
|
|
285
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} is {risk_of(tool)}; standing permissions cover reversible or spending writes only, never public or irreversible ones")
|
|
286
|
+
if not isinstance(cap, dict) or not isinstance(cap.get("perDay"), int) or cap["perDay"] < 1:
|
|
287
|
+
errors.append(f"unattended.agentKeys.standingTools: {name} needs perDay, the daily cap the server counts")
|
|
288
|
+
oauth = policy.get("oauth")
|
|
289
|
+
if oauth is None:
|
|
290
|
+
if oauth_servers:
|
|
291
|
+
errors.append("unattended.oauth is required when a signed-in (oauth) MCP server is declared: a sign-in grant acts as the full person")
|
|
292
|
+
return
|
|
293
|
+
if not isinstance(oauth, dict):
|
|
294
|
+
errors.append("unattended.oauth must be an object"); return
|
|
295
|
+
server = oauth.get("server") or (oauth_servers[0] if oauth_servers else None)
|
|
296
|
+
if server and server not in servers:
|
|
297
|
+
errors.append(f"unattended.oauth.server {server!r} is not declared in registry.mcpServers")
|
|
298
|
+
full = oauth_full_set(by_name, server)
|
|
299
|
+
default = oauth.get("defaultTools", "role")
|
|
300
|
+
if default == "role":
|
|
301
|
+
reachable = full
|
|
302
|
+
elif isinstance(default, list):
|
|
303
|
+
unknown = sorted(set(map(str, default)) - full)
|
|
304
|
+
if unknown:
|
|
305
|
+
errors.append(f"unattended.oauth.defaultTools names tools the role can't reach on MCP: {', '.join(unknown)}")
|
|
306
|
+
reachable = set(map(str, default)) & full
|
|
307
|
+
left_out = sorted(full - reachable)
|
|
308
|
+
if left_out and not str(oauth.get("narrowedReason", "")).strip():
|
|
309
|
+
errors.append(f"unattended.oauth.defaultTools exposes {len(reachable)} of the role's {len(full)} tools (missing e.g. {', '.join(left_out[:5])}); a routine connected by sign-in can't reach the rest. Use \"role\" or give narrowedReason")
|
|
310
|
+
else:
|
|
311
|
+
errors.append("unattended.oauth.defaultTools must be \"role\" (the caller's role's full set) or a list of tools"); reachable = full
|
|
312
|
+
per_tool = oauth.get("perToolPerDay") if isinstance(oauth.get("perToolPerDay"), dict) else {}
|
|
313
|
+
total = oauth.get("dailyWriteLimit")
|
|
314
|
+
has_total = isinstance(total, int) and total >= 1
|
|
315
|
+
for name in sorted(reachable):
|
|
316
|
+
if risk_of(by_name[name]) not in WRITE_RISKS:
|
|
317
|
+
continue
|
|
318
|
+
cap = per_tool.get(name)
|
|
319
|
+
if not (isinstance(cap, int) and cap >= 1) and not has_total:
|
|
320
|
+
errors.append(f"unattended.oauth: {name} is a write reachable by a sign-in grant with no server cap; set perToolPerDay.{name} or dailyWriteLimit")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
REPORT_LIMITS = {"titleMaxChars": 120, "summaryMaxChars": 500, "bodyMaxChars": 50000}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def check_report_inbox(registry: dict, by_name: dict[str, dict], errors: list[str]) -> None:
|
|
327
|
+
"""One place where scheduled agents leave their results: save, list, get; capped; bodies untrusted."""
|
|
328
|
+
inbox = registry.get("reportInbox")
|
|
329
|
+
if inbox is None:
|
|
330
|
+
return
|
|
331
|
+
if not isinstance(inbox, dict):
|
|
332
|
+
errors.append("reportInbox must be an object"); return
|
|
333
|
+
names = inbox.get("tools") if isinstance(inbox.get("tools"), dict) else {}
|
|
334
|
+
expected = {"save": ("write-reversible",), "list": ("read",), "get": ("read",)}
|
|
335
|
+
for role, risks in expected.items():
|
|
336
|
+
name = str(names.get(role, ""))
|
|
337
|
+
tool = by_name.get(name)
|
|
338
|
+
if tool is None:
|
|
339
|
+
errors.append(f"reportInbox.tools.{role} {name or '<missing>'!r} is not in the registry"); continue
|
|
340
|
+
if risk_of(tool) not in risks:
|
|
341
|
+
errors.append(f"reportInbox.tools.{role} {name} must be {risks[0]} (saving a report changes nothing else)")
|
|
342
|
+
if tool.get("scope") != "workspace":
|
|
343
|
+
errors.append(f"reportInbox.tools.{role} {name}: reports belong to one website (scope workspace)")
|
|
344
|
+
if not isinstance(inbox.get("perSitePerDay"), int) or inbox["perSitePerDay"] < 1:
|
|
345
|
+
errors.append("reportInbox.perSitePerDay is required: the server caps agent reports per website per day")
|
|
346
|
+
for key, ceiling in REPORT_LIMITS.items():
|
|
347
|
+
value = inbox.get(key, ceiling)
|
|
348
|
+
if not isinstance(value, int) or value < 1:
|
|
349
|
+
errors.append(f"reportInbox.{key} must be a positive integer")
|
|
350
|
+
if not isinstance(inbox.get("retentionDays"), int) or inbox["retentionDays"] < 1:
|
|
351
|
+
errors.append("reportInbox.retentionDays is required")
|
|
352
|
+
untrusted = set(inbox.get("untrusted") or [])
|
|
353
|
+
missing = sorted({"title", "summary", "body"} - untrusted)
|
|
354
|
+
if missing:
|
|
355
|
+
errors.append(f"reportInbox.untrusted must include {', '.join(missing)}: an agent wrote them, so readers treat them as data, never instructions")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def list_check(registry: dict, tool_name: str, result: dict) -> dict:
|
|
359
|
+
"""Check a recorded list result: every item whose declared field was shortened says so."""
|
|
360
|
+
tool = next((t for t in registry.get("tools", []) if isinstance(t, dict) and t.get("name") == tool_name), None)
|
|
361
|
+
errors: list[str] = []
|
|
362
|
+
if tool is None or not isinstance(tool.get("clips"), dict):
|
|
363
|
+
return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": False, "errors": [f"{tool_name}: not in the registry or declares no clips"], "items": 0, "clipped": 0}
|
|
364
|
+
clips = tool["clips"]
|
|
365
|
+
id_field = str(clips.get("idField", "id"))
|
|
366
|
+
max_chars = int(clips.get("maxChars", 0) or 0)
|
|
367
|
+
items = result.get("items") if isinstance(result.get("items"), list) else result.get("result", {}).get("items", []) if isinstance(result.get("result"), dict) else []
|
|
368
|
+
clipped = 0
|
|
369
|
+
for index, item in enumerate(items):
|
|
370
|
+
if not isinstance(item, dict):
|
|
371
|
+
continue
|
|
372
|
+
where = f"item {index} ({item.get(id_field, 'no ' + id_field)})"
|
|
373
|
+
if item.get(id_field) in (None, ""):
|
|
374
|
+
errors.append(f"{where}: has no {id_field}, so the full text can't be fetched with {clips.get('getTool')}")
|
|
375
|
+
marker = item.get("clipped")
|
|
376
|
+
for field in clips.get("fields", []):
|
|
377
|
+
text = item.get(field)
|
|
378
|
+
if not isinstance(text, str):
|
|
379
|
+
continue
|
|
380
|
+
shortened = (max_chars and len(text) >= max_chars) or text.rstrip().endswith(("…", "..."))
|
|
381
|
+
flagged = marker is True or (isinstance(marker, list) and field in marker)
|
|
382
|
+
if shortened and not flagged:
|
|
383
|
+
errors.append(f"{where}: {field} looks shortened ({len(text)} characters) but the item does not say clipped")
|
|
384
|
+
if flagged:
|
|
385
|
+
clipped += 1
|
|
386
|
+
return {"schemaVersion": "maggie-assistant-list-check.v1", "passed": not errors, "errors": errors, "items": len(items), "clipped": clipped, "getTool": clips.get("getTool")}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def report_check(report: dict, inbox: dict | None = None) -> dict:
|
|
390
|
+
"""Check a report an agent is about to save (maggie-assistant-report.v1)."""
|
|
391
|
+
limits = {**REPORT_LIMITS, **{k: v for k, v in (inbox or {}).items() if k in REPORT_LIMITS and isinstance(v, int)}}
|
|
392
|
+
errors: list[str] = []
|
|
393
|
+
title = str(report.get("title", "")).strip()
|
|
394
|
+
summary = str(report.get("summary", "")).strip()
|
|
395
|
+
body = str(report.get("body_md", report.get("body", "")))
|
|
396
|
+
if not title:
|
|
397
|
+
errors.append("title is required")
|
|
398
|
+
elif len(title) > limits["titleMaxChars"]:
|
|
399
|
+
errors.append(f"title is over {limits['titleMaxChars']} characters")
|
|
400
|
+
if not summary:
|
|
401
|
+
errors.append("summary is required: one or two sentences shown in the inbox")
|
|
402
|
+
elif len(summary) > limits["summaryMaxChars"]:
|
|
403
|
+
errors.append(f"summary is over {limits['summaryMaxChars']} characters")
|
|
404
|
+
if len(body) > limits["bodyMaxChars"]:
|
|
405
|
+
errors.append(f"body is over {limits['bodyMaxChars']} characters")
|
|
406
|
+
if re.search(r"(?i)\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{8,}|\bBearer\s+[A-Za-z0-9._-]{20,}|-----BEGIN [A-Z ]*PRIVATE KEY", title + summary + body):
|
|
407
|
+
errors.append("the report contains something that looks like a secret; remove it before saving")
|
|
408
|
+
return {"schemaVersion": "maggie-assistant-report-check.v1", "passed": not errors, "errors": errors, "chars": {"title": len(title), "summary": len(summary), "body": len(body)}}
|
|
409
|
+
|
|
410
|
+
|
|
194
411
|
def annotations_for(tool: dict) -> dict:
|
|
195
412
|
risk = risk_of(tool)
|
|
196
413
|
title = str(tool.get("user", {}).get("label", "")).strip() or None
|
|
@@ -348,14 +565,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
348
565
|
page = sub.add_parser("capability-page"); page.add_argument("--registry", type=Path, required=True); page.add_argument("--title", default="What the assistant can and can't do"); page.add_argument("--output", type=Path, help="Markdown file; JSON is written next to it")
|
|
349
566
|
audit = sub.add_parser("audit-check"); audit.add_argument("--registry", type=Path, required=True); audit.add_argument("--session", type=Path, required=True, help="recorded calls + audit entries (maggie-assistant-session.v1)")
|
|
350
567
|
sub.add_parser("approval-selftest")
|
|
568
|
+
lc = sub.add_parser("list-check"); lc.add_argument("--registry", type=Path, required=True); lc.add_argument("--tool", required=True); lc.add_argument("--result", type=Path, required=True, help="a recorded result of the list tool")
|
|
569
|
+
rc = sub.add_parser("report-check"); rc.add_argument("--report", type=Path, required=True, help="the report about to be saved (title, summary, body_md)"); rc.add_argument("--registry", type=Path, help="use this registry's reportInbox limits")
|
|
351
570
|
args = parser.parse_args(argv)
|
|
352
571
|
try:
|
|
353
572
|
if args.command == "approval-selftest":
|
|
354
573
|
report = approval_selftest()
|
|
574
|
+
elif args.command == "report-check":
|
|
575
|
+
inbox = _read(args.registry).get("reportInbox") if args.registry else None
|
|
576
|
+
report = report_check(_read(args.report), inbox if isinstance(inbox, dict) else None)
|
|
355
577
|
else:
|
|
356
578
|
registry = _read(args.registry)
|
|
357
579
|
if args.command == "registry-validate":
|
|
358
580
|
report = validate_registry(registry)
|
|
581
|
+
elif args.command == "list-check":
|
|
582
|
+
report = list_check(registry, args.tool, _read(args.result))
|
|
359
583
|
elif args.command == "annotations":
|
|
360
584
|
report = derive_annotations(registry, _read(args.mcp_tools) if args.mcp_tools else None)
|
|
361
585
|
elif args.command == "capability-page":
|