Files
2026-07-01 17:56:31 +08:00

296 lines
11 KiB
Python

"""Skill library — loads and manages SKILL.md format skills (nanobot-compatible)."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from loguru import logger
@dataclass
class Skill:
name: str
description: str = ""
always: bool = False
content: str = ""
source_path: str = ""
level: str = "system" # "system" or "project"
metadata: dict[str, Any] = field(default_factory=dict)
# Execution modes under which this skill is visible at all. Empty list
# means "visible everywhere" (backward compat). Non-empty list means
# the skill is filtered out entirely — body *and* description — when
# the current execution mode is not in the list. Use this for skills
# that are only meaningful under a specific runtime context, e.g. a
# collaboration playbook that only applies in company_mode.
modes: list[str] = field(default_factory=list)
class SkillLibrary:
"""Manages skills stored as ``<skill-name>/SKILL.md`` directories.
Two-level loading:
1. System skills — ``opc_home/skills/`` (shared across all projects)
2. Project skills — ``opc_home/projects/<project_id>/skills/``
Project skills with the same name override system skills.
"""
def __init__(self, opc_home: Path) -> None:
self.opc_home = opc_home
self.system_skills_dir = opc_home / "skills"
self.projects_dir = opc_home / "projects"
self._skills: dict[str, Skill] = {}
# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
def load_all(self, project_id: str | None = None) -> None:
"""Scan system + project skill directories and load metadata."""
self._skills.clear()
self._scan_dir(self.system_skills_dir, level="system")
if project_id:
project_skills_dir = self.projects_dir / project_id / "skills"
self._scan_dir(project_skills_dir, level="project")
logger.info(f"Loaded {len(self._skills)} skills")
def _scan_dir(self, base: Path, level: str) -> None:
if not base.exists():
return
for child in sorted(base.iterdir()):
if not child.is_dir():
continue
skill_md = child / "SKILL.md"
if not skill_md.exists():
continue
skill = self._parse_skill_file(skill_md, level=level)
if skill:
self._skills[skill.name] = skill
# ------------------------------------------------------------------
# Accessors
# ------------------------------------------------------------------
def get(self, name: str) -> Skill | None:
return self._skills.get(name)
def list_skills(self) -> list[Skill]:
return list(self._skills.values())
def get_skill_path(self, name: str) -> str | None:
"""Return the SKILL.md path for a given skill name."""
skill = self._skills.get(name)
return skill.source_path if skill else None
def list_project_skills(self, project_id: str) -> list[Skill]:
"""List skills belonging to a specific project (for cross-project recommendations)."""
project_skills_dir = self.projects_dir / project_id / "skills"
skills: list[Skill] = []
if not project_skills_dir.exists():
return skills
for child in sorted(project_skills_dir.iterdir()):
if not child.is_dir():
continue
skill_md = child / "SKILL.md"
if not skill_md.exists():
continue
skill = self._parse_skill_file(skill_md, level="project")
if skill:
skills.append(skill)
return skills
def list_all_project_ids_with_skills(self) -> list[str]:
"""Return project IDs that have a skills/ directory with at least one skill."""
result: list[str] = []
if not self.projects_dir.exists():
return result
for child in sorted(self.projects_dir.iterdir()):
if not child.is_dir():
continue
skills_dir = child / "skills"
if skills_dir.exists() and any(
(d / "SKILL.md").exists() for d in skills_dir.iterdir() if d.is_dir()
):
result.append(child.name)
return result
# ------------------------------------------------------------------
# Summary builder (for system prompt injection)
# ------------------------------------------------------------------
def build_skills_summary(
self,
project_id: str | None = None,
*,
execution_mode: str | None = None,
role_id: str | None = None,
user_facing: bool = False,
final_decider_role_id: str | None = None,
) -> str:
"""Build prompt text: always-on skill bodies + summary list for the rest.
``execution_mode`` is used to filter out skills whose frontmatter
declared a restricted ``modes`` list. Skills with a non-empty
``modes`` list are hidden completely (both body and description)
when the current ``execution_mode`` is not in that list. Skills
with an empty ``modes`` list are always visible.
"""
if project_id:
self.load_all(project_id)
elif not self._skills:
self.load_all()
always_parts: list[str] = []
summary_lines: list[str] = []
for skill in self._skills.values():
if not self._skill_visible_in_mode(
skill,
execution_mode,
role_id=role_id,
user_facing=user_facing,
final_decider_role_id=final_decider_role_id,
):
continue
if skill.always:
always_parts.append(f"## Skill: {skill.name}\n{skill.content}")
else:
summary_lines.append(
f"- **{skill.name}**: {skill.description} [{skill.source_path}]"
)
parts: list[str] = []
if summary_lines or always_parts:
header = (
"## Available Skills\n"
"Below are available skills. To use a skill, read its SKILL.md with `file_read`.\n"
)
if summary_lines:
header += "\n".join(summary_lines)
parts.append(header)
for ap in always_parts:
parts.append(ap)
return "\n\n".join(parts)
# ------------------------------------------------------------------
# Mode filtering
# ------------------------------------------------------------------
@staticmethod
def _skill_visible_in_mode(
skill: Skill,
execution_mode: str | None,
*,
role_id: str | None = None,
user_facing: bool = False,
final_decider_role_id: str | None = None,
) -> bool:
"""Return True if the skill should be visible under ``execution_mode``.
- Skills with no ``modes`` constraint are visible everywhere.
- Skills with a ``modes`` list are visible only when the current
``execution_mode`` (normalized to a non-empty string) is in
that list. A ``None`` or empty mode means the caller has not
supplied a mode yet (e.g. top-level context loading before
routing), and restricted skills are hidden in that case.
"""
current = str(execution_mode or "").strip()
if str(skill.name or "").strip() == "memory":
if current == "task_mode":
return True
if current == "company_mode":
current_role = str(role_id or "").strip()
final_role = str(final_decider_role_id or "").strip()
return bool(user_facing and current_role and final_role and current_role == final_role)
return False
allowed = [str(m).strip() for m in (skill.modes or []) if str(m).strip()]
if not allowed:
return True
return bool(current) and current in allowed
# ------------------------------------------------------------------
# Parsing
# ------------------------------------------------------------------
def _parse_skill_file(self, path: Path, level: str = "system") -> Skill | None:
try:
text = path.read_text(encoding="utf-8")
frontmatter: dict[str, Any] = {}
content = text
fm_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
if fm_match:
frontmatter = yaml.safe_load(fm_match.group(1)) or {}
content = text[fm_match.end():]
name = frontmatter.get("name", path.parent.name)
raw_modes = frontmatter.get("modes", [])
if isinstance(raw_modes, str):
modes_list = [raw_modes.strip()] if raw_modes.strip() else []
elif isinstance(raw_modes, list):
modes_list = [str(m).strip() for m in raw_modes if str(m).strip()]
else:
modes_list = []
return Skill(
name=name,
description=frontmatter.get("description", ""),
always=frontmatter.get("always", False),
content=content.strip(),
source_path=str(path),
level=level,
metadata=frontmatter.get("metadata", {}) or {},
modes=modes_list,
)
except Exception as e:
logger.warning(f"Failed to parse skill {path}: {e}")
return None
# ------------------------------------------------------------------
# Persistence helpers (for skill evolution / creation)
# ------------------------------------------------------------------
def save_skill(self, skill: Skill, project_id: str | None = None) -> None:
"""Save a skill to disk. Project skills go under projects/<id>/skills/."""
if project_id:
target_dir = self.projects_dir / project_id / "skills" / skill.name
else:
target_dir = self.system_skills_dir / skill.name
target_dir.mkdir(parents=True, exist_ok=True)
path = target_dir / "SKILL.md"
fm: dict[str, Any] = {"name": skill.name, "description": skill.description}
if skill.always:
fm["always"] = True
if skill.modes:
fm["modes"] = list(skill.modes)
if skill.metadata:
fm["metadata"] = skill.metadata
text = f"---\n{yaml.dump(fm, default_flow_style=False)}---\n\n{skill.content}"
path.write_text(text, encoding="utf-8")
skill.source_path = str(path)
skill.level = "project" if project_id else "system"
self._skills[skill.name] = skill
logger.info(f"Skill saved: {path}")
def delete_skill(self, name: str) -> bool:
skill = self._skills.get(name)
if not skill or not skill.source_path:
return False
path = Path(skill.source_path)
if not path.exists():
return False
import shutil
skill_dir = path.parent
shutil.rmtree(skill_dir, ignore_errors=True)
self._skills.pop(name, None)
logger.info(f"Skill deleted: {skill_dir}")
return True