File size: 6,712 Bytes
81aa0b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""Skills system β€” load markdown skill files at runtime.

Inspired by Claude Code's Skill system. Each skill is a directory with:
- SKILL.md: the skill instructions (markdown with YAML frontmatter)
- references/ (optional): supporting docs
- scripts/ (optional): helper scripts

Skills are discovered under code/skills/builtins/ and can also be loaded
from the workspace's .sonicoder/skills/ directory.
"""

from __future__ import annotations

import logging
import os
import re
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

# ─── Skill discovery roots ──────────────────────────────────────────────

_BUILTIN_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "builtins")
_USER_SKILLS_DIRNAME = ".sonicoder/skills"  # relative to workspace root


def _skill_dirs() -> list[str]:
    """Return all directories to search for skills."""
    dirs = [_BUILTIN_SKILLS_DIR]
    # Add user skills dir from workspace
    try:
        from code.tools.fs import get_workspace_root
        user_dir = os.path.join(get_workspace_root(), _USER_SKILLS_DIRNAME)
        if os.path.isdir(user_dir):
            dirs.append(user_dir)
    except Exception:
        pass
    return dirs


# ─── YAML frontmatter parsing ───────────────────────────────────────────

_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)


def _parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
    """Parse YAML frontmatter from markdown. Returns (metadata, body)."""
    match = _FRONTMATTER_RE.match(content)
    if not match:
        return {}, content

    raw_yaml = match.group(1)
    body = match.group(2)

    # Very simple YAML parser (key: value pairs only)
    meta: dict[str, str] = {}
    for line in raw_yaml.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        if ":" in line:
            key, _, value = line.partition(":")
            meta[key.strip()] = value.strip().strip("\"'")
    return meta, body


# ─── Skill loading ──────────────────────────────────────────────────────

def _load_skill(skill_dir: str) -> dict[str, Any] | None:
    """Load a single skill from a directory."""
    skill_md = os.path.join(skill_dir, "SKILL.md")
    if not os.path.isfile(skill_md):
        return None

    try:
        with open(skill_md, "r", encoding="utf-8") as f:
            content = f.read()
    except Exception as exc:
        logger.warning("Failed to read %s: %s", skill_md, exc)
        return None

    meta, body = _parse_frontmatter(content)

    # Load any reference files
    references: dict[str, str] = {}
    refs_dir = os.path.join(skill_dir, "references")
    if os.path.isdir(refs_dir):
        for fname in os.listdir(refs_dir):
            if fname.endswith((".md", ".txt")):
                try:
                    with open(os.path.join(refs_dir, fname), "r", encoding="utf-8") as f:
                        references[fname] = f.read()
                except Exception:
                    pass

    return {
        "name": meta.get("name", os.path.basename(skill_dir)),
        "description": meta.get("description", ""),
        "language": meta.get("language", ""),
        "tags": [t.strip() for t in meta.get("tags", "").split(",") if t.strip()],
        "body": body.strip(),
        "references": references,
        "path": skill_dir,
    }


def list_skills() -> list[dict[str, Any]]:
    """List all available skills (metadata only, no body)."""
    skills: list[dict[str, Any]] = []
    seen_names: set[str] = set()

    for skills_dir in _skill_dirs():
        if not os.path.isdir(skills_dir):
            continue
        for entry in sorted(os.listdir(skills_dir)):
            entry_path = os.path.join(skills_dir, entry)
            if not os.path.isdir(entry_path):
                continue
            skill = _load_skill(entry_path)
            if skill and skill["name"] not in seen_names:
                seen_names.add(skill["name"])
                skills.append({
                    "name": skill["name"],
                    "description": skill["description"],
                    "language": skill["language"],
                    "tags": skill["tags"],
                })
    return skills


def get_skill(name: str) -> dict[str, Any] | None:
    """Get full skill content by name."""
    for skills_dir in _skill_dirs():
        if not os.path.isdir(skills_dir):
            continue
        # Try directory match
        for entry in os.listdir(skills_dir):
            entry_path = os.path.join(skills_dir, entry)
            if not os.path.isdir(entry_path):
                continue
            skill = _load_skill(entry_path)
            if skill and skill["name"] == name:
                return skill
    return None


def invoke_skill(name: str) -> dict[str, Any]:
    """Invoke a skill by name β€” returns its full body and references."""
    skill = get_skill(name)
    if not skill:
        return {
            "success": False,
            "error": f"Skill not found: {name}",
            "available": [s["name"] for s in list_skills()],
        }
    return {
        "success": True,
        "name": skill["name"],
        "description": skill["description"],
        "body": skill["body"],
        "references": skill["references"],
    }


def build_skills_context(skill_names: list[str] | None = None) -> str:
    """Build a context string with skill bodies to inject into the prompt.

    If skill_names is None, includes all skills (brief listing only).
    """
    if not skill_names:
        # List all skills briefly
        skills = list_skills()
        if not skills:
            return ""
        lines = ["Available skills (use /skill <name> to load full instructions):"]
        for s in skills:
            desc = s["description"][:120]
            lines.append(f"- {s['name']}: {desc}")
        return "\n".join(lines)

    # Load full bodies for requested skills
    parts: list[str] = []
    for name in skill_names:
        skill = get_skill(name)
        if skill:
            parts.append(f"# Skill: {skill['name']}\n\n{skill['body']}")
            for ref_name, ref_body in skill["references"].items():
                parts.append(f"\n## Reference: {ref_name}\n\n{ref_body}")
        else:
            parts.append(f"# Skill: {name}\n\n(Skill not found)")
    return "\n\n---\n\n".join(parts)