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.
- cof_cli-1.1.0/PKG-INFO +46 -0
- cof_cli-1.1.0/README.md +29 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/__init__.py +2 -2
- cof_cli-1.1.0/cof_cli/cast.py +291 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/cli.py +105 -17
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/completeness.py +2 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/container.py +30 -6
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/convert.py +117 -6
- cof_cli-1.1.0/cof_cli/memories.py +203 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/model.py +17 -8
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/ops.py +261 -17
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/attitude.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/body.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/card.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/consent.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/hair.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/manifest.schema.json +213 -5
- cof_cli-1.1.0/cof_cli/schemas/memory.schema.json +358 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/pose.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/psyche.schema.json +105 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/skeleton.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/visemes.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/voice-profile.schema.json +1 -1
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/weights.schema.json +1 -1
- cof_cli-1.1.0/cof_cli/selfdesc.py +201 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/validate.py +291 -10
- cof_cli-1.1.0/cof_cli.egg-info/PKG-INFO +46 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/SOURCES.txt +4 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/pyproject.toml +2 -2
- {cof_cli-1.0.0 → cof_cli-1.1.0}/tests/test_roundtrip.py +474 -3
- cof_cli-1.0.0/PKG-INFO +0 -39
- cof_cli-1.0.0/README.md +0 -22
- cof_cli-1.0.0/cof_cli.egg-info/PKG-INFO +0 -39
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/migrate.py +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/schemas/garments-1.0.json +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli/tokens.py +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/dependency_links.txt +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/entry_points.txt +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/requires.txt +0 -0
- {cof_cli-1.0.0 → cof_cli-1.1.0}/cof_cli.egg-info/top_level.txt +0 -0
- {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).
|
cof_cli-1.1.0/README.md
ADDED
|
@@ -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).
|
|
@@ -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
|
|
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
|
|
193
|
-
done = export_agent(d, out, pid)
|
|
194
|
-
print(f"skills: {', '.join(done['skills']) or '—'} ·
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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"--
|
|
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="
|
|
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"))
|