SkillStore e a disclosure progressivaSkills são "memória procedural estreita e acionável" — a metade durável do loop de auto-melhoria, complemento da MemoryStore ampla e declarativa. Uma skill é um diretório: um SKILL.md (frontmatter + corpo) mais arquivos opcionais sob references/templates/scripts/assets. A ideia central é a disclosure progressiva: list() lê só o metadado barato; view() carrega o corpo inteiro sob demanda. Um CLONE de skills_tool.py (1.638 LOC) + skill_manager_tool.py (1.233 LOC) do Hermes.
skill-store.ts (461 LOC), frontmatter.ts (86), types.ts (124), skill-store.test.ts (488)
Esta lição destila o subsistema de skills do @alembic/hermes — lido verbatim do repositório. Tudo aqui é citado de arquivo real: nada é inventado. A proveniência do CLONE está em docs/hermes-complete-map.md §3.3 (a disclosure progressiva das Anthropic Skills).
list() (metadado), view() (corpo), arquivo vinculado — e por que ela mantém o contexto barato.SKILL.md + os quatro subdirs de suporte, e os dois tetos MAX_NAME_LENGTH = 64 / MAX_DESCRIPTION_LENGTH = 1024.chave: valor — e por que adicionar yaml é uma stop-condition.validateSupportPath): sem .., não absoluto, sob um subdir allowlistado — transformando "escreva um arquivo" em uma operação limitada.--- … --- no topo (o "frontmatter"). Se não viu, a gente mostra um.references/api.md aponta para um arquivo dentro de uma pasta.Result. Cada trecho de código vem traduzido em português logo abaixo.Memória e skills são as duas metades do loop que aprende. A memória é o que o agente sabe (declarativa, ampla); a skill é o que o agente sabe fazer (procedural, estreita). Esta lição é sobre a metade durável.
Pense num cozinheiro. A memória é tudo que ele lembra — fornecedores, preferências do cliente, o que deu errado ontem. A skill é uma receita escrita: passos precisos, repetíveis, que qualquer um na cozinha pode seguir. A receita é durável porque está num papel guardado, não na cabeça de alguém. O SkillStore é o arquivo de receitas: ele guarda, lista, abre, edita e remove cada uma — com travas de segurança para nada se corromper.
Um SkillStore é respaldado por um FsPort injetado (a fronteira de IO do @alembic/etl) sobre um único diretório-base — o análogo do ~/.hermes/skills/ da fonte. Cada skill é um diretório <base>/<name>/ com um SKILL.md mais arquivos de suporte opcionais. A classe é sem estado além do fs/baseDir injetados: toda leitura bate no disco, então stores concorrentes sobre o mesmo diretório permanecem consistentes.
Toda operação falível devolve Result<T, Error> e nunca lança exceção através da fronteira pública — no lugar do dict {"success", "error"} do Python. Quem chama ramifica em result.ok.
try/catch, controle de fluxo uniforme.Uma skill não é um registro num banco: é um diretório no disco. Um arquivo obrigatório (SKILL.md) e quatro pastas de suporte opcionais, e nada mais.
SKILL.md é a única peça obrigatória; os quatro subdirs são opcionais e allowlistados.SKILL.md com frontmatter válido. Sem isso, o store finge que a pasta nem existe — ela é pulada em silêncio, não vira um erro.O charset do nome também vem verbatim da fonte (VALID_NAME_RE): /^[a-z0-9][a-z0-9._-]*$/ — letras minúsculas, dígitos, ponto, hífen e sublinhado, começando por letra ou dígito. Tudo validado por Zod e elevado a Result, nunca lançado.
// packages/hermes/src/skills/types.ts:69-80 — o nome filesystem-safe const SKILL_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/; export const skillNameSchema = z .string() .min(1, 'Skill name is required.') .max(MAX_NAME_LENGTH, `Skill name exceeds ${MAX_NAME_LENGTH} characters.`) .regex(SKILL_NAME_RE, 'Invalid skill name. Use lowercase letters …');
Os subdirs de suporte (SKILL_SUPPORT_DIRS) também são verbatim: references, templates, scripts, assets — espelhando ALLOWED_SUBDIRS da fonte.
Esta é a peça que define o subsistema. Listar tudo é barato; abrir um corpo custa só quando você abre; abrir uma referência custa só quando você precisa. Três níveis, três custos.
Disclosure progressiva: o custo de contexto sobe degrau a degrau — você só paga pelo que abre.
Um agente chama list() sobre um diretório com 50 skills. Quanto disco ele lê? Comprometa-se com um palpite — "só o cabeçalho de cada uma" ou "o arquivo inteiro de cada uma" — antes de revelar.
list() chama readMetadata por diretório, que lê o SKILL.md e devolve apenas metadata (name + description) — nunca o corpo. Você paga por um corpo só quando faz view(name) dele, e por um arquivo vinculado só quando o abre. O teste "returns metadata only — name + description, never the body" prova exatamente isso.// packages/hermes/src/skills/skill-store.ts:80-96 — list (nível 1) async list(): Promise<Result<readonly SkillMetadata[], Error>> { // … ensureDir + readDir(baseDir) … for (const entry of entries.value) { if (!entry.isDirectory) continue; const meta = await this.readMetadata(entry.name); // só o frontmatter if (meta.ok) found.push(meta.value); // pula dirs não-skill em silêncio } found.sort((a, b) => a.name.localeCompare(b.name)); return ok(found); }
Um diretório sem um SKILL.md legível e de frontmatter válido é simplesmente pulado (a tolerância _find_all_skills da fonte). A lista volta ordenada por nome.
O orçamento de contexto de um agente é finito. Se cada list() carregasse o corpo de toda skill, listar 50 skills jogaria 50 corpos inteiros na janela — desperdício, e possivelmente estouro. Carregar só o frontmatter (name + description) é o mínimo necessário para o agente decidir qual skill abrir. Só então ele paga o nível 2. É a mesma economia do índice de um livro: o índice cabe numa página justamente porque não inclui o texto dos capítulos.
Cuidado com uma confusão sutil: nesta implementação, view(name) devolve o corpo mais a lista de caminhos dos arquivos vinculados (via listLinkedFiles). O "nível 3" é então carregar um desses arquivos pelo seu caminho relativo — uma leitura separada. Não há um view(name, relPath) de dois argumentos aqui; o conceito de níveis descreve quando você paga cada custo, não três assinaturas distintas.
view entrega metadado + corpo + os caminhos; o conteúdo de um arquivo vinculado é uma leitura posterior.A fonte parseia frontmatter com YAML completo (CSafeLoader), caindo num chave: valor ingênuo só quando o YAML dá erro. Para ficar sem dependências — adicionar um pacote yaml é uma stop-condition desta run — o clone reproduz exatamente esse fallback e nada mais: só pares escalares de topo.
---, fecha na primeira linha ---.// packages/hermes/src/skills/frontmatter.ts:60-70 — parseScalarBlock for (const line of block.split('\n')) { const colon = line.indexOf(':'); if (colon === -1) continue; // sem ':' ⇒ pula a linha const key = line.slice(0, colon).trim(); if (key.length === 0) continue; out[key] = line.slice(colon + 1).trim(); // split no 1º ':'; chave depois vence }
Um documento sem fence de abertura devolve ({}, content) — a coisa toda é o corpo. Mapeamentos aninhados, listas e aspas-com-dois-pontos estão deliberadamente fora de escopo.
---? Sim. Então o parser corta os 3 primeiros caracteres e procura a próxima fence com /\n---[ \t]*\n/.parseScalarBlock: cada linha com : é dividida no primeiro dois-pontos; name e description são extraídos e .trim()-ados..trim()-ado no parseSkill.SKILL.md com description: usa: dois pontos no valor. O que description guarda? — Resposta: usa: dois pontos. O split é só no primeiro :, então o resto da linha (inclusive outros dois-pontos) vira o valor inteiro.name e description escalares. YAML mais rico (metadata aninhado, listas, platforms) fica adiado até um humano aprovar a dependência yaml — adicionar uma é uma stop-condition desta run.:; uma chave repetida depois vence (semântica de sobrescrita de dict).Arquivos de suporte são confinados. Um caminho relativo não pode conter segmento .. nem ., não pode ser absoluto, deve usar barras normais, e seu primeiro segmento deve ser um dos quatro subdirs de suporte.
validateSupportPath: quatro travas em sequência transformam "escreva um arquivo" numa operação limitada e auditável.
// packages/hermes/src/skills/skill-store.ts:404-437 — validateSupportPath (condensado) if (relPath.includes('\\')) return err(…'use forward slashes'); if (relPath.startsWith('/')) return err(…'must be relative'); const segments = relPath.split('/').filter((s) => s.length > 0); if (segments.length < 2) return err(…"must be under references/templates/scripts/assets"); for (const segment of segments) if (segment === '..' || segment === '.') return err(…'path traversal is not allowed'); if (!isSupportDir(segments[0])) return err(…'first segment must be one of …');
Uma skill pode guardar scripts e assets que o próprio agente autorou; um caminho relativo que escapasse do diretório da skill (../../etc/...) seria um primitivo de escreve-em-qualquer-lugar. Confinar todo caminho de arquivo de suporte sob um subdir allowlistado da skill transforma "escreva um arquivo" numa operação limitada e auditável. Quatro testes guardam isso: um escape com .., um caminho absoluto, um caminho com barra invertida e um arquivo fora dos subdirs permitidos são cada um recusados.
| Caminho de entrada | Resultado | Por quê |
|---|---|---|
references/api.md | ok | primeiro segmento allowlistado, dois segmentos, sem traversal |
../../secrets.txt | err | segmento .. ⇒ "path traversal is not allowed" |
/etc/passwd | err | começa com / ⇒ "must be relative" |
references\api.md | err | barra invertida ⇒ "use forward slashes" |
logs/run.txt | err | logs não é um subdir de suporte ⇒ "first segment must be one of …" |
O patch faz um find-and-replace alvo por substring único — e reusa de propósito o mesmo motor fail-closed que o memory store usa (match exato; zero ou múltiplas ocorrências → err), não o matcher fuzzy normalizador-de-espaços da fonte. A escolha é fidelidade à convenção já entregue do Alembic, em vez do helper Python.
// packages/hermes/src/skills/skill-store.ts:449-461 — replaceUnique const first = content.indexOf(find); if (first === -1) return err(new Error(`No match for '${find}' in SKILL.md.`)); const second = content.indexOf(find, first + find.length); if (second !== -1) return err(new Error(`Multiple matches for '${find}' …`)); return ok(content.slice(0, first) + replace + content.slice(first + find.length));
Depois de substituir, o patch re-parseia o resultado e re-valida o frontmatter; se a edição quebrou a estrutura do SKILL.md em metadado inválido, a escrita é recusada com "Patch would break SKILL.md structure" — a edição nunca chega ao disco. O teste "refuses a patch that would corrupt the frontmatter" prova isso.
locateUnique). Reusá-lo em vez de portar o fuzzy_match do Python mantém uma só regra de edição em todo o Alembic: "seja específico o bastante para casar exatamente uma vez". Menos comportamento surpreendente, mais previsibilidade.O FsPort não expõe um unlink (nem remoção recursiva). Então delete e removeFile limpam o arquivo para vazio — o que o torna invisível ao list()/view(), porque frontmatter vazio falha a validação. É o marcador "deletado" portável mais próximo possível.
unlink, "limpar para vazio" é o delete portável — e o teste confirma a invisibilidade.create recusa um nome que já existe ("A skill named … already exists.").
view/edit/patch/delete/removeFile recusam uma skill (ou arquivo) que falta.
Note ainda: edit é uma reescrita completa do corpo que preserva o frontmatter parseado; ele recusa corpo vazio. E todo write é atômico (writeFileAtomic) — uma falha de IO vira err, nunca uma exceção solta.
size 0 e somem da listagem — o mesmo truque do delete.Result, nenhuma lança.Alterne entre a explicação leiga e a precisa. Use a aba "Técnico" quando quiser os nomes reais; a "Simples" quando quiser a intuição.
SkillStore é o arquivo de receitas do agente. Você pode folhear só os títulos (barato), abrir uma receita inteira (custa um pouco), ou buscar o anexo de uma receita (custa mais). Você guarda receitas novas, reescreve uma, corrige uma frase específica nela, ou tira uma de circulação. E há porteiros: nenhum anexo pode ser salvo fora da pasta da receita, e uma correção que casa em dois lugares é recusada para você não trocar o pedaço errado.SkillStore é respaldado por um FsPort injetado sobre um baseDir. list() faz readMetadata (nível 1, só frontmatter); view() faz parseSkill + listLinkedFiles (nível 2/3). As mutações — create/edit/patch/delete/writeFile/removeFile — usam writeFileAtomic e devolvem Result. patch roda replaceUnique (o motor fail-closed do memory store) e re-parseia o resultado; validateSupportPath confina cada arquivo de suporte sob um SKILL_SUPPORT_DIRS allowlistado. Frontmatter via o parser escalar dep-free.Vire cada cartão (clique, ou Enter/Espaço) e tente responder antes de ver o verso. É prática de recuperação — vale mais que reler.
list() lê só metadado; view() o corpo; o arquivo vinculado depois. Você só paga pelo que abre.SKILL.md com frontmatter válido (name + description). Sem isso, o diretório é pulado em silêncio — não é uma skill nem um erro.MAX_NAME_LENGTH = 64 e MAX_DESCRIPTION_LENGTH = 1024. Carregados exatamente como a fonte Python os define.yaml é stop-condition. Clona só o fallback escalar da fonte: pares chave: valor de topo.validateSupportPath recusa?../., e um primeiro segmento fora de references/templates/scripts/assets.patch recusa um substring ambíguo?err. Uma só regra de edição em todo o Alembic; nada é escrito se for ambíguo.SKILL.md obrigatório + references/templates/scripts/assets opcionais.SKILL.md de frontmatter válido — caso contrário ela é pulada em silêncio.list() metadado, view() corpo, arquivo vinculado pelo caminho.list() lê só o frontmatter de cada skill — nunca o corpo. Listar 50 skills é barato.MAX_NAME_LENGTH = 64, MAX_DESCRIPTION_LENGTH = 1024.yaml é stop-condition.validateSupportPath confina todo arquivo de suporte — sem .., não absoluto, sob um subdir allowlistado.patch reusa o motor de substring único do memory store (fail-closed) e re-parseia para não corromper o metadado.unlink no FsPort, delete limpa o arquivo para vazio — frontmatter vazio o some das listagens.chave: valor de topo, um clone fiel do caminho de fallback da fonte. YAML mais rico (metadata aninhado, listas) fica adiado até um humano aprovar uma dependência yaml — adicionar uma é uma stop-condition desta run.delete remove o diretório." Não — o FsPort não tem unlink recursivo, então delete limpa o SKILL.md para vazio, o que torna a skill invisível ao list()/view() (frontmatter vazio falha a validação). Arquivos de suporte são gerenciados individualmente via removeFile.patch usa fuzzy matching como o Hermes." Não — ele reusa de propósito o motor de substring exato do memory store, não o fuzzy_match normalizador-de-espaços da fonte. É fidelidade à convenção já entregue do Alembic sobre o helper Python._security_scan_skill), o pin guard (_pinned_guard), descoberta cross-profile, categorias, a semântica absorbed_into do curator, a telemetria de uso (skill_usage.py) e o schema de tool OpenAI + registro no registry. Este é o store portável, não a camada Python de tool/registry/security.Três perguntas. Escolha e leia o porquê de cada opção — o feedback ensina tanto quanto a pergunta.
Checagem cumulativa
Acerte as três para fechar a lição. A pontuação aparece abaixo.
list() sobre um diretório de 50 skills. O que ele lê do disco?list() lê só o frontmatter via readMetadata — nunca o corpo. Você paga por um corpo só ao fazer view(name), e por um arquivo vinculado só ao abri-lo. O teste "returns metadata only — name + description, never the body" confirma. (c) é falso: não há cache; toda leitura bate no disco.writeFile('minha-skill', '../../secrets.txt', …) é chamado. O resultado?.././absoluto/barra-invertida. Isso transforma "escreva um arquivo" numa operação limitada, nunca um primitivo de escreve-em-qualquer-lugar.patch recebe um substring que aparece duas vezes no SKILL.md. O que acontece?replaceUnique exige exatamente uma ocorrência; zero ou múltiplas → err. O skill store reusa de propósito o motor de substring único do Alembic, não o fuzzy matcher Python, pela fidelidade à convenção já entregue. (c) descreve o comportamento da fonte, que NÃO foi portado.list() lê só o ____, view() carrega o ____, e patch recusa um substring ____." Se você preenche as cinco lacunas, está pronto para a Lição 13.