cof-cli 1.0.0__py3-none-any.whl

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.
cof_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """cof-cli — reference tooling for COF (Character Open File)."""
2
+
3
+ __version__ = "1.0.0"
4
+ MIMETYPE = "application/vnd.cof.character+zip"
5
+ SPEC_VERSION = "1.0"
cof_cli/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ """`cof` command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .completeness import compute as compute_completeness
12
+ from .container import make_thumbnail, refresh_asset_index, ContainerError, pack, read_manifest, unpack
13
+ from .convert import card_to_cof_dir, export_soul, read_card_from_png, write_card_to_png
14
+ from .migrate import migrate_any
15
+ from .ops import extract_preset, merge_dirs, extract_tag
16
+ from .model import asset_index, default_preset_id, first, preset_age, resolved
17
+ from .validate import Report, validate_cof, validate_dir
18
+
19
+
20
+ def _print_report(rep: Report, as_json: bool) -> int:
21
+ if as_json:
22
+ print(json.dumps(rep.to_dict(), ensure_ascii=False, indent=2))
23
+ else:
24
+ for e in rep.errors:
25
+ print(f"ERROR {e}")
26
+ for w in rep.warnings:
27
+ print(f"WARN {w}")
28
+ c = rep.info.get("completeness")
29
+ if c:
30
+ print(f"completeness: {c['score']}/100 (level {c['level']}) — " + ", ".join(f"{k} {v}" for k, v in c["layers"].items()))
31
+ print("ready for: " + (", ".join(c["targets_ready"]) or "—"))
32
+ cov = rep.info.get("coverage")
33
+ if cov:
34
+ print(f"coverage: {', '.join(cov['elements']) or '—'} ({cov['variants']} variant(s), {cov['presets']} preset(s))")
35
+ if rep.info.get("presets") and len(rep.info["presets"]) > 1:
36
+ print("presets: " + ", ".join(f"{k} {v}" for k, v in rep.info["presets"].items()))
37
+ t = rep.info.get("tokens")
38
+ if t and "permanent" in t:
39
+ print(f"tokens: permanent {t['permanent']}" + (f", lore {t['lore']}" if "lore" in t else "") + f" ({t['tokenizer']})")
40
+ print("OK" if rep.ok else f"NOT CONFORMANT ({len(rep.errors)} error(s))")
41
+ return 0 if rep.ok else 1
42
+
43
+
44
+ def cmd_validate(a):
45
+ p = Path(a.path)
46
+ if p.is_dir():
47
+ rep = Report()
48
+ validate_dir(p, rep, recompute=False)
49
+ else:
50
+ rep = validate_cof(p)
51
+ return _print_report(rep, a.json)
52
+
53
+
54
+ def cmd_pack(a):
55
+ src = Path(a.dir)
56
+ # hashes first: missing sha256/bytes are schema errors that would otherwise skip the recompute of
57
+ # completeness / coverage / languages and leave stale figures in the packed manifest
58
+ mpath = src / "manifest.json"
59
+ try:
60
+ m0 = json.loads(mpath.read_text("utf-8"))
61
+ refresh_asset_index(src, m0, strict=False)
62
+ mpath.write_text(json.dumps(m0, ensure_ascii=False, indent=2) + "\n", "utf-8")
63
+ except Exception:
64
+ pass
65
+ rep = Report()
66
+ manifest = validate_dir(src, rep, recompute=True)
67
+ if rep.errors and not a.force:
68
+ _print_report(rep, a.json)
69
+ print("Folder not conformant: fix it or use --force.", file=sys.stderr)
70
+ return 1
71
+ if manifest:
72
+ make_thumbnail(src, manifest)
73
+ from datetime import datetime, timezone
74
+ manifest.setdefault("provenance", {})["modified"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
75
+ (src / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", "utf-8")
76
+ out = Path(a.out) if a.out else src.with_suffix(".cof")
77
+ try:
78
+ pack(src, out)
79
+ except ContainerError as e:
80
+ print(f"ERROR {e}", file=sys.stderr)
81
+ return 1
82
+ print(f"written: {out} ({out.stat().st_size/1e6:.2f} MB)")
83
+ return 0
84
+
85
+
86
+ def cmd_unpack(a):
87
+ out = Path(a.out) if a.out else Path(a.cof).with_suffix("")
88
+ unpack(a.cof, out)
89
+ print(f"extracted to: {out}")
90
+ return 0
91
+
92
+
93
+ def cmd_info(a):
94
+ m = read_manifest(a.cof)
95
+ c = m.get("completeness") or compute_completeness(m)
96
+ print(json.dumps({"name": m.get("name"), "id": m.get("id"), "cof": m.get("cof"), "age": {p: preset_age(m, p) for p in (m.get("presets") or {})},
97
+ "fictional": m.get("fictional"), "size_class": m.get("size_class"), "thumbnail": m.get("thumbnail"),
98
+ "completeness": c["file"] if isinstance(c, dict) and "file" in c else c, "coverage": m.get("coverage"),
99
+ "presets": {k: {kk: vv for kk, vv in {"label": v.get("label"), "identity": v.get("identity"), "extends": v.get("extends"), "slots": v.get("slots"),
100
+ "alternatives": v.get("alternatives"), "primary_image": v.get("primary_image"),
101
+ "tags": [t.get("id") for t in m.get("preset_tags") or [] if k in (t.get("presets") or [])],
102
+ "score": (v.get("completeness") or {}).get("score")}.items() if vv} for k, v in (m.get("presets") or {}).items()},
103
+ "default_preset": m.get("default_preset"),
104
+ "variants": {cat: {vid: " · ".join(str(x) for x in (v.get("kind"), v.get("label"), v.get("language"), v.get("identity")) if x)
105
+ for vid, v in (e.get("variants") or {}).items()} for cat, e in (m.get("elements") or {}).items()},
106
+ "preset_tags": [{"id": t.get("id"), "facet": t.get("facet"), "presets": t.get("presets")} for t in m.get("preset_tags") or []],
107
+ "characters": list((m.get("identities") or {}).keys()) or None,
108
+ "tags": m.get("tags"), "assets": len(m.get("assets", []))},
109
+ ensure_ascii=False, indent=2))
110
+ return 0
111
+
112
+
113
+ def cmd_tokens(a):
114
+ p = Path(a.path)
115
+ rep = Report()
116
+ if p.is_dir():
117
+ validate_dir(p, rep, recompute=False)
118
+ else:
119
+ with tempfile.TemporaryDirectory() as td:
120
+ validate_dir(unpack(p, td), rep, recompute=False)
121
+ print(json.dumps(rep.info.get("tokens", {}), ensure_ascii=False, indent=2))
122
+ return 0
123
+
124
+
125
+ def cmd_embed_text(a):
126
+ from .convert import write_text_to_png
127
+ png = Path(a.png).read_bytes(); text = Path(a.text).read_text("utf-8")
128
+ out = Path(a.out) if a.out else Path(a.png).with_name(Path(a.png).stem + ".cof.png")
129
+ out.write_bytes(write_text_to_png(png, text)); print(f"{out} — {len(text)} characters in iTXt Description")
130
+
131
+
132
+ def cmd_read_text(a):
133
+ from .convert import read_text_from_image
134
+ r = read_text_from_image(Path(a.image).read_bytes())
135
+ if not r:
136
+ print("no embedded descriptive text"); return 1
137
+ print(f"[{r['source']}] {len(r['text'])} characters\n"); print(r["text"])
138
+
139
+
140
+ def cmd_import(a):
141
+ src = Path(a.src)
142
+ if src.suffix.lower() == ".png":
143
+ png = src.read_bytes()
144
+ card = read_card_from_png(png)
145
+ elif src.suffix.lower() == ".json":
146
+ card = json.loads(src.read_text("utf-8"))
147
+ png = None
148
+ if "spec" not in card:
149
+ card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": card}
150
+ else:
151
+ print("expected source: .png (card) or .json (card)", file=sys.stderr)
152
+ return 1
153
+ dest = Path(a.out) if a.out else Path(src.stem)
154
+ card_to_cof_dir(card, dest, png=png if a.keep_png else None, age=a.age, fictional=not a.real, language=a.lang)
155
+ rep = Report()
156
+ validate_dir(dest, rep, recompute=True)
157
+ print(f"COF folder created: {dest}")
158
+ return _print_report(rep, a.json)
159
+
160
+
161
+ def cmd_export(a):
162
+ src = Path(a.src)
163
+ with tempfile.TemporaryDirectory() as td:
164
+ d = src if src.is_dir() else unpack(src, td)
165
+ manifest = json.loads((d / "manifest.json").read_text("utf-8"))
166
+ fmt = a.format
167
+ out = Path(a.out) if a.out else None
168
+ pid = a.preset or default_preset_id(manifest)
169
+ r = resolved(manifest, pid)
170
+ A = asset_index(manifest)
171
+ card_path = (first(r, "personality") or {}).get("card")
172
+ if fmt in ("png", "card") and not card_path:
173
+ print("this preset has no personality variant (card.json)", file=sys.stderr)
174
+ return 1
175
+ if fmt == "png":
176
+ card = json.loads((d / card_path).read_text("utf-8"))
177
+ head = next((A[i]["path"] for i in (first(r, "appearance", "face") or {}).get("images", []) if i in A and A[i].get("subject") == "head" and A[i].get("angle") == "front"), None)
178
+ if head and head.lower().endswith(".png"):
179
+ png = (d / head).read_bytes()
180
+ else:
181
+ png = _placeholder_png(d / head if head else None)
182
+ out = out or Path(f"{manifest['name']}.png")
183
+ out.write_bytes(write_card_to_png(png, card))
184
+ elif fmt == "soul":
185
+ soul, identity = export_soul(d, pid)
186
+ out = out or Path(".")
187
+ out.mkdir(parents=True, exist_ok=True)
188
+ (out / "SOUL.md").write_text(soul, "utf-8")
189
+ (out / "IDENTITY.md").write_text(identity, "utf-8")
190
+ elif fmt == "agent":
191
+ from .convert import export_agent
192
+ out = out or Path(f"{manifest['name']}-agent")
193
+ done = export_agent(d, out, pid)
194
+ print(f"skills: {', '.join(done['skills']) or '—'} · memories: {len(done['memories'])} · documents: {len(done['documents'])}")
195
+ elif fmt == "card":
196
+ card = json.loads((d / card_path).read_text("utf-8"))
197
+ out = out or Path(f"{manifest['name']}.card.json")
198
+ out.write_text(json.dumps(card, ensure_ascii=False, indent=2), "utf-8")
199
+ else:
200
+ print(f"unsupported format: {fmt}", file=sys.stderr)
201
+ return 1
202
+ print(f"exported: {out}")
203
+ return 0
204
+
205
+
206
+ def cmd_merge(a):
207
+ dirs = []
208
+ for src in a.sources:
209
+ p = Path(src)
210
+ if p.is_file():
211
+ td = Path(tempfile.mkdtemp()); unpack(p, td); dirs.append(td)
212
+ else:
213
+ dirs.append(p)
214
+ merge_dirs(dirs, Path(a.out))
215
+ rep = Report(); validate_dir(Path(a.out), rep, recompute=True)
216
+ Path(a.out, "manifest.json").write_text(json.dumps(json.loads(Path(a.out, "manifest.json").read_text("utf-8")) | {}, ensure_ascii=False, indent=2) + "\n", "utf-8")
217
+ print(f"merged into: {a.out}")
218
+ return _print_report(rep, a.json)
219
+
220
+
221
+ def cmd_extract(a):
222
+ src = Path(a.src)
223
+ with tempfile.TemporaryDirectory() as td:
224
+ d = src if src.is_dir() else unpack(src, td)
225
+ if a.tag:
226
+ extract_tag(d, Path(a.out), a.tag)
227
+ elif getattr(a, "identity", None):
228
+ from .ops import extract_identity
229
+ extract_identity(d, Path(a.out), a.identity)
230
+ else:
231
+ extract_preset(d, Path(a.out), a.preset)
232
+ if getattr(a, "no_intimate", False):
233
+ from .ops import strip_intimate
234
+ gone = strip_intimate(Path(a.out))
235
+ print(f"--no-intimate: removed {', '.join(gone) or 'nothing'}; allowSexualUsage set to false — review tags.content")
236
+ rep = Report(); validate_dir(Path(a.out), rep, recompute=True)
237
+ what = 'tags ' + '+'.join(a.tag) if a.tag else ('character ' + a.identity if getattr(a, "identity", None) else 'preset ' + a.preset)
238
+ print(f"{what} extracted to: {a.out}")
239
+ return _print_report(rep, a.json)
240
+
241
+
242
+ def cmd_migrate(a):
243
+ m = migrate_any(Path(a.dir))
244
+ rep = Report()
245
+ validate_dir(Path(a.dir), rep, recompute=True)
246
+ Path(a.dir, "manifest.json").write_text(json.dumps(m | {k: v for k, v in json.loads(Path(a.dir, "manifest.json").read_text("utf-8")).items()}, ensure_ascii=False, indent=2) + "\n", "utf-8")
247
+ print(f"migrated to v{m.get('cof')}: {a.dir}")
248
+ return _print_report(rep, a.json)
249
+
250
+
251
+ def _placeholder_png(image_path: Path | None) -> bytes:
252
+ """1024² PNG: converted from the given image if Pillow is available, else a flat grey PNG."""
253
+ try:
254
+ from PIL import Image
255
+ import io
256
+ if image_path and image_path.is_file():
257
+ im = Image.open(image_path).convert("RGB")
258
+ else:
259
+ im = Image.new("RGB", (1024, 1024), (64, 64, 72))
260
+ im = im.resize((1024, 1024))
261
+ buf = io.BytesIO()
262
+ im.save(buf, "PNG")
263
+ return buf.getvalue()
264
+ except Exception:
265
+ import zlib, struct
266
+ from .convert import png_build
267
+ w = h = 64
268
+ raw = b"".join(b"\x00" + bytes([64, 64, 72]) * w for _ in range(h))
269
+ ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
270
+ return png_build([(b"IHDR", ihdr), (b"IDAT", zlib.compress(raw)), (b"IEND", b"")])
271
+
272
+
273
+ def main(argv=None) -> int:
274
+ for stream in (sys.stdout, sys.stderr): # Windows consoles default to cp1252: never crash on « — ✔ é »
275
+ try:
276
+ stream.reconfigure(encoding="utf-8", errors="replace")
277
+ except Exception:
278
+ pass
279
+ ap = argparse.ArgumentParser(prog="cof", description="COF — Character Open File tooling")
280
+ ap.add_argument("--version", action="version", version=f"cof-cli {__version__}")
281
+ sub = ap.add_subparsers(dest="cmd", required=True)
282
+
283
+ s = sub.add_parser("validate", help="validate a .cof or a character folder"); s.add_argument("path"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_validate)
284
+ s = sub.add_parser("pack", help="folder → .cof (recomputes hashes and completeness)"); s.add_argument("dir"); s.add_argument("-o", "--out"); s.add_argument("--force", action="store_true"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_pack)
285
+ s = sub.add_parser("unpack", help=".cof → folder"); s.add_argument("cof"); s.add_argument("-o", "--out"); s.set_defaults(fn=cmd_unpack)
286
+ s = sub.add_parser("info", help="summary of a .cof"); s.add_argument("cof"); s.set_defaults(fn=cmd_info)
287
+ s = sub.add_parser("tokens", help="token estimate per block"); s.add_argument("path"); s.set_defaults(fn=cmd_tokens)
288
+ s = sub.add_parser("embed-text", help="write a text (.md/.txt) into a PNG as iTXt 'Description' — readable by the builder (personality + appearance)"); s.add_argument("png"); s.add_argument("text"); s.add_argument("-o", "--out"); s.set_defaults(fn=cmd_embed_text)
289
+ s = sub.add_parser("read-text", help="show the descriptive text embedded in an image (PNG/JPEG: iTXt, XMP, EXIF)"); s.add_argument("image"); s.set_defaults(fn=cmd_read_text)
290
+ s = sub.add_parser("import", help="PNG/JSON card (CCv2/V3) → COF folder"); s.add_argument("src"); s.add_argument("-o", "--out"); s.add_argument("--age", type=int); s.add_argument("--real", action="store_true", help="character based on a real person (consent required)"); s.add_argument("--lang", default="en"); s.add_argument("--keep-png", action="store_true", help="keep the PNG as the head.front view"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_import)
291
+ s = sub.add_parser("export", help="folder/.cof → png | soul | card | agent (workspace: SOUL.md, IDENTITY.md, skills/, memories) — from the default preset or --preset"); s.add_argument("src"); s.add_argument("--format", choices=["png", "soul", "card", "agent"], required=True); s.add_argument("-o", "--out"); s.add_argument("--preset"); s.set_defaults(fn=cmd_export)
292
+ s = sub.add_parser("migrate", help="folder v0.2 … v0.5 → v1.0"); s.add_argument("dir"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_migrate)
293
+ s = sub.add_parser("merge", help="merge several .cof files/folders (same name, nickname, fictional status and morphology class ⇒ same identity, otherwise multiple identities)"); s.add_argument("sources", nargs="+"); s.add_argument("-o", "--out", required=True); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_merge)
294
+ s = sub.add_parser("extract", help="extract a preset, a character (--identity), or the presets carrying one or more tags (intersection), into a lighter COF folder"); s.add_argument("src"); g = s.add_mutually_exclusive_group(required=True); g.add_argument("--preset"); g.add_argument("--identity", help="one character: all its presets, elements, files and rights"); g.add_argument("--tag", action="append", help="repeatable: --tag english --tag age-12"); s.add_argument("-o", "--out", required=True); s.add_argument("--json", action="store_true"); s.add_argument("--no-intimate", action="store_true", help="safe-for-work slice: drop intimate variants, allowSexualUsage = false"); s.set_defaults(fn=cmd_extract)
295
+
296
+ a = ap.parse_args(argv)
297
+ try:
298
+ return a.fn(a)
299
+ except (ContainerError, ValueError) as e:
300
+ print(f"ERROR {e}", file=sys.stderr)
301
+ return 1
302
+
303
+
304
+ if __name__ == "__main__":
305
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ """Completeness KPI (v0.5) — per preset, from categories/kinds. Derived assets never count."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from .model import asset_index, by_kind, default_preset_id, first, preset_identity, preset_ids, resolved
8
+
9
+ WEIGHTS = {"identity": 0.10, "personality": 0.20, "appearance": 0.20, "physique": 0.05,
10
+ "voice": 0.15, "volume": 0.10, "motion": 0.05, "rights": 0.15}
11
+
12
+
13
+ def _load(src: Path | None, rel):
14
+ try:
15
+ return json.loads((src / rel).read_text("utf-8")) if src and rel and (src / rel).is_file() else None
16
+ except Exception:
17
+ return None
18
+
19
+
20
+ def compute_preset(manifest: dict, pid: str | None, src: Path | None = None) -> dict:
21
+ m = manifest
22
+ r = resolved(m, pid)
23
+ A = asset_index(m)
24
+ ident = preset_identity(m, pid)
25
+ L: dict[str, int] = {}
26
+ imgs = lambda v: [A[i] for i in (v or {}).get("images", []) if i in A]
27
+
28
+ from .model import preset_age
29
+ s = 20 * bool(ident.get("name")) + 20 * (preset_age(m, pid) is not None) + 20 * bool(ident.get("summary"))
30
+ s += 10 * bool(m.get("languages")) + 10 * bool(ident.get("morphology"))
31
+ tags = m.get("tags", {}) or {}
32
+ s += 10 * bool(tags.get("content")) + 10 * isinstance(tags.get("ip"), dict)
33
+ L["identity"] = min(100, s)
34
+
35
+ p = first(r, "personality")
36
+ s = 0
37
+ if p and p.get("kind") == "card" and p.get("card"):
38
+ s += 30
39
+ card = _load(src, p["card"]); d = (card or {}).get("data", {})
40
+ if card is None and src is None:
41
+ s += 25
42
+ s += 10 * bool(d.get("description")) + 10 * bool(d.get("personality")) + 5 * bool(d.get("scenario")) + 10 * bool(d.get("first_mes")) + 10 * bool(d.get("mes_example"))
43
+ s += 10 * bool(p.get("story") or p.get("lorebook") or d.get("character_book"))
44
+ elif p and p.get("kind") == "description":
45
+ n = len(p.get("text") or "")
46
+ s += 35 + 15 * (n >= 400) + 10 * (n >= 1500) # a rich free-text sheet is usable for chat (≥ 50)
47
+ s += 15 * bool(p and p.get("psyche"))
48
+ L["personality"] = min(100, s)
49
+
50
+ f = first(r, "appearance", "face"); fi = imgs(f)
51
+ s = 25 * any(a.get("subject") == "head" and a.get("angle") == "front" for a in fi) + 10 * (len(fi) >= 2)
52
+ s += 10 * bool(f and (f.get("params") or f.get("mesh")))
53
+ s += 5 * bool(first(r, "volume3d", "face-mesh"))
54
+ s += 5 * bool(first(r, "appearance", "hair"))
55
+ b = first(r, "appearance", "body"); bi = imgs(b)
56
+ s += 10 * bool(bi) + 5 * (len(bi) >= 2) + 5 * bool(b and b.get("params"))
57
+ from .model import preset_primary_image
58
+ pim = A.get(preset_primary_image(m, pid) or "") or {}
59
+ outfit_seen = pim.get("subject") == "body" or pim.get("framing") == "full"
60
+ s += 10 * bool(by_kind(r, "appearance", "clothing") or outfit_seen) # outfit applied, or visible on the primary image
61
+ cs = first(r, "appearance", "character-sheet") or first(r, "appearance", "reference-set")
62
+ if not fi and cs and imgs(cs):
63
+ s += 20 # a sheet / reference set gives a usable look even without split views
64
+ s += 15 * bool(r.get("identity_weights"))
65
+ L["appearance"] = min(100, s)
66
+
67
+ po = r.get("posture", [])
68
+ s = 60 * bool(po) + 40 * any(v.get("kind") in ("openpose", "attitude", "photo", "photos") for v in po)
69
+ L["physique"] = min(100, s)
70
+
71
+ v = first(r, "voice"); samples = [A[i] for i in (v or {}).get("samples", []) if i in A]
72
+ s = 0
73
+ if samples:
74
+ s += 30 + 10 * all(a.get("transcript") for a in samples) # transcript optional: bonus
75
+ d = sorted(a.get("duration_s", 0) for a in samples)
76
+ s += 10 * (d[0] <= 12 and d[-1] >= 25)
77
+ s += 25 * bool(v and (v.get("profile") or v.get("kind") == "described")) + 20 * bool(v and v.get("vec")) + 5 * bool(v and v.get("engines"))
78
+ L["voice"] = min(100, s)
79
+
80
+ mesh = first(r, "volume3d", "mesh")
81
+ s = 60 * bool(mesh and mesh.get("path")) + 10 * bool(mesh and mesh.get("rig")) + 15 * bool(first(r, "volume3d", "point-cloud")) + 15 * bool(first(r, "volume3d", "print"))
82
+ L["volume"] = min(100, s)
83
+
84
+ mo = r.get("motion", [])
85
+ clips = [A[i] for v in mo for i in (v.get("clips") or []) if i in A]
86
+ vids = [A[i] for v in mo for i in (v.get("videos") or []) if i in A]
87
+ s = 60 * bool(clips or vids) + 25 * any(v.get("visemes") for v in mo) + 15 * (bool(clips) and all(a.get("rig") for a in clips))
88
+ s += 30 * any(v.get("kind") == "attitude" for v in mo) # movement signature (attitude.json) allowed here
89
+ L["motion"] = min(100, s)
90
+
91
+ rg = m.get("rights", {}) or {}
92
+ assets = m.get("assets", [])
93
+ s = 40 * bool(rg.get("permissions")) + 15 * (not assets or all(a.get("license") for a in assets))
94
+ needs_consent = (ident.get("fictional") is False) or any(a.get("role") == "voice-consent" for a in assets)
95
+ s += 20 * (bool(rg.get("consent") or ident.get("consent")) if needs_consent else True)
96
+ prov = m.get("provenance", {}) or {}
97
+ s += 10 * bool(prov.get("source")) + 15 * bool(prov.get("c2pa") or prov.get("signatures"))
98
+ L["rights"] = min(100, s)
99
+
100
+ score = round(sum(L[k] * w for k, w in WEIGHTS.items()))
101
+ level = "A" if score >= 85 else "B" if score >= 60 else "C" if score >= 35 else "D"
102
+ t = []
103
+ if L["personality"] >= 50:
104
+ t += ["chat-text", "agent-persona"]
105
+ if any(v.get("kind") in ("skill", "memory") for v in r.get("files", [])):
106
+ t.append("agent-runtime") # embedded skills / memories: ready for an agent (SOUL.md + skills/ + MEMORY.md)
107
+ if L["personality"] >= 50 and L["voice"] >= 40:
108
+ t.append("chat-voice")
109
+ if L["appearance"] >= 25:
110
+ t.append("image-video-gen")
111
+ if L["voice"] >= 40 and L["appearance"] >= 40 and (f and (f.get("mesh") or f.get("params")) or mesh):
112
+ t.append("video-realtime")
113
+ if mesh and mesh.get("path"):
114
+ t.append("game-3d")
115
+ if first(r, "volume3d", "print"):
116
+ t.append("print-3d")
117
+ if L["rights"] >= 60:
118
+ t.append("archive")
119
+ return {"score": score, "level": level, "layers": L, "targets_ready": t}
120
+
121
+
122
+ def compute(manifest: dict, src: Path | None = None) -> dict:
123
+ pids = preset_ids(manifest)
124
+ per = {pid: compute_preset(manifest, pid, src) for pid in pids}
125
+ dp = default_preset_id(manifest)
126
+ file_level = per[dp] if dp in per else compute_preset(manifest, None, src)
127
+ elements = manifest.get("elements", {}) or {}
128
+ coverage = {"elements": [e for e, v in elements.items() if (v or {}).get("variants")],
129
+ "variants": sum(len((v or {}).get("variants", {})) for v in elements.values()),
130
+ "presets": len(pids), "identities": len(manifest.get("identities") or {}) or 1}
131
+ return {"file": file_level, "presets": per, "coverage": coverage}