Skill: livangy/niuniu-skill-extractor (version 1.0.0)

--- name: skill-extractor description: 从复杂任务中提取可复用的技能文档，参考 Hermes Agent 的 Skill Documents 设计。用于当任务完成后，识别值得保留的步骤流程，并存储为可搜索的技能文件，下次遇到类似任务时自动检索并建议使用。 metadata: openclaw: tags: [memory, learning, skills, self-improvement] os: [darwin, linux] --- # Skill Extractor — 任务技能提取器 参考 Hermes Agent 的 Skill Documents 机制，在 OpenClaw 中实现"从经验中学习"的闭环。 ## 核心功能 1. **提取（Extract）** — 从对话历史中识别多步骤流程，生成标准化 SKILL.md 2. **存储（Store）** — 保存到 `~/.openclaw/workspace/skills/skill-extractor/skills/` 3. **检索（Search）** — FTS5 全文检索，在新任务中匹配相关技能 4. **建议（Suggest）** — 任务完成后主动提示是否提取技能 ## 工作流程 ``` 复杂任务完成 ↓ 触发技能提取检测（多步骤 / 超过N轮 / 成功率高的任务） ↓ LLM 回顾对话 → 提取关键步骤 ↓ 生成 SKILL.md 格式的技能文档 ↓ 展示给用户确认是否保存 ↓ 保存 → 下次遇到类似任务自动建议使用 ``` ## 触发条件 当检测到以下情况时，建议提取技能： - 任务超过 5 轮对话完成 - 包含多个工具调用 - 任务执行成功（有明确输出） - 涉及罕见问题或特殊解决方案 ## 技能文档格式 遵循 agentskills.io 标准： ```markdown --- name: <skill-name> description: <简短描述：何时使用> --- # <Skill Name> ## When to Use - 触发条件描述 ## Procedure 1. 步骤1 2. 步骤2 3. 步骤3（含错误处理） ## Notes - 注意事项或变体 ``` ## 使用方式 ### 手动触发 当用户完成复杂任务后，说"提取技能"或"保存为技能" ### 自动建议 系统检测到适合提取的任务后，主动提示： "这个任务完成得很好，要把它保存为可复用的技能吗？" ### 检索技能 当用户描述新任务时，自动检索相关技能： ``` # 当前任务: <用户描述> # 匹配技能: - skill-name: <名称> relevance: <相关性> trigger: <触发条件> ```

path: _meta.json
content: {
  "name": "skill-extractor",
  "version": "1.0.0",
  "description": "从复杂任务中提取可复用技能，参考 Hermes Agent 的 Skill Documents 机制",
  "author": "牛牛 (niuniu)",
  "created": "2026-04-15",
  "tags": ["skill-extractor", "memory", "learning", "self-improvement"]
}


path: scripts/extract_skill.py
content: #!/usr/bin/env python3
"""
Skill Extractor — 从对话历史中提取技能文档
参考 Hermes Agent 的 Skill Documents 机制

用法:
  python extract_skill.py --task "任务描述" --conversation "对话历史"
  python extract_skill.py --session-id <session_id>
"""

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional

SKILLS_DIR = Path.home() / ".openclaw" / "workspace" / "skills" / "skill-extractor" / "skills"
SKILLS_DIR.mkdir(parents=True, exist_ok=True)


def extract_skill_from_conversation(task: str, conversation: str) -> dict:
    """
    分析对话历史，提取技能信息
    返回: {name, description, when_to_use, steps, notes}
    """
    # 简单分析：识别工具调用序列和关键步骤
    lines = conversation.strip().split("\n")
    
    # 找出工具调用（假设格式为 "Tool: tool_name" 或 "using <tool>"）
    tool_patterns = [
        r"(?:Using|call|exec|run)\s+`?(\w+)`?",
        r"(?:Tool|Command):\s*(\w+)",
        r"→\s*(\w+)\s*\(",
    ]
    
    tools_used = []
    for line in lines:
        for pattern in tool_patterns:
            matches = re.findall(pattern, line, re.IGNORECASE)
            tools_used.extend(matches)
    
    # 去重但保持顺序
    seen = set()
    tools_used = [x for x in tools_used if not (x in seen or seen.add(x))]
    
    # 生成技能名称（从任务描述提取关键词）
    keywords = [w for w in re.findall(r"\b\w{3,}\b", task) if w.lower() not in STOP_WORDS][:3]
    skill_name = "_".join(keywords).lower() if keywords else "custom_task"
    
    # 提取步骤（简单方法：按序号或箭头分隔）
    steps = []
    step_patterns = [
        r"(?:\d+[.)]\s*)(.*?)(?=\n\d+[.)]|\n$)",
        r"(?:[-•]\s*)(.*?)(?=\n[-•]|\n$)",
    ]
    
    for pattern in step_patterns:
        matches = re.findall(pattern, conversation, re.MULTILINE)
        if matches:
            steps = [s.strip() for s in matches if len(s.strip()) > 10]
            break
    
    return {
        "name": skill_name,
        "description": f"从任务「{task[:50]}」中提取的技能",
        "when_to_use": f"处理类似「{task[:80]}」的任务时使用",
        "tools_used": tools_used,
        "steps": steps or ["无法自动提取步骤，请手动描述"],
        "extracted_at": datetime.now().isoformat(),
    }


def generate_skill_markdown(skill: dict) -> str:
    """生成 SKILL.md 格式的技能文档"""
    md = f"""---
name: {skill['name']}
description: {skill['description']}
---

# {skill['name'].replace('_', ' ').title()}

## When to Use
- {skill['when_to_use']}

## Tools Used
{', '.join(f'`{t}`' for t in skill['tools_used']) if skill['tools_used'] else '无特定工具'}

## Procedure
"""
    for i, step in enumerate(skill["steps"], 1):
        md += f"{i}. {step}\n"
    
    md += f"""
## Notes
- 自动提取自对话记录
- 提取时间: {skill['extracted_at'][:10]}

---
*此技能由 skill-extractor 自动生成*
"""
    return md


def save_skill(skill: dict) -> str:
    """保存技能到仓库，返回文件路径"""
    filename = f"{skill['name']}_{skill['extracted_at'][:10]}.md"
    filepath = SKILLS_DIR / filename
    
    md = generate_skill_markdown(skill)
    filepath.write_text(md, encoding="utf-8")
    
    return str(filepath)


def search_skills(query: str) -> list:
    """搜索相关技能（简单关键词匹配）"""
    results = []
    query_words = set(query.lower().split())
    
    for f in SKILLS_DIR.glob("*.md"):
        content = f.read_text(encoding="utf-8").lower()
        
        # 简单评分：匹配词数
        matches = sum(1 for w in query_words if w in content)
        if matches > 0:
            # 读取描述
            desc_match = re.search(r"description:\s*(.+)", content)
            desc = desc_match.group(1)[:80] if desc_match else "无描述"
            
            results.append({
                "file": f.name,
                "score": matches,
                "description": desc,
                "path": str(f),
            })
    
    # 按分数排序
    results.sort(key=lambda x: x["score"], reverse=True)
    return results[:5]


def main():
    parser = argparse.ArgumentParser(description="从对话中提取技能")
    parser.add_argument("--task", required=True, help="任务描述")
    parser.add_argument("--conversation", required=True, help="对话历史（多行文本）")
    parser.add_argument("--save", action="store_true", help="保存技能")
    parser.add_argument("--search", help="搜索相关技能")
    
    args = parser.parse_args()
    
    if args.search:
        results = search_skills(args.search)
        print(json.dumps(results, ensure_ascii=False, indent=2))
        return
    
    # 提取技能
    skill = extract_skill_from_conversation(args.task, args.conversation)
    print("=== 提取的技能 ===")
    print(json.dumps(skill, ensure_ascii=False, indent=2))
    
    # 生成 markdown 并显示
    print("\n=== SKILL.md 预览 ===")
    print(generate_skill_markdown(skill))
    
    if args.save:
        path = save_skill(skill)
        print(f"\n✅ 已保存到: {path}")


STOP_WORDS = {
    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "must", "can", "to", "of", "in", "for",
    "on", "with", "at", "by", "from", "as", "into", "through", "during",
    "before", "after", "above", "below", "between", "under", "again",
    "further", "then", "once", "here", "there", "when", "where", "why",
    "how", "all", "each", "few", "more", "most", "other", "some", "such",
    "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
    "just", "and", "but", "if", "or", "because", "until", "while", "this",
    "that", "these", "those", "it", "its", "they", "them", "their", "what",
    "which", "who", "whom", "我们", "你", "我", "他", "她", "它", "的",
    "了", "是", "在", "有", "和", "与", "或", "这", "那", "就", "也",
}


if __name__ == "__main__":
    main()


path: scripts/search_skills.py
content: #!/usr/bin/env python3
"""
Search skills in the local skill repository.
Uses simple keyword matching with FTS5-like scoring.

Usage:
  python search_skills.py "query text"
  python search_skills.py "query text" --json
"""

import argparse
import json
import re
import sqlite3
from pathlib import Path
from typing import List, Dict

SKILLS_DIR = Path.home() / ".openclaw" / "workspace" / "skills" / "skill-extractor" / "skills"
DB_PATH = Path.home() / ".openclaw" / "workspace" / "skills" / "skill-extractor" / "skills.db"

# Stop words for filtering
STOP_WORDS = {
    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "must", "can", "to", "of", "in", "for",
    "on", "with", "at", "by", "from", "as", "into", "through", "during",
    "before", "after", "above", "below", "between", "under", "again",
    "further", "then", "once", "here", "there", "when", "where", "why",
    "how", "all", "each", "few", "more", "most", "other", "some", "such",
    "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
    "just", "and", "but", "if", "or", "because", "until", "while",
    "this", "that", "these", "those", "it", "its", "they", "them", "their",
    "what", "which", "who", "whom", "one", "two", "three", "four", "five",
}


def init_db():
    """Initialize SQLite FTS5 database for skill search"""
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
            name, description, when_to_use, steps, content,
            content_rowid='rowid'
        )
    """)
    
    # Check if we need to index existing skills
    conn.execute("""
        CREATE TABLE IF NOT EXISTS skills_meta (
            rowid INTEGER PRIMARY KEY,
            filename TEXT UNIQUE,
            last_modified REAL
        )
    """)
    conn.commit()
    conn.close()


def index_skill_file(filepath: Path):
    """Index a single skill file into FTS5"""
    content = filepath.read_text(encoding="utf-8")
    
    # Extract frontmatter
    frontmatter_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
    frontmatter = frontmatter_match.group(1) if frontmatter_match else ""
    
    # Parse frontmatter
    name = re.search(r"name:\s*(.+)", frontmatter).group(1).strip() if re.search(r"name:\s*(.+)", frontmatter) else filepath.stem
    description = re.search(r"description:\s*(.+)", frontmatter).group(1).strip() if re.search(r"description:\s*(.+)", frontmatter) else ""
    
    # Extract markdown sections
    when_to_use = re.search(r"## When to Use\s*\n(.+?)(?=\n##|\n---)", content, re.DOTALL)
    when_to_use = when_to_use.group(1).strip() if when_to_use else ""
    
    steps_match = re.search(r"## Procedure\s*\n(.+?)(?=\n##|\n---)", content, re.DOTALL)
    steps = steps_match.group(1).strip() if steps_match else ""
    
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        INSERT OR REPLACE INTO skills_fts (name, description, when_to_use, steps, content)
        VALUES (?, ?, ?, ?, ?)
    """, (name, description, when_to_use, steps, content))
    conn.execute("""
        INSERT OR REPLACE INTO skills_meta (filename, last_modified)
        VALUES (?, ?)
    """, (filepath.name, filepath.stat().st_mtime))
    conn.commit()
    conn.close()


def reindex_all():
    """Reindex all skill files"""
    init_db()
    
    for f in SKILLS_DIR.glob("*.md"):
        try:
            index_skill_file(f)
            print(f"  ✓ indexed: {f.name}")
        except Exception as e:
            print(f"  ✗ {f.name}: {e}")


def search(query: str, limit: int = 5) -> List[Dict]:
    """Search skills using FTS5"""
    init_db()
    
    # Check for new/changed files
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.execute("""
        SELECT filename, last_modified FROM skills_meta
    """)
    existing = {row[0]: row[1] for row in cursor.fetchall()}
    conn.close()
    
    for f in SKILLS_DIR.glob("*.md"):
        mtime = f.stat().st_mtime
        if f.name not in existing or existing[f.name] != mtime:
            try:
                index_skill_file(f)
            except:
                pass
    
    # Search with FTS5
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.execute("""
        SELECT name, description, when_to_use, content
        FROM skills_fts
        WHERE skills_fts MATCH ?
        ORDER BY rank
        LIMIT ?
    """, (query, limit))
    
    results = []
    for row in cursor.fetchall():
        name, description, when_to_use, content = row
        # Extract skill file path
        skill_file = SKILLS_DIR / f"{name}.md"
        if not skill_file.exists():
            skill_file = list(SKILLS_DIR.glob(f"*_{name}*.md"))[0] if list(SKILLS_DIR.glob(f"*_{name}*.md")) else None
        
        results.append({
            "name": name,
            "description": description,
            "when_to_use": when_to_use[:100] + "..." if when_to_use and len(when_to_use) > 100 else when_to_use,
            "file": str(skill_file) if skill_file else None,
        })
    
    conn.close()
    return results


def main():
    parser = argparse.ArgumentParser(description="搜索技能库")
    parser.add_argument("query", help="搜索查询")
    parser.add_argument("--json", action="store_true", help="JSON格式输出")
    parser.add_argument("--reindex", action="store_true", help="重新索引所有技能")
    
    args = parser.parse_args()
    
    if args.reindex:
        print("重新索引技能库...")
        reindex_all()
        print("完成!")
        return
    
    results = search(args.query)
    
    if not results:
        print("没有找到相关技能")
        return
    
    if args.json:
        print(json.dumps(results, ensure_ascii=False, indent=2))
    else:
        print(f"找到 {len(results)} 个相关技能:\n")
        for i, r in enumerate(results, 1):
            print(f"{i}. {r['name']}")
            print(f"   描述: {r['description']}")
            print(f"   触发: {r['when_to_use']}")
            print()


if __name__ == "__main__":
    main()


path: scripts/suggest_skill.py
content: #!/usr/bin/env python3
"""
Suggest skill extraction after task completion.
This is the "nudge" mechanism inspired by Hermes Agent.

After a complex task completes, this script analyzes the conversation
and suggests whether to extract a skill.

Usage:
  python suggest_skill.py --task "任务描述" --conversation "对话历史"
  python suggest_skill.py --session-id <id> --check  # auto-check mode
"""

import argparse
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional

# Thresholds for triggering skill extraction suggestion
MIN_TURNS = 5           # 至少5轮对话
MIN_TOOLS = 2           # 至少2个工具调用
MIN_STEPS = 3           # 至少3个明确步骤


def analyze_complexity(task: str, conversation: str) -> dict:
    """
    分析任务复杂度，判断是否值得提取技能
    返回: {complexity, score, reasons, should_extract}
    """
    lines = conversation.strip().split("\n") if conversation else []
    turns = len([l for l in lines if l.strip()])
    
    # 统计工具调用
    tool_patterns = [
        r"(?:Using|call|exec|run)\s+`?(\w+)`?",
        r"(?:Tool|Command):\s*(\w+)",
        r"→\s*(\w+)\s*\(",
        r"(?:Tool call|function call):\s*(\w+)",
    ]
    
    tools_found = set()
    for line in lines:
        for pattern in tool_patterns:
            matches = re.findall(pattern, line, re.IGNORECASE)
            tools_found.update(matches)
    
    # 统计明确步骤（序号列表）
    steps_patterns = [
        r"(?:\d+[.]\s+)(.{10,})",
        r"(?:[-•]\s+)(.{10,})",
        r"(?:→\s+)(.{10,})",
    ]
    
    steps_found = []
    for pattern in steps_patterns:
        matches = re.findall(pattern, conversation, re.MULTILINE)
        steps_found.extend(matches)
    
    # 计算复杂度分数
    score = 0
    reasons = []
    
    if turns >= MIN_TURNS:
        score += turns * 2
        reasons.append(f"多轮对话: {turns}轮")
    
    if len(tools_found) >= MIN_TOOLS:
        score += len(tools_found) * 3
        reasons.append(f"多工具调用: {', '.join(list(tools_found)[:5])}")
    
    if len(steps_found) >= MIN_STEPS:
        score += len(steps_found) * 2
        reasons.append(f"明确步骤: {len(steps_found)}步")
    
    # 检查是否有特殊模式（罕见问题/特殊解决方案）
    special_patterns = [
        r"(?:首次|第一次|第一次遇到)",
        r"(?:之前|以往|历史上)",
        r"(?:特殊|罕见|少见)",
        r"(?:自定义|特殊处理|变通)",
        r"(?:修复|debug|排查)",
    ]
    
    for pattern in special_patterns:
        if re.search(pattern, conversation):
            score += 5
            reasons.append("包含特殊/罕见处理")
            break
    
    # 判断是否应该提取
    should_extract = score >= 15 and (turns >= MIN_TURNS or len(tools_found) >= MIN_TOOLS)
    
    return {
        "complexity": "high" if score >= 20 else "medium" if score >= 10 else "low",
        "score": score,
        "reasons": reasons,
        "turns": turns,
        "tools": list(tools_found),
        "steps": len(steps_found),
        "should_extract": should_extract,
        "suggestion": make_suggestion(score, turns, tools_found, steps_found),
    }


def make_suggestion(score: int, turns: int, tools: set, steps: int) -> str:
    """生成提取建议文本"""
    if score >= 20:
        verb = "强烈建议"
    elif score >= 15:
        verb = "建议"
    elif score >= 10:
        verb = "可以考虑"
    else:
        return "任务较简单，不需要提取技能。"
    
    tools_str = ", ".join(list(tools)[:4]) if tools else "无特定工具"
    return (
        f"{verb}将此任务提取为可复用技能。\n"
        f"理由：{turns}轮对话，使用了 {tools_str}，"
        f"包含 {steps} 个明确步骤。\n"
        f"提取后可帮助处理类似任务。"
    )


def generate_skill_preview(task: str, conversation: str) -> Optional[str]:
    """生成技能文档预览（供用户确认）"""
    analysis = analyze_complexity(task, conversation)
    
    if not analysis["should_extract"]:
        return None
    
    # 提取技能名称
    keywords = [w for w in re.findall(r"\b\w{3,}\b", task) 
                if w.lower() not in STOP_WORDS and not w.isdigit()][:3]
    skill_name = "_".join(keywords).lower() if keywords else "custom_task"
    
    # 生成技能文档
    preview = f"""---
name: {skill_name}
description: 从任务「{task[:60]}」中提取的技能
---

# {skill_name.replace('_', ' ').title()}

## When to Use
- 处理类似「{task[:80]}」的任务时使用

## Tools Used
{', '.join(f'`{t}`' for t in analysis['tools']) if analysis['tools'] else '无特定工具'}

## Procedure
"""
    # 尝试提取步骤
    steps_found = []
    for pattern in [r"(?:\d+[.]\s+)(.{10,200}?)(?=\n\d+[.]|\n\n|$)", 
                    r"(?:[-•]\s+)(.{10,200}?)(?=\n[-•]|\n\n|$)"]:
        matches = re.findall(pattern, conversation, re.MULTILINE)
        if matches:
            steps_found = [s.strip() for s in matches[:10] if len(s.strip()) > 15]
            break
    
    if steps_found:
        for i, step in enumerate(steps_found, 1):
            preview += f"{i}. {step}\n"
    else:
        preview += f"{len(preview.split(chr(10)))} [从对话中提取步骤]\n"
    
    preview += f"""
## Notes
- 自动提取自 {datetime.now().strftime('%Y-%m-%d')} 的对话记录
- 复杂度评分: {analysis['score']}/30

---
*此技能由 skill-extractor 自动生成*
"""
    return preview


def main():
    parser = argparse.ArgumentParser(description="分析任务复杂度并建议技能提取")
    parser.add_argument("--task", required=True, help="任务描述")
    parser.add_argument("--conversation", required=True, help="对话历史")
    parser.add_argument("--auto", action="store_true", help="自动模式：仅输出结果，不显示预览")
    parser.add_argument("--json", action="store_true", help="JSON格式输出")
    
    args = parser.parse_args()
    
    analysis = analyze_complexity(args.task, args.conversation)
    
    if args.json:
        print(json.dumps(analysis, ensure_ascii=False, indent=2))
        return
    
    if analysis["should_extract"]:
        print("=" * 50)
        print("🧠 技能提取建议")
        print("=" * 50)
        print(f"复杂度: {analysis['complexity'].upper()} (分数: {analysis['score']})")
        print(f"原因: {', '.join(analysis['reasons'])}")
        print()
        print(analysis["suggestion"])
        print()
        
        if not args.auto:
            preview = generate_skill_preview(args.task, args.conversation)
            if preview:
                print("=" * 50)
                print("📄 技能文档预览:")
                print("=" * 50)
                print(preview)
    else:
        print(f"任务复杂度: {analysis['complexity']} (分数: {analysis['score']})")
        print("不需要提取技能。")


STOP_WORDS = {
    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did", "will", "would", "could",
    "should", "may", "might", "must", "can", "to", "of", "in", "for",
    "on", "with", "at", "by", "from", "as", "into", "through", "during",
    "before", "after", "above", "below", "between", "under", "again",
    "further", "then", "once", "here", "there", "when", "where", "why",
    "how", "all", "each", "few", "more", "most", "other", "some", "such",
    "no", "nor", "not", "only", "own", "same", "so", "than", "too", "very",
    "just", "and", "but", "if", "or", "because", "until", "while",
    "this", "that", "these", "those", "it", "its", "they", "them", "their",
    "what", "which", "who", "whom", "one", "two", "three", "four", "five",
}


if __name__ == "__main__":
    main()


path: skills/test_skill.md
content: ---
name: 文献调研
description: 从Zotero和网络搜索高分文献，完成文献汇报
---

# 文献调研技能

## When to Use
- 查找并分析特定领域的高分文献
- 做文献汇报/文献综述时使用

## Procedure
1. 调用 Zotero API 搜索相关文献
2. 获取文献元数据（标题、期刊、年份、DOI、摘要）
3. 评估期刊影响力和引用情况
4. 进行网络补充搜索
5. 提取关键发现和创新点
6. 整理成结构化文献汇报

## Notes
- Zotero API 需要 userID 和 API key
- 优先选择 PNAS, Nature, Science, Cell 等顶刊


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)