From 4d5f29c74ca99928f053ac55d2f780be61b827df Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 19 Feb 2026 18:25:53 -0800 Subject: [PATCH] feat: introduce skill management tool for agent-created skills and skills migration to ~/.hermes - Added a new `skill_manager_tool` to enable agents to create, update, and delete their own skills, enhancing procedural memory capabilities. - Updated the skills directory structure to support user-created skills in `~/.hermes/skills/`, allowing for better organization and management. - Enhanced the CLI and documentation to reflect the new skill management functionalities, including detailed instructions on creating and modifying skills. - Implemented a manifest-based syncing mechanism for bundled skills to ensure user modifications are preserved during updates. --- .gitignore | 8 +- README.md | 93 ++++-- cli.py | 15 +- docs/tools.md | 70 ++++- hermes_cli/main.py | 13 + hermes_cli/setup.py | 4 +- hermes_cli/skills_hub.py | 2 +- model_tools.py | 52 +++- run_agent.py | 20 +- scripts/install.ps1 | 20 ++ scripts/install.sh | 14 +- setup-hermes.sh | 19 ++ tools/__init__.py | 10 + tools/skill_manager_tool.py | 544 ++++++++++++++++++++++++++++++++++++ tools/skills_hub.py | 3 +- tools/skills_sync.py | 150 ++++++++++ tools/skills_tool.py | 160 ++--------- toolsets.py | 14 +- 18 files changed, 1007 insertions(+), 204 deletions(-) create mode 100644 tools/skill_manager_tool.py create mode 100644 tools/skills_sync.py diff --git a/.gitignore b/.gitignore index f7f3c8fb6..c04e2fef3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,9 +47,5 @@ testlogs # CLI config (may contain sensitive SSH paths) cli-config.yaml -# Skills Hub state (local to each machine) -skills/.hub/lock.json -skills/.hub/audit.log -skills/.hub/quarantine/ -skills/.hub/index-cache/ -skills/.hub/taps.json +# Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case) +skills/.hub/ diff --git a/README.md b/README.md index 0c78d6e7d..db163135c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ All your settings are stored in `~/.hermes/` for easy access: ├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── .env # API keys and secrets ├── SOUL.md # Optional: global persona (agent embodies this personality) +├── memories/ # Persistent memory (MEMORY.md, USER.md) +├── skills/ # Agent-created skills (managed via skill_manage tool) ├── cron/ # Scheduled jobs ├── sessions/ # Gateway sessions └── logs/ # Logs @@ -574,12 +576,46 @@ hermes --toolsets browser -q "Go to amazon.com and find the price of the latest Skills are on-demand knowledge documents the agent can load when needed. They follow a **progressive disclosure** pattern to minimize token usage and are compatible with the [agentskills.io](https://agentskills.io/specification) open standard. +All skills live in **`~/.hermes/skills/`** -- a single directory that is the source of truth. On fresh install, bundled skills are copied there from the repo. Hub-installed skills and agent-created skills also go here. The agent can modify or delete any skill. `hermes update` adds only genuinely new bundled skills (via a manifest) without overwriting your changes or re-adding skills you deleted. + **Using Skills:** ```bash hermes --toolsets skills -q "What skills do you have?" hermes --toolsets skills -q "Show me the axolotl skill" ``` +**Agent-Managed Skills (skill_manage tool):** + +The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** -- when it figures out a non-trivial workflow, it can save the approach as a skill for future reuse. + +The agent is encouraged to **create** skills when: +- It completed a complex task (5+ tool calls) successfully +- It hit errors or dead ends and found the working path +- The user corrected its approach and the corrected version worked +- It discovered a non-trivial workflow (deployment, data pipeline, configuration) + +The agent is encouraged to **update** skills when: +- Instructions were stale or incorrect (outdated API, changed behavior) +- Steps didn't work on the current OS or environment +- Missing critical steps or pitfalls discovered during use + +**Actions:** + +| Action | Use for | Key params | +|--------|---------|------------| +| `create` | New skill from scratch | `name`, `content` (full SKILL.md), optional `category` | +| `patch` | Targeted fixes (preferred for updates) | `name`, `old_string`, `new_string` | +| `edit` | Major structural rewrites | `name`, `content` (full SKILL.md replacement) | +| `delete` | Remove a skill entirely | `name` | +| `write_file` | Add/update supporting files | `name`, `file_path`, `file_content` | +| `remove_file` | Remove a supporting file | `name`, `file_path` | + +The `patch` action uses the same `old_string`/`new_string` pattern as the `patch` file tool -- find a unique string and replace it. This is more token-efficient than `edit` for small fixes (updating a command, adding a pitfall, fixing a version) because the model doesn't need to rewrite the entire skill. When patching SKILL.md, frontmatter integrity is validated after the replacement. The `patch` action also works on supporting files via the `file_path` parameter. + +User-created skills are stored in `~/.hermes/skills/` and can optionally be organized into categories (subdirectories). Each skill has a `SKILL.md` file and may include supporting files under `references/`, `templates/`, `scripts/`, and `assets/`. + +The `skill_manage` tool is enabled by default in CLI and all messaging platforms. It is **not** included in batch_runner or RL training environments. + **Skills Hub — Search, install, and manage skills from online registries:** ```bash hermes skills search kubernetes # Search all sources (GitHub, ClawHub, LobeHub) @@ -595,39 +631,55 @@ hermes skills tap add myorg/skills-repo # Add a custom source All hub-installed skills go through a **security scanner** that checks for data exfiltration, prompt injection, destructive commands, and other threats. Trust levels: `builtin` (ships with Hermes), `trusted` (openai/skills, anthropics/skills), `community` (everything else — any findings = blocked unless `--force`). -**Creating Skills:** +**SKILL.md Format:** -Create `skills/category/skill-name/SKILL.md`: ```markdown --- name: my-skill -description: Brief description +description: Brief description of what this skill does version: 1.0.0 metadata: hermes: tags: [python, automation] + category: devops --- -# Skill Content +# Skill Title -Instructions, examples, and guidelines here... +## When to Use +Trigger conditions for this skill. + +## Procedure +1. Step one +2. Step two + +## Pitfalls +- Known failure modes and fixes + +## Verification +How to confirm it worked. ``` -**Skill Structure:** +**Skill Directory Structure:** ``` -skills/ -├── mlops/ +~/.hermes/skills/ # Single source of truth for all skills +├── mlops/ # Category directory │ ├── axolotl/ -│ │ ├── SKILL.md # Main instructions (required) -│ │ ├── references/ # Additional docs -│ │ ├── templates/ # Output formats -│ │ └── assets/ # Supplementary files (agentskills.io standard) +│ │ ├── SKILL.md # Main instructions (required) +│ │ ├── references/ # Additional docs +│ │ ├── templates/ # Output formats +│ │ └── assets/ # Supplementary files (agentskills.io standard) │ └── vllm/ │ └── SKILL.md -├── .hub/ # Skills Hub state (gitignored) -│ ├── lock.json # Installed skill provenance -│ ├── quarantine/ # Pending security review -│ └── audit.log # Security scan history +├── devops/ +│ └── deploy-k8s/ # Agent-created skill +│ ├── SKILL.md +│ └── references/ +├── .hub/ # Skills Hub state +│ ├── lock.json # Installed skill provenance +│ ├── quarantine/ # Pending security review +│ └── audit.log # Security scan history +└── .bundled_manifest # Tracks which bundled skills have been offered ``` ### 🤖 RL Training (Tinker + Atropos) @@ -910,7 +962,7 @@ Hermes stores all user configuration in `~/.hermes/`: ```bash # Create the directory structure -mkdir -p ~/.hermes/{cron,sessions,logs} +mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills} # Copy the example config file cp cli-config.yaml.example ~/.hermes/config.yaml @@ -924,6 +976,8 @@ Your `~/.hermes/` directory should now look like: ~/.hermes/ ├── config.yaml # Agent settings (model, terminal, toolsets, compression, etc.) ├── .env # API keys and secrets (one per line: KEY=value) +├── memories/ # Persistent memory (MEMORY.md, USER.md) +├── skills/ # Agent-created skills (auto-created on first use) ├── cron/ # Scheduled job data ├── sessions/ # Messaging gateway sessions └── logs/ # Conversation logs @@ -1050,7 +1104,7 @@ uv pip install -e "./tinker-atropos" npm install # optional, for browser tools # Configure -mkdir -p ~/.hermes/{cron,sessions,logs} +mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills} cp cli-config.yaml.example ~/.hermes/config.yaml touch ~/.hermes/.env echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.hermes/.env @@ -1207,7 +1261,8 @@ All variables go in `~/.hermes/.env`. Run `hermes config set VAR value` to set t | `~/.hermes-agent/logs/` | Session logs | | `hermes_cli/` | CLI implementation | | `tools/` | Tool implementations | -| `skills/` | Knowledge documents | +| `skills/` | Bundled skill sources (copied to `~/.hermes/skills/` on install) | +| `~/.hermes/skills/` | All active skills (bundled + hub-installed + agent-created) | | `gateway/` | Messaging platform adapters | | `cron/` | Scheduler implementation | diff --git a/cli.py b/cli.py index 7eeb15425..63c4e74e6 100755 --- a/cli.py +++ b/cli.py @@ -355,33 +355,32 @@ COMPACT_BANNER = """ def _get_available_skills() -> Dict[str, List[str]]: """ - Scan the skills directory and return skills grouped by category. + Scan ~/.hermes/skills/ and return skills grouped by category. Returns: Dict mapping category name to list of skill names """ - skills_dir = Path(__file__).parent / "skills" + import os + + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + skills_dir = hermes_home / "skills" skills_by_category = {} if not skills_dir.exists(): return skills_by_category - # Scan for SKILL.md files for skill_file in skills_dir.rglob("SKILL.md"): - # Get category (parent of parent if nested, else parent) rel_path = skill_file.relative_to(skills_dir) parts = rel_path.parts if len(parts) >= 2: category = parts[0] - skill_name = parts[-2] # Folder containing SKILL.md + skill_name = parts[-2] else: category = "general" skill_name = skill_file.parent.name - if category not in skills_by_category: - skills_by_category[category] = [] - skills_by_category[category].append(skill_name) + skills_by_category.setdefault(category, []).append(skill_name) return skills_by_category diff --git a/docs/tools.md b/docs/tools.md index 0f82af9ad..7f6f616b9 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -46,7 +46,7 @@ async def web_search(query: str) -> dict: | **Image Gen** | `image_generation_tool.py` | `image_generate` | | **TTS** | `tts_tool.py` | `text_to_speech` (Edge TTS free / ElevenLabs / OpenAI) | | **Reasoning** | `mixture_of_agents_tool.py` | `mixture_of_agents` | -| **Skills** | `skills_tool.py` | `skills_list`, `skill_view` | +| **Skills** | `skills_tool.py`, `skill_manager_tool.py` | `skills_list`, `skill_view`, `skill_manage` | | **Todo** | `todo_tool.py` | `todo` (read/write task list for multi-step planning) | | **Memory** | `memory_tool.py` | `memory` (persistent notes + user profile across sessions) | | **Session Search** | `session_search_tool.py` | `session_search` (search + summarize past conversations) | @@ -154,15 +154,22 @@ Level 2: skill_view(name) → Full content + metadata (varies) Level 3: skill_view(name, path) → Specific reference file (varies) ``` +All skills live in `~/.hermes/skills/` — a single directory that serves as the source of truth. On fresh install, bundled skills are seeded from the repo's `skills/` directory. Hub-installed and agent-created skills also go here. The agent can modify or delete any skill. + Skill directory structure: ``` -skills/ -└── mlops/ - └── axolotl/ - ├── SKILL.md # Main instructions (required) - ├── references/ # Additional docs - ├── templates/ # Output formats, configs - └── assets/ # Supplementary files (agentskills.io) +~/.hermes/skills/ +├── mlops/ +│ └── axolotl/ +│ ├── SKILL.md # Main instructions (required) +│ ├── references/ # Additional docs +│ ├── templates/ # Output formats, configs +│ └── assets/ # Supplementary files (agentskills.io) +├── devops/ +│ └── deploy-k8s/ +│ └── SKILL.md +├── .hub/ # Skills Hub state +└── .bundled_manifest # Tracks seeded bundled skills ``` SKILL.md uses YAML frontmatter (agentskills.io compatible): @@ -173,9 +180,55 @@ description: Fine-tuning LLMs with Axolotl metadata: hermes: tags: [Fine-Tuning, LoRA, DPO] + category: mlops --- ``` +## Skill Management (skill_manage) + +The `skill_manage` tool lets the agent create, update, and delete its own skills -- turning successful approaches into reusable procedural knowledge. + +**Module:** `tools/skill_manager_tool.py` + +**Actions:** +| Action | Description | Required params | +|--------|-------------|-----------------| +| `create` | Create new skill (SKILL.md + directory) | `name`, `content`, optional `category` | +| `patch` | Targeted find-and-replace in SKILL.md or supporting file | `name`, `old_string`, `new_string`, optional `file_path`, `replace_all` | +| `edit` | Full replacement of SKILL.md (major rewrites only) | `name`, `content` | +| `delete` | Remove a user skill entirely | `name` | +| `write_file` | Add/overwrite a supporting file | `name`, `file_path`, `file_content` | +| `remove_file` | Remove a supporting file | `name`, `file_path` | + +### patch vs edit + +`patch` and `edit` both modify skill files, but serve different purposes: + +**`patch`** (preferred for most updates): +- Targeted `old_string` → `new_string` replacement, same interface as the `patch` file tool +- Token-efficient: only the changed text appears in the tool call, not the full file +- Requires unique match by default; set `replace_all=true` for global replacements +- Returns match count on ambiguous matches so the model can add more context +- When targeting SKILL.md, validates that frontmatter remains intact after the patch +- Also works on supporting files via `file_path` parameter (e.g., `references/api.md`) +- Returns a file preview on not-found errors for self-correction without extra reads + +**`edit`** (for major rewrites): +- Full replacement of SKILL.md content +- Use when the skill's structure needs to change (reorganizing sections, rewriting from scratch) +- The model should `skill_view()` first, then provide the complete updated text + +**Constraints:** +- All skills live in `~/.hermes/skills/` and can be modified or deleted +- Skill names must be lowercase, filesystem-safe (`[a-z0-9._-]+`), max 64 chars +- SKILL.md must have valid YAML frontmatter with `name` and `description` fields +- Supporting files must be under `references/`, `templates/`, `scripts/`, or `assets/` +- Path traversal (`..`) in file paths is blocked + +**Availability:** Enabled by default in CLI, Telegram, Discord, WhatsApp, and Slack. Not included in batch_runner or RL training environments. + +**Behavioral guidance:** The tool description teaches the model when to create skills (after difficult tasks), when to update them (stale/broken instructions), to prefer `patch` over `edit` for targeted fixes, and the feedback loop pattern (ask user after difficult tasks, offer to save as a skill). + ## Skills Hub The Skills Hub enables searching, installing, and managing skills from online registries. It is **user-driven only** — the model cannot search for or install skills. @@ -187,6 +240,7 @@ The Skills Hub enables searching, installing, and managing skills from online re **Architecture:** - `tools/skills_guard.py` — Static scanner + LLM audit, trust-aware install policy - `tools/skills_hub.py` — SkillSource ABC, GitHubAuth (PAT + App), 4 source adapters, lock file, hub state +- `tools/skill_manager_tool.py` — Agent-managed skill CRUD (`skill_manage` tool) - `hermes_cli/skills_hub.py` — Shared `do_*` functions, CLI subcommands, `/skills` slash command handler **CLI:** `hermes skills search|install|inspect|list|audit|uninstall|publish|snapshot|tap` diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 377fd6b5b..6a455abf6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -190,6 +190,19 @@ def cmd_update(args): print() print("✓ Code updated!") + # Sync any new bundled skills (manifest-based -- won't overwrite or re-add deleted skills) + try: + from tools.skills_sync import sync_skills + print() + print("→ Checking for new bundled skills...") + result = sync_skills(quiet=True) + if result["copied"]: + print(f" + {len(result['copied'])} new skill(s): {', '.join(result['copied'])}") + else: + print(" ✓ Skills are up to date") + except Exception: + pass + # Check for config migrations print() print("→ Checking configuration for new options...") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index dc278bf1f..fea8b9eed 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -211,8 +211,8 @@ def _print_setup_summary(config: dict, hermes_home): # Task planning (always available, in-memory) tool_status.append(("Task Planning (todo)", True, None)) - # Skills (always available if skills dir exists) - tool_status.append(("Skills Knowledge Base", True, None)) + # Skills (always available -- bundled skills + user-created skills) + tool_status.append(("Skills (view, create, edit)", True, None)) # Print status available_count = sum(1 for _, avail, _ in tool_status if avail) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 031f97230..b0e4bbfe0 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -186,7 +186,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, "External skills can contain instructions that influence agent behavior,\n" "shell commands, and scripts. Even after automated scanning, you should\n" "review the installed files before use.\n\n" - f"Files will be at: [cyan]skills/{category + '/' if category else ''}{bundle.name}/[/]", + f"Files will be at: [cyan]~/.hermes/skills/{category + '/' if category else ''}{bundle.name}/[/]", title="Disclaimer", border_style="yellow", )) diff --git a/model_tools.py b/model_tools.py index ec822def2..882283a7e 100644 --- a/model_tools.py +++ b/model_tools.py @@ -42,6 +42,8 @@ from tools.vision_tools import vision_analyze_tool, check_vision_requirements from tools.mixture_of_agents_tool import mixture_of_agents_tool, check_moa_requirements from tools.image_generation_tool import image_generate_tool, check_image_generation_requirements from tools.skills_tool import skills_list, skill_view, check_skills_requirements, SKILLS_TOOL_DESCRIPTION +# Agent-managed skill creation/editing +from tools.skill_manager_tool import skill_manage, check_skill_manage_requirements, SKILL_MANAGE_SCHEMA # RL Training tools (Tinker-Atropos) from tools.rl_training_tool import ( rl_list_environments, @@ -151,7 +153,7 @@ TOOLSET_REQUIREMENTS = { "env_vars": [], # Just needs skills directory "check_fn": check_skills_requirements, "setup_url": None, - "tools": ["skills_list", "skill_view"], + "tools": ["skills_list", "skill_view", "skill_manage"], }, "rl": { "name": "RL Training (Tinker-Atropos)", @@ -513,6 +515,16 @@ def get_skills_tool_definitions() -> List[Dict[str, Any]]: ] +def get_skill_manage_tool_definitions() -> List[Dict[str, Any]]: + """ + Get tool definitions for the skill management tool. + + Returns: + List[Dict]: List containing the skill_manage tool definition compatible with OpenAI API + """ + return [{"type": "function", "function": SKILL_MANAGE_SCHEMA}] + + def get_browser_tool_definitions() -> List[Dict[str, Any]]: """ Get tool definitions for browser automation tools in OpenAI's expected format. @@ -1090,7 +1102,7 @@ def get_all_tool_names() -> List[str]: # Skills tools if check_skills_requirements(): - tool_names.extend(["skills_list", "skill_view"]) + tool_names.extend(["skills_list", "skill_view", "skill_manage"]) # Browser automation tools if check_browser_requirements(): @@ -1159,6 +1171,7 @@ TOOL_TO_TOOLSET_MAP = { # Skills tools "skills_list": "skills_tools", "skill_view": "skills_tools", + "skill_manage": "skills_tools", # Browser automation tools "browser_navigate": "browser_tools", "browser_snapshot": "browser_tools", @@ -1281,6 +1294,8 @@ def get_tool_definitions( if check_skills_requirements(): for tool in get_skills_tool_definitions(): all_available_tools_map[tool["function"]["name"]] = tool + for tool in get_skill_manage_tool_definitions(): + all_available_tools_map[tool["function"]["name"]] = tool if check_browser_requirements(): for tool in get_browser_tool_definitions(): @@ -1346,7 +1361,7 @@ def get_tool_definitions( "vision_tools": ["vision_analyze"], "moa_tools": ["mixture_of_agents"], "image_tools": ["image_generate"], - "skills_tools": ["skills_list", "skill_view"], + "skills_tools": ["skills_list", "skill_view", "skill_manage"], "browser_tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", @@ -1400,7 +1415,7 @@ def get_tool_definitions( "vision_tools": ["vision_analyze"], "moa_tools": ["mixture_of_agents"], "image_tools": ["image_generate"], - "skills_tools": ["skills_list", "skill_view"], + "skills_tools": ["skills_list", "skill_view", "skill_manage"], "browser_tools": [ "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", @@ -1676,7 +1691,7 @@ def handle_image_function_call(function_name: str, function_args: Dict[str, Any] def handle_skills_function_call(function_name: str, function_args: Dict[str, Any]) -> str: """ - Handle function calls for skills tools. + Handle function calls for skills tools (read-only and management). Args: function_name (str): Name of the skills function to call @@ -1696,6 +1711,25 @@ def handle_skills_function_call(function_name: str, function_args: Dict[str, Any file_path = function_args.get("file_path") return skill_view(name, file_path=file_path) + elif function_name == "skill_manage": + action = function_args.get("action", "") + name = function_args.get("name", "") + if not action: + return json.dumps({"error": "action is required"}, ensure_ascii=False) + if not name: + return json.dumps({"error": "name is required"}, ensure_ascii=False) + return skill_manage( + action=action, + name=name, + content=function_args.get("content"), + category=function_args.get("category"), + file_path=function_args.get("file_path"), + file_content=function_args.get("file_content"), + old_string=function_args.get("old_string"), + new_string=function_args.get("new_string"), + replace_all=function_args.get("replace_all", False), + ) + else: return json.dumps({"error": f"Unknown skills function: {function_name}"}, ensure_ascii=False) @@ -2147,7 +2181,7 @@ def handle_function_call( return handle_image_function_call(function_name, function_args) # Route skills tools - elif function_name in ["skills_list", "skill_view"]: + elif function_name in ["skills_list", "skill_view", "skill_manage"]: return handle_skills_function_call(function_name, function_args) # Route browser automation tools @@ -2249,9 +2283,9 @@ def get_available_toolsets() -> Dict[str, Dict[str, Any]]: }, "skills_tools": { "available": check_skills_requirements(), - "tools": ["skills_list", "skill_view"], - "description": "Access skill documents that provide specialized instructions, guidelines, or knowledge the agent can load on demand", - "requirements": ["skills/ directory in repo root"] + "tools": ["skills_list", "skill_view", "skill_manage"], + "description": "Access, create, edit, and manage skill documents that provide specialized instructions, guidelines, or knowledge the agent can load on demand", + "requirements": ["~/.hermes/skills/ directory (seeded from bundled skills on install)"] }, "browser_tools": { "available": check_browser_requirements(), diff --git a/run_agent.py b/run_agent.py index cc3197095..bb8c6aed1 100644 --- a/run_agent.py +++ b/run_agent.py @@ -589,7 +589,7 @@ def apply_anthropic_cache_control( # the model can match skills at a glance without extra tool calls. def build_skills_system_prompt() -> str: """ - Build a dynamic skills system prompt by scanning the skills/ directory. + Build a dynamic skills system prompt by scanning both bundled and user skill directories. Returns a prompt section that lists all skill categories (with descriptions from DESCRIPTION.md) and their skill names inline, so the model can @@ -599,10 +599,13 @@ def build_skills_system_prompt() -> str: Returns: str: The skills system prompt section, or empty string if no skills found. """ + import os import re from pathlib import Path - skills_dir = Path(__file__).parent / "skills" + hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + skills_dir = hermes_home / "skills" + if not skills_dir.exists(): return "" @@ -613,7 +616,7 @@ def build_skills_system_prompt() -> str: parts = rel_path.parts if len(parts) >= 2: category = parts[0] - skill_name = parts[-2] # Folder containing SKILL.md + skill_name = parts[-2] else: category = "general" skill_name = skill_file.parent.name @@ -622,25 +625,23 @@ def build_skills_system_prompt() -> str: if not skills_by_category: return "" - # Load category descriptions from DESCRIPTION.md files (YAML frontmatter) + # Load category descriptions from DESCRIPTION.md files category_descriptions = {} for category in skills_by_category: desc_file = skills_dir / category / "DESCRIPTION.md" if desc_file.exists(): try: content = desc_file.read_text(encoding="utf-8") - # Parse description from YAML frontmatter: ---\ndescription: ...\n--- match = re.search(r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---", content, re.MULTILINE | re.DOTALL) if match: category_descriptions[category] = match.group(1).strip() except Exception: pass - # Build compact index: category with description + skill names index_lines = [] for category in sorted(skills_by_category.keys()): desc = category_descriptions.get(category, "") - names = ", ".join(sorted(skills_by_category[category])) + names = ", ".join(sorted(set(skills_by_category[category]))) if desc: index_lines.append(f" {category}: {desc}") else: @@ -650,7 +651,8 @@ def build_skills_system_prompt() -> str: return ( "## Skills (mandatory)\n" "Before replying, scan the skills below. If one clearly matches your task, " - "load it with skill_view(name) and follow its instructions.\n" + "load it with skill_view(name) and follow its instructions. " + "If a skill has issues, fix it with skill_manage(action='patch').\n" "\n" "\n" + "\n".join(index_lines) + "\n" @@ -2156,7 +2158,7 @@ class AIAgent: if user_block: prompt_parts.append(user_block) - has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view']) + has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) skills_prompt = build_skills_system_prompt() if has_skills_tools else "" if skills_prompt: prompt_parts.append(skills_prompt) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9d99a1af8..a90fe9644 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -447,6 +447,8 @@ function Copy-ConfigTemplates { New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null + New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null + New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null # Create .env $envPath = "$HermesHome\.env" @@ -499,6 +501,24 @@ Delete the contents (or this file) to use the default personality. } Write-Success "Configuration directory ready: ~/.hermes/" + + # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) + Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..." + $pythonExe = "$InstallDir\venv\Scripts\python.exe" + if (Test-Path $pythonExe) { + try { + & $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null + Write-Success "Skills synced to ~/.hermes/skills/" + } catch { + # Fallback: simple directory copy + $bundledSkills = "$InstallDir\skills" + $userSkills = "$HermesHome\skills" + if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) { + Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue + Write-Success "Skills copied to ~/.hermes/skills/" + } + } + } } function Install-NodeDeps { diff --git a/scripts/install.sh b/scripts/install.sh index 20db6aae2..5d467f2c0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -614,7 +614,7 @@ copy_config_templates() { log_info "Setting up configuration files..." # Create ~/.hermes directory structure (config at top level, code in subdir) - mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories} + mkdir -p "$HERMES_HOME"/{cron,sessions,logs,pairing,hooks,image_cache,audio_cache,memories,skills} # Create .env at ~/.hermes/.env (top level, easy to find) if [ ! -f "$HERMES_HOME/.env" ]; then @@ -662,6 +662,18 @@ SOUL_EOF fi log_success "Configuration directory ready: ~/.hermes/" + + # Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill) + log_info "Syncing bundled skills to ~/.hermes/skills/ ..." + if "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" 2>/dev/null; then + log_success "Skills synced to ~/.hermes/skills/" + else + # Fallback: simple directory copy if Python sync fails + if [ -d "$INSTALL_DIR/skills" ] && [ ! "$(ls -A "$HERMES_HOME/skills/" 2>/dev/null | grep -v '.bundled_manifest')" ]; then + cp -r "$INSTALL_DIR/skills/"* "$HERMES_HOME/skills/" 2>/dev/null || true + log_success "Skills copied to ~/.hermes/skills/" + fi + fi } install_node_deps() { diff --git a/setup-hermes.sh b/setup-hermes.sh index a1c9b7239..9423cf164 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -240,6 +240,25 @@ if [ -n "$SHELL_CONFIG" ]; then fi fi +# ============================================================================ +# Seed bundled skills into ~/.hermes/skills/ +# ============================================================================ + +HERMES_SKILLS_DIR="${HERMES_HOME:-$HOME/.hermes}/skills" +mkdir -p "$HERMES_SKILLS_DIR" + +echo "" +echo "Syncing bundled skills to ~/.hermes/skills/ ..." +if "$SCRIPT_DIR/venv/bin/python" "$SCRIPT_DIR/tools/skills_sync.py" 2>/dev/null; then + echo -e "${GREEN}✓${NC} Skills synced" +else + # Fallback: copy if sync script fails (missing deps, etc.) + if [ -d "$SCRIPT_DIR/skills" ]; then + cp -rn "$SCRIPT_DIR/skills/"* "$HERMES_SKILLS_DIR/" 2>/dev/null || true + echo -e "${GREEN}✓${NC} Skills copied" + fi +fi + # ============================================================================ # Done # ============================================================================ diff --git a/tools/__init__.py b/tools/__init__.py index e80216347..9e75d4da5 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -65,6 +65,12 @@ from .skills_tool import ( SKILLS_TOOL_DESCRIPTION ) +from .skill_manager_tool import ( + skill_manage, + check_skill_manage_requirements, + SKILL_MANAGE_SCHEMA +) + # Browser automation tools (agent-browser + Browserbase) from .browser_tool import ( browser_navigate, @@ -175,6 +181,10 @@ __all__ = [ 'skill_view', 'check_skills_requirements', 'SKILLS_TOOL_DESCRIPTION', + # Skill management + 'skill_manage', + 'check_skill_manage_requirements', + 'SKILL_MANAGE_SCHEMA', # Browser automation tools 'browser_navigate', 'browser_snapshot', diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py new file mode 100644 index 000000000..ebbbfb11d --- /dev/null +++ b/tools/skill_manager_tool.py @@ -0,0 +1,544 @@ +#!/usr/bin/env python3 +""" +Skill Manager Tool -- Agent-Managed Skill Creation & Editing + +Allows the agent to create, update, and delete skills, turning successful +approaches into reusable procedural knowledge. New skills are created in +~/.hermes/skills/. Existing skills (bundled, hub-installed, or user-created) +can be modified or deleted wherever they live. + +Skills are the agent's procedural memory: they capture *how to do a specific +type of task* based on proven experience. General memory (MEMORY.md, USER.md) is +broad and declarative. Skills are narrow and actionable. + +Actions: + create -- Create a new skill (SKILL.md + directory structure) + edit -- Replace the SKILL.md content of a user skill (full rewrite) + patch -- Targeted find-and-replace within SKILL.md or any supporting file + delete -- Remove a user skill entirely + write_file -- Add/overwrite a supporting file (reference, template, script, asset) + remove_file-- Remove a supporting file from a user skill + +Directory layout for user skills: + ~/.hermes/skills/ + ├── my-skill/ + │ ├── SKILL.md + │ ├── references/ + │ ├── templates/ + │ ├── scripts/ + │ └── assets/ + └── category-name/ + └── another-skill/ + └── SKILL.md +""" + +import json +import os +import re +import shutil +from pathlib import Path +from typing import Dict, Any, Optional + +import yaml + + +# All skills live in ~/.hermes/skills/ (single source of truth) +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +SKILLS_DIR = HERMES_HOME / "skills" + +MAX_NAME_LENGTH = 64 +MAX_DESCRIPTION_LENGTH = 1024 + +# Characters allowed in skill names (filesystem-safe, URL-friendly) +VALID_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$') + +# Subdirectories allowed for write_file/remove_file +ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"} + + +def check_skill_manage_requirements() -> bool: + """Skill management has no external requirements -- always available.""" + return True + + +# ============================================================================= +# Validation helpers +# ============================================================================= + +def _validate_name(name: str) -> Optional[str]: + """Validate a skill name. Returns error message or None if valid.""" + if not name: + return "Skill name is required." + if len(name) > MAX_NAME_LENGTH: + return f"Skill name exceeds {MAX_NAME_LENGTH} characters." + if not VALID_NAME_RE.match(name): + return ( + f"Invalid skill name '{name}'. Use lowercase letters, numbers, " + f"hyphens, dots, and underscores. Must start with a letter or digit." + ) + return None + + +def _validate_frontmatter(content: str) -> Optional[str]: + """ + Validate that SKILL.md content has proper frontmatter with required fields. + Returns error message or None if valid. + """ + if not content.strip(): + return "Content cannot be empty." + + if not content.startswith("---"): + return "SKILL.md must start with YAML frontmatter (---). See existing skills for format." + + end_match = re.search(r'\n---\s*\n', content[3:]) + if not end_match: + return "SKILL.md frontmatter is not closed. Ensure you have a closing '---' line." + + yaml_content = content[3:end_match.start() + 3] + + try: + parsed = yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + return f"YAML frontmatter parse error: {e}" + + if not isinstance(parsed, dict): + return "Frontmatter must be a YAML mapping (key: value pairs)." + + if "name" not in parsed: + return "Frontmatter must include 'name' field." + if "description" not in parsed: + return "Frontmatter must include 'description' field." + if len(str(parsed["description"])) > MAX_DESCRIPTION_LENGTH: + return f"Description exceeds {MAX_DESCRIPTION_LENGTH} characters." + + body = content[end_match.end() + 3:].strip() + if not body: + return "SKILL.md must have content after the frontmatter (instructions, procedures, etc.)." + + return None + + +def _resolve_skill_dir(name: str, category: str = None) -> Path: + """Build the directory path for a new skill, optionally under a category.""" + if category: + return SKILLS_DIR / category / name + return SKILLS_DIR / name + + +def _find_skill(name: str) -> Optional[Dict[str, Any]]: + """ + Find a skill by name in ~/.hermes/skills/. + Returns {"path": Path} or None. + """ + if not SKILLS_DIR.exists(): + return None + for skill_md in SKILLS_DIR.rglob("SKILL.md"): + if skill_md.parent.name == name: + return {"path": skill_md.parent} + return None + + +def _validate_file_path(file_path: str) -> Optional[str]: + """ + Validate a file path for write_file/remove_file. + Must be under an allowed subdirectory and not escape the skill dir. + """ + if not file_path: + return "file_path is required." + + normalized = Path(file_path) + + # Prevent path traversal + if ".." in normalized.parts: + return "Path traversal ('..') is not allowed." + + # Must be under an allowed subdirectory + if not normalized.parts or normalized.parts[0] not in ALLOWED_SUBDIRS: + allowed = ", ".join(sorted(ALLOWED_SUBDIRS)) + return f"File must be under one of: {allowed}. Got: '{file_path}'" + + # Must have a filename (not just a directory) + if len(normalized.parts) < 2: + return f"Provide a file path, not just a directory. Example: '{normalized.parts[0]}/myfile.md'" + + return None + + +# ============================================================================= +# Core actions +# ============================================================================= + +def _create_skill(name: str, content: str, category: str = None) -> Dict[str, Any]: + """Create a new user skill with SKILL.md content.""" + # Validate name + err = _validate_name(name) + if err: + return {"success": False, "error": err} + + # Validate content + err = _validate_frontmatter(content) + if err: + return {"success": False, "error": err} + + # Check for name collisions across all directories + existing = _find_skill(name) + if existing: + return { + "success": False, + "error": f"A skill named '{name}' already exists at {existing['path']}." + } + + # Create the skill directory + skill_dir = _resolve_skill_dir(name, category) + skill_dir.mkdir(parents=True, exist_ok=True) + + # Write SKILL.md + skill_md = skill_dir / "SKILL.md" + skill_md.write_text(content, encoding="utf-8") + + result = { + "success": True, + "message": f"Skill '{name}' created.", + "path": str(skill_dir.relative_to(SKILLS_DIR)), + "skill_md": str(skill_md), + } + if category: + result["category"] = category + result["hint"] = ( + "To add reference files, templates, or scripts, use " + "skill_manage(action='write_file', name='{}', file_path='references/example.md', file_content='...')".format(name) + ) + return result + + +def _edit_skill(name: str, content: str) -> Dict[str, Any]: + """Replace the SKILL.md of any existing skill (full rewrite).""" + err = _validate_frontmatter(content) + if err: + return {"success": False, "error": err} + + existing = _find_skill(name) + if not existing: + return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."} + + skill_md = existing["path"] / "SKILL.md" + skill_md.write_text(content, encoding="utf-8") + + return { + "success": True, + "message": f"Skill '{name}' updated.", + "path": str(existing["path"]), + } + + +def _patch_skill( + name: str, + old_string: str, + new_string: str, + file_path: str = None, + replace_all: bool = False, +) -> Dict[str, Any]: + """Targeted find-and-replace within a skill file. + + Defaults to SKILL.md. Use file_path to patch a supporting file instead. + Requires a unique match unless replace_all is True. + """ + if not old_string: + return {"success": False, "error": "old_string is required for 'patch'."} + if new_string is None: + return {"success": False, "error": "new_string is required for 'patch'. Use an empty string to delete matched text."} + + existing = _find_skill(name) + if not existing: + return {"success": False, "error": f"Skill '{name}' not found."} + + skill_dir = existing["path"] + + if file_path: + # Patching a supporting file + err = _validate_file_path(file_path) + if err: + return {"success": False, "error": err} + target = skill_dir / file_path + else: + # Patching SKILL.md + target = skill_dir / "SKILL.md" + + if not target.exists(): + return {"success": False, "error": f"File not found: {target.relative_to(skill_dir)}"} + + content = target.read_text(encoding="utf-8") + + count = content.count(old_string) + if count == 0: + # Show a short preview of the file so the model can self-correct + preview = content[:500] + ("..." if len(content) > 500 else "") + return { + "success": False, + "error": "old_string not found in the file.", + "file_preview": preview, + } + + if count > 1 and not replace_all: + return { + "success": False, + "error": ( + f"old_string matched {count} times. Provide more surrounding context " + f"to make the match unique, or set replace_all=true to replace all occurrences." + ), + "match_count": count, + } + + new_content = content.replace(old_string, new_string) if replace_all else content.replace(old_string, new_string, 1) + + # If patching SKILL.md, validate frontmatter is still intact + if not file_path: + err = _validate_frontmatter(new_content) + if err: + return { + "success": False, + "error": f"Patch would break SKILL.md structure: {err}", + } + + target.write_text(new_content, encoding="utf-8") + + replacements = count if replace_all else 1 + return { + "success": True, + "message": f"Patched {'SKILL.md' if not file_path else file_path} in skill '{name}' ({replacements} replacement{'s' if replacements > 1 else ''}).", + } + + +def _delete_skill(name: str) -> Dict[str, Any]: + """Delete a skill.""" + existing = _find_skill(name) + if not existing: + return {"success": False, "error": f"Skill '{name}' not found."} + + skill_dir = existing["path"] + shutil.rmtree(skill_dir) + + # Clean up empty category directories (don't remove SKILLS_DIR itself) + parent = skill_dir.parent + if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()): + parent.rmdir() + + return { + "success": True, + "message": f"Skill '{name}' deleted.", + } + + +def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]: + """Add or overwrite a supporting file within any skill directory.""" + err = _validate_file_path(file_path) + if err: + return {"success": False, "error": err} + + if not file_content and file_content != "": + return {"success": False, "error": "file_content is required."} + + existing = _find_skill(name) + if not existing: + return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."} + + target = existing["path"] / file_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(file_content, encoding="utf-8") + + return { + "success": True, + "message": f"File '{file_path}' written to skill '{name}'.", + "path": str(target), + } + + +def _remove_file(name: str, file_path: str) -> Dict[str, Any]: + """Remove a supporting file from any skill directory.""" + err = _validate_file_path(file_path) + if err: + return {"success": False, "error": err} + + existing = _find_skill(name) + if not existing: + return {"success": False, "error": f"Skill '{name}' not found."} + skill_dir = existing["path"] + + target = skill_dir / file_path + if not target.exists(): + # List what's actually there for the model to see + available = [] + for subdir in ALLOWED_SUBDIRS: + d = skill_dir / subdir + if d.exists(): + for f in d.rglob("*"): + if f.is_file(): + available.append(str(f.relative_to(skill_dir))) + return { + "success": False, + "error": f"File '{file_path}' not found in skill '{name}'.", + "available_files": available if available else None, + } + + target.unlink() + + # Clean up empty subdirectories + parent = target.parent + if parent != skill_dir and parent.exists() and not any(parent.iterdir()): + parent.rmdir() + + return { + "success": True, + "message": f"File '{file_path}' removed from skill '{name}'.", + } + + +# ============================================================================= +# Main entry point +# ============================================================================= + +def skill_manage( + action: str, + name: str, + content: str = None, + category: str = None, + file_path: str = None, + file_content: str = None, + old_string: str = None, + new_string: str = None, + replace_all: bool = False, +) -> str: + """ + Manage user-created skills. Dispatches to the appropriate action handler. + + Returns JSON string with results. + """ + if action == "create": + if not content: + return json.dumps({"success": False, "error": "content is required for 'create'. Provide the full SKILL.md text (frontmatter + body)."}, ensure_ascii=False) + result = _create_skill(name, content, category) + + elif action == "edit": + if not content: + return json.dumps({"success": False, "error": "content is required for 'edit'. Provide the full updated SKILL.md text."}, ensure_ascii=False) + result = _edit_skill(name, content) + + elif action == "patch": + if not old_string: + return json.dumps({"success": False, "error": "old_string is required for 'patch'. Provide the text to find."}, ensure_ascii=False) + if new_string is None: + return json.dumps({"success": False, "error": "new_string is required for 'patch'. Use empty string to delete matched text."}, ensure_ascii=False) + result = _patch_skill(name, old_string, new_string, file_path, replace_all) + + elif action == "delete": + result = _delete_skill(name) + + elif action == "write_file": + if not file_path: + return json.dumps({"success": False, "error": "file_path is required for 'write_file'. Example: 'references/api-guide.md'"}, ensure_ascii=False) + if file_content is None: + return json.dumps({"success": False, "error": "file_content is required for 'write_file'."}, ensure_ascii=False) + result = _write_file(name, file_path, file_content) + + elif action == "remove_file": + if not file_path: + return json.dumps({"success": False, "error": "file_path is required for 'remove_file'."}, ensure_ascii=False) + result = _remove_file(name, file_path) + + else: + result = {"success": False, "error": f"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file"} + + return json.dumps(result, ensure_ascii=False) + + +# ============================================================================= +# OpenAI Function-Calling Schema +# ============================================================================= + +SKILL_MANAGE_SCHEMA = { + "name": "skill_manage", + "description": ( + "Manage skills (create, update, delete). Skills are your procedural " + "memory — reusable approaches for recurring task types. " + "New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live.\n\n" + "Actions: create (full SKILL.md + optional category), " + "patch (old_string/new_string — preferred for fixes), " + "edit (full SKILL.md rewrite — major overhauls only), " + "delete, write_file, remove_file.\n\n" + "Create when: complex task succeeded (5+ calls), errors overcome, " + "user-corrected approach worked, non-trivial workflow discovered, " + "or user asks you to remember a procedure.\n" + "Update when: instructions stale/wrong, OS-specific failures, " + "missing steps or pitfalls found during use.\n\n" + "After difficult/iterative tasks, offer to save as a skill. " + "Skip for simple one-offs. Confirm with user before creating/deleting.\n\n" + "Good skills: trigger conditions, numbered steps with exact commands, " + "pitfalls section, verification steps. Use skill_view() to see format examples." + ), + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "patch", "edit", "delete", "write_file", "remove_file"], + "description": "The action to perform." + }, + "name": { + "type": "string", + "description": ( + "Skill name (lowercase, hyphens/underscores, max 64 chars). " + "Must match an existing skill for patch/edit/delete/write_file/remove_file." + ) + }, + "content": { + "type": "string", + "description": ( + "Full SKILL.md content (YAML frontmatter + markdown body). " + "Required for 'create' and 'edit'. For 'edit', read the skill " + "first with skill_view() and provide the complete updated text." + ) + }, + "old_string": { + "type": "string", + "description": ( + "Text to find in the file (required for 'patch'). Must be unique " + "unless replace_all=true. Include enough surrounding context to " + "ensure uniqueness." + ) + }, + "new_string": { + "type": "string", + "description": ( + "Replacement text (required for 'patch'). Can be empty string " + "to delete the matched text." + ) + }, + "replace_all": { + "type": "boolean", + "description": "For 'patch': replace all occurrences instead of requiring a unique match (default: false)." + }, + "category": { + "type": "string", + "description": ( + "Optional category/domain for organizing the skill (e.g., 'devops', " + "'data-science', 'mlops'). Creates a subdirectory grouping. " + "Only used with 'create'." + ) + }, + "file_path": { + "type": "string", + "description": ( + "Path to a supporting file within the skill directory. " + "For 'write_file'/'remove_file': required, must be under references/, " + "templates/, scripts/, or assets/. " + "For 'patch': optional, defaults to SKILL.md if omitted." + ) + }, + "file_content": { + "type": "string", + "description": "Content for the file. Required for 'write_file'." + }, + }, + "required": ["action", "name"], + }, +} diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 643200732..fc7544ed9 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -40,7 +40,8 @@ logger = logging.getLogger(__name__) # Paths # --------------------------------------------------------------------------- -SKILLS_DIR = Path(__file__).parent.parent / "skills" +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +SKILLS_DIR = HERMES_HOME / "skills" HUB_DIR = SKILLS_DIR / ".hub" LOCK_FILE = HUB_DIR / "lock.json" QUARANTINE_DIR = HUB_DIR / "quarantine" diff --git a/tools/skills_sync.py b/tools/skills_sync.py new file mode 100644 index 000000000..7e5c26e99 --- /dev/null +++ b/tools/skills_sync.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Skills Sync -- Manifest-based seeding of bundled skills into ~/.hermes/skills/. + +On fresh install: copies all bundled skills from the repo's skills/ directory +into ~/.hermes/skills/ and records every skill name in a manifest file. + +On update: copies only NEW bundled skills (names not in the manifest) so that +user deletions are permanent and user modifications are never overwritten. + +The manifest lives at ~/.hermes/skills/.bundled_manifest and is a simple +newline-delimited list of skill names that have been offered to the user. +""" + +import json +import os +import shutil +from pathlib import Path +from typing import List, Tuple + + +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +SKILLS_DIR = HERMES_HOME / "skills" +MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest" + + +def _get_bundled_dir() -> Path: + """Locate the bundled skills/ directory in the repo.""" + return Path(__file__).parent.parent / "skills" + + +def _read_manifest() -> set: + """Read the set of skill names already offered to the user.""" + if not MANIFEST_FILE.exists(): + return set() + try: + return set( + line.strip() + for line in MANIFEST_FILE.read_text(encoding="utf-8").splitlines() + if line.strip() + ) + except (OSError, IOError): + return set() + + +def _write_manifest(names: set): + """Write the manifest file.""" + MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True) + MANIFEST_FILE.write_text( + "\n".join(sorted(names)) + "\n", + encoding="utf-8", + ) + + +def _discover_bundled_skills(bundled_dir: Path) -> List[Tuple[str, Path]]: + """ + Find all SKILL.md files in the bundled directory. + Returns list of (skill_name, skill_directory_path) tuples. + """ + skills = [] + if not bundled_dir.exists(): + return skills + + for skill_md in bundled_dir.rglob("SKILL.md"): + path_str = str(skill_md) + if "/.git/" in path_str or "/.github/" in path_str or "/.hub/" in path_str: + continue + skill_dir = skill_md.parent + skill_name = skill_dir.name + skills.append((skill_name, skill_dir)) + + return skills + + +def _compute_relative_dest(skill_dir: Path, bundled_dir: Path) -> Path: + """ + Compute the destination path in SKILLS_DIR preserving the category structure. + e.g., bundled/skills/mlops/axolotl -> ~/.hermes/skills/mlops/axolotl + """ + rel = skill_dir.relative_to(bundled_dir) + return SKILLS_DIR / rel + + +def sync_skills(quiet: bool = False) -> dict: + """ + Sync bundled skills into ~/.hermes/skills/ using the manifest. + + - Skills whose names are already in the manifest are skipped (even if deleted by user). + - New skills (not in manifest) are copied to SKILLS_DIR and added to the manifest. + + Returns: + dict with keys: copied (list of names), skipped (int), total_bundled (int) + """ + bundled_dir = _get_bundled_dir() + if not bundled_dir.exists(): + return {"copied": [], "skipped": 0, "total_bundled": 0} + + SKILLS_DIR.mkdir(parents=True, exist_ok=True) + manifest = _read_manifest() + bundled_skills = _discover_bundled_skills(bundled_dir) + copied = [] + skipped = 0 + + for skill_name, skill_src in bundled_skills: + if skill_name in manifest: + skipped += 1 + continue + + dest = _compute_relative_dest(skill_src, bundled_dir) + try: + if dest.exists(): + # Skill dir exists (maybe user created one with same name) -- don't overwrite + skipped += 1 + else: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(skill_src, dest) + copied.append(skill_name) + if not quiet: + print(f" + {skill_name}") + except (OSError, IOError) as e: + if not quiet: + print(f" ! Failed to copy {skill_name}: {e}") + + manifest.add(skill_name) + + # Also copy DESCRIPTION.md files for categories (if not already present) + for desc_md in bundled_dir.rglob("DESCRIPTION.md"): + rel = desc_md.relative_to(bundled_dir) + dest_desc = SKILLS_DIR / rel + if not dest_desc.exists(): + try: + dest_desc.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(desc_md, dest_desc) + except (OSError, IOError): + pass + + _write_manifest(manifest) + + return { + "copied": copied, + "skipped": skipped, + "total_bundled": len(bundled_skills), + } + + +if __name__ == "__main__": + print("Syncing bundled skills into ~/.hermes/skills/ ...") + result = sync_skills(quiet=False) + print(f"\nDone: {len(result['copied'])} new, {result['skipped']} skipped, " + f"{result['total_bundled']} total bundled.") diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 09d02dbba..f93ae0c4f 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -68,8 +68,11 @@ from typing import Dict, Any, List, Optional, Tuple import yaml -# Default skills directory (relative to repo root) -SKILLS_DIR = Path(__file__).parent.parent / "skills" +# All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install). +# This is the single source of truth -- agent edits, hub installs, and bundled +# skills all coexist here without polluting the git repo. +HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) +SKILLS_DIR = HERMES_HOME / "skills" # Anthropic-recommended limits for progressive disclosure efficiency MAX_NAME_LENGTH = 64 @@ -77,13 +80,8 @@ MAX_DESCRIPTION_LENGTH = 1024 def check_skills_requirements() -> bool: - """ - Check if skills tool requirements are met. - - Returns: - bool: True if the skills directory exists, False otherwise - """ - return SKILLS_DIR.exists() and SKILLS_DIR.is_dir() + """Skills are always available -- the directory is created on first use if needed.""" + return True def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: @@ -127,21 +125,11 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]: """ Extract category from skill path based on directory structure. - For paths like: skills/03-fine-tuning/axolotl/SKILL.md - Returns: "03-fine-tuning" - - Args: - skill_path: Path to SKILL.md file - - Returns: - Category name or None if skill is at root level + For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops" """ try: - # Get path relative to skills directory rel_path = skill_path.relative_to(SKILLS_DIR) parts = rel_path.parts - - # If there are at least 2 parts (category/skill/SKILL.md), return category if len(parts) >= 3: return parts[0] return None @@ -194,18 +182,10 @@ def _parse_tags(tags_value) -> List[str]: def _find_all_skills() -> List[Dict[str, Any]]: """ - Recursively find all skills in the skills directory. + Recursively find all skills in ~/.hermes/skills/. Returns metadata for progressive disclosure (tier 1): - - name (≤64 chars) - - description (≤1024 chars) - - category, path, tags, related_skills - - reference/template file counts - - estimated token count for full content - - Skills can be: - 1. Directories containing SKILL.md (preferred) - 2. Flat .md files (legacy support) + - name, description, category Returns: List of skill metadata dicts @@ -215,9 +195,7 @@ def _find_all_skills() -> List[Dict[str, Any]]: if not SKILLS_DIR.exists(): return skills - # Find all SKILL.md files recursively for skill_md in SKILLS_DIR.rglob("SKILL.md"): - # Skip hidden directories, hub state, and common non-skill folders path_str = str(skill_md) if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str: continue @@ -228,10 +206,8 @@ def _find_all_skills() -> List[Dict[str, Any]]: content = skill_md.read_text(encoding='utf-8') frontmatter, body = _parse_frontmatter(content) - # Get name from frontmatter or directory name (max 64 chars) name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH] - # Get description from frontmatter or first paragraph (max 1024 chars) description = frontmatter.get('description', '') if not description: for line in body.strip().split('\n'): @@ -240,93 +216,20 @@ def _find_all_skills() -> List[Dict[str, Any]]: description = line break - # Truncate description to limit if len(description) > MAX_DESCRIPTION_LENGTH: description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." - # Get category from path category = _get_category_from_path(skill_md) - # Track the path internally for excluding from legacy search - skill_path = str(skill_dir.relative_to(SKILLS_DIR)) - - # Minimal entry for list - full details in skill_view() skills.append({ "name": name, "description": description, "category": category, - "_path": skill_path # Internal only, removed before return - }) - - except Exception as e: - # Skip files that can't be read - continue - - # Also find flat .md files at any level (legacy support) - # But exclude files in skill directories (already handled above) - skill_dirs = {s["_path"] for s in skills} - - for md_file in SKILLS_DIR.rglob("*.md"): - # Skip SKILL.md files (already handled) - if md_file.name == "SKILL.md": - continue - - # Skip hidden directories and hub state - path_str = str(md_file) - if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str: - continue - - # Skip files inside skill directories (they're references, not standalone skills) - rel_dir = str(md_file.parent.relative_to(SKILLS_DIR)) - if any(rel_dir.startswith(sd) for sd in skill_dirs): - continue - - # Skip common non-skill files - if md_file.name in ['README.md', 'CONTRIBUTING.md', 'CLAUDE.md', 'LICENSE']: - continue - if md_file.name.startswith('_'): - continue - - try: - content = md_file.read_text(encoding='utf-8') - frontmatter, body = _parse_frontmatter(content) - - name = frontmatter.get('name', md_file.stem)[:MAX_NAME_LENGTH] - description = frontmatter.get('description', '') - - if not description: - for line in body.strip().split('\n'): - line = line.strip() - if line and not line.startswith('#'): - description = line - break - - if len(description) > MAX_DESCRIPTION_LENGTH: - description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." - - # Get category from parent directory if not at root - category = None - rel_path = md_file.relative_to(SKILLS_DIR) - if len(rel_path.parts) > 1: - category = rel_path.parts[0] - - # Parse optional fields - tags = _parse_tags(frontmatter.get('tags', '')) - - # Minimal entry for list - full details in skill_view() - skills.append({ - "name": name, - "description": description, - "category": category }) except Exception: continue - # Strip internal _path field before returning - for skill in skills: - skill.pop("_path", None) - return skills @@ -390,7 +293,6 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str: "message": "No skills directory found." }, ensure_ascii=False) - # Scan for categories (top-level directories containing skills) category_dirs = {} for skill_md in SKILLS_DIR.rglob("SKILL.md"): category = _get_category_from_path(skill_md) @@ -399,22 +301,15 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str: if category not in category_dirs: category_dirs[category] = category_dir - # Build category list with descriptions categories = [] for name in sorted(category_dirs.keys()): category_dir = category_dirs[name] description = _load_category_description(category_dir) - - # Count skills in this category skill_count = sum(1 for _ in category_dir.rglob("SKILL.md")) - cat_entry = { - "name": name, - "skill_count": skill_count - } + cat_entry = {"name": name, "skill_count": skill_count} if description: cat_entry["description"] = description - categories.append(cat_entry) return json.dumps({ @@ -445,14 +340,13 @@ def skills_list(category: str = None, task_id: str = None) -> str: JSON string with minimal skill info: name, description, category """ try: - # Ensure skills directory exists if not SKILLS_DIR.exists(): SKILLS_DIR.mkdir(parents=True, exist_ok=True) return json.dumps({ "success": True, "skills": [], "categories": [], - "message": "Skills directory created. No skills available yet." + "message": "No skills found. Skills directory created at ~/.hermes/skills/" }, ensure_ascii=False) # Find all skills @@ -507,35 +401,34 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: if not SKILLS_DIR.exists(): return json.dumps({ "success": False, - "error": "Skills directory does not exist." + "error": "Skills directory does not exist yet. It will be created on first install." }, ensure_ascii=False) - # Find the skill skill_dir = None skill_md = None - # Try direct path first (e.g., "03-fine-tuning/axolotl") + # Try direct path first (e.g., "mlops/axolotl") direct_path = SKILLS_DIR / name if direct_path.is_dir() and (direct_path / "SKILL.md").exists(): skill_dir = direct_path skill_md = direct_path / "SKILL.md" elif direct_path.with_suffix('.md').exists(): - # Legacy flat file skill_md = direct_path.with_suffix('.md') - else: - # Search for skill by name + + # Search by directory name + if not skill_md: for found_skill_md in SKILLS_DIR.rglob("SKILL.md"): if found_skill_md.parent.name == name: skill_dir = found_skill_md.parent skill_md = found_skill_md break - - # Also check flat .md files - if not skill_md: - for found_md in SKILLS_DIR.rglob(f"{name}.md"): - if found_md.name != "SKILL.md": - skill_md = found_md - break + + # Legacy: flat .md files + if not skill_md: + for found_md in SKILLS_DIR.rglob(f"{name}.md"): + if found_md.name != "SKILL.md": + skill_md = found_md + break if not skill_md or not skill_md.exists(): # List available skills in error message @@ -660,7 +553,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: if script_files: linked_files["scripts"] = script_files - # Build response with agentskills.io standard fields when present + rel_path = str(skill_md.relative_to(SKILLS_DIR)) + result = { "success": True, "name": frontmatter.get('name', skill_md.stem if not skill_dir else skill_dir.name), @@ -668,7 +562,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str: "tags": tags, "related_skills": related_skills, "content": content, - "path": str(skill_md.relative_to(SKILLS_DIR)), + "path": rel_path, "linked_files": linked_files if linked_files else None, "usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files else None } diff --git a/toolsets.py b/toolsets.py index 0e17dd03a..e0f93d7cb 100644 --- a/toolsets.py +++ b/toolsets.py @@ -68,8 +68,8 @@ TOOLSETS = { }, "skills": { - "description": "Access skill documents with specialized instructions and knowledge", - "tools": ["skills_list", "skill_view"], + "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge", + "tools": ["skills_list", "skill_view", "skill_manage"], "includes": [] }, @@ -167,7 +167,7 @@ TOOLSETS = { # MoA "mixture_of_agents", # Skills - "skills_list", "skill_view", + "skills_list", "skill_view", "skill_manage", # Browser "browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_scroll", "browser_back", @@ -212,7 +212,7 @@ TOOLSETS = { "browser_press", "browser_close", "browser_get_images", "browser_vision", # Skills - access knowledge base - "skills_list", "skill_view", + "skills_list", "skill_view", "skill_manage", # Planning & task management "todo", # Persistent memory @@ -248,7 +248,7 @@ TOOLSETS = { "browser_press", "browser_close", "browser_get_images", "browser_vision", # Skills - access knowledge base - "skills_list", "skill_view", + "skills_list", "skill_view", "skill_manage", # Planning & task management "todo", # Persistent memory @@ -284,7 +284,7 @@ TOOLSETS = { "browser_press", "browser_close", "browser_get_images", "browser_vision", # Skills - "skills_list", "skill_view", + "skills_list", "skill_view", "skill_manage", # Planning & task management "todo", # Persistent memory @@ -320,7 +320,7 @@ TOOLSETS = { "browser_press", "browser_close", "browser_get_images", "browser_vision", # Skills - access knowledge base - "skills_list", "skill_view", + "skills_list", "skill_view", "skill_manage", # Planning & task management "todo", # Persistent memory