mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
d070b8698d
commit
4d5f29c74c
18 changed files with 1007 additions and 204 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
93
README.md
93
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 |
|
||||
|
||||
|
|
|
|||
15
cli.py
15
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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...")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
20
run_agent.py
20
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"
|
||||
"<available_skills>\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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
544
tools/skill_manager_tool.py
Normal file
544
tools/skill_manager_tool.py
Normal 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"],
|
||||
},
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
150
tools/skills_sync.py
Normal file
150
tools/skills_sync.py
Normal 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.")
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
14
toolsets.py
14
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue