180 lines
6 KiB
Python
180 lines
6 KiB
Python
"""Load the static system prompt and optional per-game skill overlays."""
|
|
|
|
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_GAME_SKILL_RELATIVE,
|
|
LEGACY_QUIRKS_FILENAME,
|
|
PROMPT_PATH,
|
|
)
|
|
|
|
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
|
|
# Drop legacy IDE-only sections from older game skill files.
|
|
_IDE_SECTION_RE = re.compile(
|
|
r"\n## (?:Voice rules|Tool boundaries)\b.*?(?=\n## |\Z)",
|
|
re.DOTALL | re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def quirks_path_for_game(game_root: str | Path | None) -> Path | None:
|
|
"""Return ``<game_root>/skills/quirks.md`` when *game_root* is set.
|
|
|
|
Also migrates a legacy ``translation_quirks.txt`` in the game root if present.
|
|
"""
|
|
if not game_root:
|
|
return None
|
|
root = Path(game_root)
|
|
preferred = root / GAME_QUIRKS_RELATIVE
|
|
legacy = root / LEGACY_QUIRKS_FILENAME
|
|
if not preferred.is_file() and legacy.is_file():
|
|
preferred.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
legacy.rename(preferred)
|
|
except OSError:
|
|
# Fall back to reading the legacy path if rename fails.
|
|
return legacy
|
|
return preferred
|
|
|
|
|
|
def game_skill_path_for_game(game_root: str | Path | None) -> Path | None:
|
|
"""Return ``<game_root>/skills/game.md`` when *game_root* is set.
|
|
|
|
Migrates legacy ``skills/translation.md`` to ``skills/game.md`` when needed.
|
|
"""
|
|
if not game_root:
|
|
return None
|
|
root = Path(game_root)
|
|
preferred = root / GAME_SKILL_RELATIVE
|
|
legacy = root / LEGACY_GAME_SKILL_RELATIVE
|
|
if not preferred.is_file() and legacy.is_file():
|
|
preferred.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
legacy.rename(preferred)
|
|
except OSError:
|
|
return legacy
|
|
return preferred
|
|
|
|
|
|
def migrate_game_skill_text(text: str) -> str:
|
|
"""Normalize a game skill body for API use (path fixes + drop IDE scaffolding)."""
|
|
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`")
|
|
updated = re.sub(
|
|
r"(?<!skills/)(?<![\w./-])quirks\.md(?![\w.-])",
|
|
"skills/quirks.md",
|
|
updated,
|
|
)
|
|
updated = updated.replace("skills/skills/quirks.md", "skills/quirks.md")
|
|
# Older templates included IDE-only Voice rules / Tool boundaries sections.
|
|
updated = _IDE_SECTION_RE.sub("\n", updated)
|
|
updated = re.sub(r"\n{3,}", "\n\n", updated).strip() + "\n"
|
|
return updated
|
|
|
|
|
|
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 ``game.md`` (and legacy ``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 (when ``DAZED_GAME_ROOT`` / *game_root* is set):
|
|
1. ``skills/game.md`` - Translation Frame / game skill (API)
|
|
2. ``skills/quirks.md`` - cross-cutting voice quirks
|
|
3. other ``skills/*.md`` - optional custom overlays
|
|
|
|
Path resolution order for the game root:
|
|
1. Explicit *game_root* argument
|
|
2. ``DAZED_GAME_ROOT`` environment variable
|
|
"""
|
|
base = ""
|
|
if PROMPT_PATH.is_file():
|
|
base = PROMPT_PATH.read_text(encoding="utf-8")
|
|
|
|
root = game_root or os.getenv("DAZED_GAME_ROOT") or ""
|
|
root = str(root).strip()
|
|
if not root:
|
|
return base
|
|
|
|
parts = [base.rstrip()] if base.strip() else []
|
|
|
|
skill_file = game_skill_path_for_game(root)
|
|
if skill_file and skill_file.is_file():
|
|
skill = migrate_game_skill_text(skill_file.read_text(encoding="utf-8")).strip()
|
|
if skill:
|
|
parts.append("## Game Translation Frame\n\n" + skill)
|
|
|
|
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:
|
|
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"
|