Updating stuff

This commit is contained in:
DazedAnon 2026-07-11 11:54:15 -05:00
parent 2e17604718
commit 601c64fe9f
9 changed files with 449 additions and 34 deletions

2
.gitignore vendored
View file

@ -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

10
TODO.md
View file

@ -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. `<game_root>/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.

View file

@ -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``
<!-- /engine:rpgmaker -->
<!-- engine:wolf -->
@ -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
# <Game title> - Translation skill
## Identity
<1-3 sentences: tone, genre, content notes>
## Files that matter
- <dialogue / events paths for this repo>
- <database paths>
## 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 `<known_speakers>` block above this skill, prefer those names in the glossary, then cross-check Actors.json for other major named actors.

View file

@ -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 `<game>/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]:`

View file

@ -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)

View file

@ -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] = {
"• <code>speakers</code> → apply flags above<br>"
"• <code>translation_quirks</code> → Quirks tab "
"(<code>skills/quirks.md</code>)<br>"
"• <code>game_skill</code> → Game skill tab "
"• <code>game_skill</code> → Game skills → translation tab "
"(<code>skills/translation.md</code>)<br><br>"
"Optional: <b>+</b> on Game skills adds custom <code>skills/*.md</code> "
"overlays (merged into the API prompt - can hurt quality; use sparingly).<br><br>"
"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(
"<game>/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 <game>/skills/<stem>.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"<game>/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 <game>/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(
"<b>Custom skills are merged into the translation system prompt.</b><br><br>"
"Extra or poorly written skills can <b>distract the model and hurt "
"translation quality</b> (conflicting rules, prompt bloat, diluted quirks).<br><br>"
"Prefer <code>quirks.md</code> for voice rules and <code>translation.md</code> "
"for IDE guidance. Add a custom skill only if you need a rare, tightly scoped "
"overlay - <b>at your own risk</b>."
)
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 <game>/skills/<name>.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

View file

@ -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 <game>/skills/ (not user-custom overlays).
GAME_SKILL_RESERVED_NAMES = frozenset({"quirks.md", "translation.md"})
_ROOT_DATA_FILES = (
"vocab.txt",

View file

@ -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",
]

View file

@ -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 ``<game_root>/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"(?<!skills/)(?<![\w./-])quirks\.md(?![\w.-])",
"skills/quirks.md",
updated,
)
# Avoid double skills/skills/
updated = updated.replace("skills/skills/quirks.md", "skills/quirks.md")
return updated
Quirks path resolution order:
def game_skills_dir(game_root: str | Path | None) -> Path | None:
"""Return ``<game_root>/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 ``<game_root>/skills/<name>.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 ``<game>/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"
)
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"