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 +5 -0
- cof_cli/cli.py +305 -0
- cof_cli/completeness.py +131 -0
- cof_cli/container.py +230 -0
- cof_cli/convert.py +362 -0
- cof_cli/migrate.py +297 -0
- cof_cli/model.py +465 -0
- cof_cli/ops.py +381 -0
- cof_cli/schemas/attitude.schema.json +248 -0
- cof_cli/schemas/body.schema.json +98 -0
- cof_cli/schemas/card.schema.json +107 -0
- cof_cli/schemas/consent.schema.json +134 -0
- cof_cli/schemas/garments-1.0.json +41 -0
- cof_cli/schemas/hair.schema.json +104 -0
- cof_cli/schemas/manifest.schema.json +1757 -0
- cof_cli/schemas/pose.schema.json +206 -0
- cof_cli/schemas/psyche.schema.json +252 -0
- cof_cli/schemas/skeleton.schema.json +88 -0
- cof_cli/schemas/visemes.schema.json +28 -0
- cof_cli/schemas/voice-profile.schema.json +254 -0
- cof_cli/schemas/weights.schema.json +188 -0
- cof_cli/tokens.py +30 -0
- cof_cli/validate.py +985 -0
- cof_cli-1.0.0.dist-info/METADATA +39 -0
- cof_cli-1.0.0.dist-info/RECORD +28 -0
- cof_cli-1.0.0.dist-info/WHEEL +5 -0
- cof_cli-1.0.0.dist-info/entry_points.txt +2 -0
- cof_cli-1.0.0.dist-info/top_level.txt +1 -0
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("<", "<").replace(">", ">").replace("&", "&").replace(""", '"').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
|