Curso / Lição 12  ·  EN
Lição 12 · Mergulho profundo · subsistema 6 de 7

SkillStore — divulgação 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 divulgação progressiva: list() lê só metadados baratos; view() carrega o corpo completo sob demanda. Um CLONE do skills_tool.py (1638 LOC) + skill_manager_tool.py (1233 LOC) do Hermes.

Três níveis de custo

nível 1 · list() lê SÓ o frontmatter name ≤64 · description ≤1024 nunca o corpo nível 2 · view(name) corpo completo do SKILL.md + lista de arquivos vinculados nível 3 · arquivo vinculado references/api.md etc. carregado por caminho relativo barato listar tudo; pague por um corpo só ao abri-lo; pague por uma referência só ao precisar
// 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 silenciosamente
  }
  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 — não é uma skill (a tolerância do _find_all_skills da fonte). Os dois tetos de metadados são carregados literalmente: MAX_NAME_LENGTH = 64, MAX_DESCRIPTION_LENGTH = 1024.

O parser de frontmatter sem dependências

A fonte parseia frontmatter com 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 para esta run — o clone reproduz exatamente esse fallback e nada mais: só pares escalares de nível superior:

// 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;
  out[key] = line.slice(colon + 1).trim();      // divide no PRIMEIRO 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). Um documento sem fence de abertura retorna ({}, content) — tudo é o corpo. Mapeamentos aninhados, listas, aspas-com-dois-pontos são deliberadamente fora de escopo.

Segurança de caminho: sem traversal, sob um subdir permitido

Arquivos de suporte são confinados. Um caminho relativo não pode conter segmento .. ou ., não pode ser absoluto, deve usar barras normais, e seu primeiro segmento deve ser um dos quatro subdirs de suporte:

// 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 …');
Por que tão estrito? Uma skill pode conter scripts e assets autorados pelo agente; um caminho relativo que escapasse do diretório da skill (../../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 e auditável. Quatro testes a protegem: um escape .., um caminho absoluto, um caminho com barra invertida, e um arquivo fora dos subdirs permitidos são cada um recusados.

Um matcher, reusado: patch por substring única

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:

// 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));
Um patch não pode corromper os metadados — re-parse após o replace

Depois de substituir, patch re-parseia o resultado e re-valida o frontmatter; se a edição quebrou a estrutura do SKILL.md em metadados inválidos, a escrita é recusada com "Patch would break SKILL.md structure" — a edição nunca cai. O teste "refuses a patch that would corrupt the frontmatter" prova. Mais dois desvios a notar: o FsPort não expõe unlink, então delete/removeFile limpam o arquivo para vazio (o que o torna invisível a list()/view() — frontmatter vazio), e create recusa uma colisão de nome enquanto view/edit/patch/delete recusam uma skill ausente.

1. Um agente chama list() sobre um diretório de 50 skills. O que ele lê do disco?
Correto: b. list() lê só frontmatter via readMetadata — nunca o corpo. Você paga por um corpo só ao view(name), e por um arquivo vinculado só ao abri-lo. O teste "returns metadata only — name + description, never the body" confirma.
2. writeFile('my-skill', '../../secrets.txt', …) é chamado. O resultado?
Correto: c. Todo caminho de arquivo de suporte é confinado sob um subdir permitido da skill, com rejeição explícita de .././absoluto/barra-invertida. Isto transforma "escrever um arquivo" numa operação delimitada, nunca uma primitiva de escrever-em-qualquer-lugar.
3. patch é pedido para substituir uma substring que aparece duas vezes no SKILL.md. O que acontece?
Correto: d. replaceUnique exige exatamente uma ocorrência; zero ou múltiplas → err. O skill store deliberadamente reusa o motor de substring única do Alembic, não o matcher fuzzy do Python, por fidelidade à convenção entregue.

Confusões comuns

"Ele parseia frontmatter YAML completo." Não — só pares escalares key: value de nível superior, um clone fiel do caminho de fallback da fonte. YAML mais rico (metadata aninhado, listas) é 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.