tiphys.skills
Tiphys skills system.
Provides lazy-loading of skill files for agent knowledge.
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.
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.
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.
59 @property 60 def skills_dir(self) -> Path: 61 """Get the skills directory path.""" 62 return self._skills_dir
Get the skills directory path.
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.
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.
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.
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.
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.
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.
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.