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.
This commit is contained in:
teknium1 2026-02-19 18:25:53 -08:00
parent d070b8698d
commit 4d5f29c74c
18 changed files with 1007 additions and 204 deletions

8
.gitignore vendored
View file

@ -47,9 +47,5 @@ testlogs
# CLI config (may contain sensitive SSH paths) # CLI config (may contain sensitive SSH paths)
cli-config.yaml cli-config.yaml
# Skills Hub state (local to each machine) # Skills Hub state (lives in ~/.hermes/skills/.hub/ at runtime, but just in case)
skills/.hub/lock.json skills/.hub/
skills/.hub/audit.log
skills/.hub/quarantine/
skills/.hub/index-cache/
skills/.hub/taps.json

View file

@ -80,6 +80,8 @@ All your settings are stored in `~/.hermes/` for easy access:
├── config.yaml # Settings (model, terminal, TTS, compression, etc.) ├── config.yaml # Settings (model, terminal, TTS, compression, etc.)
├── .env # API keys and secrets ├── .env # API keys and secrets
├── SOUL.md # Optional: global persona (agent embodies this personality) ├── 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 ├── cron/ # Scheduled jobs
├── sessions/ # Gateway sessions ├── sessions/ # Gateway sessions
└── logs/ # Logs └── 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. 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:** **Using Skills:**
```bash ```bash
hermes --toolsets skills -q "What skills do you have?" hermes --toolsets skills -q "What skills do you have?"
hermes --toolsets skills -q "Show me the axolotl skill" 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:** **Skills Hub — Search, install, and manage skills from online registries:**
```bash ```bash
hermes skills search kubernetes # Search all sources (GitHub, ClawHub, LobeHub) hermes skills search kubernetes # Search all sources (GitHub, ClawHub, LobeHub)
@ -595,28 +631,39 @@ 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`). 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 ```markdown
--- ---
name: my-skill name: my-skill
description: Brief description description: Brief description of what this skill does
version: 1.0.0 version: 1.0.0
metadata: metadata:
hermes: hermes:
tags: [python, automation] 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/ ~/.hermes/skills/ # Single source of truth for all skills
├── mlops/ ├── mlops/ # Category directory
│ ├── axolotl/ │ ├── axolotl/
│ │ ├── SKILL.md # Main instructions (required) │ │ ├── SKILL.md # Main instructions (required)
│ │ ├── references/ # Additional docs │ │ ├── references/ # Additional docs
@ -624,10 +671,15 @@ skills/
│ │ └── assets/ # Supplementary files (agentskills.io standard) │ │ └── assets/ # Supplementary files (agentskills.io standard)
│ └── vllm/ │ └── vllm/
│ └── SKILL.md │ └── SKILL.md
├── .hub/ # Skills Hub state (gitignored) ├── devops/
│ └── deploy-k8s/ # Agent-created skill
│ ├── SKILL.md
│ └── references/
├── .hub/ # Skills Hub state
│ ├── lock.json # Installed skill provenance │ ├── lock.json # Installed skill provenance
│ ├── quarantine/ # Pending security review │ ├── quarantine/ # Pending security review
│ └── audit.log # Security scan history │ └── audit.log # Security scan history
└── .bundled_manifest # Tracks which bundled skills have been offered
``` ```
### 🤖 RL Training (Tinker + Atropos) ### 🤖 RL Training (Tinker + Atropos)
@ -910,7 +962,7 @@ Hermes stores all user configuration in `~/.hermes/`:
```bash ```bash
# Create the directory structure # Create the directory structure
mkdir -p ~/.hermes/{cron,sessions,logs} mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
# Copy the example config file # Copy the example config file
cp cli-config.yaml.example ~/.hermes/config.yaml cp cli-config.yaml.example ~/.hermes/config.yaml
@ -924,6 +976,8 @@ Your `~/.hermes/` directory should now look like:
~/.hermes/ ~/.hermes/
├── config.yaml # Agent settings (model, terminal, toolsets, compression, etc.) ├── config.yaml # Agent settings (model, terminal, toolsets, compression, etc.)
├── .env # API keys and secrets (one per line: KEY=value) ├── .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 ├── cron/ # Scheduled job data
├── sessions/ # Messaging gateway sessions ├── sessions/ # Messaging gateway sessions
└── logs/ # Conversation logs └── logs/ # Conversation logs
@ -1050,7 +1104,7 @@ uv pip install -e "./tinker-atropos"
npm install # optional, for browser tools npm install # optional, for browser tools
# Configure # Configure
mkdir -p ~/.hermes/{cron,sessions,logs} mkdir -p ~/.hermes/{cron,sessions,logs,memories,skills}
cp cli-config.yaml.example ~/.hermes/config.yaml cp cli-config.yaml.example ~/.hermes/config.yaml
touch ~/.hermes/.env touch ~/.hermes/.env
echo 'OPENROUTER_API_KEY=sk-or-v1-your-key' >> ~/.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-agent/logs/` | Session logs |
| `hermes_cli/` | CLI implementation | | `hermes_cli/` | CLI implementation |
| `tools/` | Tool implementations | | `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 | | `gateway/` | Messaging platform adapters |
| `cron/` | Scheduler implementation | | `cron/` | Scheduler implementation |

15
cli.py
View file

@ -355,33 +355,32 @@ COMPACT_BANNER = """
def _get_available_skills() -> Dict[str, List[str]]: 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: Returns:
Dict mapping category name to list of skill names 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 = {} skills_by_category = {}
if not skills_dir.exists(): if not skills_dir.exists():
return skills_by_category return skills_by_category
# Scan for SKILL.md files
for skill_file in skills_dir.rglob("SKILL.md"): 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) rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts parts = rel_path.parts
if len(parts) >= 2: if len(parts) >= 2:
category = parts[0] category = parts[0]
skill_name = parts[-2] # Folder containing SKILL.md skill_name = parts[-2]
else: else:
category = "general" category = "general"
skill_name = skill_file.parent.name skill_name = skill_file.parent.name
if category not in skills_by_category: skills_by_category.setdefault(category, []).append(skill_name)
skills_by_category[category] = []
skills_by_category[category].append(skill_name)
return skills_by_category return skills_by_category

View file

@ -46,7 +46,7 @@ async def web_search(query: str) -> dict:
| **Image Gen** | `image_generation_tool.py` | `image_generate` | | **Image Gen** | `image_generation_tool.py` | `image_generate` |
| **TTS** | `tts_tool.py` | `text_to_speech` (Edge TTS free / ElevenLabs / OpenAI) | | **TTS** | `tts_tool.py` | `text_to_speech` (Edge TTS free / ElevenLabs / OpenAI) |
| **Reasoning** | `mixture_of_agents_tool.py` | `mixture_of_agents` | | **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) | | **Todo** | `todo_tool.py` | `todo` (read/write task list for multi-step planning) |
| **Memory** | `memory_tool.py` | `memory` (persistent notes + user profile across sessions) | | **Memory** | `memory_tool.py` | `memory` (persistent notes + user profile across sessions) |
| **Session Search** | `session_search_tool.py` | `session_search` (search + summarize past conversations) | | **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) 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: Skill directory structure:
``` ```
skills/ ~/.hermes/skills/
└── mlops/ ├── mlops/
└── axolotl/ │ └── axolotl/
├── SKILL.md # Main instructions (required) │ ├── SKILL.md # Main instructions (required)
├── references/ # Additional docs │ ├── references/ # Additional docs
├── templates/ # Output formats, configs │ ├── templates/ # Output formats, configs
└── assets/ # Supplementary files (agentskills.io) │ └── 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): SKILL.md uses YAML frontmatter (agentskills.io compatible):
@ -173,9 +180,55 @@ description: Fine-tuning LLMs with Axolotl
metadata: metadata:
hermes: hermes:
tags: [Fine-Tuning, LoRA, DPO] 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 ## 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. 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:** **Architecture:**
- `tools/skills_guard.py` — Static scanner + LLM audit, trust-aware install policy - `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/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 - `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` **CLI:** `hermes skills search|install|inspect|list|audit|uninstall|publish|snapshot|tap`

View file

@ -190,6 +190,19 @@ def cmd_update(args):
print() print()
print("✓ Code updated!") 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 # Check for config migrations
print() print()
print("→ Checking configuration for new options...") print("→ Checking configuration for new options...")

View file

@ -211,8 +211,8 @@ def _print_setup_summary(config: dict, hermes_home):
# Task planning (always available, in-memory) # Task planning (always available, in-memory)
tool_status.append(("Task Planning (todo)", True, None)) tool_status.append(("Task Planning (todo)", True, None))
# Skills (always available if skills dir exists) # Skills (always available -- bundled skills + user-created skills)
tool_status.append(("Skills Knowledge Base", True, None)) tool_status.append(("Skills (view, create, edit)", True, None))
# Print status # Print status
available_count = sum(1 for _, avail, _ in tool_status if avail) available_count = sum(1 for _, avail, _ in tool_status if avail)

View file

@ -186,7 +186,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
"External skills can contain instructions that influence agent behavior,\n" "External skills can contain instructions that influence agent behavior,\n"
"shell commands, and scripts. Even after automated scanning, you should\n" "shell commands, and scripts. Even after automated scanning, you should\n"
"review the installed files before use.\n\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", title="Disclaimer",
border_style="yellow", border_style="yellow",
)) ))

View file

@ -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.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.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 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) # RL Training tools (Tinker-Atropos)
from tools.rl_training_tool import ( from tools.rl_training_tool import (
rl_list_environments, rl_list_environments,
@ -151,7 +153,7 @@ TOOLSET_REQUIREMENTS = {
"env_vars": [], # Just needs skills directory "env_vars": [], # Just needs skills directory
"check_fn": check_skills_requirements, "check_fn": check_skills_requirements,
"setup_url": None, "setup_url": None,
"tools": ["skills_list", "skill_view"], "tools": ["skills_list", "skill_view", "skill_manage"],
}, },
"rl": { "rl": {
"name": "RL Training (Tinker-Atropos)", "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]]: def get_browser_tool_definitions() -> List[Dict[str, Any]]:
""" """
Get tool definitions for browser automation tools in OpenAI's expected format. 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 # Skills tools
if check_skills_requirements(): if check_skills_requirements():
tool_names.extend(["skills_list", "skill_view"]) tool_names.extend(["skills_list", "skill_view", "skill_manage"])
# Browser automation tools # Browser automation tools
if check_browser_requirements(): if check_browser_requirements():
@ -1159,6 +1171,7 @@ TOOL_TO_TOOLSET_MAP = {
# Skills tools # Skills tools
"skills_list": "skills_tools", "skills_list": "skills_tools",
"skill_view": "skills_tools", "skill_view": "skills_tools",
"skill_manage": "skills_tools",
# Browser automation tools # Browser automation tools
"browser_navigate": "browser_tools", "browser_navigate": "browser_tools",
"browser_snapshot": "browser_tools", "browser_snapshot": "browser_tools",
@ -1281,6 +1294,8 @@ def get_tool_definitions(
if check_skills_requirements(): if check_skills_requirements():
for tool in get_skills_tool_definitions(): for tool in get_skills_tool_definitions():
all_available_tools_map[tool["function"]["name"]] = tool 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(): if check_browser_requirements():
for tool in get_browser_tool_definitions(): for tool in get_browser_tool_definitions():
@ -1346,7 +1361,7 @@ def get_tool_definitions(
"vision_tools": ["vision_analyze"], "vision_tools": ["vision_analyze"],
"moa_tools": ["mixture_of_agents"], "moa_tools": ["mixture_of_agents"],
"image_tools": ["image_generate"], "image_tools": ["image_generate"],
"skills_tools": ["skills_list", "skill_view"], "skills_tools": ["skills_list", "skill_view", "skill_manage"],
"browser_tools": [ "browser_tools": [
"browser_navigate", "browser_snapshot", "browser_click", "browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back", "browser_type", "browser_scroll", "browser_back",
@ -1400,7 +1415,7 @@ def get_tool_definitions(
"vision_tools": ["vision_analyze"], "vision_tools": ["vision_analyze"],
"moa_tools": ["mixture_of_agents"], "moa_tools": ["mixture_of_agents"],
"image_tools": ["image_generate"], "image_tools": ["image_generate"],
"skills_tools": ["skills_list", "skill_view"], "skills_tools": ["skills_list", "skill_view", "skill_manage"],
"browser_tools": [ "browser_tools": [
"browser_navigate", "browser_snapshot", "browser_click", "browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back", "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: 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: Args:
function_name (str): Name of the skills function to call 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") file_path = function_args.get("file_path")
return skill_view(name, file_path=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: else:
return json.dumps({"error": f"Unknown skills function: {function_name}"}, ensure_ascii=False) 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) return handle_image_function_call(function_name, function_args)
# Route skills tools # 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) return handle_skills_function_call(function_name, function_args)
# Route browser automation tools # Route browser automation tools
@ -2249,9 +2283,9 @@ def get_available_toolsets() -> Dict[str, Dict[str, Any]]:
}, },
"skills_tools": { "skills_tools": {
"available": check_skills_requirements(), "available": check_skills_requirements(),
"tools": ["skills_list", "skill_view"], "tools": ["skills_list", "skill_view", "skill_manage"],
"description": "Access skill documents that provide specialized instructions, guidelines, or knowledge the agent can load on demand", "description": "Access, create, edit, and manage skill documents that provide specialized instructions, guidelines, or knowledge the agent can load on demand",
"requirements": ["skills/ directory in repo root"] "requirements": ["~/.hermes/skills/ directory (seeded from bundled skills on install)"]
}, },
"browser_tools": { "browser_tools": {
"available": check_browser_requirements(), "available": check_browser_requirements(),

View file

@ -589,7 +589,7 @@ def apply_anthropic_cache_control(
# the model can match skills at a glance without extra tool calls. # the model can match skills at a glance without extra tool calls.
def build_skills_system_prompt() -> str: 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 Returns a prompt section that lists all skill categories (with descriptions
from DESCRIPTION.md) and their skill names inline, so the model can from DESCRIPTION.md) and their skill names inline, so the model can
@ -599,10 +599,13 @@ def build_skills_system_prompt() -> str:
Returns: Returns:
str: The skills system prompt section, or empty string if no skills found. str: The skills system prompt section, or empty string if no skills found.
""" """
import os
import re import re
from pathlib import Path 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(): if not skills_dir.exists():
return "" return ""
@ -613,7 +616,7 @@ def build_skills_system_prompt() -> str:
parts = rel_path.parts parts = rel_path.parts
if len(parts) >= 2: if len(parts) >= 2:
category = parts[0] category = parts[0]
skill_name = parts[-2] # Folder containing SKILL.md skill_name = parts[-2]
else: else:
category = "general" category = "general"
skill_name = skill_file.parent.name skill_name = skill_file.parent.name
@ -622,25 +625,23 @@ def build_skills_system_prompt() -> str:
if not skills_by_category: if not skills_by_category:
return "" return ""
# Load category descriptions from DESCRIPTION.md files (YAML frontmatter) # Load category descriptions from DESCRIPTION.md files
category_descriptions = {} category_descriptions = {}
for category in skills_by_category: for category in skills_by_category:
desc_file = skills_dir / category / "DESCRIPTION.md" desc_file = skills_dir / category / "DESCRIPTION.md"
if desc_file.exists(): if desc_file.exists():
try: try:
content = desc_file.read_text(encoding="utf-8") 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) match = re.search(r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---", content, re.MULTILINE | re.DOTALL)
if match: if match:
category_descriptions[category] = match.group(1).strip() category_descriptions[category] = match.group(1).strip()
except Exception: except Exception:
pass pass
# Build compact index: category with description + skill names
index_lines = [] index_lines = []
for category in sorted(skills_by_category.keys()): for category in sorted(skills_by_category.keys()):
desc = category_descriptions.get(category, "") desc = category_descriptions.get(category, "")
names = ", ".join(sorted(skills_by_category[category])) names = ", ".join(sorted(set(skills_by_category[category])))
if desc: if desc:
index_lines.append(f" {category}: {desc}") index_lines.append(f" {category}: {desc}")
else: else:
@ -650,7 +651,8 @@ def build_skills_system_prompt() -> str:
return ( return (
"## Skills (mandatory)\n" "## Skills (mandatory)\n"
"Before replying, scan the skills below. If one clearly matches your task, " "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"
"<available_skills>\n" "<available_skills>\n"
+ "\n".join(index_lines) + "\n" + "\n".join(index_lines) + "\n"
@ -2156,7 +2158,7 @@ class AIAgent:
if user_block: if user_block:
prompt_parts.append(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 "" skills_prompt = build_skills_system_prompt() if has_skills_tools else ""
if skills_prompt: if skills_prompt:
prompt_parts.append(skills_prompt) prompt_parts.append(skills_prompt)

View file

@ -447,6 +447,8 @@ function Copy-ConfigTemplates {
New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null 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\image_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_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 # Create .env
$envPath = "$HermesHome\.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/" 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 { function Install-NodeDeps {

View file

@ -614,7 +614,7 @@ copy_config_templates() {
log_info "Setting up configuration files..." log_info "Setting up configuration files..."
# Create ~/.hermes directory structure (config at top level, code in subdir) # 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) # Create .env at ~/.hermes/.env (top level, easy to find)
if [ ! -f "$HERMES_HOME/.env" ]; then if [ ! -f "$HERMES_HOME/.env" ]; then
@ -662,6 +662,18 @@ SOUL_EOF
fi fi
log_success "Configuration directory ready: ~/.hermes/" 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() { install_node_deps() {

View file

@ -240,6 +240,25 @@ if [ -n "$SHELL_CONFIG" ]; then
fi fi
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 # Done
# ============================================================================ # ============================================================================

View file

@ -65,6 +65,12 @@ from .skills_tool import (
SKILLS_TOOL_DESCRIPTION SKILLS_TOOL_DESCRIPTION
) )
from .skill_manager_tool import (
skill_manage,
check_skill_manage_requirements,
SKILL_MANAGE_SCHEMA
)
# Browser automation tools (agent-browser + Browserbase) # Browser automation tools (agent-browser + Browserbase)
from .browser_tool import ( from .browser_tool import (
browser_navigate, browser_navigate,
@ -175,6 +181,10 @@ __all__ = [
'skill_view', 'skill_view',
'check_skills_requirements', 'check_skills_requirements',
'SKILLS_TOOL_DESCRIPTION', 'SKILLS_TOOL_DESCRIPTION',
# Skill management
'skill_manage',
'check_skill_manage_requirements',
'SKILL_MANAGE_SCHEMA',
# Browser automation tools # Browser automation tools
'browser_navigate', 'browser_navigate',
'browser_snapshot', 'browser_snapshot',

544
tools/skill_manager_tool.py Normal file
View file

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

View file

@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
# Paths # 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" HUB_DIR = SKILLS_DIR / ".hub"
LOCK_FILE = HUB_DIR / "lock.json" LOCK_FILE = HUB_DIR / "lock.json"
QUARANTINE_DIR = HUB_DIR / "quarantine" QUARANTINE_DIR = HUB_DIR / "quarantine"

150
tools/skills_sync.py Normal file
View file

@ -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.")

View file

@ -68,8 +68,11 @@ from typing import Dict, Any, List, Optional, Tuple
import yaml import yaml
# Default skills directory (relative to repo root) # All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install).
SKILLS_DIR = Path(__file__).parent.parent / "skills" # 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 # Anthropic-recommended limits for progressive disclosure efficiency
MAX_NAME_LENGTH = 64 MAX_NAME_LENGTH = 64
@ -77,13 +80,8 @@ MAX_DESCRIPTION_LENGTH = 1024
def check_skills_requirements() -> bool: def check_skills_requirements() -> bool:
""" """Skills are always available -- the directory is created on first use if needed."""
Check if skills tool requirements are met. return True
Returns:
bool: True if the skills directory exists, False otherwise
"""
return SKILLS_DIR.exists() and SKILLS_DIR.is_dir()
def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: 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. Extract category from skill path based on directory structure.
For paths like: skills/03-fine-tuning/axolotl/SKILL.md For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops"
Returns: "03-fine-tuning"
Args:
skill_path: Path to SKILL.md file
Returns:
Category name or None if skill is at root level
""" """
try: try:
# Get path relative to skills directory
rel_path = skill_path.relative_to(SKILLS_DIR) rel_path = skill_path.relative_to(SKILLS_DIR)
parts = rel_path.parts parts = rel_path.parts
# If there are at least 2 parts (category/skill/SKILL.md), return category
if len(parts) >= 3: if len(parts) >= 3:
return parts[0] return parts[0]
return None return None
@ -194,18 +182,10 @@ def _parse_tags(tags_value) -> List[str]:
def _find_all_skills() -> List[Dict[str, Any]]: 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): Returns metadata for progressive disclosure (tier 1):
- name (64 chars) - name, description, category
- 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)
Returns: Returns:
List of skill metadata dicts List of skill metadata dicts
@ -215,9 +195,7 @@ def _find_all_skills() -> List[Dict[str, Any]]:
if not SKILLS_DIR.exists(): if not SKILLS_DIR.exists():
return skills return skills
# Find all SKILL.md files recursively
for skill_md in SKILLS_DIR.rglob("SKILL.md"): for skill_md in SKILLS_DIR.rglob("SKILL.md"):
# Skip hidden directories, hub state, and common non-skill folders
path_str = str(skill_md) path_str = str(skill_md)
if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str: if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str:
continue continue
@ -228,10 +206,8 @@ def _find_all_skills() -> List[Dict[str, Any]]:
content = skill_md.read_text(encoding='utf-8') content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content) 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] name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH]
# Get description from frontmatter or first paragraph (max 1024 chars)
description = frontmatter.get('description', '') description = frontmatter.get('description', '')
if not description: if not description:
for line in body.strip().split('\n'): for line in body.strip().split('\n'):
@ -240,93 +216,20 @@ def _find_all_skills() -> List[Dict[str, Any]]:
description = line description = line
break break
# Truncate description to limit
if len(description) > MAX_DESCRIPTION_LENGTH: if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
# Get category from path
category = _get_category_from_path(skill_md) 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({ skills.append({
"name": name, "name": name,
"description": description, "description": description,
"category": category, "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: except Exception:
continue continue
# Strip internal _path field before returning
for skill in skills:
skill.pop("_path", None)
return skills return skills
@ -390,7 +293,6 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str:
"message": "No skills directory found." "message": "No skills directory found."
}, ensure_ascii=False) }, ensure_ascii=False)
# Scan for categories (top-level directories containing skills)
category_dirs = {} category_dirs = {}
for skill_md in SKILLS_DIR.rglob("SKILL.md"): for skill_md in SKILLS_DIR.rglob("SKILL.md"):
category = _get_category_from_path(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: if category not in category_dirs:
category_dirs[category] = category_dir category_dirs[category] = category_dir
# Build category list with descriptions
categories = [] categories = []
for name in sorted(category_dirs.keys()): for name in sorted(category_dirs.keys()):
category_dir = category_dirs[name] category_dir = category_dirs[name]
description = _load_category_description(category_dir) description = _load_category_description(category_dir)
# Count skills in this category
skill_count = sum(1 for _ in category_dir.rglob("SKILL.md")) skill_count = sum(1 for _ in category_dir.rglob("SKILL.md"))
cat_entry = { cat_entry = {"name": name, "skill_count": skill_count}
"name": name,
"skill_count": skill_count
}
if description: if description:
cat_entry["description"] = description cat_entry["description"] = description
categories.append(cat_entry) categories.append(cat_entry)
return json.dumps({ 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 JSON string with minimal skill info: name, description, category
""" """
try: try:
# Ensure skills directory exists
if not SKILLS_DIR.exists(): if not SKILLS_DIR.exists():
SKILLS_DIR.mkdir(parents=True, exist_ok=True) SKILLS_DIR.mkdir(parents=True, exist_ok=True)
return json.dumps({ return json.dumps({
"success": True, "success": True,
"skills": [], "skills": [],
"categories": [], "categories": [],
"message": "Skills directory created. No skills available yet." "message": "No skills found. Skills directory created at ~/.hermes/skills/"
}, ensure_ascii=False) }, ensure_ascii=False)
# Find all skills # Find all skills
@ -507,30 +401,29 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
if not SKILLS_DIR.exists(): if not SKILLS_DIR.exists():
return json.dumps({ return json.dumps({
"success": False, "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) }, ensure_ascii=False)
# Find the skill
skill_dir = None skill_dir = None
skill_md = 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 direct_path = SKILLS_DIR / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists(): if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path skill_dir = direct_path
skill_md = direct_path / "SKILL.md" skill_md = direct_path / "SKILL.md"
elif direct_path.with_suffix('.md').exists(): elif direct_path.with_suffix('.md').exists():
# Legacy flat file
skill_md = direct_path.with_suffix('.md') 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"): for found_skill_md in SKILLS_DIR.rglob("SKILL.md"):
if found_skill_md.parent.name == name: if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent skill_dir = found_skill_md.parent
skill_md = found_skill_md skill_md = found_skill_md
break break
# Also check flat .md files # Legacy: flat .md files
if not skill_md: if not skill_md:
for found_md in SKILLS_DIR.rglob(f"{name}.md"): for found_md in SKILLS_DIR.rglob(f"{name}.md"):
if found_md.name != "SKILL.md": if found_md.name != "SKILL.md":
@ -660,7 +553,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
if script_files: if script_files:
linked_files["scripts"] = 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 = { result = {
"success": True, "success": True,
"name": frontmatter.get('name', skill_md.stem if not skill_dir else skill_dir.name), "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, "tags": tags,
"related_skills": related_skills, "related_skills": related_skills,
"content": content, "content": content,
"path": str(skill_md.relative_to(SKILLS_DIR)), "path": rel_path,
"linked_files": linked_files if linked_files else None, "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 "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
} }

View file

@ -68,8 +68,8 @@ TOOLSETS = {
}, },
"skills": { "skills": {
"description": "Access skill documents with specialized instructions and knowledge", "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge",
"tools": ["skills_list", "skill_view"], "tools": ["skills_list", "skill_view", "skill_manage"],
"includes": [] "includes": []
}, },
@ -167,7 +167,7 @@ TOOLSETS = {
# MoA # MoA
"mixture_of_agents", "mixture_of_agents",
# Skills # Skills
"skills_list", "skill_view", "skills_list", "skill_view", "skill_manage",
# Browser # Browser
"browser_navigate", "browser_snapshot", "browser_click", "browser_navigate", "browser_snapshot", "browser_click",
"browser_type", "browser_scroll", "browser_back", "browser_type", "browser_scroll", "browser_back",
@ -212,7 +212,7 @@ TOOLSETS = {
"browser_press", "browser_close", "browser_get_images", "browser_press", "browser_close", "browser_get_images",
"browser_vision", "browser_vision",
# Skills - access knowledge base # Skills - access knowledge base
"skills_list", "skill_view", "skills_list", "skill_view", "skill_manage",
# Planning & task management # Planning & task management
"todo", "todo",
# Persistent memory # Persistent memory
@ -248,7 +248,7 @@ TOOLSETS = {
"browser_press", "browser_close", "browser_get_images", "browser_press", "browser_close", "browser_get_images",
"browser_vision", "browser_vision",
# Skills - access knowledge base # Skills - access knowledge base
"skills_list", "skill_view", "skills_list", "skill_view", "skill_manage",
# Planning & task management # Planning & task management
"todo", "todo",
# Persistent memory # Persistent memory
@ -284,7 +284,7 @@ TOOLSETS = {
"browser_press", "browser_close", "browser_get_images", "browser_press", "browser_close", "browser_get_images",
"browser_vision", "browser_vision",
# Skills # Skills
"skills_list", "skill_view", "skills_list", "skill_view", "skill_manage",
# Planning & task management # Planning & task management
"todo", "todo",
# Persistent memory # Persistent memory
@ -320,7 +320,7 @@ TOOLSETS = {
"browser_press", "browser_close", "browser_get_images", "browser_press", "browser_close", "browser_get_images",
"browser_vision", "browser_vision",
# Skills - access knowledge base # Skills - access knowledge base
"skills_list", "skill_view", "skills_list", "skill_view", "skill_manage",
# Planning & task management # Planning & task management
"todo", "todo",
# Persistent memory # Persistent memory