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/container.py ADDED
@@ -0,0 +1,230 @@
1
+ """ZIP container: pack / unpack / read, with the COF rules (sentinel, paths, storage)."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import re
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ from . import MIMETYPE
12
+
13
+ SAFE_PATH = re.compile(r"^(?!/)(?!.*\.\.)[A-Za-z0-9._/-]+$")
14
+
15
+ # High-entropy media that gains nothing from deflate → stored (ZIP_STORED)
16
+ STORED_EXT = {
17
+ ".jpg", ".jpeg", ".webp", ".avif", ".png", ".gif",
18
+ ".opus", ".ogg", ".mp3", ".flac", ".m4a",
19
+ ".mp4", ".webm", ".mkv",
20
+ ".glb", ".vrm", ".spz", ".ply", ".3mf", ".safetensors", ".onnx", ".pt", ".npz", ".zip",
21
+ }
22
+
23
+ MAX_RATIO = 100 # anti zip-bomb: uncompressed / compressed
24
+ MAX_UNCOMPRESSED = 4 * 1024 ** 3 # 4 GiB default ceiling
25
+
26
+
27
+ class ContainerError(Exception):
28
+ pass
29
+
30
+
31
+ THUMBNAIL_PATH = "Thumbnails/thumbnail.png" # reserved: ≤ 256 px PNG for file managers (ODF convention)
32
+
33
+
34
+ def make_thumbnail(src: Path, manifest: dict, size: int = 256) -> str | None:
35
+ """Derive Thumbnails/thumbnail.png from manifest['thumbnail'] (an image asset). Returns the path or None."""
36
+ rel = manifest.get("thumbnail")
37
+ if not rel or rel == THUMBNAIL_PATH or not (src / rel).is_file():
38
+ return None
39
+ try:
40
+ from PIL import Image
41
+ except ImportError:
42
+ return None
43
+ try:
44
+ im = Image.open(src / rel)
45
+ im.thumbnail((size, size))
46
+ if im.mode not in ("RGB", "RGBA"):
47
+ im = im.convert("RGBA")
48
+ out = src / THUMBNAIL_PATH
49
+ out.parent.mkdir(parents=True, exist_ok=True)
50
+ im.save(out, "PNG", optimize=True)
51
+ except Exception:
52
+ return None
53
+ srca = next((a for a in manifest.get("assets", []) if a.get("path") == rel), {})
54
+ manifest["assets"] = [a for a in manifest.get("assets", []) if a.get("path") != THUMBNAIL_PATH]
55
+ manifest["assets"].append({"id": "thumbnail", "path": THUMBNAIL_PATH, "role": "derived", "method": "thumbnail",
56
+ "source": srca.get("id"), "mediaType": "image/png", "bytes": out.stat().st_size,
57
+ "sha256": sha256_file(out), "license": srca.get("license", "LicenseRef-Unknown")})
58
+ if not manifest["assets"][-1]["source"]:
59
+ manifest["assets"][-1].pop("source")
60
+ manifest["thumbnail"] = THUMBNAIL_PATH
61
+ return THUMBNAIL_PATH
62
+
63
+
64
+ def sha256_file(path: Path) -> str:
65
+ h = hashlib.sha256()
66
+ with open(path, "rb") as f:
67
+ for chunk in iter(lambda: f.read(1 << 20), b""):
68
+ h.update(chunk)
69
+ return h.hexdigest()
70
+
71
+
72
+ def check_path(rel: str) -> None:
73
+ if not SAFE_PATH.match(rel):
74
+ raise ContainerError(f"non-conformant path (ASCII, no '..', no leading '/'): {rel!r}")
75
+ if rel in ("mimetype",):
76
+ raise ContainerError("'mimetype' is reserved for the packer")
77
+
78
+
79
+ def iter_source_files(src: Path):
80
+ for root, dirs, files in os.walk(src):
81
+ dirs.sort()
82
+ for name in sorted(files):
83
+ p = Path(root) / name
84
+ if p.is_symlink():
85
+ raise ContainerError(f"symbolic link forbidden: {p}")
86
+ rel = p.relative_to(src).as_posix()
87
+ if rel == "mimetype":
88
+ continue
89
+ yield rel, p
90
+
91
+
92
+ def refresh_asset_index(src: Path, manifest: dict, strict: bool = True) -> list[str]:
93
+ """Fill bytes/sha256 for every asset entry that points to a packed path. Returns notes."""
94
+ notes = []
95
+ for a in manifest.get("assets", []):
96
+ rel = a.get("path")
97
+ if not rel:
98
+ continue
99
+ p = src / rel
100
+ if not p.is_file():
101
+ if a.get("uri"):
102
+ notes.append(f"external asset without local copy: {rel}")
103
+ continue
104
+ msg = f"asset declared but missing: {rel}"
105
+ if strict:
106
+ raise ContainerError(msg)
107
+ notes.append(msg)
108
+ continue
109
+ a["bytes"] = p.stat().st_size
110
+ a["sha256"] = sha256_file(p)
111
+ return notes
112
+
113
+
114
+ def pack(src: str | os.PathLike, dest: str | os.PathLike, *, refresh: bool = True, aligned: int = 0) -> Path:
115
+ src, dest = Path(src), Path(dest)
116
+ mpath = src / "manifest.json"
117
+ if not mpath.is_file():
118
+ raise ContainerError("manifest.json not found at the root of the folder")
119
+ manifest = json.loads(mpath.read_text("utf-8"))
120
+ if refresh:
121
+ refresh_asset_index(src, manifest)
122
+ mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", "utf-8")
123
+
124
+ with zipfile.ZipFile(dest, "w", allowZip64=True) as z:
125
+ # 1) sentinel, stored, first
126
+ zi = zipfile.ZipInfo("mimetype", date_time=(1980, 1, 1, 0, 0, 0))
127
+ zi.compress_type = zipfile.ZIP_STORED
128
+ z.writestr(zi, MIMETYPE)
129
+ # 2) manifest second
130
+ z.write(mpath, "manifest.json", compress_type=zipfile.ZIP_DEFLATED)
131
+ # 3) OS thumbnail third (reserved path, stored) so file-manager thumbnailers find it at once
132
+ tp = src / THUMBNAIL_PATH
133
+ if tp.is_file():
134
+ z.write(tp, THUMBNAIL_PATH, compress_type=zipfile.ZIP_STORED)
135
+ # 4) everything else, deterministic order
136
+ for rel, p in iter_source_files(src):
137
+ if rel in ("manifest.json", THUMBNAIL_PATH):
138
+ continue
139
+ check_path(rel)
140
+ ct = zipfile.ZIP_STORED if p.suffix.lower() in STORED_EXT else zipfile.ZIP_DEFLATED
141
+ z.write(p, rel, compress_type=ct)
142
+ return dest
143
+
144
+
145
+ def read_manifest(cof: str | os.PathLike) -> dict:
146
+ with zipfile.ZipFile(cof) as z:
147
+ names = z.namelist()
148
+ if not names or names[0] != "mimetype":
149
+ raise ContainerError("the first entry must be 'mimetype'")
150
+ info = z.getinfo("mimetype")
151
+ if info.compress_type != zipfile.ZIP_STORED:
152
+ raise ContainerError("'mimetype' must be stored uncompressed")
153
+ if z.read("mimetype").decode("ascii", "replace").strip() != MIMETYPE:
154
+ raise ContainerError("unexpected 'mimetype' content")
155
+ if "manifest.json" not in names:
156
+ raise ContainerError("manifest.json missing")
157
+ return json.loads(z.read("manifest.json").decode("utf-8"))
158
+
159
+
160
+ def inspect(cof: str | os.PathLike) -> dict:
161
+ """Structural checks on the archive itself (no schema): returns {'errors': [...], 'warnings': [...], 'entries': n}."""
162
+ errors, warnings = [], []
163
+ with zipfile.ZipFile(cof) as z:
164
+ infos = z.infolist()
165
+ names = [i.filename for i in infos]
166
+ if not names or names[0] != "mimetype":
167
+ errors.append("first entry ≠ 'mimetype'")
168
+ elif infos[0].compress_type != zipfile.ZIP_STORED:
169
+ errors.append("'mimetype' compressed")
170
+ if len(names) > 1 and names[1] != "manifest.json":
171
+ warnings.append("manifest.json is not the second entry (slower partial reads)")
172
+ total_u = total_c = 0
173
+ for i in infos:
174
+ n = i.filename
175
+ if n != "mimetype" and not SAFE_PATH.match(n):
176
+ errors.append(f"non-conformant path: {n}")
177
+ if n.endswith("/"):
178
+ warnings.append(f"unnecessary directory entry: {n}")
179
+ if i.flag_bits & 0x1:
180
+ errors.append(f"encrypted entry forbidden: {n}")
181
+ if i.compress_size and i.file_size / max(i.compress_size, 1) > MAX_RATIO:
182
+ errors.append(f"suspicious compression ratio (> {MAX_RATIO}:1): {n}")
183
+ total_u += i.file_size
184
+ total_c += i.compress_size
185
+ ext = Path(n).suffix.lower()
186
+ if ext in STORED_EXT and i.compress_type != zipfile.ZIP_STORED:
187
+ warnings.append(f"high-entropy binary compressed (should be stored): {n}")
188
+ if total_u > MAX_UNCOMPRESSED:
189
+ errors.append("uncompressed size > 4 GiB")
190
+ return {"errors": errors, "warnings": warnings, "entries": len(names), "uncompressed": total_u, "compressed": total_c}
191
+
192
+
193
+ def unpack(cof: str | os.PathLike, dest: str | os.PathLike) -> Path:
194
+ dest = Path(dest)
195
+ dest.mkdir(parents=True, exist_ok=True)
196
+ with zipfile.ZipFile(cof) as z:
197
+ for info in z.infolist():
198
+ n = info.filename
199
+ if n == "mimetype":
200
+ continue
201
+ if not SAFE_PATH.match(n):
202
+ raise ContainerError(f"non-conformant path: {n}")
203
+ target = (dest / n).resolve()
204
+ if not str(target).startswith(str(dest.resolve())):
205
+ raise ContainerError(f"zip-slip detected: {n}")
206
+ target.parent.mkdir(parents=True, exist_ok=True)
207
+ with z.open(info) as s, open(target, "wb") as d:
208
+ d.write(s.read())
209
+ return dest
210
+
211
+
212
+ def verify_hashes(cof: str | os.PathLike, manifest: dict) -> list[str]:
213
+ """Return list of error strings for assets whose sha256/bytes do not match the archive."""
214
+ errs = []
215
+ with zipfile.ZipFile(cof) as z:
216
+ names = set(z.namelist())
217
+ for a in manifest.get("assets", []):
218
+ rel = a.get("path")
219
+ if not rel:
220
+ continue
221
+ if rel not in names:
222
+ if not a.get("uri"):
223
+ errs.append(f"declared asset missing from archive: {rel}")
224
+ continue
225
+ data = z.read(rel)
226
+ if a.get("bytes") is not None and a["bytes"] != len(data):
227
+ errs.append(f"size mismatch: {rel} ({a['bytes']} declared, {len(data)} actual)")
228
+ if a.get("sha256") and a["sha256"] != hashlib.sha256(data).hexdigest():
229
+ errs.append(f"sha256 mismatch: {rel}")
230
+ return errs
cof_cli/convert.py ADDED
@@ -0,0 +1,362 @@
1
+ """Converters: PNG character cards (chara / ccv3 tEXt chunks) ⇄ COF directory, SOUL.md / IDENTITY.md export."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import re
7
+ import struct
8
+ import zlib
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ PNG_SIG = b"\x89PNG\r\n\x1a\n"
13
+
14
+
15
+ # ---------- PNG chunk helpers ----------
16
+
17
+ def png_chunks(data: bytes):
18
+ if not data.startswith(PNG_SIG):
19
+ raise ValueError("not a PNG")
20
+ pos = 8
21
+ while pos + 8 <= len(data):
22
+ length = struct.unpack(">I", data[pos:pos + 4])[0]
23
+ ctype = data[pos + 4:pos + 8]
24
+ body = data[pos + 8:pos + 8 + length]
25
+ pos += 12 + length
26
+ yield ctype, body
27
+ if ctype == b"IEND":
28
+ break
29
+
30
+
31
+ def png_build(chunks) -> bytes:
32
+ out = bytearray(PNG_SIG)
33
+ for ctype, body in chunks:
34
+ out += struct.pack(">I", len(body)) + ctype + body + struct.pack(">I", zlib.crc32(ctype + body) & 0xFFFFFFFF)
35
+ return bytes(out)
36
+
37
+
38
+ def read_card_from_png(data: bytes) -> dict:
39
+ """Return the richest card found: ccv3 first, else chara (V2/V1)."""
40
+ found = {}
41
+ for ctype, body in png_chunks(data):
42
+ if ctype == b"tEXt" and b"\x00" in body:
43
+ key, _, val = body.partition(b"\x00")
44
+ k = key.decode("latin-1")
45
+ if k in ("chara", "ccv3"):
46
+ try:
47
+ found[k] = json.loads(base64.b64decode(val).decode("utf-8"))
48
+ except Exception:
49
+ pass
50
+ if "ccv3" in found:
51
+ return found["ccv3"]
52
+ if "chara" in found:
53
+ card = found["chara"]
54
+ if "spec" not in card: # V1 flat → wrap as V2
55
+ card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": card}
56
+ return card
57
+ raise ValueError("no character card ('chara'/'ccv3' chunks) in this PNG")
58
+
59
+
60
+ def write_card_to_png(png: bytes, card: dict) -> bytes:
61
+ """Embed both 'ccv3' (V3) and 'chara' (V2 downgrade) chunks, replacing existing ones."""
62
+ v3 = {**card, "spec": "chara_card_v3", "spec_version": "3.0"}
63
+ v2 = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {k: v for k, v in card.get("data", {}).items()
64
+ if k not in ("assets", "nickname", "creator_notes_multilingual", "source", "group_only_greetings",
65
+ "creation_date", "modification_date")}}
66
+ chunks = [(t, b) for t, b in png_chunks(png) if not (t == b"tEXt" and (b.startswith(b"chara\x00") or b.startswith(b"ccv3\x00")))]
67
+ iend = chunks.pop() # IEND
68
+ for key, obj in (("chara", v2), ("ccv3", v3)):
69
+ body = key.encode() + b"\x00" + base64.b64encode(json.dumps(obj, ensure_ascii=False).encode("utf-8"))
70
+ chunks.append((b"tEXt", body))
71
+ chunks.append(iend)
72
+ return png_build(chunks)
73
+
74
+
75
+ # ---------- description text embedded in an image (no card) ----------
76
+
77
+ def write_text_to_png(png: bytes, text: str, keyword: str = "Description") -> bytes:
78
+ """Embed a UTF-8 text as a PNG iTXt chunk (standard keyword 'Description'), replacing an existing one."""
79
+ chunks = []
80
+ for t, b in png_chunks(png):
81
+ if t == b"iTXt" and b.split(b"\x00", 1)[0].decode("latin1") == keyword:
82
+ continue
83
+ chunks.append((t, b))
84
+ iend = chunks.pop()
85
+ body = keyword.encode("latin1") + b"\x00" + b"\x00\x00" + b"\x00" + b"\x00" + text.encode("utf-8") # compression flag 0, method 0, no language, no translated keyword
86
+ chunks.append((b"iTXt", body)); chunks.append(iend)
87
+ return png_build(chunks)
88
+
89
+
90
+ def _exif_texts(ex: bytes) -> dict:
91
+ out = {}
92
+ if len(ex) < 8:
93
+ return out
94
+ le = ex[:2] == b"II"; fmt = "<" if le else ">"
95
+ u16 = lambda o: struct.unpack(fmt + "H", ex[o:o + 2])[0]; u32 = lambda o: struct.unpack(fmt + "I", ex[o:o + 4])[0]
96
+ SZ = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8}
97
+
98
+ def ifd(off, depth=0):
99
+ if depth > 3 or off + 2 > len(ex):
100
+ return
101
+ for k in range(u16(off)):
102
+ e = off + 2 + 12 * k
103
+ if e + 12 > len(ex):
104
+ break
105
+ tag, typ, cnt = u16(e), u16(e + 2), u32(e + 4); sz = SZ.get(typ, 1) * cnt
106
+ vo = e + 8 if sz <= 4 else u32(e + 8); val = ex[vo:vo + sz]
107
+ if tag == 0x8769:
108
+ ifd(u32(e + 8), depth + 1)
109
+ elif tag == 0x010E:
110
+ out["ImageDescription"] = val.rstrip(b"\x00").decode("utf-8", "replace")
111
+ elif tag == 0x9286:
112
+ h = val[:8]
113
+ out["UserComment"] = (val[8:].decode("utf-16-le" if le else "utf-16-be", "replace") if h.startswith(b"UNICODE") else (val[8:] if h.startswith((b"ASCII", b"JIS")) else val).decode("utf-8", "replace")).rstrip("\x00")
114
+ elif tag == 0x9C9C:
115
+ out["XPComment"] = val.decode("utf-16-le", "replace").rstrip("\x00")
116
+ try:
117
+ ifd(u32(4))
118
+ except Exception:
119
+ pass
120
+ return out
121
+
122
+
123
+ def _xmp_description(xml: str) -> str:
124
+ m = re.search(r"<dc:description>[\s\S]*?<rdf:li[^>]*>([\s\S]*?)</rdf:li>", xml) or re.search(r'dc:description="([^"]*)"', xml)
125
+ return (m.group(1) if m else "").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&").replace("&quot;", '"').strip()
126
+
127
+
128
+ def read_text_from_image(data: bytes) -> dict | None:
129
+ """Longest descriptive text found in a PNG (iTXt/tEXt/zTXt Description|Comment, XMP, EXIF incl. 'Raw profile type') or JPEG (EXIF, XMP, COM)."""
130
+ cands = []
131
+ push = lambda source, text: cands.append({"source": source, "text": text.strip()}) if text and len(text.strip()) >= 20 else None
132
+ if data.startswith(PNG_SIG):
133
+ for t, b in png_chunks(data):
134
+ try:
135
+ if t == b"tEXt":
136
+ k, v = b.split(b"\x00", 1); key = k.decode("latin1")
137
+ if key.lower() in ("description", "comment", "cof:description"):
138
+ push("PNG tEXt " + key, v.decode("latin1"))
139
+ elif key == "XML:com.adobe.xmp":
140
+ push("PNG XMP", _xmp_description(v.decode("utf-8", "replace")))
141
+ elif t == b"iTXt":
142
+ k, rest = b.split(b"\x00", 1); key = k.decode("latin1"); cf = rest[0]; rest = rest[2:]
143
+ _lang, rest = rest.split(b"\x00", 1); _tk, txt = rest.split(b"\x00", 1)
144
+ if cf:
145
+ txt = zlib.decompress(txt)
146
+ if key.lower() in ("description", "comment", "cof:description"):
147
+ push("PNG iTXt " + key, txt.decode("utf-8", "replace"))
148
+ elif key == "XML:com.adobe.xmp":
149
+ push("PNG XMP", _xmp_description(txt.decode("utf-8", "replace")))
150
+ elif t == b"zTXt":
151
+ k, rest = b.split(b"\x00", 1); key = k.decode("latin1"); txt = zlib.decompress(rest[1:]).decode("latin1")
152
+ if key.lower() in ("description", "comment"):
153
+ push("PNG zTXt " + key, txt)
154
+ elif re.match(r"Raw profile type (APP1|exif)$", key, re.I):
155
+ hexs = "".join(txt.split("\n")[3:]).replace(" ", ""); ex = bytes.fromhex(hexs)
156
+ for kk, vv in _exif_texts(ex[6:] if ex.startswith(b"Exif") else ex).items():
157
+ push("EXIF " + kk, vv)
158
+ elif re.match(r"Raw profile type xmp$", key, re.I):
159
+ push("PNG XMP", _xmp_description(bytes.fromhex("".join(txt.split("\n")[3:]).replace(" ", "")).decode("utf-8", "replace")))
160
+ elif t == b"eXIf":
161
+ for kk, vv in _exif_texts(b).items():
162
+ push("EXIF " + kk, vv)
163
+ except Exception:
164
+ continue
165
+ elif data[:2] == b"\xff\xd8":
166
+ p = 2
167
+ while p + 4 < len(data) and data[p] == 0xFF:
168
+ marker = data[p + 1]
169
+ if marker in (0xDA, 0xD9):
170
+ break
171
+ ln = struct.unpack(">H", data[p + 2:p + 4])[0]; seg = data[p + 4:p + 2 + ln]
172
+ if marker == 0xE1:
173
+ if seg.startswith(b"Exif\x00\x00"):
174
+ for kk, vv in _exif_texts(seg[6:]).items():
175
+ push("EXIF " + kk, vv)
176
+ elif seg.startswith(b"http://ns.adobe.com/xap/1.0/"):
177
+ push("JPEG XMP", _xmp_description(seg[29:].decode("utf-8", "replace")))
178
+ elif marker == 0xFE:
179
+ push("JPEG COM", seg.decode("utf-8", "replace"))
180
+ p += 2 + ln
181
+ cands.sort(key=lambda c: -len(c["text"]))
182
+ return cands[0] if cands else None
183
+
184
+
185
+ # ---------- card → COF directory ----------
186
+
187
+ def _slug(s: str) -> str:
188
+ s = re.sub(r"[^A-Za-z0-9._-]+", "-", s).strip("-")
189
+ return s or "character"
190
+
191
+
192
+ def ulid_like() -> str:
193
+ """26-char Crockford base32 ULID (time + randomness)."""
194
+ import os, time
195
+ alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
196
+ t = int(time.time() * 1000)
197
+ out = ""
198
+ for _ in range(10):
199
+ out = alphabet[t & 31] + out
200
+ t >>= 5
201
+ rnd = int.from_bytes(os.urandom(10), "big")
202
+ tail = ""
203
+ for _ in range(16):
204
+ tail = alphabet[rnd & 31] + tail
205
+ rnd >>= 5
206
+ return out + tail
207
+
208
+
209
+ def default_permissions() -> dict:
210
+ return {"avatarPermission": "onlyAuthor", "commercialUsage": "personalNonProfit", "modification": "prohibited",
211
+ "allowRedistribution": False, "creditNotation": "required",
212
+ "allowExcessivelyViolentUsage": False, "allowSexualUsage": False, "allowPoliticalOrReligiousUsage": False,
213
+ "allowAntisocialOrHateUsage": False, "allowVoiceCloning": False, "allowTraining": "none",
214
+ "allowImpersonationOfRealPerson": False, "aiDisclosure": True}
215
+
216
+
217
+ def card_to_cof_dir(card: dict, dest: Path, *, png: bytes | None = None, age: int | None = None,
218
+ fictional: bool = True, language: str = "en") -> Path:
219
+ """Character Card (V2/V3) → v0.3 directory: elements.personality.p1 (+ face.f1 from the PNG) + preset 'default'."""
220
+ data = card.get("data", {})
221
+ dest.mkdir(parents=True, exist_ok=True)
222
+ pdir = dest / "elements" / "personality" / "p1"
223
+ pdir.mkdir(parents=True, exist_ok=True)
224
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
225
+
226
+ v3 = {**card, "spec": "chara_card_v3", "spec_version": "3.0"}
227
+ v3.setdefault("data", {}).setdefault("extensions", {})
228
+ (pdir / "card.json").write_text(json.dumps(v3, ensure_ascii=False, indent=2) + "\n", "utf-8")
229
+
230
+ elements = {"personality": {"variants": {"p1": {"kind": "card", "label": data.get("name") or "Personality", "language": language, "card": "elements/personality/p1/card.json"}}}}
231
+ slots = {"personality": ["p1"]}
232
+ assets = []
233
+ if png:
234
+ fdir = dest / "elements" / "appearance" / "f1"
235
+ fdir.mkdir(parents=True, exist_ok=True)
236
+ (fdir / "head.front.png").write_bytes(png)
237
+ assets.append({"id": "a-face-f1-front", "path": "elements/appearance/f1/head.front.png", "role": "view", "subject": "head", "angle": "front",
238
+ "framing": "head", "mediaType": "image/png", "bytes": 0, "sha256": "0" * 64, "license": "LicenseRef-Unknown"})
239
+ fv = {"kind": "face", "label": "Face (card thumbnail)", "images": ["a-face-f1-front"]}
240
+ if age is not None:
241
+ fv["age"] = {"value": age, "unit": "years", "basis": "declared"} # v0.5: age is carried by the representation
242
+ elements["appearance"] = {"variants": {"f1": fv}}
243
+ slots["appearance"] = ["f1"]
244
+
245
+ manual = [re.sub(r"[^a-z0-9-]", "-", t.lower()).strip("-") for t in (data.get("tags") or []) if isinstance(t, str)]
246
+ manifest = {
247
+ "cof": "1.0", "id": ulid_like(),
248
+ "name": data.get("name") or "Unnamed", "nickname": data.get("nickname") or "",
249
+ "languages": [language], "summary": (data.get("description") or "")[:300], "fictional": fictional,
250
+ "morphology": {"class": "human", "species": "human"},
251
+ "tags": {"auto": ["humanoid", "imported-ccv3"], "manual": [t for t in manual if t][:30], "content": ["none"],
252
+ "ip": {"original": True, "franchise": None, "based_on": None}},
253
+ "elements": elements,
254
+ "presets": {"default": {"label": "Default", "slots": slots, **({"age_override": {"value": age, "unit": "years", "basis": "declared"}} if age is not None else {})}},
255
+ "default_preset": "default",
256
+ "priorities": {"visual": ["identity_weights", "images", "avatar", "descriptive"], "voice": ["samples", "described"]},
257
+ "mapping_vocabularies": {}, "assets": assets,
258
+ "size_class": "lite", "container": {"aligned": 0, "executableContent": "none"},
259
+ "rights": {"permissions": default_permissions(), "consent": None},
260
+ "provenance": {"created": now, "modified": now, "generator": "cof-cli", "source": list(data.get("source") or []), "c2pa": None, "signatures": []},
261
+ "metadata": {"@context": {"dc": "http://purl.org/dc/elements/1.1/"}, "dc:creator": [data.get("creator") or ""]},
262
+ "extensions": {},
263
+ }
264
+ (dest / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", "utf-8")
265
+ return dest
266
+
267
+
268
+ # ---------- COF directory → SOUL.md / IDENTITY.md ----------
269
+
270
+ def export_soul(src: Path, preset: str | None = None) -> tuple[str, str]:
271
+ from .model import asset_index, default_preset_id, effective_permissions, first, preset_identity, preset_primary_image, resolved
272
+ manifest = json.loads((src / "manifest.json").read_text("utf-8"))
273
+ pid = preset or default_preset_id(manifest)
274
+ r = resolved(manifest, pid)
275
+ ident = preset_identity(manifest, pid) or {}
276
+ name = ident.get("name") or manifest.get("name")
277
+ p = first(r, "personality") or {}
278
+ data = json.loads((src / p["card"]).read_text("utf-8")).get("data", {}) if p.get("card") else {}
279
+ psyche = json.loads((src / p["psyche"]).read_text("utf-8")) if p.get("psyche") and (src / p["psyche"]).is_file() else {}
280
+ A = asset_index(manifest)
281
+
282
+ lines = [f"# SOUL.md — {name}", ""]
283
+ if p.get("kind") == "description" and (p.get("text") or "").strip():
284
+ lines += ["## Persona", p["text"].strip(), ""]
285
+ if data.get("personality"):
286
+ lines += ["## Voice & stance", data["personality"].strip(), ""]
287
+ beh = (data.get("extensions", {}).get("cof", {}) or {}).get("behavior")
288
+ if beh:
289
+ lines += ["## Habits", *[f"- {b}" for b in beh], ""]
290
+ if psyche.get("values"):
291
+ lines += ["## Values", *[f"- {v}" for v in psyche["values"]], ""]
292
+ if psyche.get("goals"):
293
+ lines += ["## Goals", *[f"- {g.get('text')}" for g in psyche["goals"]], ""]
294
+ if psyche.get("fears"):
295
+ lines += ["## Fears", *[f"- {f.get('text')}" for f in psyche["fears"]], ""]
296
+ if data.get("system_prompt"):
297
+ lines += ["## Boundaries", data["system_prompt"].strip(), ""]
298
+ perms = effective_permissions(manifest, pid)
299
+ if perms.get("aiDisclosure", True):
300
+ lines += ["## Disclosure", "I am a synthetic character. I say so when asked.", ""]
301
+ soul = "\n".join(lines)[:20000]
302
+
303
+ pim = A.get(preset_primary_image(manifest, pid) or "") or {}
304
+ avatar = pim.get("path") or manifest.get("thumbnail") or next((A[i]["path"] for i in (first(r, "appearance", "face") or {}).get("images", []) if i in A and A[i].get("subject") == "head"), "")
305
+ identity = "\n".join([
306
+ f"- Name: {name}",
307
+ f"- Creature: {(ident.get('morphology') or manifest.get('morphology') or {}).get('species') or 'human'}",
308
+ f"- Vibe: {(ident.get('summary') or manifest.get('summary') or '').strip()}",
309
+ f"- Emoji: {manifest.get('extensions', {}).get('emoji', '🙂')}",
310
+ f"- Avatar: {avatar}",
311
+ "",
312
+ ])
313
+ return soul, identity
314
+
315
+
316
+ def export_agent(src: Path, out: Path, preset: str | None = None) -> dict:
317
+ """A ready-to-use agent workspace from one preset: SOUL.md, IDENTITY.md, avatar, skills/<name>/,
318
+ memories at the root, documents in docs/, and COF.md (rights and provenance the agent host must honour)."""
319
+ import shutil
320
+ from .model import asset_index, default_preset_id, effective_permissions, preset_identity, resolved
321
+ manifest = json.loads((src / "manifest.json").read_text("utf-8"))
322
+ pid = preset or default_preset_id(manifest)
323
+ out.mkdir(parents=True, exist_ok=True)
324
+ soul, identity = export_soul(src, pid)
325
+ A = asset_index(manifest)
326
+ av = next((l.split(": ", 1)[1] for l in identity.splitlines() if l.startswith("- Avatar: ")), "")
327
+ if av and (src / av).is_file():
328
+ dst = out / ("avatar" + Path(av).suffix.lower()); shutil.copy(src / av, dst)
329
+ identity = identity.replace(f"- Avatar: {av}", f"- Avatar: {dst.name}")
330
+ (out / "SOUL.md").write_text(soul, "utf-8"); (out / "IDENTITY.md").write_text(identity, "utf-8")
331
+ done = {"skills": [], "memories": [], "documents": []}
332
+ for v in resolved(manifest, pid).get("files", []):
333
+ base = f"elements/files/{v['_id']}/"
334
+ files = [A[i] for i in v.get("files") or [] if i in A and A[i].get("path")]
335
+ kind = v.get("kind")
336
+ if kind == "skill":
337
+ name = v.get("name") or v["_id"]
338
+ entry = src / v["entry"] if v.get("entry") else None
339
+ if entry and entry.is_file(): # Agent Skills: the folder is named after the front-matter `name`
340
+ import re as _re
341
+ mm = _re.search(r"^name:\s*([A-Za-z0-9._-]+)\s*$", entry.read_text("utf-8", errors="replace"), _re.M)
342
+ if mm:
343
+ name = mm.group(1)
344
+ root = out / "skills" / name
345
+ for a in files:
346
+ rel = a["path"][len(base):] if a["path"].startswith(base) else Path(a["path"]).name
347
+ (root / rel).parent.mkdir(parents=True, exist_ok=True); shutil.copy(src / a["path"], root / rel)
348
+ done["skills"].append(name)
349
+ else:
350
+ dest = out if kind == "memory" else out / "docs"
351
+ for a in files:
352
+ dest.mkdir(parents=True, exist_ok=True); shutil.copy(src / a["path"], dest / Path(a["path"]).name)
353
+ done["memories" if kind == "memory" else "documents"].append(Path(a["path"]).name)
354
+ perms = effective_permissions(manifest, pid)
355
+ ident = preset_identity(manifest, pid) or {}
356
+ note = [f"# COF.md — {ident.get('name') or manifest.get('name')} (preset `{pid}`)", "",
357
+ f"Exported from `{manifest.get('id')}` ({manifest.get('cof')}). The host MUST honour these permissions:", ""]
358
+ note += [f"- `{k}`: {json.dumps(v)}" for k, v in sorted(perms.items())]
359
+ if manifest.get("container", {}).get("executableContent") == "scripts":
360
+ note += ["", "Skills may contain scripts: never run them without the user's explicit approval."]
361
+ (out / "COF.md").write_text("\n".join(note) + "\n", "utf-8")
362
+ return done