From 601c64fe9fa7aee39963ab332433985b11c6c81d Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sat, 11 Jul 2026 11:54:15 -0500 Subject: [PATCH] Updating stuff --- .gitignore | 2 +- TODO.md | 10 +- data/skills/project_setup.md | 33 +++- data/skills/system.md | 2 +- gui/skills_tab.py | 4 +- gui/workflow_tab.py | 313 ++++++++++++++++++++++++++++++++++- util/paths.py | 2 + util/skills/__init__.py | 8 + util/skills/system.py | 109 ++++++++++-- 9 files changed, 449 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 9d1f119..d6d354c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,7 @@ __pycache__ !assets/*.png !.pre-commit-config.yaml -!data/prompt.txt +!data/skills/** !data/vocab_base.txt !requirements.txt !util/tl_inspector/TLInspector.js diff --git a/TODO.md b/TODO.md index 92aaf67..328092f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,19 +1,13 @@ # TODO -## Custom game prompt - -- Keep the default prompt (`data/skills/system.md`) as the base for every game. -- Add an optional **per-game custom prompt** in the UI that merges with the default (custom rules layered on top of shared instructions, not a full replacement unless explicitly chosen). -- Surface this in settings / project setup so each game folder can carry its own tone, terminology notes, or content warnings without forking the global prompt. - ## Local translation cache (game retranslate) - Persist every successful translation locally so a full retranslate from scratch can reuse prior results instead of paying again. -- **Cache key:** hash the translation **payload** (source Japanese text sent to the model) plus target language — same approach as the existing global cache in `util/translation.py` (`get_cache_key`, `log/translation_cache.json`). +- **Cache key:** hash the translation **payload** (source Japanese text sent to the model) plus target language - same approach as the existing global cache in `util.translation` (`get_cache_key`, `log/translation_cache.json`). Payload keys naturally dedupe identical lines across files and survive re-extract / re-import as long as the source string is unchanged. - **Scope:** consider a per-game cache file under the game project (e.g. `/translation_cache.json` or alongside `wolf_json/`) in addition to (or merged with) the tool-wide `log/translation_cache.json`, so caches travel with the game folder and are easy to back up. - **Write path:** on every successful translate (live and batch consume), store `{cache_key: translation}` before or as results land in `translated/`. - **Read path:** before calling the API, check cache; on hit, skip the request and apply the cached translation (respecting file-specific re-expansion where the payload is Japanese-only). - **Retranslate workflow:** when the user re-imports fresh JSON and runs Translate again, cache hits should fill `translated/` automatically for unchanged source lines; only new or edited source text should incur API cost. -- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional “prefer cache” toggle. +- **UI (later):** cache stats (hits / misses / estimated savings), export/import, clear cache for one game, optional "prefer cache" toggle. - **Invalidation:** optional metadata (model id, prompt version) in the key or entry if we need to avoid reusing stale translations after prompt or model changes. diff --git a/data/skills/project_setup.md b/data/skills/project_setup.md index 54ae94d..5e0ccd2 100644 --- a/data/skills/project_setup.md +++ b/data/skills/project_setup.md @@ -28,7 +28,7 @@ Hard rules: 1. Per-character voice → `glossary` only. 2. Category-wide / cross-cutting voice → `translation_quirks` only. 3. Default honorifics policy is owned by the tool base prompt - only note **unusual** honorific habits in quirks. -4. `game_skill` **references** `skills/quirks.md`; do not reprint those rules. +4. `game_skill` **references** `skills/quirks.md` only; do not reprint those rules or cite legacy names like `translation_quirks.txt`. 5. `speakers` is config, not lore. --- @@ -101,10 +101,15 @@ Output as short imperative bullets suitable to paste into `skills/quirks.md`. Produce a durable IDE skill for this title saved at `skills/translation.md`: - Short game identity (tone/genre/content notes) - Which files matter for dialogue/DB in this repo -- Explicit pointer: follow `skills/quirks.md` for voice rules (authoritative) +- A **Voice rules** section that points only at ``skills/quirks.md`` (authoritative) - What not to change (codes, formatting - tool-owned via base prompt) - Do **not** reprint quirks or the glossary +**Path names (required):** +- Voice quirks file: ``skills/quirks.md`` (never ``translation_quirks.txt`` or any other legacy name) +- This IDE skill file: ``skills/translation.md`` +- Optional user overlays (if mentioned): other ``skills/*.md`` files except ``quirks.md`` / ``translation.md`` + @@ -160,6 +165,28 @@ Label the fence language as `translation_quirks`. Inside: imperative bullet list ### Block `game_skill` -Label the fence language as `game_skill`. Inside: full markdown for `skills/translation.md` (identity, file map, quirks pointer, tool boundaries). +Label the fence language as `game_skill`. Inside: full markdown for `skills/translation.md`. + +Required skeleton (fill in the blanks; keep the Voice rules path exactly): + +```markdown +# - Translation skill + +## Identity +<1-3 sentences: tone, genre, content notes> + +## Files that matter +- +- + +## Voice rules +Follow `skills/quirks.md` as the authoritative cross-cutting voice guide for this title. +Do not invent alternate quirk filenames. + +## Tool boundaries +- DazedMTLTool owns formatting codes, wrap, line counts, and honorifics defaults via its base system skill. +- Per-character register lives in the glossary (`vocab.txt`), not here. +- Do not reprint quirks or glossary entries in this file. +``` If known speakers were prepended in a `` block above this skill, prefer those names in the glossary, then cross-check Actors.json for other major named actors. diff --git a/data/skills/system.md b/data/skills/system.md index 3bfb4ad..90cbb5b 100644 --- a/data/skills/system.md +++ b/data/skills/system.md @@ -41,7 +41,7 @@ You will be translating erotic and sexual content. You will receive lines of dia - **Always preserve and enforce Japanese honorifics** in the English translation: -san, -kun, -chan, -senpai, -sensei, -sama, -dono, etc. - Do **not** drop honorifics to make the English sound more "natural" unless the source Japanese also lacks them. -- If `# Game Characters` (or game quirks) specifies how a character addresses someone, follow that address pattern. +- If `# Game Characters` (or `/skills/quirks.md`) specifies how a character addresses someone, follow that address pattern. - The `=` or `=` character in a Japanese name marks a foreign or nickname component. Wrap that part in parentheses. - Example: `バンカー=ベット` → `Bunker (Bet)` - Always translate speaker tags to English: `[クロネ]:` → `[Kurone]:` diff --git a/gui/skills_tab.py b/gui/skills_tab.py index aa4fbb8..2f60bc3 100644 --- a/gui/skills_tab.py +++ b/gui/skills_tab.py @@ -59,8 +59,8 @@ class SkillsTab(QWidget): intro = QLabel( "Tool-level AI instructions live here. " - "Per-game quirks and the game IDE skill are edited in Workflow Step 3 " - "(they travel with the game folder)." + "Per-game quirks, the game IDE skill, and optional custom skills are edited " + "in Workflow Step 2 (they travel with the game folder)." ) intro.setWordWrap(True) intro.setStyleSheet(_HINT_STYLE) diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index a80d5ea..0170e56 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -22,9 +22,13 @@ from pathlib import Path from util.paths import VOCAB_PATH from util.skills import ( + custom_skill_path_for_game, game_skill_path_for_game, + list_custom_skill_paths, load_project_setup, + migrate_game_skill_text, quirks_path_for_game, + sanitize_custom_skill_stem, ) from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR from util.vocab import read_game_vocab, write_game_vocab @@ -42,6 +46,7 @@ from PyQt5.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -52,7 +57,9 @@ from PyQt5.QtWidgets import ( QSizePolicy, QSpinBox, QSplitter, + QStackedWidget, QStyle, + QTabBar, QTabWidget, QTextEdit, QToolButton, @@ -437,8 +444,10 @@ _STEP_HELP: dict[int, str] = { "• speakers → apply flags above
" "• translation_quirks → Quirks tab " "(skills/quirks.md)
" - "• game_skill → Game skill tab " + "• game_skill → Game skills → translation tab " "(skills/translation.md)

" + "Optional: + on Game skills adds custom skills/*.md " + "overlays (merged into the API prompt - can hurt quality; use sparingly).

" "Use one game folder per translation run so quirks stay in the prompt cache." ), 3: ( @@ -1858,21 +1867,77 @@ class WorkflowTab(QWidget): ), "Quirks", ) - editors.addTab( + + # Game skills: built-in translation.md + optional custom overlays + game_skills_page = QWidget() + gs_layout = QVBoxLayout(game_skills_page) + gs_layout.setContentsMargins(0, 0, 0, 0) + gs_layout.setSpacing(0) + + self._custom_skill_editors: dict[str, QTextEdit] = {} + self._game_skills_bar = QTabBar() + self._game_skills_bar.setDocumentMode(True) + self._game_skills_bar.setExpanding(False) + self._game_skills_bar.setDrawBase(False) + self._game_skills_bar.setMinimumHeight(30) + self._game_skills_bar.setStyleSheet( + "QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;" + "border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}" + "QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}" + "QTabBar::tab:hover{color:#ffffff;}" + ) + self._game_skills_stack = QStackedWidget() + self._game_skills_stack.setStyleSheet("background:#1e1e1e;") + self._game_skills_bar.currentChanged.connect(self._game_skills_stack.setCurrentIndex) + + strip = QWidget() + strip.setStyleSheet("background-color:#252526;") + strip_l = QHBoxLayout(strip) + strip_l.setContentsMargins(8, 6, 10, 0) + strip_l.setSpacing(6) + strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom) + + add_btn = QPushButton("+ Add custom") + add_btn.setCursor(Qt.PointingHandCursor) + add_btn.setFixedHeight(30) + add_btn.setToolTip( + "Create a custom skills/*.md overlay.\n" + "Merged into the translation system prompt - can hurt quality." + ) + add_btn.setStyleSheet( + "QPushButton{background-color:#2d2d30;color:#4ec9b0;" + "border:1px solid #3c3c3c;border-bottom:none;" + "border-top-left-radius:3px;border-top-right-radius:3px;" + "padding:0 12px;font-size:12px;font-weight:bold;}" + "QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;" + "border-color:#4ec9b0;}" + "QPushButton:pressed{background-color:#1e1e1e;}" + ) + add_btn.clicked.connect(self._add_custom_skill) + strip_l.addWidget(add_btn, 0, Qt.AlignBottom) + strip_l.addStretch(1) + gs_layout.addWidget(strip) + gs_layout.addWidget(self._game_skills_stack, 1) + + self._add_game_skill_page( + "translation", _editor_page( "/skills/translation.md", - "Paste the game_skill block. IDE companion skill for this game repo.", + "Paste the game_skill block. IDE companion skill for this game repo " + "(not injected into the API prompt).", self._save_game_skill, self._reload_game_skill, "game_skill_editor", ), - "Game skill", ) + + editors.addTab(game_skills_page, "Game skills") layout.addWidget(editors, 1) self._reload_vocab() self._reload_quirks() self._reload_game_skill() + self._reload_custom_skills() def _run_parse_speakers(self): @@ -3122,8 +3187,6 @@ class WorkflowTab(QWidget): self._detected_on_show = True # new folder chosen — treat as already-shown self._ask_clear_old_files() self._detect_folder() - self._reload_quirks() - self._reload_game_skill() def _ask_clear_old_files(self): """Prompt the user to clear /files and /translated to avoid stale data conflicts.""" @@ -3224,6 +3287,9 @@ class WorkflowTab(QWidget): return self._save_setting("last_game_folder", folder) + self._reload_quirks() + self._reload_game_skill() + self._reload_custom_skills() self.detected_label.setText("Scanning…") self.detected_label.setStyleSheet( "color:#9d9d9d;font-size:13px;padding:4px 8px;" @@ -3620,7 +3686,14 @@ class WorkflowTab(QWidget): self.game_skill_editor.setPlainText("") return try: - self.game_skill_editor.setPlainText(path.read_text(encoding="utf-8")) + raw = path.read_text(encoding="utf-8") + fixed = migrate_game_skill_text(raw) + self.game_skill_editor.setPlainText(fixed) + if fixed != raw: + path.write_text(fixed.rstrip() + "\n", encoding="utf-8") + self._log( + f"↺ Updated legacy quirk path in {path.name} → skills/quirks.md" + ) except Exception as exc: self._log(f"❌ Could not load game skill: {exc}") @@ -3631,11 +3704,235 @@ class WorkflowTab(QWidget): path = game_skill_path_for_game(root) try: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(self.game_skill_editor.toPlainText().rstrip() + "\n", encoding="utf-8") + text = migrate_game_skill_text(self.game_skill_editor.toPlainText()) + self.game_skill_editor.setPlainText(text) + path.write_text(text.rstrip() + "\n", encoding="utf-8") self._log(f"✅ Saved {path}") except Exception as exc: self._log(f"❌ Could not save game skill: {exc}") + def _add_game_skill_page(self, title: str, page: QWidget) -> int: + """Add a page to the Game skills bar + stack. Returns the new index.""" + idx = self._game_skills_stack.addWidget(page) + self._game_skills_bar.addTab(title) + return idx + + def _select_game_skill_tab(self, title: str) -> None: + for i in range(self._game_skills_bar.count()): + if self._game_skills_bar.tabText(i) == title: + self._game_skills_bar.setCurrentIndex(i) + return + + def _custom_skill_editor_page(self, stem: str) -> QWidget: + """Editor page for one custom skill file under /skills/.md.""" + page = QWidget() + vl = QVBoxLayout(page) + vl.setContentsMargins(8, 8, 8, 8) + vl.setSpacing(6) + + tip = QLabel( + "Custom skill - merged into the translation system prompt. " + "Extra or conflicting rules can hurt quality." + ) + tip.setWordWrap(True) + tip.setStyleSheet("color:#c9a227;font-size:12px;") + vl.addWidget(tip) + + path_lbl = QLabel(f"/skills/{stem}.md") + path_lbl.setStyleSheet( + "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" + ) + vl.addWidget(path_lbl) + + ed = _PlainPasteTextEdit() + ed.setMinimumHeight(160) + ed.setFont(QFont("Consolas", 9)) + ed.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" + "border:none;padding:8px;" + "selection-background-color:#264f78;}" + ) + self._custom_skill_editors[stem] = ed + vl.addWidget(ed, 1) + + row = QHBoxLayout() + row.setSpacing(8) + save_btn = _make_btn("💾 Save", "#3a7a3a") + save_btn.setFixedWidth(110) + save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s)) + row.addWidget(save_btn) + + reload_btn = _make_btn("↺ Reload", "#555") + reload_btn.setFixedWidth(110) + reload_btn.clicked.connect(lambda _=False, s=stem: self._reload_one_custom_skill(s)) + row.addWidget(reload_btn) + + delete_btn = _make_btn("🗑 Delete", "#8b0000") + delete_btn.setFixedWidth(110) + delete_btn.setToolTip("Delete this custom skill file from the game folder") + delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s)) + row.addWidget(delete_btn) + row.addStretch() + vl.addLayout(row) + return page + + def _reload_custom_skills(self): + """Rebuild custom skill tabs from /skills/*.md (excluding built-ins).""" + bar = getattr(self, "_game_skills_bar", None) + stack = getattr(self, "_game_skills_stack", None) + if bar is None or stack is None: + return + + # Remove existing custom tabs (index 0 is always translation). + while bar.count() > 1: + bar.removeTab(1) + while stack.count() > 1: + w = stack.widget(1) + stack.removeWidget(w) + if w is not None: + w.deleteLater() + self._custom_skill_editors = {} + + root = self.folder_edit.text().strip() + for path in list_custom_skill_paths(root): + stem = path.stem + page = self._custom_skill_editor_page(stem) + self._add_game_skill_page(stem, page) + try: + self._custom_skill_editors[stem].setPlainText( + path.read_text(encoding="utf-8") + ) + except Exception as exc: + self._log(f"❌ Could not load skills/{stem}.md: {exc}") + + def _reload_one_custom_skill(self, stem: str): + root = self.folder_edit.text().strip() + path = custom_skill_path_for_game(root, stem) + ed = self._custom_skill_editors.get(stem) + if ed is None: + return + if not path or not path.is_file(): + ed.setPlainText("") + return + try: + ed.setPlainText(path.read_text(encoding="utf-8")) + except Exception as exc: + self._log(f"❌ Could not load skills/{stem}.md: {exc}") + + def _save_custom_skill(self, stem: str): + root = self._game_root_or_warn() + if not root: + return + path = custom_skill_path_for_game(root, stem) + ed = self._custom_skill_editors.get(stem) + if path is None or ed is None: + self._log("❌ Invalid custom skill.") + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8") + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save skills/{stem}.md: {exc}") + + def _add_custom_skill(self): + root = self._game_root_or_warn() + if not root: + return + + warn = QMessageBox(self) + warn.setIcon(QMessageBox.Warning) + warn.setWindowTitle("Custom skill - quality warning") + warn.setTextFormat(Qt.RichText) + warn.setText( + "Custom skills are merged into the translation system prompt.

" + "Extra or poorly written skills can distract the model and hurt " + "translation quality (conflicting rules, prompt bloat, diluted quirks).

" + "Prefer quirks.md for voice rules and translation.md " + "for IDE guidance. Add a custom skill only if you need a rare, tightly scoped " + "overlay - at your own risk." + ) + warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) + warn.button(QMessageBox.Ok).setText("I understand - continue") + warn.setStyleSheet( + "QMessageBox{background-color:#252526;}" + "QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}" + ) + if warn.exec_() != QMessageBox.Ok: + return + + name, ok = QInputDialog.getText( + self, + "New custom skill", + "Skill file name (letters, numbers, ._- ; saved as /skills/.md):", + ) + if not ok: + return + stem = sanitize_custom_skill_stem(name) + if not stem: + QMessageBox.warning( + self, + "Invalid name", + "Use a short name like battle-log or honorifics_extra.\n" + "Reserved: quirks, translation.", + ) + return + if stem in self._custom_skill_editors: + QMessageBox.information( + self, "Already exists", f"Custom skill '{stem}' is already open." + ) + self._select_game_skill_tab(stem) + return + + path = custom_skill_path_for_game(root, stem) + if path is None: + return + if path.is_file(): + # File exists on disk but tab missing - just reload tabs. + self._reload_custom_skills() + self._select_game_skill_tab(stem) + return + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"# {stem}\n\n" + "# Keep this short and specific. Conflicting rules hurt quality.\n", + encoding="utf-8", + ) + except Exception as exc: + self._log(f"❌ Could not create skills/{stem}.md: {exc}") + return + + page = self._custom_skill_editor_page(stem) + idx = self._add_game_skill_page(stem, page) + self._game_skills_bar.setCurrentIndex(idx) + self._reload_one_custom_skill(stem) + self._log(f"✅ Created custom skill {path}") + + def _delete_custom_skill(self, stem: str): + root = self._game_root_or_warn() + if not root: + return + path = custom_skill_path_for_game(root, stem) + reply = QMessageBox.question( + self, + "Delete custom skill", + f"Delete skills/{stem}.md from the game folder?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + try: + if path and path.is_file(): + path.unlink() + self._log(f"🗑 Deleted {path}") + except Exception as exc: + self._log(f"❌ Could not delete skills/{stem}.md: {exc}") + return + self._reload_custom_skills() + def _read_vocab_speakers(self) -> list[tuple[str, str]]: """Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs.""" vocab_path = VOCAB_PATH diff --git a/util/paths.py b/util/paths.py index 5c1c140..7a9551b 100644 --- a/util/paths.py +++ b/util/paths.py @@ -21,6 +21,8 @@ TRANSLATION_CONTEXTS_PATH = DATA_DIR / "translation_contexts.json" GAME_QUIRKS_RELATIVE = Path("skills") / "quirks.md" LEGACY_QUIRKS_FILENAME = "translation_quirks.txt" GAME_SKILL_RELATIVE = Path("skills") / "translation.md" +# Built-in skill filenames under /skills/ (not user-custom overlays). +GAME_SKILL_RESERVED_NAMES = frozenset({"quirks.md", "translation.md"}) _ROOT_DATA_FILES = ( "vocab.txt", diff --git a/util/skills/__init__.py b/util/skills/__init__.py index e1c22a5..93ab092 100644 --- a/util/skills/__init__.py +++ b/util/skills/__init__.py @@ -5,17 +5,25 @@ from __future__ import annotations from util.skills.contexts import ctx, reload_contexts from util.skills.setup import load_project_setup, skills_dir from util.skills.system import ( + custom_skill_path_for_game, game_skill_path_for_game, + list_custom_skill_paths, load_system_prompt, + migrate_game_skill_text, quirks_path_for_game, + sanitize_custom_skill_stem, ) __all__ = [ "ctx", + "custom_skill_path_for_game", "game_skill_path_for_game", + "list_custom_skill_paths", "load_project_setup", "load_system_prompt", + "migrate_game_skill_text", "quirks_path_for_game", "reload_contexts", + "sanitize_custom_skill_stem", "skills_dir", ] diff --git a/util/skills/system.py b/util/skills/system.py index d145d8b..4e65692 100644 --- a/util/skills/system.py +++ b/util/skills/system.py @@ -3,15 +3,19 @@ from __future__ import annotations import os +import re from pathlib import Path from util.paths import ( GAME_QUIRKS_RELATIVE, GAME_SKILL_RELATIVE, + GAME_SKILL_RESERVED_NAMES, LEGACY_QUIRKS_FILENAME, PROMPT_PATH, ) +_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") + def quirks_path_for_game(game_root: str | Path | None) -> Path | None: """Return ``/skills/quirks.md`` when *game_root* is set. @@ -40,10 +44,83 @@ def game_skill_path_for_game(game_root: str | Path | None) -> Path | None: return Path(game_root) / GAME_SKILL_RELATIVE -def load_system_prompt(game_root: str | Path | None = None) -> str: - """Load ``data/skills/system.md`` and optionally append per-game quirks. +def migrate_game_skill_text(text: str) -> str: + """Rewrite legacy quirk path names inside a game skill markdown body.""" + if not text: + return text + # Common stale pointers from older Project Setup output. + updated = text.replace("translation_quirks.txt", "skills/quirks.md") + updated = updated.replace("`translation_quirks.md`", "`skills/quirks.md`") + # Bare relative quirks.md without skills/ prefix (keep skills/quirks.md intact). + updated = re.sub( + r"(? Path | None: + """Return ``/skills`` when *game_root* is set.""" + if not game_root: + return None + return Path(game_root) / "skills" + + +def sanitize_custom_skill_stem(name: str) -> str | None: + """Return a safe skill stem (no ``.md``) or None if invalid/reserved.""" + raw = (name or "").strip() + if not raw: + return None + if raw.lower().endswith(".md"): + raw = raw[:-3].strip() + if not _SKILL_NAME_RE.match(raw): + return None + filename = f"{raw}.md" + if filename.lower() in {n.lower() for n in GAME_SKILL_RESERVED_NAMES}: + return None + return raw + + +def custom_skill_path_for_game( + game_root: str | Path | None, name: str +) -> Path | None: + """Return ``/skills/.md`` for a valid custom skill name.""" + stem = sanitize_custom_skill_stem(name) + skills = game_skills_dir(game_root) + if not stem or skills is None: + return None + return skills / f"{stem}.md" + + +def list_custom_skill_paths(game_root: str | Path | None) -> list[Path]: + """List user custom skill markdown files under ``/skills/``. + + Excludes built-in ``quirks.md`` and ``translation.md``. + """ + skills = game_skills_dir(game_root) + if skills is None or not skills.is_dir(): + return [] + reserved = {n.lower() for n in GAME_SKILL_RESERVED_NAMES} + out: list[Path] = [] + for path in sorted(skills.glob("*.md"), key=lambda p: p.name.lower()): + if path.name.lower() in reserved: + continue + if path.is_file(): + out.append(path) + return out + + +def load_system_prompt(game_root: str | Path | None = None) -> str: + """Load ``data/skills/system.md`` plus optional per-game overlays. + + Overlay order: + 1. ``skills/quirks.md`` (voice quirks) + 2. Any other ``skills/*.md`` except ``translation.md`` (custom skills) + + Path resolution order for the game root: 1. Explicit *game_root* argument 2. ``DAZED_GAME_ROOT`` environment variable """ @@ -53,14 +130,24 @@ def load_system_prompt(game_root: str | Path | None = None) -> str: root = game_root or os.getenv("DAZED_GAME_ROOT") or "" root = str(root).strip() - quirks_file = quirks_path_for_game(root) if root else None + if not root: + return base + + parts = [base.rstrip()] if base.strip() else [] + + quirks_file = quirks_path_for_game(root) if quirks_file and quirks_file.is_file(): quirks = quirks_file.read_text(encoding="utf-8").strip() if quirks: - return ( - base.rstrip() - + "\n\n## Game-Specific Translation Quirks\n\n" - + quirks - + "\n" - ) - return base + parts.append("## Game-Specific Translation Quirks\n\n" + quirks) + + for path in list_custom_skill_paths(root): + body = path.read_text(encoding="utf-8").strip() + if not body: + continue + title = path.stem.replace("_", " ").replace("-", " ").strip() or path.stem + parts.append(f"## Custom Game Skill: {title}\n\n{body}") + + if not parts: + return base + return "\n\n".join(parts) + "\n"