This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/api/services/skills.py
T
chaim 7f2f29ebc8 feat: write-through sync of user profile + skills to EspoCRM entities
After SmartAssistant 2.10.0 introduced UserProfile and AssistantSkill
entities, the Python side kept writing only to the filesystem. So an
admin browsing the EspoCRM UI saw nothing — but the system prompt did
pull from CRM via context['userProfile'] / context['availableSkills'].

Result: edits made by Shira (via update_user_profile / skill_manage tool)
were invisible to admins; edits made by admins took effect immediately
because the prompt fetched from DB. Now the two sources agree.

* memory.py — added _sync_user_profile_to_crm() that upserts a UserProfile
  row whenever update_user_profile writes the file. Best-effort with
  logging; HTTP failures don't break the tool.

* skills.py — added _sync_skill_to_crm() that upserts an AssistantSkill
  row from the SKILL.md frontmatter (description, triggers, version) plus
  the full body in content. Called after every save_skill in skill_manage.

Read path is unchanged: the EspoCRM CaseContextBuilder / SmartAssistantService
populate context fields, and the Python prompt_builder consumes them with
in-code defaults as a fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:13:47 +00:00

237 lines
8.9 KiB
Python

"""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,
}