CheddahBot/cheddahbot/skills.py

133 lines
3.7 KiB
Python

"""Markdown skill registry.
Skills are .md files in the skills/ directory with YAML frontmatter:
---
name: press-release-writer
description: Write professional press releases
tools: [write_press_releases, submit_press_release]
agents: [writer, default]
---
# Press Release Workflow
...
Files without frontmatter (like companies.md, headlines.md) are data
files and get skipped.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
import yaml
log = logging.getLogger(__name__)
@dataclass
class SkillDef:
name: str
description: str
content: str
file_path: Path
tools: list[str] = field(default_factory=list)
agents: list[str] = field(default_factory=list)
def _parse_frontmatter(text: str) -> tuple[dict, str]:
"""Split YAML frontmatter from markdown content.
Returns (frontmatter_dict, body) or ({}, full_text) if no frontmatter.
"""
if not text.startswith("---"):
return {}, text
end = text.find("---", 3)
if end == -1:
return {}, text
yaml_block = text[3:end].strip()
body = text[end + 3 :].strip()
try:
meta = yaml.safe_load(yaml_block) or {}
except yaml.YAMLError as e:
log.warning("Failed to parse YAML frontmatter: %s", e)
return {}, text
if not isinstance(meta, dict):
return {}, text
return meta, body
class SkillRegistry:
"""Discovers and holds markdown skill definitions."""
def __init__(self, skills_dir: Path):
self._skills: dict[str, SkillDef] = {}
self._skills_dir = skills_dir
self._discover()
def _discover(self):
"""Load all .md files with valid frontmatter from the skills directory."""
if not self._skills_dir.exists():
log.warning("Skills directory not found: %s", self._skills_dir)
return
for path in sorted(self._skills_dir.glob("*.md")):
try:
text = path.read_text(encoding="utf-8")
except Exception as e:
log.warning("Failed to read skill file %s: %s", path.name, e)
continue
meta, body = _parse_frontmatter(text)
if not meta.get("name"):
# No frontmatter or no name — skip (data file)
continue
skill = SkillDef(
name=meta["name"],
description=meta.get("description", ""),
content=body,
file_path=path,
tools=meta.get("tools", []),
agents=meta.get("agents", []),
)
self._skills[skill.name] = skill
log.info("Loaded skill: %s (%s)", skill.name, path.name)
def get(self, name: str) -> SkillDef | None:
return self._skills.get(name)
def list_skills(self) -> list[SkillDef]:
return list(self._skills.values())
def get_prompt_section(self, agent_name: str = "default") -> str:
"""Build a prompt section with skills relevant to an agent.
If a skill's agents list is empty, it's available to all agents.
Otherwise, only agents listed in the skill's agents list get it.
"""
parts = []
for skill in self._skills.values():
if skill.agents and agent_name not in skill.agents:
continue
parts.append(
f"### Skill: {skill.name}\n"
f"{skill.description}\n"
)
if not parts:
return ""
return "# Available Skills\n" + "\n".join(parts)
def get_body(self, name: str) -> str:
"""Get the body content of a skill (without frontmatter)."""
skill = self._skills.get(name)
return skill.content if skill else ""