"""Skills engine — list, view, manage, and register skill tools.""" from __future__ import annotations import logging import os import re import shutil from pathlib import Path import yaml logger = logging.getLogger("shira.skills") SKILLS_DIR = os.environ.get("SHIRA_DATA_DIR", "/opt/data") + "/skills" # Baked-in copy of pre-seeded skills, kept inside the image at /app/config/skills/. # Source-of-truth for skills shipped with the repo. Never under the persistent # volume mount, so it survives across deploys and is read-only at runtime. BAKED_SKILLS_DIR = Path(__file__).resolve().parents[2] / "config" / "skills" _SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$") def _safe_skill_name(name: str) -> str: """Validate and normalize a skill name for use in file paths.""" name = name.lower().strip() if not name or not _SAFE_NAME_RE.match(name): raise ValueError(f"Invalid skill name: {name!r}") return name def seed_skills_from_image() -> tuple[int, int]: """Copy any baked-in skill that doesn't already exist in the persistent skills dir. Runs at startup. Newly pre-seeded skills (added to config/skills/ in the repo) appear after the next deploy. Existing skills — whether learned by the model or edited by a user — are never touched. Returns (added, skipped). """ target = Path(SKILLS_DIR) target.mkdir(parents=True, exist_ok=True) if not BAKED_SKILLS_DIR.is_dir(): return 0, 0 added = skipped = 0 for src in sorted(BAKED_SKILLS_DIR.iterdir()): if not (src.is_dir() and (src / "SKILL.md").is_file()): continue dst = target / src.name if dst.exists(): skipped += 1 continue shutil.copytree(src, dst) added += 1 logger.info("[skills] seeded baked skill: %s", src.name) return added, skipped def _parse_frontmatter(text: str) -> tuple[dict, str]: """Parse YAML frontmatter from a SKILL.md file. Returns (meta, body).""" if not text.startswith("---"): return {}, text end = text.find("---", 3) if end == -1: return {}, text try: meta = yaml.safe_load(text[3:end]) or {} except yaml.YAMLError: meta = {} body = text[end + 3:].lstrip("\n") return meta, body def list_skills() -> list[dict]: """Scan the skills directory and return metadata for each skill.""" skills_path = Path(SKILLS_DIR) if not skills_path.exists(): return [] result = [] for entry in sorted(skills_path.iterdir()): skill_file = entry / "SKILL.md" if entry.is_dir() else None if not skill_file or not skill_file.exists(): continue try: meta, _ = _parse_frontmatter(skill_file.read_text(encoding="utf-8")) result.append({ "name": meta.get("name", entry.name), "description": meta.get("description", ""), "version": meta.get("version", "1.0.0"), }) except Exception as e: logger.warning("Failed to parse skill %s: %s", entry.name, e) return result def view_skill(skill_name: str) -> str | None: """Load the full SKILL.md content for a given skill name.""" skill_file = Path(SKILLS_DIR) / _safe_skill_name(skill_name) / "SKILL.md" if not skill_file.exists(): return None return skill_file.read_text(encoding="utf-8") def save_skill(skill_name: str, content: str) -> None: """Create or overwrite a skill file.""" safe_name = _safe_skill_name(skill_name) skill_dir = Path(SKILLS_DIR) / safe_name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") logger.info("Saved skill: %s", safe_name) async def _sync_skill_to_crm(skill_name: str, content: str) -> None: """Write-through to AssistantSkill entity in EspoCRM. Best-effort.""" import os import httpx base = (os.environ.get("ESPOCRM_URL") or "").rstrip("/") key = os.environ.get("ESPOCRM_API_KEY") or "" if not base or not key: return # Parse description + triggers from frontmatter for the metadata columns. fm, body = _parse_frontmatter(content) description = (fm.get("description") or "")[:500] triggers = fm.get("triggers", "") if isinstance(triggers, list): triggers = ", ".join(str(t) for t in triggers) version = str(fm.get("version") or "1.0")[:20] headers = {"X-Api-Key": key, "Content-Type": "application/json"} try: async with httpx.AsyncClient(timeout=15.0) as client: r = await client.get( f"{base}/api/v1/AssistantSkill", headers=headers, params={ "where[0][type]": "equals", "where[0][attribute]": "name", "where[0][value]": skill_name, "maxSize": 1, }, ) existing = (r.json().get("list", []) if r.status_code == 200 else []) row = existing[0] if existing else None payload = { "description": description, "triggers": triggers, "content": content, "version": version, "isActive": True, } if row: resp = await client.put(f"{base}/api/v1/AssistantSkill/{row['id']}", headers=headers, json=payload) else: payload["name"] = skill_name resp = await client.post(f"{base}/api/v1/AssistantSkill", headers=headers, json=payload) if resp.status_code >= 400: logger.warning("AssistantSkill sync HTTP %s: %s", resp.status_code, resp.text[:200]) except Exception as e: logger.warning("AssistantSkill sync failed: %s", e) def register_skill_tools(tools: dict) -> None: """Register skill-related tools into the tools dict.""" async def skills_list() -> str: import json skills = list_skills() if not skills: return "אין skills זמינים כרגע." lines = ["Skills זמינים:"] for s in skills: lines.append(f'- **{s["name"]}**: {s["description"]}') return "\n".join(lines) async def skill_view(skill_name: str) -> str: content = view_skill(skill_name) if content is None: return f'❌ Skill "{skill_name}" לא נמצא.' return content async def skill_manage(action: str, skill_name: str, content: str = "") -> str: if action == "create": if not content: return "❌ צריך לספק תוכן ל-skill חדש." existing = view_skill(skill_name) if existing: return f'❌ Skill "{skill_name}" כבר קיים. השתמש ב-action "update" כדי לעדכן.' save_skill(skill_name, content) await _sync_skill_to_crm(skill_name, content) return f'✅ Skill "{skill_name}" נוצר בהצלחה.' elif action == "update": existing = view_skill(skill_name) if not existing: return f'❌ Skill "{skill_name}" לא נמצא. השתמש ב-action "create" כדי ליצור.' if not content: return "❌ צריך לספק תוכן מעודכן." save_skill(skill_name, content) await _sync_skill_to_crm(skill_name, content) return f'✅ Skill "{skill_name}" עודכן בהצלחה.' else: return f'❌ פעולה לא מוכרת: {action}. השתמש ב-"create" או "update".' tools["skills_list"] = { "description": "List all available skills (learned workflows and templates). Returns skill names and descriptions.", "parameters": {"type": "object", "properties": {}, "required": []}, "handler": skills_list, } tools["skill_view"] = { "description": "View the full content of a specific skill. Use after skills_list to load a relevant workflow before a complex task.", "parameters": { "type": "object", "properties": { "skill_name": {"type": "string", "description": "The skill name (from skills_list)"}, }, "required": ["skill_name"], }, "handler": skill_view, } tools["skill_manage"] = { "description": "Create or update a skill. Use when you discover a reusable workflow pattern during a conversation.", "parameters": { "type": "object", "properties": { "action": {"type": "string", "enum": ["create", "update"], "description": "Create new or update existing"}, "skill_name": {"type": "string", "description": "Skill name (lowercase-dashes)"}, "content": {"type": "string", "description": "Full skill content in markdown with YAML frontmatter"}, }, "required": ["action", "skill_name", "content"], }, "handler": skill_manage, }