cof-cli 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (41) hide show
  1. cof_cli-1.1.0/PKG-INFO +46 -0
  2. cof_cli-1.1.0/README.md +29 -0
  3. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/__init__.py +2 -2
  4. cof_cli-1.1.0/cof_cli/cast.py +291 -0
  5. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/cli.py +105 -17
  6. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/completeness.py +2 -0
  7. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/container.py +30 -6
  8. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/convert.py +117 -6
  9. cof_cli-1.1.0/cof_cli/memories.py +203 -0
  10. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/model.py +17 -8
  11. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/ops.py +261 -17
  12. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/attitude.schema.json +1 -1
  13. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/body.schema.json +1 -1
  14. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/card.schema.json +1 -1
  15. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/consent.schema.json +1 -1
  16. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/hair.schema.json +1 -1
  17. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/manifest.schema.json +213 -5
  18. cof_cli-1.1.0/cof_cli/schemas/memory.schema.json +358 -0
  19. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/pose.schema.json +1 -1
  20. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/psyche.schema.json +105 -1
  21. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/skeleton.schema.json +1 -1
  22. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/visemes.schema.json +1 -1
  23. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/voice-profile.schema.json +1 -1
  24. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/weights.schema.json +1 -1
  25. cof_cli-1.1.0/cof_cli/selfdesc.py +201 -0
  26. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/validate.py +291 -10
  27. cof_cli-1.1.0/cof_cli.egg-info/PKG-INFO +46 -0
  28. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/SOURCES.txt +4 -0
  29. {cof_cli-1.0.0 → cof_cli-1.1.0}/pyproject.toml +2 -2
  30. {cof_cli-1.0.0 → cof_cli-1.1.0}/tests/test_roundtrip.py +474 -3
  31. cof_cli-1.0.0/PKG-INFO +0 -39
  32. cof_cli-1.0.0/README.md +0 -22
  33. cof_cli-1.0.0/cof_cli.egg-info/PKG-INFO +0 -39
  34. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/migrate.py +0 -0
  35. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/garments-1.0.json +0 -0
  36. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/tokens.py +0 -0
  37. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/dependency_links.txt +0 -0
  38. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/entry_points.txt +0 -0
  39. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/requires.txt +0 -0
  40. {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/top_level.txt +0 -0
  41. {cof_cli-1.0.0 → cof_cli-1.1.0}/setup.cfg +0 -0
cof_cli-1.1.0/PKG-INFO ADDED
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: cof-cli
3
+ Version: 1.1.0
4
+ Summary: COF — Character Open File: validate, pack, unpack and convert .cof character containers
5
+ Author: Yann Souetre
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: jsonschema>=4.20
10
+ Provides-Extra: tokens
11
+ Requires-Dist: tiktoken>=0.7; extra == "tokens"
12
+ Provides-Extra: png
13
+ Requires-Dist: Pillow>=10; extra == "png"
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+ Requires-Dist: Pillow>=10; extra == "dev"
17
+
18
+ # cof-cli
19
+
20
+ Outil de référence pour COF (Character Open File) : validation, empaquetage, extraction, fusion, conversions, et l'ajout d'un souvenir vécu (1.1). `pip install cof-cli` (PyPI) ou, depuis ce dossier, `pip install -e ".[dev]"`. Python ≥ 3.10.
21
+
22
+ ```bash
23
+ cof validate spec/examples/10-clara-mercier # dossier ou fichier .cof : erreurs, avertissements, complétude (--json pour la CI)
24
+ cof pack spec/examples/10-clara-mercier -o clara.cof # recalcule hashes, complétude, langues ; écrit COF.md et la vignette
25
+ cof info clara.cof # presets, déclinaisons, âges, tags — le point de départ pour un agent
26
+ cof unpack clara.cof -o clara/
27
+ cof tokens clara.cof
28
+ cof extract clara.cof --preset fr-65 -o clara-65/ # une tranche plus légère : un preset, un personnage (--identity) ou des tags (--tag fr --tag age-65)
29
+ cof extract clara.cof --identity clara-mercier --all-audiences -o clara-sfw/ # sans nudité ni intime, médias des souvenirs explicites retirés (gist conservé)
30
+ cof extract clara.cof --preset default --keep-personal -o clara-perso/ # garde les souvenirs `visibility: personal` (retirés par défaut)
31
+ cof merge clara.cof martien.cof -o duo/ # un casting : même nom + surnom + statut fictif + morphologie ⇒ même personnage, sinon plusieurs
32
+ cof memory add clara.cof --gist fr="Ce matin, relu avec Léo." --gist en="This morning, reread with Léo." --date 2026-10-25 --who "Léo" --tag travail --personal
33
+ cof memory add clara/ --kind period --gist en="The agency years." --age 24 --approx --fidelity faded # sur un dossier ; `--help` liste tout (--where, --self-shown, --media, --preset…)
34
+ cof import carte.png -o perso/ --age 30 --lang fr --keep-png # carte PNG CCv2/V3 → dossier COF
35
+ cof export clara.cof --format png -o clara.png # → PNG avec chunks chara + ccv3
36
+ cof export clara.cof --format card -o clara.card.json
37
+ cof export clara.cof --format soul -o ./agent/ # → SOUL.md + IDENTITY.md
38
+ cof export clara.cof --format agent -o ./agent/ # → espace de travail d'agent : SOUL.md, IDENTITY.md, skills/, MEMORY.md + memories/ (--keep-personal, --preset)
39
+ cof embed-text clara.png fiche.md -o clara-fiche.png # texte descriptif dans un PNG (iTXt), lisible par le builder
40
+ cof read-text clara-fiche.png
41
+ cof migrate dossier-v0.5/ # v0.2 … v0.5 → 1.0
42
+ ```
43
+
44
+ Ce que `validate` vérifie : la structure de l'archive (sentinelle `mimetype`, chemins sûrs, pas d'entrée en double ni chiffrée, taux de compression, taille), les hashes SHA-256 et tailles des assets, les schémas JSON du manifeste et des sous-fichiers (`card`, `psyche`, `weights`, `voice-profile`, `memory`…), la résolution des presets (slots, alternatives, `extends`, `derives_from`), les identifiants d'assets et de déclinaisons, les règles d'âge sur l'**âge le plus bas** d'un preset (déclinaisons appliquées et offertes, `age_override`, souvenirs qui montrent le personnage), l'interdiction de tout contenu nu ou intime sous 18 ans, le consentement d'une personne réelle, les règles des souvenirs (RFC 0002 : forme de `when`, liens, `media_from`, nœuds explicites — qui est montré, âges connus et adultes, âge vécu ≥ 18, drapeau `nudity`, `tags.content`), les relations entre personnages d'un casting (RFC 0003), les styles mélangés, les transcriptions des échantillons de voix, le contenu exécutable non déclaré, les doublons, les budgets de tokens ; puis recalcule la complétude et les cibles prêtes. Les chemins et noms lus dans un manifeste ne sortent jamais du dossier de sortie (`export`, `extract`, `merge`).
45
+
46
+ Tests : `pytest` (dossier `cli/`, 68 tests) ; le corpus négatif de `tests/invalid/` (63 cas, un par règle) est régénéré par `python tests/invalid/build.py`. Tokens : installer `tiktoken` pour un comptage exact (sinon heuristique par caractères).
@@ -0,0 +1,29 @@
1
+ # cof-cli
2
+
3
+ Outil de référence pour COF (Character Open File) : validation, empaquetage, extraction, fusion, conversions, et l'ajout d'un souvenir vécu (1.1). `pip install cof-cli` (PyPI) ou, depuis ce dossier, `pip install -e ".[dev]"`. Python ≥ 3.10.
4
+
5
+ ```bash
6
+ cof validate spec/examples/10-clara-mercier # dossier ou fichier .cof : erreurs, avertissements, complétude (--json pour la CI)
7
+ cof pack spec/examples/10-clara-mercier -o clara.cof # recalcule hashes, complétude, langues ; écrit COF.md et la vignette
8
+ cof info clara.cof # presets, déclinaisons, âges, tags — le point de départ pour un agent
9
+ cof unpack clara.cof -o clara/
10
+ cof tokens clara.cof
11
+ cof extract clara.cof --preset fr-65 -o clara-65/ # une tranche plus légère : un preset, un personnage (--identity) ou des tags (--tag fr --tag age-65)
12
+ cof extract clara.cof --identity clara-mercier --all-audiences -o clara-sfw/ # sans nudité ni intime, médias des souvenirs explicites retirés (gist conservé)
13
+ cof extract clara.cof --preset default --keep-personal -o clara-perso/ # garde les souvenirs `visibility: personal` (retirés par défaut)
14
+ cof merge clara.cof martien.cof -o duo/ # un casting : même nom + surnom + statut fictif + morphologie ⇒ même personnage, sinon plusieurs
15
+ cof memory add clara.cof --gist fr="Ce matin, relu avec Léo." --gist en="This morning, reread with Léo." --date 2026-10-25 --who "Léo" --tag travail --personal
16
+ cof memory add clara/ --kind period --gist en="The agency years." --age 24 --approx --fidelity faded # sur un dossier ; `--help` liste tout (--where, --self-shown, --media, --preset…)
17
+ cof import carte.png -o perso/ --age 30 --lang fr --keep-png # carte PNG CCv2/V3 → dossier COF
18
+ cof export clara.cof --format png -o clara.png # → PNG avec chunks chara + ccv3
19
+ cof export clara.cof --format card -o clara.card.json
20
+ cof export clara.cof --format soul -o ./agent/ # → SOUL.md + IDENTITY.md
21
+ cof export clara.cof --format agent -o ./agent/ # → espace de travail d'agent : SOUL.md, IDENTITY.md, skills/, MEMORY.md + memories/ (--keep-personal, --preset)
22
+ cof embed-text clara.png fiche.md -o clara-fiche.png # texte descriptif dans un PNG (iTXt), lisible par le builder
23
+ cof read-text clara-fiche.png
24
+ cof migrate dossier-v0.5/ # v0.2 … v0.5 → 1.0
25
+ ```
26
+
27
+ Ce que `validate` vérifie : la structure de l'archive (sentinelle `mimetype`, chemins sûrs, pas d'entrée en double ni chiffrée, taux de compression, taille), les hashes SHA-256 et tailles des assets, les schémas JSON du manifeste et des sous-fichiers (`card`, `psyche`, `weights`, `voice-profile`, `memory`…), la résolution des presets (slots, alternatives, `extends`, `derives_from`), les identifiants d'assets et de déclinaisons, les règles d'âge sur l'**âge le plus bas** d'un preset (déclinaisons appliquées et offertes, `age_override`, souvenirs qui montrent le personnage), l'interdiction de tout contenu nu ou intime sous 18 ans, le consentement d'une personne réelle, les règles des souvenirs (RFC 0002 : forme de `when`, liens, `media_from`, nœuds explicites — qui est montré, âges connus et adultes, âge vécu ≥ 18, drapeau `nudity`, `tags.content`), les relations entre personnages d'un casting (RFC 0003), les styles mélangés, les transcriptions des échantillons de voix, le contenu exécutable non déclaré, les doublons, les budgets de tokens ; puis recalcule la complétude et les cibles prêtes. Les chemins et noms lus dans un manifeste ne sortent jamais du dossier de sortie (`export`, `extract`, `merge`).
28
+
29
+ Tests : `pytest` (dossier `cli/`, 68 tests) ; le corpus négatif de `tests/invalid/` (63 cas, un par règle) est régénéré par `python tests/invalid/build.py`. Tokens : installer `tiktoken` pour un comptage exact (sinon heuristique par caractères).
@@ -1,5 +1,5 @@
1
1
  """cof-cli — reference tooling for COF (Character Open File)."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
4
4
  MIMETYPE = "application/vnd.cof.character+zip"
5
- SPEC_VERSION = "1.0"
5
+ SPEC_VERSION = "1.1"
@@ -0,0 +1,291 @@
1
+ """RFC 0003 (1.1): gender, archetype, narrative role, relations between characters.
2
+
3
+ Shared by the validator (vocabularies, cast rules, coherence), the exports (IDENTITY.md / SOUL.md lines)
4
+ and `extract --identity` (relations whose target leaves the file).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from .model import resolve_preset_slots, resolve_variant
12
+
13
+ GENDERS = ("female", "male", "non-binary", "none", "other")
14
+ RELATION_TYPES = ("parent", "child", "sibling", "partner", "ex-partner", "spouse", "friend", "colleague", "mentor", "student",
15
+ "rival", "enemy", "acquaintance", "creator", "creation", "owner", "pet", "other")
16
+ SYMMETRIC = {"sibling", "partner", "ex-partner", "spouse", "friend", "colleague", "rival", "enemy", "acquaintance"}
17
+ INVERSE = {"parent": "child", "child": "parent", "mentor": "student", "student": "mentor", "creator": "creation", "creation": "creator",
18
+ "owner": "pet", "pet": "owner"}
19
+ ARCHETYPES = ("innocent", "everyman", "hero", "caregiver", "explorer", "rebel", "lover", "creator", "jester", "sage", "magician", "ruler")
20
+ ROLES = ("protagonist", "antagonist", "mentor", "ally", "sidekick", "foil", "love-interest", "narrator", "ensemble", "other")
21
+
22
+ GENDER_WORDS = {"en": {"female": "female", "male": "male", "non-binary": "non-binary", "none": "no gender (entity)", "other": "other"},
23
+ "fr": {"female": "féminin", "male": "masculin", "non-binary": "non binaire", "none": "sans genre (entité)", "other": "autre"}}
24
+ ARCHETYPE_WORDS = {"en": {"innocent": "the Innocent", "everyman": "the Everyman", "hero": "the Hero", "caregiver": "the Caregiver", "explorer": "the Explorer",
25
+ "rebel": "the Rebel", "lover": "the Lover", "creator": "the Creator", "jester": "the Jester", "sage": "the Sage",
26
+ "magician": "the Magician", "ruler": "the Ruler"},
27
+ "fr": {"innocent": "l'Innocent", "everyman": "le Citoyen ordinaire", "hero": "le Héros", "caregiver": "le Protecteur", "explorer": "l'Explorateur",
28
+ "rebel": "le Rebelle", "lover": "l'Amoureux", "creator": "le Créateur", "jester": "le Bouffon", "sage": "le Sage",
29
+ "magician": "le Magicien", "ruler": "le Souverain"}}
30
+ RELATION_WORDS = {"en": {t: t.replace("-", " ") for t in RELATION_TYPES},
31
+ "fr": {"parent": "parent", "child": "enfant", "sibling": "frère / sœur", "partner": "partenaire", "ex-partner": "ex-partenaire", "spouse": "époux / épouse",
32
+ "friend": "ami(e)", "colleague": "collègue", "mentor": "mentor", "student": "élève", "rival": "rival(e)", "enemy": "ennemi(e)",
33
+ "acquaintance": "connaissance", "creator": "créateur", "creation": "création", "owner": "maître", "pet": "animal", "other": "autre"}}
34
+ ROLE_WORDS = {"en": {r: r.replace("-", " ") for r in ROLES},
35
+ "fr": {"protagonist": "protagoniste", "antagonist": "antagoniste", "mentor": "mentor", "ally": "allié(e)", "sidekick": "acolyte", "foil": "faire-valoir",
36
+ "love-interest": "intérêt amoureux", "narrator": "narrateur", "ensemble": "ensemble", "other": "autre"}}
37
+
38
+ # the voice profile's gender_presentation vocabulary (voice-profile.schema.json) mapped onto RFC 0003 genders
39
+ VOICE_TO_GENDER = {"feminine": "female", "masculine": "male", "female": "female", "male": "male"} # androgynous / neutral never conflict (RFC 0003 § 6)
40
+
41
+
42
+ def text_in(m, lang: str | None) -> str:
43
+ """A language-map entry: the language, else its base, else the first (RFC 0002 § 3 reading rule, short texts)."""
44
+ if isinstance(m, str):
45
+ return m
46
+ if not isinstance(m, dict) or not m:
47
+ return ""
48
+ if lang:
49
+ if lang in m:
50
+ return m[lang]
51
+ base = lang.lower().split("-")[0]
52
+ for k, v in m.items():
53
+ if k.lower().split("-")[0] == base:
54
+ return v
55
+ return next(iter(m.values()))
56
+
57
+
58
+ OVERRIDABLE = ("name", "given_name", "family_name", "nicknames", "gender", "gender_label", "summary")
59
+
60
+
61
+ def identity_override(manifest: dict, pid: str | None) -> dict:
62
+ """The preset chain's identity_override, merged field by field (nearest declaration wins) — RFC 0003 § 4."""
63
+ presets = manifest.get("presets") or {}
64
+ out, q, seen = {}, pid, set()
65
+ while q and q in presets and q not in seen:
66
+ seen.add(q)
67
+ for k, v in (presets[q].get("identity_override") or {}).items():
68
+ if k in OVERRIDABLE and k not in out and v is not None:
69
+ out[k] = v
70
+ q = presets[q].get("extends")
71
+ return out
72
+
73
+
74
+ def identity_view(manifest: dict, pid: str | None, iid: str | None = None) -> dict:
75
+ """The identity as presented by a preset: the identity's record with the override applied (never morphology, fictional, consent, rights)."""
76
+ presets = manifest.get("presets") or {}
77
+ if iid is None and pid and pid in presets:
78
+ iid = presets[pid].get("identity") or manifest.get("default_identity")
79
+ base = dict((manifest.get("identities") or {}).get(iid or "", {}) or {})
80
+ if not base: # a root-only 1.0 file: the character's record is the root copy (SPEC § 2.1)
81
+ base = {k: manifest[k] for k in ("name", "nickname", "summary", "fictional", "morphology", "language", "rights") if k in manifest}
82
+ base.update(identity_override(manifest, pid))
83
+ return base
84
+
85
+
86
+ def preset_prompts(manifest: dict, pid: str | None) -> dict:
87
+ """The preset's own default prompts (`scene`, `greeting`) — not inherited through extends: a scene fits one presentation (RFC 0003 § 4 bis)."""
88
+ p = (manifest.get("presets") or {}).get(pid or "") or {}
89
+ return {k: v for k, v in (p.get("prompts") or {}).items() if k in ("scene", "greeting") and v}
90
+
91
+
92
+ def preset_gender(manifest: dict, pid: str | None, iid: str | None = None) -> str | None:
93
+ """identity_override.gender of the preset chain (nearest wins), else the identity's gender, else None (RFC 0003 § 4)."""
94
+ return identity_view(manifest, pid, iid).get("gender")
95
+
96
+
97
+ def nicknames(i: dict) -> list[str]:
98
+ """Every nickname: `nicknames` (1.1) else `nickname` (1.0), semicolon-separated values accepted."""
99
+ out = list(i.get("nicknames") or [])
100
+ if not out and i.get("nickname"):
101
+ out = [x.strip() for x in str(i["nickname"]).split(";") if x.strip()]
102
+ return out
103
+
104
+
105
+ def uses_1_1_fields(manifest: dict, src: Path | None = None) -> list[str]:
106
+ """Which RFC 0003 fields a manifest uses (for the 'declare 1.1' rule)."""
107
+ used = []
108
+ for iid, i in (manifest.get("identities") or {}).items():
109
+ for k in ("gender", "gender_label", "relations", "given_name", "family_name", "nicknames"):
110
+ if (i or {}).get(k) is not None:
111
+ used.append(f"identities.{iid}.{k}")
112
+ for pid, p in (manifest.get("presets") or {}).items():
113
+ if (p or {}).get("identity_override") is not None:
114
+ used.append(f"presets.{pid}.identity_override")
115
+ if (p or {}).get("prompts") is not None:
116
+ used.append(f"presets.{pid}.prompts")
117
+ if src is not None:
118
+ for vid, v in (((manifest.get("elements") or {}).get("personality") or {}).get("variants") or {}).items():
119
+ if v.get("psyche") and (src / v["psyche"]).is_file():
120
+ try:
121
+ ps = json.loads((src / v["psyche"]).read_text("utf-8"))
122
+ except Exception:
123
+ continue
124
+ for k in ("archetype", "role"):
125
+ if ps.get(k) is not None:
126
+ used.append(f"personality.{vid}.psyche.{k}")
127
+ return used
128
+
129
+
130
+ def cast_rules(manifest: dict, src: Path, rep) -> None:
131
+ """Errors and warnings of RFC 0003 § 6 (vocabularies are enforced by the schema; this is what the schema cannot say)."""
132
+ ids = manifest.get("identities") or {}
133
+ for iid, i in ids.items():
134
+ i = i or {}
135
+ if i.get("gender") == "other" and not i.get("gender_label"):
136
+ rep.warnings.append(f"identities.{iid}: gender 'other' without gender_label")
137
+ for n, r in enumerate(i.get("relations") or []):
138
+ where = f"identities.{iid}.relations[{n}]"
139
+ to = r.get("to")
140
+ if to == iid:
141
+ rep.errors.append(f"{where}: a relation to oneself")
142
+ elif to not in ids:
143
+ rep.errors.append(f"{where}: unknown identity {to!r} (a relation names another character of the same file)")
144
+ if r.get("type") == "other" and not r.get("label"):
145
+ rep.errors.append(f"{where}: type 'other' needs a label")
146
+ pairs = [(r.get("to"), r.get("type")) for r in i.get("relations") or []]
147
+ for pr in {x for x in pairs if pairs.count(x) > 1}:
148
+ rep.errors.append(f"identities.{iid}.relations: the same relation twice ({pr[1]} → {pr[0]}); two relations to one character must differ in type (colleague and friend)")
149
+ if i.get("nicknames") and i.get("nickname") and i["nickname"] not in i["nicknames"]:
150
+ rep.warnings.append(f"identities.{iid}: nickname {i['nickname']!r} is not among nicknames — 1.0 readers see `nickname`, keep it the first of `nicknames`")
151
+ if i.get("given_name") and i.get("name") and i["given_name"].lower() not in i["name"].lower():
152
+ rep.warnings.append(f"identities.{iid}: given_name {i['given_name']!r} does not appear in name {i['name']!r}")
153
+ for pid, p in (manifest.get("presets") or {}).items():
154
+ ov = (p or {}).get("identity_override") or {}
155
+ if ov.get("gender") == "other" and not ov.get("gender_label") and not (ids.get((p or {}).get("identity") or manifest.get("default_identity") or "", {}) or {}).get("gender_label"):
156
+ rep.warnings.append(f"presets.{pid}.identity_override: gender 'other' without gender_label")
157
+ # both sides stored: they must agree (coherence)
158
+ for iid, i in ids.items():
159
+ for r in (i or {}).get("relations") or []:
160
+ to, ty = r.get("to"), r.get("type")
161
+ if to not in ids or to == iid:
162
+ continue
163
+ back = [b for b in ((ids[to] or {}).get("relations") or []) if b.get("to") == iid]
164
+ if not back or r.get("mutual") is False or any(b.get("mutual") is False for b in back): # a one-sided relation is declared on purpose
165
+ continue
166
+ want = ty if ty in SYMMETRIC else INVERSE.get(ty)
167
+ if want and all(b.get("type") != want for b in back):
168
+ rep.warnings.append(f"identities.{iid}.relations → {to}: '{ty}' on this side, {[b.get('type') for b in back]} on the other — the two sides disagree (coherence)")
169
+ # a relation that began after the age the preset shows (coherence, RFC 0003 § 6)
170
+ from .model import preset_age, preset_owner
171
+ for pid in (manifest.get("presets") or {}):
172
+ pa = preset_age(manifest, pid, ignore={"memories"})
173
+ oid = preset_owner(manifest, pid)
174
+ if pa is None or not oid:
175
+ continue
176
+ for r in ((ids.get(oid) or {}).get("relations") or []):
177
+ sa = ((r.get("since") or {}).get("age") or {}).get("value") if isinstance(r.get("since"), dict) else None
178
+ if isinstance(sa, (int, float)) and sa > pa:
179
+ rep.warnings.append(f"presets.{pid}: relation {r.get('type')} → {r.get('to')} began at {sa:g}, after the age this preset shows ({pa:g}) — the character does not know that person yet (coherence)")
180
+ # preset gender vs the voice's presentation (never blocking)
181
+ for pid, p in (manifest.get("presets") or {}).items():
182
+ g = preset_gender(manifest, pid)
183
+ if not g or g in ("none", "other"):
184
+ continue
185
+ slots = resolve_preset_slots(manifest, pid)
186
+ for vid in slots.get("voice") or []:
187
+ v = resolve_variant(manifest, "voice", vid) or {}
188
+ if not v.get("profile") or not (src / v["profile"]).is_file():
189
+ continue
190
+ try:
191
+ prof = json.loads((src / v["profile"]).read_text("utf-8"))
192
+ except Exception:
193
+ continue
194
+ if not isinstance(prof, dict) or not isinstance(prof.get("canonical"), dict):
195
+ continue
196
+ gp = str(((prof.get("canonical") or {}).get("gender_presentation") or "")).lower()
197
+ vg = VOICE_TO_GENDER.get(gp)
198
+ if vg and vg != g and g in ("female", "male"): # only female × masculine and male × feminine are conflicts
199
+ rep.warnings.append(f"presets.{pid}: gender {g} but voice {vid} presents as {gp} (coherence, not an error: a voice may differ on purpose)")
200
+
201
+
202
+ def relations_of(manifest: dict, iid: str) -> list[dict]:
203
+ """The relations of a character, stored on either side: own entries, plus the inverse of what others declare (RFC 0003 § 3)."""
204
+ ids = manifest.get("identities") or {}
205
+ out = [dict(r, _stored=True) for r in ((ids.get(iid) or {}).get("relations") or []) if r.get("to") in ids and r.get("to") != iid]
206
+ seen = {(r["to"], r.get("type")) for r in out}
207
+ for oid, o in ids.items():
208
+ if oid == iid:
209
+ continue
210
+ for r in (o or {}).get("relations") or []:
211
+ if r.get("to") != iid or r.get("mutual") is False:
212
+ continue
213
+ ty = r.get("type")
214
+ inv = ty if ty in SYMMETRIC else INVERSE.get(ty)
215
+ if inv and inv != "other" and (oid, inv) not in seen:
216
+ out.append({"to": oid, "type": inv, "_stored": False, "_declared_by": oid}) # the label and `since` are the other side's words and age: not carried over
217
+ seen.add((oid, inv))
218
+ return out
219
+
220
+
221
+ def drop_foreign_relations(manifest: dict) -> list[str]:
222
+ """After an extract: relations whose target is no longer an identity of the file leave; returns what was dropped."""
223
+ ids = manifest.get("identities") or {}
224
+ gone = []
225
+ for iid, i in ids.items():
226
+ rel = (i or {}).get("relations")
227
+ if not rel:
228
+ continue
229
+ keep = [r for r in rel if r.get("to") in ids and r.get("to") != iid]
230
+ for r in rel:
231
+ if r not in keep:
232
+ gone.append(f"identities.{iid} → {r.get('to')} ({r.get('type')})")
233
+ if keep:
234
+ i["relations"] = keep
235
+ else:
236
+ i.pop("relations", None)
237
+ return gone
238
+
239
+
240
+ def identity_lines(manifest: dict, iid: str, pid: str | None, lang: str) -> list[str]:
241
+ """Lines for IDENTITY.md / a character card: gender (preset override first), relations."""
242
+ L = "fr" if str(lang).lower().startswith("fr") else "en"
243
+ i = identity_view(manifest, pid, iid)
244
+ base = (manifest.get("identities") or {}).get(iid) or {}
245
+ out = []
246
+ nk = nicknames(i)
247
+ if nk:
248
+ out.append(("Surnoms" if L == "fr" else "Nicknames") + ": " + ", ".join(nk))
249
+ g = preset_gender(manifest, pid, iid)
250
+ if g:
251
+ word = GENDER_WORDS[L].get(g, g)
252
+ lab = text_in(i.get("gender_label"), lang)
253
+ if g == "other" and lab:
254
+ word = lab
255
+ elif lab:
256
+ word += f" ({lab})"
257
+ if base.get("gender") and g != base.get("gender"):
258
+ word += " — " + ("dans ce preset" if L == "fr" else "in this preset") + f" ({GENDER_WORDS[L].get(base['gender'], base['gender'])} " + ("au canon" if L == "fr" else "in canon") + ")"
259
+ out.append(("Genre" if L == "fr" else "Gender") + f": {word}")
260
+ rels = relations_of(manifest, iid)
261
+ if rels:
262
+ ids = manifest.get("identities") or {}
263
+ items = []
264
+ for r in rels:
265
+ name = (ids.get(r["to"]) or {}).get("name") or r["to"]
266
+ word = RELATION_WORDS[L].get(r["type"], r["type"])
267
+ lab = text_in(r.get("label"), lang)
268
+ items.append(f"{name} — {word}" + (f" ({lab})" if lab else "") + ((" (" + ("déclaré par " if L == "fr" else "declared by ") + name + ")") if r.get("_declared_by") else ""))
269
+ out.append("Relations: " + " · ".join(items))
270
+ return out
271
+
272
+
273
+ def psyche_lines(psyche: dict | None, lang: str) -> list[str]:
274
+ """Lines for SOUL.md: the archetype and the role (RFC 0003 § 5)."""
275
+ if not psyche:
276
+ return []
277
+ L = "fr" if str(lang).lower().startswith("fr") else "en"
278
+ out = []
279
+ a = psyche.get("archetype")
280
+ if isinstance(a, dict) and a.get("value"):
281
+ word = text_in(a.get("label"), lang) or (ARCHETYPE_WORDS[L].get(a["value"], a["value"]) if a.get("model") == "pearson-12" else a["value"])
282
+ sec = [ARCHETYPE_WORDS[L].get(x, x) for x in (a.get("secondary") or [])]
283
+ line = ("Archétype" if L == "fr" else "Archetype") + f": {word}"
284
+ if sec:
285
+ line += " (" + ("secondaire" if L == "fr" else "secondary") + ": " + ", ".join(sec) + ")"
286
+ if a.get("model") == "pearson-12":
287
+ line += " — Mark & Pearson" if L == "en" else " — modèle Mark & Pearson"
288
+ out.append(line)
289
+ if psyche.get("role"):
290
+ out.append(("Rôle narratif" if L == "fr" else "Narrative role") + f": {ROLE_WORDS[L].get(psyche['role'], psyche['role'])}")
291
+ return out
@@ -3,6 +3,10 @@ from __future__ import annotations
3
3
 
4
4
  import argparse
5
5
  import json
6
+ import zipfile
7
+ import shutil
8
+ import math
9
+ import re
6
10
  import sys
7
11
  import tempfile
8
12
  from pathlib import Path
@@ -73,6 +77,9 @@ def cmd_pack(a):
73
77
  from datetime import datetime, timezone
74
78
  manifest.setdefault("provenance", {})["modified"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
75
79
  (src / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", "utf-8")
80
+ if manifest and not getattr(a, "no_readme", False):
81
+ from .selfdesc import write_self_description
82
+ write_self_description(src, manifest) # COF.md: the container describes itself (never normative)
76
83
  out = Path(a.out) if a.out else src.with_suffix(".cof")
77
84
  try:
78
85
  pack(src, out)
@@ -158,6 +165,11 @@ def cmd_import(a):
158
165
  return _print_report(rep, a.json)
159
166
 
160
167
 
168
+ def _fname(manifest: dict) -> str:
169
+ """A default output name from the character's name: a plain file name, never a path."""
170
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", str(manifest.get("name") or "character")).strip("-.") or "character"
171
+
172
+
161
173
  def cmd_export(a):
162
174
  src = Path(a.src)
163
175
  with tempfile.TemporaryDirectory() as td:
@@ -179,7 +191,7 @@ def cmd_export(a):
179
191
  png = (d / head).read_bytes()
180
192
  else:
181
193
  png = _placeholder_png(d / head if head else None)
182
- out = out or Path(f"{manifest['name']}.png")
194
+ out = out or Path(f"{_fname(manifest)}.png")
183
195
  out.write_bytes(write_card_to_png(png, card))
184
196
  elif fmt == "soul":
185
197
  soul, identity = export_soul(d, pid)
@@ -189,12 +201,14 @@ def cmd_export(a):
189
201
  (out / "IDENTITY.md").write_text(identity, "utf-8")
190
202
  elif fmt == "agent":
191
203
  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'])}")
204
+ out = out or Path(f"{_fname(manifest)}-agent")
205
+ done = export_agent(d, out, pid, keep_personal=getattr(a, "keep_personal", False))
206
+ print(f"skills: {', '.join(done['skills']) or '—'} · memory files: {len(done['memories'])} · memories (nodes): {done.get('memory_nodes', 0)} · documents: {len(done['documents'])}")
207
+ if done.get("personal_dropped"):
208
+ print(f"personal memories not exported: {done['personal_dropped']} (--keep-personal exports them)")
195
209
  elif fmt == "card":
196
210
  card = json.loads((d / card_path).read_text("utf-8"))
197
- out = out or Path(f"{manifest['name']}.card.json")
211
+ out = out or Path(f"{_fname(manifest)}.card.json")
198
212
  out.write_text(json.dumps(card, ensure_ascii=False, indent=2), "utf-8")
199
213
  else:
200
214
  print(f"unsupported format: {fmt}", file=sys.stderr)
@@ -205,34 +219,90 @@ def cmd_export(a):
205
219
 
206
220
  def cmd_merge(a):
207
221
  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))
222
+ with tempfile.TemporaryDirectory(prefix="cof-merge-") as td:
223
+ for i, src in enumerate(a.sources):
224
+ p = Path(src)
225
+ if p.is_file():
226
+ d = Path(td) / str(i); unpack(p, d); dirs.append(d)
227
+ else:
228
+ dirs.append(p)
229
+ merge_dirs(dirs, Path(a.out))
215
230
  rep = Report(); validate_dir(Path(a.out), rep, recompute=True)
216
231
  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
232
  print(f"merged into: {a.out}")
218
233
  return _print_report(rep, a.json)
219
234
 
220
235
 
236
+ def cmd_memory_add(a):
237
+ import tempfile
238
+ from .selfdesc import memory_add, write_self_description
239
+ gists = {}
240
+ for g in a.gist:
241
+ if "=" not in g:
242
+ print("ERROR --gist expects LANG=TEXT (e.g. fr=\"…\")", file=sys.stderr); return 2
243
+ k, v = g.split("=", 1); gists[k.strip()] = v.strip()
244
+ src = Path(a.src)
245
+ if not src.exists():
246
+ print(f"ERROR {src}: no such file or folder", file=sys.stderr); return 2
247
+ if a.age is not None and not (math.isfinite(a.age) and 0 <= a.age <= 200):
248
+ print("ERROR --age must be a number of years between 0 and 200", file=sys.stderr); return 2
249
+ is_file = src.is_file()
250
+ tmp = tempfile.TemporaryDirectory(prefix="cof-mem-") # a folder is edited through a copy: nothing lands in it unless the result validates
251
+ work = Path(tmp.name) / "x"
252
+ try:
253
+ if is_file:
254
+ unpack(src, work)
255
+ else:
256
+ shutil.copytree(src, work)
257
+ vid = memory_add(work, identity=a.identity, kind=a.kind, gists=gists, label=a.label, age=a.age, date=a.date, approx=a.approx, where=a.where,
258
+ who=a.who, tags=a.tag, personal=a.personal, origin=a.origin, disclosure=a.disclosure, fidelity=a.fidelity, salience=a.salience,
259
+ media=[Path(x) for x in (a.media or [])], presets=a.preset, self_shown=a.self_shown)
260
+ rep = Report()
261
+ manifest = validate_dir(work, rep, recompute=True)
262
+ if rep.errors:
263
+ _print_report(rep, False); print("Memory not added: the file would not validate.", file=sys.stderr); return 1
264
+ if manifest:
265
+ (work / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", "utf-8")
266
+ write_self_description(work, manifest)
267
+ if is_file:
268
+ out = Path(a.out) if a.out else src
269
+ pack(work, out)
270
+ print(f"added {vid} → {out}")
271
+ else:
272
+ shutil.copytree(work, src, dirs_exist_ok=True)
273
+ print(f"added {vid} in {src}")
274
+ for w in rep.warnings:
275
+ print(f"WARN {w}")
276
+ return 0
277
+ except (ValueError, ContainerError) as e:
278
+ print(f"ERROR {e}", file=sys.stderr); return 2
279
+ finally:
280
+ tmp.cleanup()
281
+
282
+
221
283
  def cmd_extract(a):
222
284
  src = Path(a.src)
223
285
  with tempfile.TemporaryDirectory() as td:
224
286
  d = src if src.is_dir() else unpack(src, td)
287
+ notes: list[str] = []
225
288
  if a.tag:
226
289
  extract_tag(d, Path(a.out), a.tag)
227
290
  elif getattr(a, "identity", None):
228
291
  from .ops import extract_identity
229
- extract_identity(d, Path(a.out), a.identity)
292
+ extract_identity(d, Path(a.out), a.identity, notes)
230
293
  else:
231
294
  extract_preset(d, Path(a.out), a.preset)
295
+ for n in notes:
296
+ print(f"WARN {n}")
297
+ if not getattr(a, "keep_personal", False): # RFC 0002 § 5.7: personal memories never travel unasked
298
+ from .ops import drop_personal_memories
299
+ dropped = drop_personal_memories(Path(a.out))
300
+ if dropped:
301
+ print(f"personal memories dropped: {', '.join(dropped)} (--keep-personal keeps them)")
232
302
  if getattr(a, "no_intimate", False):
233
303
  from .ops import strip_intimate
234
304
  gone = strip_intimate(Path(a.out))
235
- print(f"--no-intimate: removed {', '.join(gone) or 'nothing'}; allowSexualUsage set to false — review tags.content")
305
+ print(f"--all-audiences: removed {', '.join(gone) or 'nothing'}; allowSexualUsage set to false — review tags.content")
236
306
  rep = Report(); validate_dir(Path(a.out), rep, recompute=True)
237
307
  what = 'tags ' + '+'.join(a.tag) if a.tag else ('character ' + a.identity if getattr(a, "identity", None) else 'preset ' + a.preset)
238
308
  print(f"{what} extracted to: {a.out}")
@@ -282,23 +352,41 @@ def main(argv=None) -> int:
282
352
 
283
353
  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
354
  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)
355
+ s = sub.add_parser("memory", help="memories: `memory add` appends a lived memory to a .cof or a folder (RFC 0002, the write path of COF.md)")
356
+ ms = s.add_subparsers(dest="mcmd", required=True)
357
+ ma = ms.add_parser("add", help="append one memory node"); ma.add_argument("src", help=".cof file or character folder")
358
+ ma.add_argument("--identity", help="character id (default: the file's default character)"); ma.add_argument("--kind", choices=["moment", "period"], default="moment")
359
+ ma.add_argument("--gist", action="append", required=True, metavar="LANG=TEXT", help="the memory in one paragraph; repeat per language")
360
+ ma.add_argument("--label"); ma.add_argument("--age", type=float); ma.add_argument("--date", help="YYYY, YYYY-MM or YYYY-MM-DD"); ma.add_argument("--approx", action="store_true")
361
+ ma.add_argument("--where"); ma.add_argument("--who", action="append", metavar="LABEL[|shown][|AGE][|real]", help="a person: label, then optional words 'shown' (appears in a media), their age, 'real' (a real person who is not a character of the file); repeat");
362
+ ma.add_argument("--self-shown", action="store_true", help="the character appears in the media (requires --age: the minor rules read it)"); ma.add_argument("--tag", action="append")
363
+ ma.add_argument("--personal", action="store_true", help="visibility: personal (one user's data) instead of canon"); ma.add_argument("--origin", choices=["lived", "authored"], default="lived")
364
+ ma.add_argument("--disclosure", choices=["open", "guarded", "secret"], default="open"); ma.add_argument("--fidelity", choices=["vivid", "clear", "faded", "fragment"], default="clear"); ma.add_argument("--salience", type=int)
365
+ ma.add_argument("--media", action="append", help="image / audio / video / short text file; repeat"); ma.add_argument("--preset", action="append", help="preset(s) that may recall it (default: the character's presets)")
366
+ ma.add_argument("-o", "--out", help="output .cof (default: in place)"); ma.set_defaults(fn=cmd_memory_add)
285
367
  s = sub.add_parser("unpack", help=".cof → folder"); s.add_argument("cof"); s.add_argument("-o", "--out"); s.set_defaults(fn=cmd_unpack)
286
368
  s = sub.add_parser("info", help="summary of a .cof"); s.add_argument("cof"); s.set_defaults(fn=cmd_info)
287
369
  s = sub.add_parser("tokens", help="token estimate per block"); s.add_argument("path"); s.set_defaults(fn=cmd_tokens)
288
370
  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
371
  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
372
  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)
373
+ 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.add_argument("--keep-personal", action="store_true", help="agent: also export `visibility: personal` memories (dropped by default)"); s.set_defaults(fn=cmd_export)
292
374
  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
375
  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)
376
+ 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("--all-audiences", "--no-intimate", dest="no_intimate", action="store_true", help="all-audiences slice: drop intimate / nude variants and the media of explicit memories (gist kept), allowSexualUsage = false"); s.add_argument("--keep-personal", action="store_true", help="keep `visibility: personal` memories (a user's data; dropped by default)"); s.set_defaults(fn=cmd_extract)
295
377
 
296
378
  a = ap.parse_args(argv)
297
379
  try:
298
380
  return a.fn(a)
299
- except (ContainerError, ValueError) as e:
381
+ except (ContainerError, ValueError) as e: # ValueError covers JSONDecodeError
300
382
  print(f"ERROR {e}", file=sys.stderr)
301
383
  return 1
384
+ except zipfile.BadZipFile as e:
385
+ print(f"ERROR not a .cof archive: {e}", file=sys.stderr)
386
+ return 1
387
+ except OSError as e:
388
+ print(f"ERROR {e.strerror or e}: {e.filename or ''}".rstrip(": "), file=sys.stderr)
389
+ return 1
302
390
 
303
391
 
304
392
  if __name__ == "__main__":
@@ -37,6 +37,8 @@ def compute_preset(manifest: dict, pid: str | None, src: Path | None = None) ->
37
37
  if p and p.get("kind") == "card" and p.get("card"):
38
38
  s += 30
39
39
  card = _load(src, p["card"]); d = (card or {}).get("data", {})
40
+ if not isinstance(d, dict):
41
+ d = {}
40
42
  if card is None and src is None:
41
43
  s += 25
42
44
  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"))