SkillStore — disclosure progressiva
Skills são "memória procedural estreita e acionável" — a metade durável do ciclo de auto-aperfeiçoamento, complementando a memória declarativa ampla do MemoryStore. Uma skill é um diretório: um SKILL.md (frontmatter + corpo) mais arquivos opcionais sob references/templates/scripts/assets. A ideia definidora é a disclosure progressiva: list() lê só metadados baratos; view() carrega o corpo completo sob demanda. Um CLONE do skills_tool.py (1.638 LOC) + skill_manager_tool.py (1.233 LOC) do Hermes.
Esta lição destila o código real do subsistema (rodapé com linhas verbatim) + a proveniência em docs/hermes-complete-map.md §3.3. Por que importa pra missão: é a memória procedural que deixa o Alembic guardar um procedimento aprendido e recuperá-lo barato depois — sem reler tudo a cada vez.
- Explicar a disclosure progressiva em três níveis e por que
list()nunca lê o corpo. - Ler o parser de frontmatter sem dependências (só
key: valueescalar) e seus limites deliberados. - Rastrear o
validateSupportPathe por que confinar caminhos vira segurança. - Distinguir as cinco operações de CRUD e por que
patchreusa o matcher de substring única.
01 · O que é, de fato, uma "skill"
Antes do mecanismo, o objeto. Uma skill não é uma linha num banco — é um diretório no disco. No fundo dele há um arquivo obrigatório, SKILL.md, com duas partes: um cabeçalho --- … --- (o frontmatter, que guarda name e description) e o corpo de instruções abaixo. Em volta, quatro pastas opcionais carregam material de apoio.
Um SkillStore é apoiado 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. Isso é a invariante de injeção da Lição 05: o store nunca toca fs direto, então é testável com um FsPort em memória.
Procedural vs declarativa: skill não é memória
Por que um subsistema separado, e não só guardar isso no MemoryStore? Porque os dois guardam coisas diferentes, com formas diferentes. A memória é ampla e declarativa ("o que eu sei"); a skill é estreita e procedural ("como eu faço"). Veja lado a lado:
02 · Disclosure progressiva: três níveis de custo
Aqui está a ideia central. Carregar o corpo de toda skill toda vez que você quer só saber que elas existem seria caro — desperdício de contexto e de tokens. A disclosure progressiva resolve isso pagando o custo em três níveis: você lista de graça, e só paga pelo conteúdo no momento exato em que abre.
O list() percorre o diretório-base, lê apenas o frontmatter de cada SKILL.md via readMetadata, ignora pastas sem skill válida, e devolve a lista ordenada por nome. Note o que ele não faz: nunca toca no corpo.
// packages/hermes/src/skills/skill-store.ts:80-96 — list (nível 1) async list(): Promise<Result<readonly SkillMetadata[], Error>> { // … readDir(baseDir) … for (const entry of entries.value) { if (!entry.isDirectory) continue; const meta = await this.readMetadata(entry.name); // só 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); }
SKILL.md legível e de frontmatter válido é simplesmente ignorado — não é uma skill. É a tolerância do _find_all_skills da fonte: misture skills e pastas avulsas no mesmo lugar, e list() só enxerga as válidas, sem quebrar. Os dois tetos de metadados são carregados literalmente: MAX_NAME_LENGTH = 64, MAX_DESCRIPTION_LENGTH = 1024 (types.ts:41/47).list() sobre um diretório com 50 skills, cada SKILL.md com ~6 KB de corpo e ~120 bytes de frontmatter. Quantos bytes de corpo o list() lê do disco? Chute antes de revelar.
list() lê só o frontmatter de cada skill — ~120 bytes × 50 ≈ 6 KB no total, contra os ~300 KB que custaria ler os 50 corpos. Se você chutou "300 KB", caiu na armadilha de assumir que listar = carregar. É justamente o que a disclosure progressiva evita: o nível 1 é deliberadamente barato para você poder listar tudo sem medo. [uncertain] os tamanhos por arquivo são ilustrativos; a invariante — "list não lê o corpo" — é o teste real "returns metadata only — name + description, never the body" (linha 122).03 · list vs view — o comparativo que define o subsistema
Os dois lados da disclosure são list() e view(). Eles parecem irmãos, mas fazem leituras opostas do disco. Ver os dois lado a lado é entender o subsistema inteiro:
| Aspecto | list() — nível 1 | view(name) — nível 2/3 |
|---|---|---|
| Lê do disco | só o frontmatter de cada skill | corpo completo + lista de arquivos vinculados |
| Custo | barato (poucos bytes × N) | caro (proporcional ao corpo) |
| Quando | "que skills existem?" | "abra esta skill agora" |
| Devolve | SkillMetadata[] ordenado por nome | um Skill (metadata + body + linkedFiles) |
| Erra quando | nunca por skill inválida (pula) | nome inválido · skill ausente · frontmatter inválido |
| Fonte | skill-store.ts:80–96 | skill-store.ts:103–122 |
O view() também junta os linkedFiles: percorre as quatro pastas de apoio e lista os arquivos não vazios (size > 0) — detalhe que volta na seção 09 quando virmos como o delete funciona. O nível 3 é carregar um desses arquivos por caminho relativo, num view(name, relPath) posterior.
04 · O parser de frontmatter sem dependências
Como o frontmatter vira name e description? A fonte parseia YAML completo (CSafeLoader), com um fallback ingênuo de key: value quando o YAML dá erro. Para ficar sem dependências — adicionar uma dep yaml é uma condição de parada desta run — o clone reproduz exatamente esse fallback, e nada mais: só pares escalares de nível superior.
campo: valor". O parser olha cada linha, acha o primeiro ":", chama o que vem antes de chave e o que vem depois de valor. Linha sem ":"? Ignora. Sem chave? Ignora. É só isso — nada de listas, nada aninhado. Deliberadamente burro, deliberadamente sem dependências.parseScalarBlock (frontmatter.ts:60–70) faz block.split('\n'), descarta linhas sem dois-pontos (indexOf(':') === -1), corta no primeiro dois-pontos, faz trim() dos dois lados e grava out[key] = value. Chaves repetidas: a última vence (semântica de sobrescrita de dict). Mapeamentos aninhados, listas, block scalars, âncoras e aspas-com-dois-pontos ficam fora de escopo até um humano aprovar a dep yaml.// 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 dois-pontos ⇒ pula a linha const key = line.slice(0, colon).trim(); if (key.length === 0) continue; // chave vazia ⇒ pula out[key] = line.slice(colon + 1).trim(); // 1º dois-pontos; chaves posteriores vencem }
A detecção de fronteira do bloco espelha a fonte: o documento deve começar com ---, e o bloco fecha na primeira linha que é --- (casada como /\n---[ \t]*\n/ a partir do offset 3, frontmatter.ts:22). Um documento sem fence de abertura retorna ({}, content) — tudo vira corpo (frontmatter.ts:40–52).
05 · Faça o parse na mão (passo a passo → agora você)
Você viu a regra. Agora rode-a na mão, devagar, sobre um frontmatter de verdade — depois um caso é seu. Recuperar o procedimento (não só ler a regra) é o que fixa.
name: pesquisar-web / description: chama webSearch: e resume / (linha em branco). Repare no segundo dois-pontos na description — é a pegadinha.name: pesquisar-web. indexOf(':') = 4. Chave = "name" (slice 0..4, trim). Valor = "pesquisar-web" (slice 5, trim). Grava out.name = "pesquisar-web".description: chama webSearch: e resume. O indexOf(':') acha o primeiro dois-pontos (após "description"). Valor = "chama webSearch: e resume" — o segundo ":" fica dentro do valor, intacto. Grava out.description.indexOf(':') = -1 → continue. A linha é pulada sem erro.{ name: "pesquisar-web", description: "chama webSearch: e resume" }. O corpo é tudo depois do --- de fechamento, com trim().name: x / name: y / : solto. Qual o out.name final, e o que acontece com a terceira linha? Faça antes de revelar.
out.name = "y" — chaves posteriores vencem (a segunda linha sobrescreve a primeira). A terceira linha (: solto) tem dois-pontos no índice 0 → chave = "" (string vazia após trim) → key.length === 0 → continue, pulada. Dica: as duas guardas (sem-dois-pontos e chave-vazia) são exatamente o que torna o parser robusto a lixo sem precisar de uma lib de YAML.06 · Segurança de caminho: sem traversal, sob um subdir permitido
Uma skill pode guardar scripts e assets que o agente autorou. Escrever um arquivo dentro de uma skill é, no fundo, "escrever um arquivo no disco" — e isso é perigoso se o caminho puder escapar. O validateSupportPath é o portão: ele transforma "escrever um arquivo" numa operação delimitada e auditável, recusando qualquer caminho que tente sair da skill.
Diante de um caminho relativo, siga o fluxograma de cabeça — cada losango é uma checagem que ou barra o caminho ou o deixa passar. O caminho malicioso ../../secrets.txt está destacado: ele cai no losango de traversal.
// 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 …');
../../etc/...) seria uma primitiva de escrever-em-qualquer-lugar. Confinar todo caminho de arquivo de suporte sob um subdir permitido da única skill transforma "escrever um arquivo" numa operação delimitada. Quatro testes a protegem: um escape .. (linha 378), um caminho absoluto e um com barra invertida (linha 386), e um arquivo fora dos subdirs permitidos (linha 399) — cada um recusado.07 · CRUD: as cinco operações de mutação
Além de list/view (leitura), o store tem cinco operações que mudam o disco — todas devolvendo Result<T, Error>, nunca lançando exceção na fronteira pública. Cada uma tem regras de colisão/ausência próprias:
| Operação | Falha-fechada quando… | Fonte |
|---|---|---|
| create | nome inválido · metadata inválida · corpo vazio · nome já existe | 129–159 |
| edit | nome inválido · corpo vazio · skill ausente | 166–187 |
| patch | find vazio · 0/múltiplas ocorrências · re-parse quebra · skill ausente | 196–220 |
| delete | nome inválido · skill ausente | 228–242 |
| writeFile / removeFile | caminho inseguro · skill ausente · (removeFile) arquivo ausente | 249–284 |
Vistas como um todo, as operações desenham o ciclo de vida de uma skill: ela nasce com create, é descoberta e aberta por list/view, evolui por edit/patch, e some por delete. Siga as transições:
08 · patch: um matcher, reusado — substring única
O patch faz um find-and-replace direcionado por substring ÚNICA — e deliberadamente reusa 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 entregue do Alembic em vez do helper Python.
Por que isso importa? Compare as duas filosofias de matcher lado a lado:
// 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));
09 · O guard de re-parse: um patch não pode corromper os metadados
Aqui está a sutileza que protege a skill de si mesma. Um patch opera sobre o texto bruto do SKILL.md — inclusive sobre o frontmatter. E se a substituição quebrar a estrutura do frontmatter em metadados inválidos? A resposta: depois de substituir, o patch re-parseia o resultado e re-valida; se quebrou, a escrita é recusada e a edição nunca cai.
O caso na linha 294 do skill-store.test.ts aplica um patch que destruiria o frontmatter e verifica que a operação retorna err e o arquivo no disco fica intacto. É a garantia: você nunca consegue, via patch, deixar uma skill num estado em que list()/view() não conseguem mais lê-la.
10 · delete sem unlink: limpar para vazio
Última peça, e um desvio honesto. O FsPort do Alembic não expõe um unlink (nem remoção recursiva de diretório). Então como o delete "apaga"? Ele escreve uma string vazia por cima do SKILL.md. Um arquivo vazio tem frontmatter vazio → falha a validação → fica invisível a list() e view(). O marcador portável mais próximo de "deletado".
delete e removeFile limpam o arquivo para vazio em vez de fazer unlink — o teste "makes the skill invisible to list and view" (linha 320) prova; (2) create recusa uma colisão de nome enquanto view/edit/patch/delete recusam uma skill ausente. São escolhas explícitas da portabilidade, documentadas no cabeçalho do skill-store.ts.Confusões comuns
key: value de nível superior, um clone fiel do caminho de fallback da fonte. YAML mais rico (metadata aninhado, listas, platforms, prerequisites) é adiado até um humano aprovar uma dependência yaml — adicionar uma é uma condição de parada para esta 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 a list()/view() (frontmatter vazio falha a validação). Arquivos de suporte são geridos individualmente via removeFile.11 · Como isso se encaixa
O SkillStore não é uma peça solta — é a metade durável do ciclo de auto-aperfeiçoamento do Alembic. Pense nele como a memória de longo prazo procedural: enquanto o MemoryStore (Lição 07) guarda o que o agente sabe, o SkillStore guarda como ele faz. Os dois alimentam o mesmo loop fechado (Lição 08), e a disclosure progressiva é o que torna esse acervo barato de carregar quando o agente decide agir. Veja o trajeto, da origem ao consumo:
SkillStore é o sexto, e o par durável do MemoryStore (Lição 07). Ele não fica na linha da pipeline de execução (escopo → council → proof → validator → publish); ele fica no eixo de aprendizado, que corre ao lado: um run termina, o reviewAndLearn (Lição 08) decide se um procedimento merece virar skill, e na próxima vez a disclosure progressiva o devolve barato. Veja o eixo de aprendizado dentro da máquina inteira no mapa interativo da metodologia.Por que conecta: o
SkillStore recebe um FsPort injetado e nunca toca fs direto — é a disciplina da Lição 05 encarnada, e o que torna os 35 testes hermético em memória.
Lição 07 · MemoryStorePor que conecta: a outra metade do acervo — declarativa ("o que sei") vs. a procedural daqui ("como faço"). E o
patch reusa o mesmo matcher de substring única do memory store.
Lição 08 · reviewAndLearnPor que conecta: é o upstream — o ciclo que, após um run, propõe guardar um procedimento aprendido. O
SkillStore é onde esse procedimento durável mora.
Lição 13 · transcribe / analyzeImagePor que conecta: o sétimo e último subsistema do mergulho — mesma forma (porta injetada → kernel puro →
Result), fechando os sete kernels do @alembic/hermes.
Lição 29 · Estendendo a fusãoPor que conecta: quando você adiciona um oitavo subsistema, o
SkillStore é o gabarito de "store sobre FsPort com fronteira fail-closed" que você copia.
12 · Na prática
Sendo honesto: não existe um comando alembic skills que exponha o SkillStore na CLI — ele é uma biblioteca interna do @alembic/hermes, consumida pelo runtime, não um subcomando. [uncertain] nenhum comando da CLI canônica exercita diretamente este subsistema hoje. O caminho real para vê-lo rodar é o pacote de testes — que exercita as três níveis de disclosure e os cinco CRUD contra um FsPort em memória, sem tocar disco real:
# exercita o SkillStore — os 35 casos de skill-store.test.ts rodam aqui pnpm --filter @alembic/hermes test # … # ✓ src/skills/skill-store.test.ts (FsPort em memória → zero IO real) # ✓ list returns metadata only — name + description, never the body # ✓ view loads the full body + linkedFiles # ✓ rejects path traversal '..' in writeFile # ✓ refuses a patch that would corrupt the frontmatter # ✓ delete makes the skill invisible to list and view # Test Files 1 passed (entre os do pacote) # Tests 35 passed ← todos com um FsPort FALSO, nada toca seu disco
Os nomes dos casos acima são os reais de skill-store.test.ts (rodapé). A contagem de 35 é a do repo na escrita desta lição — o que importa é a invariante que cada caso prova, não o número exato.
E como o SkillStore é usado como API? Você o constrói com um FsPort e um diretório-base, e chama os mesmos métodos da disclosure progressiva. Este é o uso real — a mesma forma do teste (new SkillStore(fs, BASE)):
// uso da API — list (nível 1, barato) → view (nível 2, só ao abrir) import { SkillStore } from '@alembic/hermes'; const store = new SkillStore(fs, '/skills'); // fs: um FsPort injetado const all = await store.list(); // só frontmatter de cada skill if (!all.ok) return all; // fail-closed: trate o err antes for (const meta of all.value) console.error(meta.name, '—', meta.description); const one = await store.view('pesquisar-web'); // AGORA paga o corpo if (one.ok) console.error(one.value.body, one.value.linkedFiles); // segurança: um caminho que tenta escapar é recusado ANTES de qualquer IO await store.writeFile('pesquisar-web', '../../secrets.txt', 'x'); // ⇒ err('path traversal is not allowed') — nada é escrito (Result, nunca throw)
Repare em console.error, não console.log — saída de diagnóstico fora do canal padrão é a convenção do projeto. E todo método devolve Result<T, Error>: o if (!r.ok) return r é obrigatório antes de ler r.value — o typecheck recusa o contrário.
cd na raiz do monorepo e rode pnpm --filter @alembic/hermes test. Termina em segundos, sem rede e sem escrever no seu disco — porque o teste injeta um FsPort em memória (MemFs).packages/hermes/src/skills/skill-store.test.ts, procure new SkillStore(fs, BASE): o fs é a classe MemFs implements FsPort do topo do arquivo — não node:fs. É a injeção da Lição 05, no teste."returns metadata only — name + description, never the body": ele afirma que list() devolve só o frontmatter. Compare com o caso de view, que carrega o corpo. É a diferença entre nível 1 e nível 2 — provada, não só desenhada.pnpm -r typecheck && pnpm -r build && pnpm -w test na raiz. O typecheck é a metade silenciosa da fronteira: ele recusa compilar se você ler r.value sem ter tratado !r.ok antes — exatamente o que o snippet da API faz de propósito.SkillStore é consumido por dentro do runtime, não por um subcomando próprio. Um run inteiro — alembic run --goal GOAL.md --plan alembic.plan.ts --yes — exercita o motor cujos kernels (incluindo este) seguem a mesma forma; e o ciclo de aprendizado que escreve skills aparece quando o run roda com aprendizado ligado (Lição 08). [uncertain] esses comandos exercitam o motor inteiro, não isolam o SkillStore; para isolar esta peça, o caminho direto continua sendo o pnpm --filter @alembic/hermes test acima.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
list() lê do disco?parseScalarBlock trata uma linha sem dois-pontos?indexOf(':') === -1 → continue (pula). Corta no PRIMEIRO dois-pontos; chaves repetidas: a última vence. Só escalar top-level.../../secrets.txt é recusado?validateSupportPath rejeita qualquer segmento ../. (+ absoluto, barra invertida, <2 segmentos, 1º segmento fora dos 4 subdirs). Vira operação delimitada.patch com 2 ocorrências de find?err "Multiple matches… Be more specific" — substring única, fail-closed, nada escrito. Mesmo motor do memory store, não fuzzy.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
list() sobre um diretório de 50 skills. O que ele lê do disco?list() lê só frontmatter via readMetadata — nunca o corpo (o teste "returns metadata only — name + description, never the body", linha 122). a inverte a ideia inteira da disclosure: ler o corpo de todas seria o desperdício que o subsistema existe para evitar. c inventa um cache que não existe — todo list() bate no disco (o store é stateless além do fs/baseDir). d confunde nível 1 com nível 3: arquivos de apoio só são carregados por caminho relativo, muito depois.writeFile('my-skill', '../../secrets.txt', …) é chamado. O resultado?..) e é recusado antes de qualquer IO. a é exatamente o ataque que a guarda existe para impedir — uma primitiva de escrever-em-qualquer-lugar. b "ignorar o .." seria pior: silenciosamente escreveria no lugar errado; o store prefere falhar alto. d erra a forma do erro: o store nunca lança na fronteira — devolve Result com err (o teste linha 378).patch é pedido para substituir uma substring que aparece duas vezes no SKILL.md. O que acontece?replaceUnique exige exatamente uma ocorrência; zero ou múltiplas → err (teste linha 286). a e b são justamente o não-determinismo que a regra evita: substituir "todas" ou "a primeira" esconde a ambiguidade do autor. c descreve o matcher fuzzy da fonte Python, que o clone deliberadamente NÃO usa — escolheu o motor de substring única do Alembic por previsibilidade.patch que quebraria o frontmatter nunca chega ao disco?patch chama parseSkill no candidato; se reparsed.ok for falso, retorna "Patch would break SKILL.md structure" e a escrita nunca acontece (linhas 211–215; teste linha 294). b atribui ao FsPort uma inteligência que ele não tem — ele só faz IO; a validação é do store. c é falso: patch opera sobre o texto bruto inteiro, frontmatter incluído — é justamente por isso que o guard existe. d inventa concorrência: o problema aqui é integridade do conteúdo, não corrida entre escritas.view monta os linkedFiles?", "O que muda quando aprovarem a dep yaml?". É só dizer.