feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
20
coworker/skills/__init__.py
Normal file
20
coworker/skills/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from .base import Skill, SkillLoader, skill_catalog_text, skill_tools
|
||||
from .store import (
|
||||
SessionSkillStore,
|
||||
SkillStore,
|
||||
effective_skills,
|
||||
save_skill_tool,
|
||||
validate_name,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillLoader",
|
||||
"skill_catalog_text",
|
||||
"skill_tools",
|
||||
"SkillStore",
|
||||
"SessionSkillStore",
|
||||
"effective_skills",
|
||||
"save_skill_tool",
|
||||
"validate_name",
|
||||
]
|
||||
145
coworker/skills/base.py
Normal file
145
coworker/skills/base.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Skill loading — Anthropic SKILL.md format with progressive disclosure.
|
||||
|
||||
A skill is a folder containing `SKILL.md` (YAML frontmatter: name, description,
|
||||
optional allowed-tools) + a markdown body of instructions + optional resources/scripts.
|
||||
|
||||
Progressive disclosure: at session start only the catalog (name + description) is injected
|
||||
into the agent's context; the full body is loaded on demand via the `load_skill` tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Union
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skill:
|
||||
name: str
|
||||
description: str
|
||||
instructions: str = "" # full body — loaded on demand
|
||||
path: Optional[str] = None
|
||||
allowed_tools: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SkillLoader:
|
||||
def __init__(self, dirs: list[str | Path]) -> None:
|
||||
self._dirs = [Path(d) for d in dirs]
|
||||
self._skills: dict[str, Skill] = {}
|
||||
self.rescan()
|
||||
|
||||
def rescan(self) -> None:
|
||||
"""Re-read the skill dirs. load_skill rescans on a miss so a skill created AFTER
|
||||
the session's engine was built is still loadable (the catalog line stays static
|
||||
until the next session, but an explicitly requested skill must not 404)."""
|
||||
self._skills = {}
|
||||
for directory in self._dirs:
|
||||
self._discover(directory)
|
||||
|
||||
def _discover(self, directory: Path) -> None:
|
||||
if not directory.is_dir():
|
||||
return
|
||||
for sub in sorted(directory.iterdir()):
|
||||
md = sub / "SKILL.md"
|
||||
if md.is_file():
|
||||
skill = _parse_skill(md)
|
||||
self._skills[skill.name] = skill
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._skills)
|
||||
|
||||
def get(self, name: str) -> Optional[Skill]:
|
||||
return self._skills.get(name)
|
||||
|
||||
def catalog(self) -> list[dict]:
|
||||
return [
|
||||
{"name": s.name, "description": s.description}
|
||||
for s in self._skills.values()
|
||||
]
|
||||
|
||||
|
||||
def _parse_skill(md: Path) -> Skill:
|
||||
text = md.read_text(encoding="utf-8")
|
||||
name, description, allowed, body = md.parent.name, "", [], text
|
||||
if text.startswith("---"):
|
||||
end = text.find("\n---", 3)
|
||||
if end != -1:
|
||||
frontmatter = text[3:end]
|
||||
body = text[end + 4 :].lstrip("\n")
|
||||
for line in frontmatter.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
key, value = key.strip().lower(), value.strip()
|
||||
if key == "name" and value:
|
||||
name = value
|
||||
elif key == "description":
|
||||
description = value
|
||||
elif key in ("allowed-tools", "allowed_tools"):
|
||||
allowed = [t.strip() for t in value.split(",") if t.strip()]
|
||||
return Skill(
|
||||
name=name,
|
||||
description=description,
|
||||
instructions=body.strip(),
|
||||
path=str(md.parent),
|
||||
allowed_tools=allowed,
|
||||
)
|
||||
|
||||
|
||||
def skill_catalog_text(
|
||||
loader: SkillLoader, allowed: Optional[set[str]] = None
|
||||
) -> str:
|
||||
catalog = [
|
||||
c for c in loader.catalog() if allowed is None or c["name"] in allowed
|
||||
]
|
||||
if not catalog:
|
||||
return ""
|
||||
lines = [f"- {c['name']}: {c['description']}" for c in catalog]
|
||||
return (
|
||||
"Available skills — call load_skill(name) to load one's full instructions when "
|
||||
"it's relevant to the task:\n" + "\n".join(lines)
|
||||
)
|
||||
|
||||
|
||||
AllowedSkills = Union[set, Callable[[], set], None]
|
||||
|
||||
|
||||
def skill_tools(loader: SkillLoader, allowed: AllowedSkills = None) -> list:
|
||||
"""`allowed` gates load_skill: a set is a build-time snapshot; a CALLABLE is consulted
|
||||
on every call — the manager passes one so Settings disables apply to live sessions
|
||||
immediately, and skills created after the engine was built are still loadable
|
||||
(loader rescans on a miss)."""
|
||||
|
||||
def _allowed_now() -> Optional[set]:
|
||||
return allowed() if callable(allowed) else allowed
|
||||
|
||||
def load_skill(name: str) -> dict:
|
||||
"""Load a skill's full instructions + resources path by name. Call this when a
|
||||
skill from the catalog is relevant to the current task."""
|
||||
skill = loader.get(name)
|
||||
if skill is None:
|
||||
loader.rescan() # created after this session started? pick it up now
|
||||
skill = loader.get(name)
|
||||
gate = _allowed_now()
|
||||
if skill is None or (gate is not None and name not in gate):
|
||||
available = sorted(
|
||||
n for n in loader.names() if gate is None or n in gate
|
||||
)
|
||||
return {"error": f"unknown skill: {name}", "available": available}
|
||||
return {
|
||||
"name": skill.name,
|
||||
"instructions": skill.instructions,
|
||||
"resources_path": skill.path,
|
||||
}
|
||||
|
||||
return [
|
||||
ai.tool(
|
||||
load_skill,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="skills", risk_level="low", capabilities=["load_skill"]
|
||||
),
|
||||
)
|
||||
]
|
||||
620
coworker/skills/store.py
Normal file
620
coworker/skills/store.py
Normal file
@@ -0,0 +1,620 @@
|
||||
"""Skill management — CRUD over skill folders + per-session mutes (SKILLS-SPEC §4).
|
||||
|
||||
Scope = folder location (folder-is-truth): global skills live in ``state_dir()/skills``,
|
||||
project skills in ``<workspace>/.coworker/skills``. There is no database; every operation
|
||||
is a folder + ``SKILL.md`` operation, which keeps project skills shareable via git for free.
|
||||
|
||||
Disable state is deliberately NOT a marker inside the skill folder: project folders travel
|
||||
with the repo and one user's disable must not be committed to teammates. It lives in the
|
||||
personal ``state_dir()/skills-settings.json`` instead.
|
||||
|
||||
Uploads are staged (parse → preview → confirm) so the user always reviews exactly what will
|
||||
be saved before anything lands in a scope dir. Staged content sits under
|
||||
``state_dir()/skills-staged/<token>`` until confirmed or discarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from ..secrets import state_dir
|
||||
from .base import Skill, _parse_skill
|
||||
|
||||
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_MAX_NAME = 64
|
||||
GLOBAL_SCOPE = "global"
|
||||
PROJECT_SCOPE = "project"
|
||||
|
||||
|
||||
def validate_name(name: str) -> str:
|
||||
"""Skill names become folder names — reject anything that could escape the scope dir."""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Skill name is required.")
|
||||
if len(name) > _MAX_NAME:
|
||||
raise ValueError(f"Skill name too long (limit {_MAX_NAME} characters).")
|
||||
if ".." in name or "/" in name or "\\" in name or not _NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
"Skill name may only contain letters, digits, dots, dashes, and underscores."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _frontmatter_source(md: Path) -> str:
|
||||
"""Read the optional ``source:`` frontmatter key (``uploaded`` etc.). Absent → created here."""
|
||||
try:
|
||||
text = md.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
if not text.startswith("---"):
|
||||
return ""
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return ""
|
||||
for line in text[3:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "source":
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _write_skill_md(
|
||||
folder: Path, *, name: str, description: str, instructions: str, source: str = ""
|
||||
) -> None:
|
||||
lines = ["---", f"name: {name}", f"description: {description}"]
|
||||
if source:
|
||||
lines.append(f"source: {source}")
|
||||
lines += ["---", "", instructions.strip(), ""]
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "SKILL.md").write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
class SkillStore:
|
||||
"""Folder-backed skill CRUD across the global + project scopes."""
|
||||
|
||||
def __init__(self, global_dir: Optional[str | Path] = None) -> None:
|
||||
self.global_dir = Path(global_dir) if global_dir else state_dir() / "skills"
|
||||
self._settings_path = state_dir() / "skills-settings.json"
|
||||
self._staging_dir = state_dir() / "skills-staged"
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- scope dirs ---------------------------------------------------------------
|
||||
def project_dir(self, workspace: str | Path) -> Path:
|
||||
return Path(workspace).expanduser().resolve() / ".coworker" / "skills"
|
||||
|
||||
def _base(self, scope: str, workspace: Optional[str | Path]) -> Path:
|
||||
if scope == GLOBAL_SCOPE:
|
||||
return self.global_dir
|
||||
if scope == PROJECT_SCOPE:
|
||||
if not workspace:
|
||||
raise ValueError("A workspace is required for a project-scoped skill.")
|
||||
ws = Path(workspace).expanduser()
|
||||
if not ws.is_dir():
|
||||
raise ValueError(f"Unknown workspace: {workspace}")
|
||||
return self.project_dir(ws)
|
||||
raise ValueError(f"Unknown scope: {scope}")
|
||||
|
||||
def _folder_of(self, base: Path, name: str) -> Path:
|
||||
"""The skill's folder, guarded against escaping its scope dir (symlinked folders
|
||||
that resolve elsewhere are treated as absent rather than followed)."""
|
||||
folder = base / name
|
||||
try:
|
||||
resolved = folder.resolve()
|
||||
base_resolved = base.resolve()
|
||||
except OSError:
|
||||
raise ValueError(f"Unreadable skill folder: {name}")
|
||||
if base_resolved not in resolved.parents and resolved != base_resolved / name:
|
||||
raise ValueError(f"Skill folder escapes its scope: {name}")
|
||||
return folder
|
||||
|
||||
# -- queries ------------------------------------------------------------------
|
||||
def find(
|
||||
self, name: str, workspace: Optional[str | Path] = None
|
||||
) -> tuple[Path, str]:
|
||||
"""Locate a skill by name, most-local first (project before global) — mirrors the
|
||||
loader's collision precedence so management operates on the copy the model sees."""
|
||||
name = validate_name(name)
|
||||
if workspace:
|
||||
project = self.project_dir(Path(workspace).expanduser())
|
||||
if (project / name / "SKILL.md").is_file():
|
||||
return self._folder_of(project, name), PROJECT_SCOPE
|
||||
if (self.global_dir / name / "SKILL.md").is_file():
|
||||
return self._folder_of(self.global_dir, name), GLOBAL_SCOPE
|
||||
raise ValueError(f"Unknown skill: {name}")
|
||||
|
||||
def rows(self, workspace: Optional[str | Path] = None) -> list[dict[str, Any]]:
|
||||
"""Enriched listing for the Settings screen: scope, source, enabled. Global first,
|
||||
then project (a project row with a colliding name is the effective copy)."""
|
||||
disabled = self.disabled_names()
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
scopes: list[tuple[Path, str]] = [(self.global_dir, GLOBAL_SCOPE)]
|
||||
if workspace:
|
||||
scopes.append((self.project_dir(Path(workspace).expanduser()), PROJECT_SCOPE))
|
||||
for base, scope in scopes:
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for sub in sorted(base.iterdir()):
|
||||
md = sub / "SKILL.md"
|
||||
if not md.is_file():
|
||||
continue
|
||||
skill = _parse_skill(md)
|
||||
try:
|
||||
# Bundled resources beyond SKILL.md (§6): a rich skill must not look
|
||||
# identical to a one-file one in the Settings list.
|
||||
bundled = sum(1 for p in sub.rglob("*") if p.is_file()) - 1
|
||||
except OSError:
|
||||
bundled = 0
|
||||
row = {
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions, # Settings editor prefill
|
||||
"scope": scope,
|
||||
"source": _frontmatter_source(md) or "local",
|
||||
"enabled": skill.name not in disabled,
|
||||
"path": str(sub),
|
||||
"files": max(bundled, 0),
|
||||
}
|
||||
if skill.name in seen: # project copy shadows the global one
|
||||
out[seen[skill.name]] = row
|
||||
else:
|
||||
seen[skill.name] = len(out)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
# -- mutations ----------------------------------------------------------------
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str,
|
||||
instructions: str,
|
||||
scope: str = GLOBAL_SCOPE,
|
||||
workspace: Optional[str | Path] = None,
|
||||
source: str = "",
|
||||
) -> dict[str, Any]:
|
||||
name = validate_name(name)
|
||||
description = (description or "").strip()
|
||||
if not (instructions or "").strip():
|
||||
raise ValueError("Skill instructions are required.")
|
||||
base = self._base(scope, workspace)
|
||||
folder = self._folder_of(base, name)
|
||||
if (folder / "SKILL.md").is_file():
|
||||
raise ValueError(f"A skill named '{name}' already exists in that scope.")
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=name,
|
||||
description=description,
|
||||
instructions=instructions,
|
||||
source=source,
|
||||
)
|
||||
return {"name": name, "scope": scope, "path": str(folder)}
|
||||
|
||||
def update(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
instructions: Optional[str] = None,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite SKILL.md fields in place; sibling resource files are untouched."""
|
||||
folder, scope = self.find(name, workspace)
|
||||
current = _parse_skill(folder / "SKILL.md")
|
||||
if instructions is not None and not instructions.strip():
|
||||
raise ValueError("Skill instructions are required.")
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=current.name,
|
||||
description=(
|
||||
description if description is not None else current.description
|
||||
),
|
||||
instructions=(
|
||||
instructions if instructions is not None else current.instructions
|
||||
),
|
||||
source=_frontmatter_source(folder / "SKILL.md"),
|
||||
)
|
||||
return {"name": current.name, "scope": scope}
|
||||
|
||||
def delete(self, name: str, workspace: Optional[str | Path] = None) -> None:
|
||||
folder, _scope = self.find(name, workspace)
|
||||
if folder.is_symlink(): # never follow a link out of the scope dir
|
||||
folder.unlink()
|
||||
return
|
||||
shutil.rmtree(folder)
|
||||
|
||||
def move(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
to_scope: str,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
folder, from_scope = self.find(name, workspace)
|
||||
if from_scope == to_scope:
|
||||
return {"name": name, "scope": to_scope}
|
||||
target_base = self._base(to_scope, workspace)
|
||||
target = self._folder_of(target_base, name)
|
||||
if (target / "SKILL.md").is_file():
|
||||
raise ValueError(
|
||||
f"A skill named '{name}' already exists in the target scope."
|
||||
)
|
||||
target_base.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(folder), str(target))
|
||||
return {"name": name, "scope": to_scope}
|
||||
|
||||
# -- enable / disable (personal, survives restarts) -----------------------------
|
||||
def disabled_names(self) -> set[str]:
|
||||
try:
|
||||
data = json.loads(self._settings_path.read_text(encoding="utf-8"))
|
||||
return {str(n) for n in data.get("disabled", [])}
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
|
||||
def set_enabled(self, name: str, enabled: bool) -> None:
|
||||
name = validate_name(name)
|
||||
with self._lock:
|
||||
disabled = self.disabled_names()
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
self._settings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._settings_path.write_text(
|
||||
json.dumps({"disabled": sorted(disabled)}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# -- uploads: stage → preview → confirm -----------------------------------------
|
||||
def stage_upload(self, data: bytes, filename: str = "") -> dict[str, Any]:
|
||||
"""Stage an upload and return the parsed preview. Accepts a ``.zip`` (folder skill)
|
||||
or a bare ``SKILL.md`` with YAML frontmatter. Nothing is installed until
|
||||
:meth:`confirm_upload`. (A ``.skill`` file is a renamed zip and still unpacks —
|
||||
just not advertised.)"""
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(data))
|
||||
except zipfile.BadZipFile:
|
||||
return self._stage_single_md(data, filename)
|
||||
# macOS Finder's "Compress" injects __MACOSX/ shadow entries (._*) and .DS_Store —
|
||||
# metadata, not skill content. Strip them so a Mac-made zip installs clean.
|
||||
names = [
|
||||
n
|
||||
for n in archive.namelist()
|
||||
if not n.endswith("/")
|
||||
and "__MACOSX" not in Path(n).parts
|
||||
and Path(n).name != ".DS_Store"
|
||||
and not Path(n).name.startswith("._")
|
||||
]
|
||||
for entry in names:
|
||||
p = Path(entry)
|
||||
if p.is_absolute() or ".." in p.parts or (p.parts and ":" in p.parts[0]):
|
||||
raise ValueError("Archive contains unsafe paths.")
|
||||
# SKILL.md at the root, or inside exactly one top-level folder.
|
||||
md_entries = [n for n in names if Path(n).name == "SKILL.md"]
|
||||
roots = {Path(n).parts[0] if len(Path(n).parts) > 1 else "" for n in md_entries}
|
||||
if not md_entries or len(roots) != 1:
|
||||
raise ValueError("Archive must contain exactly one skill (one SKILL.md).")
|
||||
root = roots.pop()
|
||||
token = uuid.uuid4().hex
|
||||
staged = self._staging_dir / token
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
for entry in names:
|
||||
parts = Path(entry).parts
|
||||
rel = Path(*parts[1:]) if root and parts[0] == root else Path(entry)
|
||||
if not str(rel):
|
||||
continue
|
||||
target = staged / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(archive.read(entry))
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
name = skill.name if skill.name else staged.name
|
||||
try:
|
||||
validate_name(name)
|
||||
except ValueError:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
extras = sorted(
|
||||
str(p.relative_to(staged))
|
||||
for p in staged.rglob("*")
|
||||
if p.is_file() and p.name != "SKILL.md"
|
||||
)
|
||||
return {
|
||||
"token": token,
|
||||
"name": name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions,
|
||||
"files": extras,
|
||||
}
|
||||
|
||||
def _stage_single_md(self, data: bytes, filename: str) -> dict[str, Any]:
|
||||
"""The bare-.md path: one SKILL.md, no resources. Frontmatter must carry the name
|
||||
(there is no folder to fall back to)."""
|
||||
if filename.lower().endswith((".zip", ".skill")):
|
||||
raise ValueError("Not a valid .zip archive.")
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError("Not a valid skill file — upload a .zip or a SKILL.md.")
|
||||
token = uuid.uuid4().hex
|
||||
staged = self._staging_dir / token
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
(staged / "SKILL.md").write_text(text, encoding="utf-8")
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
if skill.name == token: # no frontmatter name → parser fell back to the folder
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise ValueError(
|
||||
"The .md file needs YAML frontmatter with at least a skill name."
|
||||
)
|
||||
try:
|
||||
validate_name(skill.name)
|
||||
except ValueError:
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
raise
|
||||
return {
|
||||
"token": token,
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"instructions": skill.instructions,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
def confirm_upload(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
scope: str = GLOBAL_SCOPE,
|
||||
workspace: Optional[str | Path] = None,
|
||||
) -> dict[str, Any]:
|
||||
staged = self._staging_dir / str(token)
|
||||
if not (staged / "SKILL.md").is_file():
|
||||
raise ValueError("Unknown or expired upload.")
|
||||
skill = _parse_skill(staged / "SKILL.md")
|
||||
name = validate_name(skill.name)
|
||||
base = self._base(scope, workspace)
|
||||
folder = self._folder_of(base, name)
|
||||
if (folder / "SKILL.md").is_file():
|
||||
raise ValueError(f"A skill named '{name}' already exists in that scope.")
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(staged), str(folder))
|
||||
# Stamp provenance so the Settings screen can distinguish uploaded from local.
|
||||
if not _frontmatter_source(folder / "SKILL.md"):
|
||||
_write_skill_md(
|
||||
folder,
|
||||
name=name,
|
||||
description=skill.description,
|
||||
instructions=skill.instructions,
|
||||
source="uploaded",
|
||||
)
|
||||
return {"name": name, "scope": scope, "path": str(folder)}
|
||||
|
||||
def discard_upload(self, token: str) -> None:
|
||||
staged = self._staging_dir / str(token)
|
||||
shutil.rmtree(staged, ignore_errors=True)
|
||||
|
||||
|
||||
class SessionSkillStore:
|
||||
"""``{session_id: {skill: bool}}`` — per-session mutes only; an absent entry means the
|
||||
session inherits (enabled unless disabled in Settings). Mirrors SessionConnectionStore."""
|
||||
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
self._lock = threading.Lock()
|
||||
self._rows: dict[str, dict[str, bool]] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.path and self.path.is_file():
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
self._rows = {
|
||||
sid: {str(s): bool(v) for s, v in (row or {}).items()}
|
||||
for sid, row in data.get("sessions", {}).items()
|
||||
}
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps({"sessions": self._rows}, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
def get(self, session_id: str) -> dict[str, bool]:
|
||||
return dict(self._rows.get(session_id, {}))
|
||||
|
||||
def set(self, session_id: str, skill: str, enabled: bool) -> None:
|
||||
with self._lock:
|
||||
self._rows.setdefault(session_id, {})[skill] = bool(enabled)
|
||||
self._save()
|
||||
|
||||
def clear(self, session_id: str, skill: str) -> None:
|
||||
with self._lock:
|
||||
row = self._rows.get(session_id)
|
||||
if row and skill in row:
|
||||
del row[skill]
|
||||
if not row:
|
||||
del self._rows[session_id]
|
||||
self._save()
|
||||
|
||||
def remove_session(self, session_id: str) -> None:
|
||||
with self._lock:
|
||||
if session_id in self._rows:
|
||||
del self._rows[session_id]
|
||||
self._save()
|
||||
|
||||
|
||||
def effective_skills(
|
||||
*,
|
||||
names: set[str],
|
||||
disabled: set[str],
|
||||
session_overrides: dict[str, bool],
|
||||
) -> set[str]:
|
||||
"""The single source of truth for a session's skill menu (SKILLS-SPEC §3): any-off-wins.
|
||||
A Settings disable removes the skill everywhere — a session override can NOT resurrect
|
||||
it. Absent any opinion, a skill is on."""
|
||||
out: set[str] = set()
|
||||
for name in names:
|
||||
if name in disabled:
|
||||
continue
|
||||
if not session_overrides.get(name, True):
|
||||
continue
|
||||
out.add(name)
|
||||
return out
|
||||
|
||||
|
||||
# -- the worker-authors door (SKILLS-SPEC §5.2) -------------------------------------
|
||||
|
||||
_SAVE_SKILL_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "save_skill",
|
||||
"description": (
|
||||
"Propose adding a finished skill to the user's skills. The user reviews the "
|
||||
"name, description, full instructions, and any bundled files on an approval "
|
||||
"card before anything is saved; once they approve, the skill is usable in "
|
||||
"every conversation. Use this after building or refining a skill in "
|
||||
"conversation, and offer it in words like: 'Want me to add <name> to your "
|
||||
"skills?' — say 'your skills', never the app name; say 'add', never "
|
||||
"'install'. If a skill with this name already exists, approving overwrites "
|
||||
"its instructions and adds the files."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short folder-safe skill name (letters, digits, dots, dashes, underscores).",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One line saying when the skill applies — this is its menu entry.",
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"description": "The full instruction body (markdown). Becomes SKILL.md.",
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Optional paths of files in this session's folders to bundle into "
|
||||
"the skill (scripts, examples, README). Copied in by basename."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "instructions"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def save_skill_tool(
|
||||
store: Optional[SkillStore] = None,
|
||||
*,
|
||||
allowed_dirs: Optional[list[str | Path]] = None,
|
||||
) -> Callable:
|
||||
"""Build the `save_skill` tool (SKILLS-SPEC §5.2). `requires_approval=True` routes every
|
||||
call through the standard approval card — the tool's ARGUMENTS are the review surface,
|
||||
which is why the schema carries the full instructions and file list. Bundled files may
|
||||
only be read from `allowed_dirs` (the session's roots): the worker must never bundle
|
||||
arbitrary machine paths into a skill."""
|
||||
store = store or SkillStore()
|
||||
dirs: list[Path] = []
|
||||
for d in allowed_dirs or []:
|
||||
try:
|
||||
dirs.append(Path(d).expanduser().resolve())
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
def save_skill(
|
||||
name: str,
|
||||
description: str = "",
|
||||
instructions: str = "",
|
||||
files: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
name = validate_name(name)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
if not (description or "").strip():
|
||||
return {"error": "A one-line description is required — it becomes the skill's menu entry."}
|
||||
if not (instructions or "").strip():
|
||||
return {"error": "Skill instructions are required."}
|
||||
|
||||
# Resolve + vet the bundle BEFORE touching disk, so a bad file never leaves a
|
||||
# half-written skill behind.
|
||||
staged: list[tuple[Path, str]] = []
|
||||
for raw in files or []:
|
||||
p = Path(str(raw)).expanduser()
|
||||
if not p.is_absolute():
|
||||
if not dirs:
|
||||
return {"error": f"File is outside this session's folders: {raw}"}
|
||||
p = dirs[0] / p
|
||||
try:
|
||||
rp = p.resolve()
|
||||
except OSError:
|
||||
return {"error": f"Unreadable file: {raw}"}
|
||||
if not rp.is_file():
|
||||
return {"error": f"Not a file: {raw}"}
|
||||
if not any(d == rp or d in rp.parents for d in dirs):
|
||||
return {"error": f"File is outside this session's folders: {raw}"}
|
||||
base = rp.name
|
||||
if base.lower() == "skill.md":
|
||||
# The instructions argument BECOMES SKILL.md; models routinely draft one in
|
||||
# the workspace and bundle it. Skip silently — erroring here cost the user a
|
||||
# second approval round for a self-healing retry (live drive 2026-07-27).
|
||||
continue
|
||||
if any(base == b for _, b in staged):
|
||||
return {"error": f"Duplicate bundled filename: {base}"}
|
||||
staged.append((rp, base))
|
||||
|
||||
# Worker-authored skills always land GLOBAL (§3.4: never a throwaway location).
|
||||
try:
|
||||
folder, _scope = store.find(name)
|
||||
action = "updated"
|
||||
store.update(name, description=description.strip(), instructions=instructions)
|
||||
except ValueError:
|
||||
action = "added"
|
||||
created = store.create(
|
||||
name=name, description=description.strip(), instructions=instructions
|
||||
)
|
||||
folder = Path(created["path"])
|
||||
for src, base in staged:
|
||||
shutil.copy2(src, folder / base)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"action": action,
|
||||
"files": [b for _, b in staged],
|
||||
"note": (
|
||||
"Saved to the user's skills — usable in every conversation from now on. "
|
||||
"Confirm in one short sentence. To browse the installed files, point the "
|
||||
"user to Settings > Skills (the file-count chip opens the folder) — do NOT "
|
||||
"link the workspace build folder as an artifact; folders don't open there."
|
||||
),
|
||||
}
|
||||
|
||||
save_skill.__name__ = "save_skill"
|
||||
save_skill.__doc__ = _SAVE_SKILL_SCHEMA["function"]["description"]
|
||||
save_skill.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="save_skill",
|
||||
category="skills",
|
||||
risk_level="medium",
|
||||
capabilities=["save_skill"],
|
||||
requires_approval=True,
|
||||
)
|
||||
save_skill.__coworker_schema__ = _SAVE_SKILL_SCHEMA
|
||||
return save_skill
|
||||
Reference in New Issue
Block a user