'v1'
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Skill Matching Algorithm for Agent Orchestrator.
|
||||
|
||||
Scores and ranks skills against a user query to determine
|
||||
which agents are relevant for the current request.
|
||||
|
||||
Scoring:
|
||||
- Skill name appears in query: +15
|
||||
- Exact trigger keyword match: +10 per keyword
|
||||
- Capability category match: +5 per category
|
||||
- Description word overlap: +1 per word
|
||||
- Project assignment boost: +20 if skill is assigned to active project
|
||||
|
||||
Usage:
|
||||
python match_skills.py "raspar dados de um site"
|
||||
python match_skills.py "coletar precos e enviar por whatsapp"
|
||||
python match_skills.py --project myproject "query here"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
# Resolve paths relative to this script's location
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
|
||||
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
|
||||
DATA_DIR = ORCHESTRATOR_DIR / "data"
|
||||
REGISTRY_PATH = DATA_DIR / "registry.json"
|
||||
PROJECTS_PATH = DATA_DIR / "projects.json"
|
||||
SCAN_SCRIPT = _SCRIPT_DIR / "scan_registry.py"
|
||||
|
||||
# Capability keywords for query -> category matching (PT + EN)
|
||||
CAPABILITY_KEYWORDS = {
|
||||
"data-extraction": [
|
||||
"scrape", "extract", "crawl", "parse", "harvest", "collect", "data",
|
||||
"raspar", "extrair", "coletar", "dados", "tabela", "table", "csv",
|
||||
"web data", "pull info", "get data",
|
||||
],
|
||||
"messaging": [
|
||||
"whatsapp", "message", "send", "chat", "notify", "notification", "sms",
|
||||
"mensagem", "enviar", "notificar", "notificacao", "atendimento",
|
||||
"comunicar", "avisar",
|
||||
],
|
||||
"social-media": [
|
||||
"instagram", "facebook", "twitter", "post", "stories", "reels",
|
||||
"social", "feed", "follower", "publicar", "rede social", "engajamento",
|
||||
],
|
||||
"government-data": [
|
||||
"junta", "leiloeiro", "cadastro", "governo", "comercial", "tribunal",
|
||||
"diario oficial", "certidao", "registro", "uf", "estado",
|
||||
],
|
||||
"web-automation": [
|
||||
"browser", "selenium", "playwright", "automate", "click", "fill form",
|
||||
"navegador", "automatizar", "automacao", "preencher",
|
||||
],
|
||||
"api-integration": [
|
||||
"api", "endpoint", "webhook", "rest", "graph", "oauth", "token",
|
||||
"integracao", "integrar", "conectar",
|
||||
],
|
||||
"analytics": [
|
||||
"insight", "analytics", "metrics", "dashboard", "report", "stats",
|
||||
"relatorio", "metricas", "analise", "estatistica",
|
||||
],
|
||||
"content-management": [
|
||||
"publish", "schedule", "template", "content", "media", "upload",
|
||||
"publicar", "agendar", "conteudo", "midia",
|
||||
],
|
||||
"legal": [
|
||||
"advogado", "direito", "juridico", "lei", "processo",
|
||||
"acao", "peticao", "recurso", "sentenca", "juiz",
|
||||
"divorcio", "guarda", "alimentos", "pensao", "alimenticia", "inventario", "heranca", "partilha",
|
||||
"acidente de trabalho", "acidente",
|
||||
"familia", "criminal", "penal", "crime", "feminicidio", "maria da penha",
|
||||
"violencia domestica", "medida protetiva", "stalking",
|
||||
"danos morais", "responsabilidade civil", "indenizacao", "dano",
|
||||
"consumidor", "cdc", "plano de saude",
|
||||
"trabalhista", "clt", "rescisao", "fgts", "horas extras",
|
||||
"previdenciario", "aposentadoria", "aposentar", "inss",
|
||||
"imobiliario", "usucapiao", "despejo", "inquilinato",
|
||||
"alienacao fiduciaria", "bem de familia",
|
||||
"tributario", "imposto", "icms", "execucao fiscal",
|
||||
"administrativo", "licitacao", "improbidade", "mandado de seguranca",
|
||||
"empresarial", "societario", "falencia", "recuperacao judicial",
|
||||
"empresa", "ltda", "cnpj", "mei", "eireli", "contrato social",
|
||||
"contrato", "clausula", "contestacao", "apelacao", "agravo",
|
||||
"habeas corpus", "mandado", "liminar", "tutela",
|
||||
"cpc", "stj", "stf", "sumula", "jurisprudencia",
|
||||
"oab", "honorarios", "custas",
|
||||
],
|
||||
"auction": [
|
||||
"leilao", "leilao judicial", "leilao extrajudicial", "hasta publica",
|
||||
"arrematacao", "arrematar", "arrematante", "lance", "desagio",
|
||||
"edital leilao", "penhora", "adjudicacao", "praca",
|
||||
"imissao na posse", "carta arrematacao", "vil preco",
|
||||
"avaliacao imovel", "laudo", "perito", "matricula",
|
||||
"leiloeiro", "comissao leiloeiro",
|
||||
],
|
||||
"security": [
|
||||
"seguranca", "security", "owasp", "vulnerability", "incident",
|
||||
"pentest", "firewall", "malware", "phishing", "cve",
|
||||
"autenticacao", "criptografia", "encryption",
|
||||
],
|
||||
"image-generation": [
|
||||
"imagem", "image", "gerar imagem", "generate image",
|
||||
"stable diffusion", "comfyui", "midjourney", "dall-e",
|
||||
"foto", "ilustracao", "arte", "design",
|
||||
],
|
||||
"monitoring": [
|
||||
"monitor", "monitorar", "health", "status",
|
||||
"audit", "auditoria", "sentinel", "check",
|
||||
],
|
||||
"context-management": [
|
||||
"contexto", "context", "sessao", "session", "compactacao", "compaction",
|
||||
"comprimir", "compress", "snapshot", "checkpoint", "briefing",
|
||||
"continuidade", "continuity", "preservar", "preserve",
|
||||
"memoria", "memory", "resumo", "summary",
|
||||
"salvar estado", "save state", "context window", "janela de contexto",
|
||||
"perda de dados", "data loss", "backup",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
def ensure_registry():
|
||||
"""Run scan if registry doesn't exist."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
subprocess.run(
|
||||
[sys.executable, str(SCAN_SCRIPT)],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
|
||||
def load_registry() -> list[dict]:
|
||||
"""Load skills from registry.json."""
|
||||
ensure_registry()
|
||||
if not REGISTRY_PATH.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
return data.get("skills", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def load_projects() -> dict:
|
||||
"""Load project assignments."""
|
||||
if not PROJECTS_PATH.exists():
|
||||
return {"projects": []}
|
||||
try:
|
||||
return json.loads(PROJECTS_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {"projects": []}
|
||||
|
||||
|
||||
def get_project_skills(project_name: str) -> set:
|
||||
"""Get set of skill names assigned to a project."""
|
||||
projects = load_projects()
|
||||
for p in projects.get("projects", []):
|
||||
if p.get("name", "").lower() == project_name.lower():
|
||||
return set(p.get("skills", []))
|
||||
return set()
|
||||
|
||||
|
||||
def query_to_capabilities(query: str) -> list[str]:
|
||||
"""Map a query to capability categories using word boundary matching."""
|
||||
q_lower = query.lower()
|
||||
q_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', q_lower))
|
||||
caps = []
|
||||
for cap, keywords in CAPABILITY_KEYWORDS.items():
|
||||
for kw in keywords:
|
||||
# Multi-word keywords: substring match. Single-word: exact word match.
|
||||
if " " in kw:
|
||||
if kw in q_lower:
|
||||
caps.append(cap)
|
||||
break
|
||||
elif kw in q_words:
|
||||
caps.append(cap)
|
||||
break
|
||||
return caps
|
||||
|
||||
|
||||
def normalize(text: str) -> set[str]:
|
||||
"""Normalize text to a set of lowercase words."""
|
||||
return set(re.findall(r'[a-zA-ZÀ-ÿ]{3,}', text.lower()))
|
||||
|
||||
|
||||
def score_skill(skill: dict, query: str, project_skills: set = None) -> dict:
|
||||
"""
|
||||
Score a skill's relevance to a query.
|
||||
|
||||
Returns dict with score, reasons, and skill info.
|
||||
"""
|
||||
q_lower = query.lower()
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
name = skill.get("name", "")
|
||||
description = skill.get("description", "")
|
||||
triggers = skill.get("triggers", [])
|
||||
capabilities = skill.get("capabilities", [])
|
||||
|
||||
# 1. Skill name in query (+15)
|
||||
if name.lower() in q_lower or name.lower().replace("-", " ") in q_lower:
|
||||
score += 15
|
||||
reasons.append(f"name:{name}")
|
||||
|
||||
# 2. Trigger keyword matches (+10 each) - word boundary matching
|
||||
q_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', q_lower))
|
||||
for trigger in triggers:
|
||||
trigger_lower = trigger.lower()
|
||||
# Multi-word triggers: substring match. Single-word: exact word match.
|
||||
if " " in trigger_lower:
|
||||
if trigger_lower in q_lower:
|
||||
score += 10
|
||||
reasons.append(f"trigger:{trigger}")
|
||||
elif trigger_lower in q_words:
|
||||
score += 10
|
||||
reasons.append(f"trigger:{trigger}")
|
||||
|
||||
# 3. Capability category match (+5 each)
|
||||
query_caps = query_to_capabilities(query)
|
||||
for cap in capabilities:
|
||||
if cap in query_caps:
|
||||
score += 5
|
||||
reasons.append(f"capability:{cap}")
|
||||
|
||||
# 4. Description word overlap (+1 each, max 10)
|
||||
query_words = normalize(query)
|
||||
desc_words = normalize(description)
|
||||
overlap = query_words & desc_words
|
||||
overlap_score = min(len(overlap), 10)
|
||||
if overlap_score > 0:
|
||||
score += overlap_score
|
||||
reasons.append(f"word_overlap:{overlap_score}")
|
||||
|
||||
# 5. Project assignment boost (+20)
|
||||
if project_skills and name in project_skills:
|
||||
score += 20
|
||||
reasons.append("project_boost")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"location": skill.get("location", ""),
|
||||
"skill_md": skill.get("skill_md", ""),
|
||||
"capabilities": capabilities,
|
||||
"status": skill.get("status", "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def match(query: str, project: str = None, top_n: int = 5, threshold: int = 5) -> list[dict]:
|
||||
"""
|
||||
Match a query against all registered skills.
|
||||
|
||||
Returns top N skills with score >= threshold, sorted by score descending.
|
||||
"""
|
||||
skills = load_registry()
|
||||
if not skills:
|
||||
return []
|
||||
|
||||
project_skills = get_project_skills(project) if project else set()
|
||||
|
||||
results = []
|
||||
for skill in skills:
|
||||
result = score_skill(skill, query, project_skills)
|
||||
if result["score"] >= threshold:
|
||||
results.append(result)
|
||||
|
||||
results.sort(key=lambda x: x["score"], reverse=True)
|
||||
return results[:top_n]
|
||||
|
||||
|
||||
# ── CLI Entry Point ────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
project = None
|
||||
query_parts = []
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--project" and i + 1 < len(args):
|
||||
project = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
query_parts.append(args[i])
|
||||
i += 1
|
||||
|
||||
query = " ".join(query_parts)
|
||||
|
||||
if not query:
|
||||
print(json.dumps({
|
||||
"error": "No query provided",
|
||||
"usage": 'python match_skills.py "your query here"'
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
results = match(query, project=project)
|
||||
|
||||
output = {
|
||||
"query": query,
|
||||
"project": project,
|
||||
"matched": len(results),
|
||||
"skills": results,
|
||||
}
|
||||
|
||||
if len(results) == 0:
|
||||
output["recommendation"] = "No skills matched. Operate without skills or suggest creating a new one."
|
||||
elif len(results) == 1:
|
||||
output["recommendation"] = f"Single skill match: use '{results[0]['name']}' directly."
|
||||
output["action"] = "load_skill"
|
||||
else:
|
||||
output["recommendation"] = f"Multiple skills matched ({len(results)}). Use orchestration."
|
||||
output["action"] = "orchestrate"
|
||||
|
||||
print(json.dumps(output, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Multi-Skill Orchestration Engine for Agent Orchestrator.
|
||||
|
||||
Given matched skills and a query, determines the orchestration pattern
|
||||
and generates an execution plan for Claude to follow.
|
||||
|
||||
Patterns:
|
||||
- single: One skill handles the entire request
|
||||
- sequential: Skills form a pipeline (A output -> B input)
|
||||
- parallel: Skills work independently on different aspects
|
||||
- primary_support: One skill leads, others provide supporting data
|
||||
|
||||
Usage:
|
||||
python orchestrate.py --skills web-scraper,whatsapp-cloud-api --query "monitorar precos e enviar alerta"
|
||||
python orchestrate.py --match-result '{"skills": [...]}' --query "query"
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
# Resolve paths relative to this script's location
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
|
||||
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
|
||||
DATA_DIR = ORCHESTRATOR_DIR / "data"
|
||||
REGISTRY_PATH = DATA_DIR / "registry.json"
|
||||
|
||||
# Define which capabilities are typically "producers" vs "consumers"
|
||||
# Producers generate data; consumers act on data
|
||||
PRODUCER_CAPABILITIES = {"data-extraction", "government-data", "analytics"}
|
||||
CONSUMER_CAPABILITIES = {"messaging", "social-media", "content-management"}
|
||||
HYBRID_CAPABILITIES = {"api-integration", "web-automation"}
|
||||
|
||||
|
||||
# ── Functions ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_registry() -> dict[str, dict]:
|
||||
"""Load registry as name->skill dict."""
|
||||
if not REGISTRY_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
return {s["name"]: s for s in data.get("skills", [])}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_skill_role(skill: dict) -> str:
|
||||
"""Determine if a skill is primarily a producer, consumer, or hybrid.
|
||||
|
||||
Uses weighted scoring: more specific capabilities (data-extraction,
|
||||
messaging) outweigh generic ones (api-integration, content-management).
|
||||
"""
|
||||
caps = set(skill.get("capabilities", []))
|
||||
|
||||
producer_count = len(caps & PRODUCER_CAPABILITIES)
|
||||
consumer_count = len(caps & CONSUMER_CAPABILITIES)
|
||||
|
||||
# If skill has both producer and consumer caps, use the dominant one
|
||||
if producer_count > consumer_count:
|
||||
return "producer"
|
||||
elif consumer_count > producer_count:
|
||||
return "consumer"
|
||||
elif producer_count > 0 and consumer_count > 0:
|
||||
# Equal weight - check if core name suggests a role
|
||||
name = skill.get("name", "").lower()
|
||||
if any(kw in name for kw in ["scraper", "extract", "collect", "data", "junta"]):
|
||||
return "producer"
|
||||
if any(kw in name for kw in ["whatsapp", "instagram", "messenger", "notify"]):
|
||||
return "consumer"
|
||||
return "hybrid"
|
||||
else:
|
||||
return "hybrid"
|
||||
|
||||
|
||||
def classify_pattern(skills: list[dict], query: str) -> str:
|
||||
"""
|
||||
Determine the orchestration pattern based on skill roles and query.
|
||||
|
||||
Rules:
|
||||
1. Single skill -> "single"
|
||||
2. Producer(s) + Consumer(s) -> "sequential" (data flows producer->consumer)
|
||||
3. All same role -> "parallel" (independent work)
|
||||
4. One high-score + others lower -> "primary_support"
|
||||
"""
|
||||
if len(skills) <= 1:
|
||||
return "single"
|
||||
|
||||
roles = [get_skill_role(s) for s in skills]
|
||||
has_producer = "producer" in roles
|
||||
has_consumer = "consumer" in roles
|
||||
|
||||
# Producer -> Consumer pipeline
|
||||
if has_producer and has_consumer:
|
||||
return "sequential"
|
||||
|
||||
# Check if one skill dominates by score
|
||||
scores = [s.get("score", 0) for s in skills]
|
||||
if len(scores) >= 2:
|
||||
scores_sorted = sorted(scores, reverse=True)
|
||||
if scores_sorted[0] >= scores_sorted[1] * 2:
|
||||
return "primary_support"
|
||||
|
||||
# All same role or no clear pipeline
|
||||
return "parallel"
|
||||
|
||||
|
||||
def generate_plan(skills: list[dict], query: str, pattern: str) -> dict:
|
||||
"""Generate an execution plan based on the pattern."""
|
||||
|
||||
if pattern == "single":
|
||||
skill = skills[0]
|
||||
return {
|
||||
"pattern": "single",
|
||||
"description": f"Use '{skill['name']}' to handle the entire request.",
|
||||
"steps": [
|
||||
{
|
||||
"order": 1,
|
||||
"skill": skill["name"],
|
||||
"skill_md": skill.get("skill_md", skill.get("location", "")),
|
||||
"action": f"Load SKILL.md and follow its workflow for: {query}",
|
||||
"input": "user_query",
|
||||
"output": "result",
|
||||
}
|
||||
],
|
||||
"data_flow": "user_query -> result",
|
||||
}
|
||||
|
||||
elif pattern == "sequential":
|
||||
# Order: producers first, then consumers
|
||||
producers = [s for s in skills if get_skill_role(s) in ("producer", "hybrid")]
|
||||
consumers = [s for s in skills if get_skill_role(s) == "consumer"]
|
||||
|
||||
# If no clear producers, use score order
|
||||
if not producers:
|
||||
producers = [skills[0]]
|
||||
consumers = skills[1:]
|
||||
|
||||
ordered = producers + consumers
|
||||
steps = []
|
||||
for i, skill in enumerate(ordered):
|
||||
role = get_skill_role(skill)
|
||||
if i == 0:
|
||||
input_src = "user_query"
|
||||
action = f"Extract/collect data: {query}"
|
||||
else:
|
||||
prev = ordered[i - 1]["name"]
|
||||
input_src = f"{prev}.output"
|
||||
if role == "consumer":
|
||||
action = f"Process/deliver data from {prev}"
|
||||
else:
|
||||
action = f"Continue processing with data from {prev}"
|
||||
|
||||
steps.append({
|
||||
"order": i + 1,
|
||||
"skill": skill["name"],
|
||||
"skill_md": skill.get("skill_md", skill.get("location", "")),
|
||||
"action": action,
|
||||
"input": input_src,
|
||||
"output": f"{skill['name']}.output",
|
||||
"role": role,
|
||||
})
|
||||
|
||||
flow_parts = [s["skill"] for s in steps]
|
||||
data_flow = " -> ".join(["user_query"] + flow_parts + ["result"])
|
||||
|
||||
return {
|
||||
"pattern": "sequential",
|
||||
"description": f"Pipeline: {' -> '.join(flow_parts)}",
|
||||
"steps": steps,
|
||||
"data_flow": data_flow,
|
||||
}
|
||||
|
||||
elif pattern == "parallel":
|
||||
steps = []
|
||||
for i, skill in enumerate(skills):
|
||||
steps.append({
|
||||
"order": 1, # All run at the same "order" level
|
||||
"skill": skill["name"],
|
||||
"skill_md": skill.get("skill_md", skill.get("location", "")),
|
||||
"action": f"Handle independently: aspect of '{query}' related to {', '.join(skill.get('capabilities', []))}",
|
||||
"input": "user_query",
|
||||
"output": f"{skill['name']}.output",
|
||||
})
|
||||
|
||||
return {
|
||||
"pattern": "parallel",
|
||||
"description": f"Execute {len(skills)} skills in parallel, each handling their domain.",
|
||||
"steps": steps,
|
||||
"data_flow": "user_query -> [parallel] -> aggregated_result",
|
||||
"aggregation": "Combine results from all skills into a unified response.",
|
||||
}
|
||||
|
||||
elif pattern == "primary_support":
|
||||
primary = skills[0] # Highest score
|
||||
support = skills[1:]
|
||||
|
||||
steps = [
|
||||
{
|
||||
"order": 1,
|
||||
"skill": primary["name"],
|
||||
"skill_md": primary.get("skill_md", primary.get("location", "")),
|
||||
"action": f"Primary: handle main request: {query}",
|
||||
"input": "user_query",
|
||||
"output": f"{primary['name']}.output",
|
||||
"role": "primary",
|
||||
}
|
||||
]
|
||||
|
||||
for i, skill in enumerate(support):
|
||||
steps.append({
|
||||
"order": 2,
|
||||
"skill": skill["name"],
|
||||
"skill_md": skill.get("skill_md", skill.get("location", "")),
|
||||
"action": f"Support: provide {', '.join(skill.get('capabilities', []))} data if needed",
|
||||
"input": "user_query",
|
||||
"output": f"{skill['name']}.output",
|
||||
"role": "support",
|
||||
})
|
||||
|
||||
return {
|
||||
"pattern": "primary_support",
|
||||
"description": f"Primary: '{primary['name']}'. Support: {', '.join(s['name'] for s in support)}.",
|
||||
"steps": steps,
|
||||
"data_flow": f"user_query -> {primary['name']} (primary) + support skills as needed -> result",
|
||||
}
|
||||
|
||||
return {"pattern": "unknown", "steps": [], "data_flow": ""}
|
||||
|
||||
|
||||
# ── CLI Entry Point ────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
skill_names = []
|
||||
query = ""
|
||||
match_result = None
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--skills" and i + 1 < len(args):
|
||||
skill_names = [s.strip() for s in args[i + 1].split(",")]
|
||||
i += 2
|
||||
elif args[i] == "--query" and i + 1 < len(args):
|
||||
query = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--match-result" and i + 1 < len(args):
|
||||
match_result = json.loads(args[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
# Treat as query if no flag
|
||||
query = args[i]
|
||||
i += 1
|
||||
|
||||
# Get skill data from match result or registry
|
||||
skills = []
|
||||
if match_result:
|
||||
skills = match_result.get("skills", [])
|
||||
elif skill_names:
|
||||
registry = load_registry()
|
||||
for name in skill_names:
|
||||
if name in registry:
|
||||
skill_data = registry[name]
|
||||
skill_data["score"] = 10 # default score
|
||||
skills.append(skill_data)
|
||||
|
||||
if not skills:
|
||||
print(json.dumps({
|
||||
"error": "No skills provided",
|
||||
"usage": 'python orchestrate.py --skills skill1,skill2 --query "your query"'
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
if not query:
|
||||
print(json.dumps({
|
||||
"error": "No query provided",
|
||||
"usage": 'python orchestrate.py --skills skill1,skill2 --query "your query"'
|
||||
}, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
# Classify and generate plan
|
||||
pattern = classify_pattern(skills, query)
|
||||
plan = generate_plan(skills, query, pattern)
|
||||
plan["query"] = query
|
||||
plan["skill_count"] = len(skills)
|
||||
|
||||
# Add instructions for Claude
|
||||
plan["instructions"] = []
|
||||
for step in plan.get("steps", []):
|
||||
skill_md = step.get("skill_md", "")
|
||||
if skill_md:
|
||||
plan["instructions"].append(
|
||||
f"Step {step['order']}: Read {skill_md} and follow its workflow for: {step['action']}"
|
||||
)
|
||||
|
||||
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
pyyaml>=6.0
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auto-Discovery Engine for Agent Orchestrator.
|
||||
|
||||
Scans the skills ecosystem for SKILL.md files, parses metadata,
|
||||
and maintains a centralized registry (registry.json).
|
||||
|
||||
Features:
|
||||
- Runs automatically on every request (called by CLAUDE.md)
|
||||
- Ultra-fast via MD5 hash caching (~<100ms when nothing changed)
|
||||
- Auto-includes new skills, auto-removes deleted skills
|
||||
- Zero manual intervention required
|
||||
|
||||
Usage:
|
||||
python scan_registry.py # Quick scan (hash-based)
|
||||
python scan_registry.py --status # Verbose status table
|
||||
python scan_registry.py --force # Full re-scan ignoring hashes
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────
|
||||
|
||||
# Resolve paths relative to this script's location
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
ORCHESTRATOR_DIR = _SCRIPT_DIR.parent
|
||||
SKILLS_ROOT = ORCHESTRATOR_DIR.parent
|
||||
DATA_DIR = ORCHESTRATOR_DIR / "data"
|
||||
REGISTRY_PATH = DATA_DIR / "registry.json"
|
||||
HASHES_PATH = DATA_DIR / "registry_hashes.json"
|
||||
|
||||
# Where to search for SKILL.md files
|
||||
SEARCH_PATHS = [
|
||||
SKILLS_ROOT / ".claude" / "skills", # registered skills
|
||||
SKILLS_ROOT, # top-level standalone
|
||||
]
|
||||
MAX_DEPTH = 3 # max directory depth for SKILL.md search
|
||||
|
||||
# Capability keyword mapping (PT + EN)
|
||||
CAPABILITY_MAP = {
|
||||
"data-extraction": [
|
||||
"scrape", "extract", "crawl", "parse", "harvest", "collect",
|
||||
"raspar", "extrair", "coletar", "dados",
|
||||
],
|
||||
"messaging": [
|
||||
"whatsapp", "message", "send", "chat", "notification", "sms",
|
||||
"mensagem", "enviar", "notificacao", "atendimento",
|
||||
],
|
||||
"social-media": [
|
||||
"instagram", "facebook", "twitter", "post", "stories", "reels",
|
||||
"social", "engagement", "feed", "follower",
|
||||
],
|
||||
"government-data": [
|
||||
"junta", "leiloeiro", "cadastro", "governo", "comercial",
|
||||
"tribunal", "diario oficial", "certidao", "registro",
|
||||
],
|
||||
"web-automation": [
|
||||
"browser", "selenium", "playwright", "automate", "click",
|
||||
"navegador", "automatizar", "automacao",
|
||||
],
|
||||
"api-integration": [
|
||||
"api", "endpoint", "webhook", "rest", "graph", "oauth",
|
||||
"integracao", "integrar",
|
||||
],
|
||||
"analytics": [
|
||||
"insight", "analytics", "metrics", "dashboard", "report",
|
||||
"relatorio", "metricas", "analise",
|
||||
],
|
||||
"content-management": [
|
||||
"publish", "schedule", "template", "content", "media",
|
||||
"publicar", "agendar", "conteudo", "midia",
|
||||
],
|
||||
"legal": [
|
||||
"advogado", "direito", "juridico", "lei", "processo",
|
||||
"acao", "peticao", "recurso", "sentenca", "juiz",
|
||||
"divorcio", "guarda", "alimentos", "pensao", "alimenticia", "inventario", "heranca", "partilha",
|
||||
"acidente de trabalho", "acidente",
|
||||
"familia", "criminal", "penal", "crime", "feminicidio", "maria da penha",
|
||||
"violencia domestica", "medida protetiva", "stalking",
|
||||
"danos morais", "responsabilidade civil", "indenizacao", "dano",
|
||||
"consumidor", "cdc", "plano de saude",
|
||||
"trabalhista", "clt", "rescisao", "fgts", "horas extras",
|
||||
"previdenciario", "aposentadoria", "aposentar", "inss",
|
||||
"imobiliario", "usucapiao", "despejo", "inquilinato",
|
||||
"alienacao fiduciaria", "bem de familia",
|
||||
"tributario", "imposto", "icms", "execucao fiscal",
|
||||
"administrativo", "licitacao", "improbidade", "mandado de seguranca",
|
||||
"empresarial", "societario", "falencia", "recuperacao judicial",
|
||||
"empresa", "ltda", "cnpj", "mei", "eireli", "contrato social",
|
||||
"contrato", "clausula", "contestacao", "apelacao", "agravo",
|
||||
"habeas corpus", "mandado", "liminar", "tutela",
|
||||
"cpc", "stj", "stf", "sumula", "jurisprudencia",
|
||||
"oab", "honorarios", "custas",
|
||||
],
|
||||
"auction": [
|
||||
"leilao", "leilao judicial", "leilao extrajudicial", "hasta publica",
|
||||
"arrematacao", "arrematar", "arrematante", "lance", "desagio",
|
||||
"edital leilao", "penhora", "adjudicacao", "praca",
|
||||
"imissao na posse", "carta arrematacao", "vil preco",
|
||||
"avaliacao imovel", "laudo", "perito", "matricula",
|
||||
"leiloeiro", "comissao leiloeiro",
|
||||
],
|
||||
"security": [
|
||||
"seguranca", "security", "owasp", "vulnerability", "incident",
|
||||
"pentest", "firewall", "malware", "phishing", "cve",
|
||||
"autenticacao", "criptografia", "encryption",
|
||||
],
|
||||
"image-generation": [
|
||||
"imagem", "image", "gerar imagem", "generate image",
|
||||
"stable diffusion", "comfyui", "midjourney", "dall-e",
|
||||
"foto", "ilustracao", "arte", "design",
|
||||
],
|
||||
"monitoring": [
|
||||
"monitor", "monitorar", "health", "status",
|
||||
"audit", "auditoria", "sentinel", "check",
|
||||
],
|
||||
"context-management": [
|
||||
"contexto", "context", "sessao", "session", "compactacao", "compaction",
|
||||
"comprimir", "compress", "snapshot", "checkpoint", "briefing",
|
||||
"continuidade", "continuity", "preservar", "preserve",
|
||||
"memoria", "memory", "resumo", "summary",
|
||||
"salvar estado", "save state", "context window", "janela de contexto",
|
||||
"perda de dados", "data loss", "backup",
|
||||
],
|
||||
}
|
||||
|
||||
# ── Utility Functions ──────────────────────────────────────────────────────
|
||||
|
||||
def md5_file(path: Path) -> str:
|
||||
"""Compute MD5 hash of a file."""
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def parse_yaml_frontmatter(path: Path) -> dict:
|
||||
"""Extract YAML frontmatter from a SKILL.md file."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
|
||||
if not match:
|
||||
return {}
|
||||
|
||||
try:
|
||||
import yaml
|
||||
return yaml.safe_load(match.group(1)) or {}
|
||||
except Exception:
|
||||
# Fallback: manual parsing for name/description
|
||||
result = {}
|
||||
block = match.group(1)
|
||||
for key in ("name", "description", "version"):
|
||||
m = re.search(rf'^{key}:\s*["\']?(.+?)["\']?\s*$', block, re.MULTILINE)
|
||||
if m:
|
||||
result[key] = m.group(1).strip()
|
||||
else:
|
||||
# Handle multi-line description with >- or >
|
||||
m2 = re.search(rf'^{key}:\s*>-?\s*\n((?:\s+.+\n?)+)', block, re.MULTILINE)
|
||||
if m2:
|
||||
lines = m2.group(1).strip().split("\n")
|
||||
result[key] = " ".join(line.strip() for line in lines)
|
||||
return result
|
||||
|
||||
|
||||
def find_skill_files() -> list[Path]:
|
||||
"""Find all SKILL.md files in the ecosystem."""
|
||||
found = set()
|
||||
|
||||
for base in SEARCH_PATHS:
|
||||
if not base.exists():
|
||||
continue
|
||||
for root, dirs, files in os.walk(base):
|
||||
depth = len(Path(root).relative_to(base).parts)
|
||||
if depth > MAX_DEPTH:
|
||||
dirs.clear()
|
||||
continue
|
||||
|
||||
# Skip the orchestrator itself
|
||||
if "agent-orchestrator" in Path(root).parts:
|
||||
continue
|
||||
|
||||
if "SKILL.md" in files:
|
||||
found.add(Path(root) / "SKILL.md")
|
||||
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def detect_language(skill_dir: Path) -> str:
|
||||
"""Detect primary language from scripts/ directory."""
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
if not scripts_dir.exists():
|
||||
return "none"
|
||||
|
||||
extensions = set()
|
||||
for f in scripts_dir.rglob("*"):
|
||||
if f.is_file():
|
||||
extensions.add(f.suffix.lower())
|
||||
|
||||
if ".py" in extensions:
|
||||
return "python"
|
||||
if ".ts" in extensions or ".js" in extensions:
|
||||
return "nodejs"
|
||||
if ".sh" in extensions:
|
||||
return "bash"
|
||||
return "none"
|
||||
|
||||
|
||||
def extract_capabilities(description: str) -> list[str]:
|
||||
"""Map description keywords to capability tags using word boundary matching."""
|
||||
if not description:
|
||||
return []
|
||||
|
||||
desc_lower = description.lower()
|
||||
desc_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', desc_lower))
|
||||
caps = []
|
||||
for cap, keywords in CAPABILITY_MAP.items():
|
||||
for kw in keywords:
|
||||
# Multi-word keywords: substring match. Single-word: exact word match.
|
||||
if " " in kw:
|
||||
if kw in desc_lower:
|
||||
caps.append(cap)
|
||||
break
|
||||
elif kw in desc_words:
|
||||
caps.append(cap)
|
||||
break
|
||||
return sorted(caps)
|
||||
|
||||
|
||||
def extract_triggers(description: str) -> list[str]:
|
||||
"""Extract trigger keywords from description text using word boundary matching."""
|
||||
if not description:
|
||||
return []
|
||||
|
||||
# Collect all keywords from all capability categories
|
||||
all_keywords = set()
|
||||
for keywords in CAPABILITY_MAP.values():
|
||||
all_keywords.update(keywords)
|
||||
|
||||
desc_lower = description.lower()
|
||||
desc_words = set(re.findall(r'[a-zA-ZÀ-ÿ]+', desc_lower))
|
||||
found = []
|
||||
for kw in sorted(all_keywords):
|
||||
if " " in kw:
|
||||
if kw in desc_lower:
|
||||
found.append(kw)
|
||||
elif kw in desc_words:
|
||||
found.append(kw)
|
||||
return found
|
||||
|
||||
|
||||
def assess_status(skill_dir: Path) -> str:
|
||||
"""Check if skill is complete (active) or incomplete."""
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
return "missing"
|
||||
|
||||
has_scripts = (skill_dir / "scripts").exists()
|
||||
has_refs = (skill_dir / "references").exists()
|
||||
|
||||
# Parse frontmatter to check for required fields
|
||||
meta = parse_yaml_frontmatter(skill_md)
|
||||
has_name = bool(meta.get("name"))
|
||||
has_desc = bool(meta.get("description"))
|
||||
|
||||
if has_name and has_desc:
|
||||
return "active"
|
||||
return "incomplete"
|
||||
|
||||
|
||||
def is_registered(skill_dir: Path) -> bool:
|
||||
"""Check if skill is in .claude/skills/."""
|
||||
claude_skills = SKILLS_ROOT / ".claude" / "skills"
|
||||
try:
|
||||
skill_dir.relative_to(claude_skills)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
# ── Main Logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
def load_hashes() -> dict:
|
||||
"""Load stored hashes from registry_hashes.json."""
|
||||
if HASHES_PATH.exists():
|
||||
try:
|
||||
return json.loads(HASHES_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_hashes(hashes: dict):
|
||||
"""Save hashes to registry_hashes.json."""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
HASHES_PATH.write_text(json.dumps(hashes, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def load_registry() -> dict:
|
||||
"""Load existing registry.json."""
|
||||
if REGISTRY_PATH.exists():
|
||||
try:
|
||||
return json.loads(REGISTRY_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {"generated_at": None, "skills_root": str(SKILLS_ROOT), "skills": []}
|
||||
|
||||
|
||||
def save_registry(registry: dict):
|
||||
"""Save registry.json."""
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
registry["generated_at"] = datetime.now().isoformat()
|
||||
REGISTRY_PATH.write_text(json.dumps(registry, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def build_skill_entry(skill_md_path: Path) -> dict:
|
||||
"""Build a registry entry from a SKILL.md file."""
|
||||
skill_dir = skill_md_path.parent
|
||||
meta = parse_yaml_frontmatter(skill_md_path)
|
||||
description = meta.get("description", "")
|
||||
|
||||
# Support explicit capabilities in frontmatter
|
||||
explicit_caps = meta.get("capabilities", [])
|
||||
if isinstance(explicit_caps, str):
|
||||
explicit_caps = [c.strip() for c in explicit_caps.split(",")]
|
||||
|
||||
auto_caps = extract_capabilities(description)
|
||||
all_caps = sorted(set(auto_caps + explicit_caps))
|
||||
|
||||
return {
|
||||
"name": meta.get("name", skill_dir.name),
|
||||
"description": description,
|
||||
"version": meta.get("version", ""),
|
||||
"location": str(skill_dir),
|
||||
"skill_md": str(skill_md_path),
|
||||
"registered": is_registered(skill_dir),
|
||||
"has_scripts": (skill_dir / "scripts").exists(),
|
||||
"has_references": (skill_dir / "references").exists(),
|
||||
"has_data": (skill_dir / "data").exists(),
|
||||
"capabilities": all_caps,
|
||||
"triggers": extract_triggers(description),
|
||||
"language": detect_language(skill_dir),
|
||||
"status": assess_status(skill_dir),
|
||||
"last_modified": datetime.fromtimestamp(
|
||||
skill_md_path.stat().st_mtime
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def scan(force: bool = False) -> dict:
|
||||
"""
|
||||
Main scan function.
|
||||
|
||||
With hash caching:
|
||||
1. Find all SKILL.md files
|
||||
2. Compare MD5 hashes with stored values
|
||||
3. Only re-parse files that changed, were added, or removed
|
||||
4. Update registry incrementally
|
||||
"""
|
||||
current_files = find_skill_files()
|
||||
current_paths = {str(f): f for f in current_files}
|
||||
|
||||
stored_hashes = load_hashes()
|
||||
registry = load_registry()
|
||||
|
||||
# Build lookup of existing registry entries by skill_md path
|
||||
existing_by_path = {}
|
||||
for entry in registry.get("skills", []):
|
||||
existing_by_path[entry.get("skill_md", "")] = entry
|
||||
|
||||
# Compute current hashes
|
||||
new_hashes = {}
|
||||
changed = False
|
||||
|
||||
for path_str, path_obj in current_paths.items():
|
||||
current_hash = md5_file(path_obj)
|
||||
new_hashes[path_str] = current_hash
|
||||
|
||||
if force or path_str not in stored_hashes or stored_hashes[path_str] != current_hash:
|
||||
# New or modified - rebuild entry
|
||||
entry = build_skill_entry(path_obj)
|
||||
existing_by_path[path_str] = entry
|
||||
changed = True
|
||||
|
||||
# Detect removed skills
|
||||
for old_path in list(existing_by_path.keys()):
|
||||
if old_path not in current_paths and old_path != "":
|
||||
del existing_by_path[old_path]
|
||||
changed = True
|
||||
|
||||
# Check if file set changed (additions/removals)
|
||||
if set(new_hashes.keys()) != set(stored_hashes.keys()):
|
||||
changed = True
|
||||
|
||||
# Deduplicate by skill name (case-insensitive).
|
||||
# When the same skill exists in both skills/ and .claude/skills/,
|
||||
# prefer the primary location (skills/) over the registered copy.
|
||||
if changed or not REGISTRY_PATH.exists():
|
||||
by_name = {}
|
||||
for entry in existing_by_path.values():
|
||||
name = entry.get("name", "").lower()
|
||||
if not name:
|
||||
continue
|
||||
if name not in by_name:
|
||||
by_name[name] = entry
|
||||
else:
|
||||
# Prefer the version NOT in .claude/skills/ (the primary source)
|
||||
existing = by_name[name]
|
||||
existing_is_registered = existing.get("registered", False)
|
||||
new_is_registered = entry.get("registered", False)
|
||||
if existing_is_registered and not new_is_registered:
|
||||
by_name[name] = entry
|
||||
# If both are primary or both registered, keep first found
|
||||
|
||||
registry["skills"] = sorted(by_name.values(), key=lambda s: s.get("name", ""))
|
||||
save_registry(registry)
|
||||
save_hashes(new_hashes)
|
||||
return registry
|
||||
else:
|
||||
# Nothing changed, return existing
|
||||
return registry
|
||||
|
||||
|
||||
def print_status(registry: dict):
|
||||
"""Print a formatted status table."""
|
||||
skills = registry.get("skills", [])
|
||||
|
||||
if not skills:
|
||||
print("No skills found in the ecosystem.")
|
||||
return
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f" Agent Orchestrator - Skill Registry Status")
|
||||
print(f" Scanned at: {registry.get('generated_at', 'N/A')}")
|
||||
print(f" Root: {registry.get('skills_root', 'N/A')}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Header
|
||||
print(f" {'Name':<22} {'Status':<12} {'Lang':<10} {'Registered':<12} {'Capabilities'}")
|
||||
print(f" {'-'*22} {'-'*12} {'-'*10} {'-'*12} {'-'*30}")
|
||||
|
||||
for s in sorted(skills, key=lambda x: x.get("name", "")):
|
||||
name = s.get("name", "?")[:20]
|
||||
status = s.get("status", "?")
|
||||
lang = s.get("language", "none")
|
||||
reg = "Yes" if s.get("registered") else "No"
|
||||
caps = ", ".join(s.get("capabilities", []))[:30]
|
||||
print(f" {name:<22} {status:<12} {lang:<10} {reg:<12} {caps}")
|
||||
|
||||
print(f"\n Total: {len(skills)} skills")
|
||||
|
||||
# Recommendations
|
||||
unregistered = [s for s in skills if not s.get("registered")]
|
||||
incomplete = [s for s in skills if s.get("status") == "incomplete"]
|
||||
|
||||
if unregistered:
|
||||
print(f"\n [!] {len(unregistered)} skill(s) not registered in .claude/skills/:")
|
||||
for s in unregistered:
|
||||
print(f" - {s['name']} ({s['location']})")
|
||||
|
||||
if incomplete:
|
||||
print(f"\n [!] {len(incomplete)} skill(s) with incomplete status:")
|
||||
for s in incomplete:
|
||||
print(f" - {s['name']} ({s['location']})")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
# ── CLI Entry Point ────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
force = "--force" in sys.argv
|
||||
show_status = "--status" in sys.argv
|
||||
|
||||
registry = scan(force=force)
|
||||
|
||||
if show_status:
|
||||
print_status(registry)
|
||||
else:
|
||||
# Default: output JSON summary for Claude to parse
|
||||
skills = registry.get("skills", [])
|
||||
summary = {
|
||||
"total": len(skills),
|
||||
"active": len([s for s in skills if s.get("status") == "active"]),
|
||||
"incomplete": len([s for s in skills if s.get("status") == "incomplete"]),
|
||||
"skills": [
|
||||
{
|
||||
"name": s.get("name"),
|
||||
"status": s.get("status"),
|
||||
"capabilities": s.get("capabilities", []),
|
||||
}
|
||||
for s in skills
|
||||
],
|
||||
}
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user