tiphys.skills

Tiphys skills system.

Provides lazy-loading of skill files for agent knowledge.

 1"""
 2Tiphys skills system.
 3
 4Provides lazy-loading of skill files for agent knowledge.
 5"""
 6
 7from tiphys.skills.loader import (
 8    Skill,
 9    SkillLoader,
10    SkillMetadata,
11    format_skill_index,
12)
13
14__all__ = [
15    "Skill",
16    "SkillLoader",
17    "SkillMetadata",
18    "format_skill_index",
19]
@dataclass
class Skill:
31@dataclass
32class Skill:
33    """A loaded skill with content."""
34
35    metadata: SkillMetadata
36    content: str
37    loaded_at: datetime = field(default_factory=datetime.utcnow)

A loaded skill with content.

Skill( metadata: SkillMetadata, content: str, loaded_at: datetime.datetime = <factory>)
metadata: SkillMetadata
content: str
loaded_at: datetime.datetime
class SkillLoader:
 40class SkillLoader:
 41    """
 42    Loader for skill markdown files.
 43
 44    Skills are lazily loaded from a directory of markdown files.
 45    Each file represents a skill that can be "read" by the agent.
 46    """
 47
 48    def __init__(self, skills_dir: Path | str) -> None:
 49        """
 50        Initialize the skill loader.
 51
 52        Args:
 53            skills_dir: Directory containing skill markdown files.
 54        """
 55        self._skills_dir = Path(skills_dir)
 56        self._metadata_cache: dict[str, SkillMetadata] = {}
 57        self._content_cache: dict[str, Skill] = {}
 58
 59    @property
 60    def skills_dir(self) -> Path:
 61        """Get the skills directory path."""
 62        return self._skills_dir
 63
 64    def scan(self) -> list[SkillMetadata]:
 65        """
 66        Scan the skills directory for available skills.
 67
 68        Returns:
 69            List of skill metadata for all found skills.
 70        """
 71        if not self._skills_dir.exists():
 72            return []
 73
 74        skills: list[SkillMetadata] = []
 75
 76        for file_path in self._skills_dir.glob("**/*.md"):
 77            if file_path.name.startswith("."):
 78                continue
 79
 80            try:
 81                metadata = self._extract_metadata(file_path)
 82                self._metadata_cache[metadata.name] = metadata
 83                skills.append(metadata)
 84            except Exception as e:
 85                logger.warning(
 86                    "Failed to extract skill metadata",
 87                    path=str(file_path),
 88                    error=str(e),
 89                )
 90
 91        return sorted(skills, key=lambda s: s.name)
 92
 93    def get_index(self) -> list[SkillMetadata]:
 94        """
 95        Get the current skill index.
 96
 97        Returns cached metadata or scans if empty.
 98
 99        Returns:
100            List of skill metadata.
101        """
102        if not self._metadata_cache:
103            self.scan()
104
105        return list(self._metadata_cache.values())
106
107    def get_metadata(self, name: str) -> SkillMetadata | None:
108        """
109        Get metadata for a specific skill.
110
111        Args:
112            name: Skill name (filename without extension).
113
114        Returns:
115            Skill metadata or None if not found.
116        """
117        if not self._metadata_cache:
118            self.scan()
119
120        return self._metadata_cache.get(name)
121
122    def load(self, name: str) -> Skill | None:
123        """
124        Load a skill by name.
125
126        Args:
127            name: Skill name (filename without extension).
128
129        Returns:
130            Loaded skill with content, or None if not found.
131        """
132        # Check content cache first
133        if name in self._content_cache:
134            return self._content_cache[name]
135
136        # Get metadata
137        metadata = self.get_metadata(name)
138        if not metadata:
139            return None
140
141        # Load content
142        try:
143            content = metadata.path.read_text(encoding="utf-8")
144            skill = Skill(metadata=metadata, content=content)
145            self._content_cache[name] = skill
146            return skill
147        except Exception as e:
148            logger.error(
149                "Failed to load skill",
150                name=name,
151                path=str(metadata.path),
152                error=str(e),
153            )
154            return None
155
156    def reload(self, name: str) -> Skill | None:
157        """
158        Force reload a skill from disk.
159
160        Args:
161            name: Skill name.
162
163        Returns:
164            Reloaded skill or None if not found.
165        """
166        # Clear caches for this skill
167        self._content_cache.pop(name, None)
168        self._metadata_cache.pop(name, None)
169
170        # Rescan and load
171        self.scan()
172        return self.load(name)
173
174    def clear_cache(self) -> None:
175        """Clear all caches."""
176        self._metadata_cache.clear()
177        self._content_cache.clear()
178
179    def _extract_metadata(self, file_path: Path) -> SkillMetadata:
180        """
181        Extract metadata from a skill file.
182
183        Parses frontmatter if present, otherwise uses defaults.
184        """
185        stat = file_path.stat()
186        name = file_path.stem  # filename without extension
187
188        # Read first few lines to extract metadata
189        try:
190            content = file_path.read_text(encoding="utf-8")
191        except Exception:
192            content = ""
193
194        title = name.replace("_", " ").replace("-", " ").title()
195        description = ""
196        tags: list[str] = []
197
198        # Try to extract frontmatter or first heading
199        lines = content.split("\n")
200
201        # Check for YAML frontmatter
202        if lines and lines[0].strip() == "---":
203            frontmatter_end = -1
204            for i, line in enumerate(lines[1:], 1):
205                if line.strip() == "---":
206                    frontmatter_end = i
207                    break
208
209            if frontmatter_end > 0:
210                frontmatter = "\n".join(lines[1:frontmatter_end])
211                title, description, tags = self._parse_frontmatter(frontmatter, title)
212
213        # Fall back to first heading as title
214        if title == name.replace("_", " ").replace("-", " ").title():
215            for line in lines:
216                line = line.strip()
217                if line.startswith("# "):
218                    title = line[2:].strip()
219                    break
220
221        # Get first paragraph as description if not set
222        if not description:
223            in_content = False
224            for line in lines:
225                line = line.strip()
226                if not line or line.startswith("#") or line.startswith("---"):
227                    if in_content:
228                        break
229                    continue
230                in_content = True
231                description = line
232                break
233
234        return SkillMetadata(
235            name=name,
236            path=file_path,
237            title=title,
238            description=description[:200] if description else "",
239            tags=tags,
240            size_bytes=stat.st_size,
241            modified_at=datetime.fromtimestamp(stat.st_mtime),
242        )
243
244    def _parse_frontmatter(
245        self, frontmatter: str, default_title: str
246    ) -> tuple[str, str, list[str]]:
247        """Parse YAML-like frontmatter."""
248        title = default_title
249        description = ""
250        tags: list[str] = []
251
252        for line in frontmatter.split("\n"):
253            line = line.strip()
254            if ":" in line:
255                key, value = line.split(":", 1)
256                key = key.strip().lower()
257                value = value.strip().strip('"').strip("'")
258
259                if key == "title":
260                    title = value
261                elif key == "description":
262                    description = value
263                elif key == "tags":
264                    # Handle comma-separated or YAML list
265                    if value.startswith("["):
266                        value = value.strip("[]")
267                    tags = [t.strip().strip('"').strip("'") for t in value.split(",")]
268
269        return title, description, tags

Loader for skill markdown files.

Skills are lazily loaded from a directory of markdown files. Each file represents a skill that can be "read" by the agent.

SkillLoader(skills_dir: pathlib.Path | str)
48    def __init__(self, skills_dir: Path | str) -> None:
49        """
50        Initialize the skill loader.
51
52        Args:
53            skills_dir: Directory containing skill markdown files.
54        """
55        self._skills_dir = Path(skills_dir)
56        self._metadata_cache: dict[str, SkillMetadata] = {}
57        self._content_cache: dict[str, Skill] = {}

Initialize the skill loader.

Args: skills_dir: Directory containing skill markdown files.

skills_dir: pathlib.Path
59    @property
60    def skills_dir(self) -> Path:
61        """Get the skills directory path."""
62        return self._skills_dir

Get the skills directory path.

def scan(self) -> list[SkillMetadata]:
64    def scan(self) -> list[SkillMetadata]:
65        """
66        Scan the skills directory for available skills.
67
68        Returns:
69            List of skill metadata for all found skills.
70        """
71        if not self._skills_dir.exists():
72            return []
73
74        skills: list[SkillMetadata] = []
75
76        for file_path in self._skills_dir.glob("**/*.md"):
77            if file_path.name.startswith("."):
78                continue
79
80            try:
81                metadata = self._extract_metadata(file_path)
82                self._metadata_cache[metadata.name] = metadata
83                skills.append(metadata)
84            except Exception as e:
85                logger.warning(
86                    "Failed to extract skill metadata",
87                    path=str(file_path),
88                    error=str(e),
89                )
90
91        return sorted(skills, key=lambda s: s.name)

Scan the skills directory for available skills.

Returns: List of skill metadata for all found skills.

def get_index(self) -> list[SkillMetadata]:
 93    def get_index(self) -> list[SkillMetadata]:
 94        """
 95        Get the current skill index.
 96
 97        Returns cached metadata or scans if empty.
 98
 99        Returns:
100            List of skill metadata.
101        """
102        if not self._metadata_cache:
103            self.scan()
104
105        return list(self._metadata_cache.values())

Get the current skill index.

Returns cached metadata or scans if empty.

Returns: List of skill metadata.

def get_metadata(self, name: str) -> SkillMetadata | None:
107    def get_metadata(self, name: str) -> SkillMetadata | None:
108        """
109        Get metadata for a specific skill.
110
111        Args:
112            name: Skill name (filename without extension).
113
114        Returns:
115            Skill metadata or None if not found.
116        """
117        if not self._metadata_cache:
118            self.scan()
119
120        return self._metadata_cache.get(name)

Get metadata for a specific skill.

Args: name: Skill name (filename without extension).

Returns: Skill metadata or None if not found.

def load(self, name: str) -> Skill | None:
122    def load(self, name: str) -> Skill | None:
123        """
124        Load a skill by name.
125
126        Args:
127            name: Skill name (filename without extension).
128
129        Returns:
130            Loaded skill with content, or None if not found.
131        """
132        # Check content cache first
133        if name in self._content_cache:
134            return self._content_cache[name]
135
136        # Get metadata
137        metadata = self.get_metadata(name)
138        if not metadata:
139            return None
140
141        # Load content
142        try:
143            content = metadata.path.read_text(encoding="utf-8")
144            skill = Skill(metadata=metadata, content=content)
145            self._content_cache[name] = skill
146            return skill
147        except Exception as e:
148            logger.error(
149                "Failed to load skill",
150                name=name,
151                path=str(metadata.path),
152                error=str(e),
153            )
154            return None

Load a skill by name.

Args: name: Skill name (filename without extension).

Returns: Loaded skill with content, or None if not found.

def reload(self, name: str) -> Skill | None:
156    def reload(self, name: str) -> Skill | None:
157        """
158        Force reload a skill from disk.
159
160        Args:
161            name: Skill name.
162
163        Returns:
164            Reloaded skill or None if not found.
165        """
166        # Clear caches for this skill
167        self._content_cache.pop(name, None)
168        self._metadata_cache.pop(name, None)
169
170        # Rescan and load
171        self.scan()
172        return self.load(name)

Force reload a skill from disk.

Args: name: Skill name.

Returns: Reloaded skill or None if not found.

def clear_cache(self) -> None:
174    def clear_cache(self) -> None:
175        """Clear all caches."""
176        self._metadata_cache.clear()
177        self._content_cache.clear()

Clear all caches.

@dataclass
class SkillMetadata:
18@dataclass
19class SkillMetadata:
20    """Metadata about a skill file."""
21
22    name: str
23    path: Path
24    title: str = ""
25    description: str = ""
26    tags: list[str] = field(default_factory=list)
27    size_bytes: int = 0
28    modified_at: datetime | None = None

Metadata about a skill file.

SkillMetadata( name: str, path: pathlib.Path, title: str = '', description: str = '', tags: list[str] = <factory>, size_bytes: int = 0, modified_at: datetime.datetime | None = None)
name: str
path: pathlib.Path
title: str = ''
description: str = ''
tags: list[str]
size_bytes: int = 0
modified_at: datetime.datetime | None = None
def format_skill_index(skills: list[SkillMetadata]) -> str:
272def format_skill_index(skills: list[SkillMetadata]) -> str:
273    """
274    Format skill metadata as a readable index for prompts.
275
276    Args:
277        skills: List of skill metadata.
278
279    Returns:
280        Formatted string listing available skills.
281    """
282    if not skills:
283        return "No skills available."
284
285    lines = ["# Available Skills", ""]
286
287    for skill in skills:
288        line = f"- **{skill.title}** (`{skill.name}`)"
289        if skill.description:
290            line += f": {skill.description}"
291        if skill.tags:
292            line += f" [tags: {', '.join(skill.tags)}]"
293        lines.append(line)
294
295    lines.append("")
296    lines.append("Use `read_skill(name)` to load a skill's content.")
297
298    return "\n".join(lines)

Format skill metadata as a readable index for prompts.

Args: skills: List of skill metadata.

Returns: Formatted string listing available skills.