my_agent/skills.py

86 lines
2.6 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import yaml
@dataclass(slots=True)
class Skill:
name: str
description: str
path: Path
content: str
metadata: Dict[str, Any] = field(default_factory=dict)
def summary(self) -> Dict[str, str]:
return {
"name": self.name,
"description": self.description,
"path": str(self.path),
}
class SkillStore:
"""Loads skills from one or more directories using SKILL.md frontmatter."""
def __init__(self, roots: Iterable[str | Path]) -> None:
self.roots = [Path(root).resolve() for root in roots]
def list_skills(self) -> List[Dict[str, str]]:
return [skill.summary() for skill in self._discover()]
def load_skill(self, name: str) -> Skill:
normalized = name.strip().lower()
for skill in self._discover():
if skill.name.lower() == normalized:
return skill
raise KeyError(f"Skill not found: {name}")
def build_prompt_fragment(self, active_skills: Iterable[str]) -> str:
chunks: List[str] = []
for skill_name in active_skills:
skill = self.load_skill(skill_name)
chunks.append(f"## Skill: {skill.name}\n{skill.content.strip()}")
return "\n\n".join(chunks)
def _discover(self) -> List[Skill]:
skills: List[Skill] = []
for root in self.roots:
if not root.exists():
continue
for path in sorted(root.rglob("SKILL.md")):
parsed = self._parse_skill(path)
if parsed:
skills.append(parsed)
return skills
def _parse_skill(self, path: Path) -> Optional[Skill]:
raw = path.read_text(encoding="utf-8")
frontmatter, body = _split_frontmatter(raw)
data = yaml.safe_load(frontmatter) if frontmatter else {}
if not isinstance(data, dict):
data = {}
name = str(data.get("name") or path.parent.name).strip()
description = str(data.get("description") or "").strip()
return Skill(
name=name,
description=description,
path=path,
content=body.strip(),
metadata=data,
)
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
def _split_frontmatter(raw: str) -> tuple[str, str]:
match = _FRONTMATTER_RE.match(raw)
if not match:
return "", raw
return match.group(1), match.group(2)