Lição 9 · Curso de Fusão · Parte 2 · Mergulhos profundos · UsageStore + runCurator
Alembic × Hermes · Curso de Fusão · Parte 2 · Mergulho 3 de 7

Curadoria profunda — UsageStore + runCurator, a metade do descarte

O loop fechado tem duas metades: o SkillStore autora memória procedural, e o curador descarta o que cai em desuso. UsageStore é um sidecar de telemetria que conta uso/visualização/patch de cada skill; runCurator é uma passada determinística que move skills de agente há muito ociosas active → stale → archived. Duas invariantes o definem: nunca deletar (archive é o estado terminal) e o TEMPO é um Clock injetado — nunca Date.now(). Um CLONE do skill_usage.py + curator.py do Hermes.

Leia primeiro (fonte primária)
packages/hermes/src/curator/curator.ts & usage-store.ts — lidos verbatim do repositório

Esta lição destila a metade de descarte do loop de skills. Tudo aqui é citado de arquivo real do monorepo — curator.ts, usage-store.ts, types.ts e o suite curator.test.ts (21 casos). A proveniência do CLONE está em docs/hermes-complete-map.md §3.3.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
O que você vai conseguir fazer
  • Desenhar o ciclo de vida active → stale → archived com as duas linhas de corte (30d / 90d) e o caminho de reativação.
  • Explicar por que a ordem dos ramos em nextState (archive → stale → reativar) é load-bearing.
  • Justificar a âncora +Infinity para uma skill nunca-ativa (lastActivityAt = 0).
  • Reconstruir os dois portões — proveniência e pin — e por que os skips são reportados, não escondidos.
  • Distinguir a assimetria leitura best-effort × escrita atômica do UsageStore.
O que assumimos de você (muito pouco)
  • Você sabe que uma skill é uma habilidade que o agente pode aprender, salvar e reusar (Lição 8 cobre a metade que autora).
  • Você lembra que o Alembic devolve Result<T, Error> em vez de lançar exceções (ok / err).
  • Não assumimos que você conhece o Hermes em Python — todo trecho aqui é a versão TypeScript do Alembic.
01

A grande ideia

Imagine uma oficina cheia de ferramentas que o próprio agente fabricou. Algumas são usadas toda semana; outras foram úteis uma vez e nunca mais. Sem manutenção, a bancada vira um entulho — e quanto mais ferramentas inúteis, mais difícil achar a certa. O curador é o organizador da bancada: ele observa o que anda sem uso e guarda na gaveta o que está parado há tempo demais. Mas há uma regra de ouro: ele nunca joga nada fora. Guardar é reversível; jogar fora não é.

Analogia. O SkillStore (Lição 8) é o marceneiro que cria a ferramenta. O curador é o faxineiro disciplinado que, à noite, move para uma gaveta etiquetada .archive/ tudo o que ninguém pegou há 90 dias — e, se você pegar de volta uma ferramenta da "quase-gaveta" (stale), ela volta para a bancada (active) na hora.

Duas peças, um relógio

São dois módulos que compartilham UM Clock injetado. O UsageStore registra eventos (use/view/patch) incrementando um contador e carimbando lastActivityAt = clock(). O runCurator lê esse sidecar e, para cada skill, decide o próximo estado puramente a partir da ociosidade e de duas linhas de corte. Como o mesmo relógio alimenta os dois, "evento registrado agora" e "transição decidida agora" concordam — não há descompasso (skew) entre o carimbo e o julgamento.

Por que isso é um produto, não um detalhe

Um agente que cria skills sem nunca podá-las acumula ruído procedural: a recuperação fica mais cara e mais sujeita a erro. O curador é o que torna a memória procedural sustentável — ela cresce e encolhe. É a metade que fecha o loop de auto-aprendizado.

O que um evento faz no sidecar

eventouse · view · patch counter += 1 lastActivityAt = clock() save (atômico)
Linha do tempo de três estados active, stale e archived com setas de transição, reativação curva, pulo direto tracejado e selo de nunca-deleta

Fig. 2 — O ciclo de vida completo de uma skill: as duas linhas de corte, a reativação e a regra de que archive é recuperável (gerada à parte).

02

O ciclo de vida

Uma skill de agente atravessa três estados. As transições são dirigidas por uma âncora de inatividade e duas linhas de corte derivadas do Clock: staleCutoff (30 dias atrás) e archiveCutoff (90 dias atrás). Tudo em milissegundos, para ser determinístico e replayável.

30d
staleAfterMs · DEFAULT_STALE_AFTER_MS
90d
archiveAfterMs · DEFAULT_ARCHIVE_AFTER_MS
3
estados · active / stale / archived
0
deleções — sempre, por invariante
Detalhe As linhas de corte são "instantes", não "durações". O código pré-calcula staleCutoff = now − staleAfterMs e archiveCutoff = now − archiveAfterMs. Então a comparação é "a última atividade aconteceu antes da linha de corte?" — anchor <= cutoff. Como archiveAfterMs ≥ staleAfterMs, a linha de archive fica sempre mais antiga (mais à esquerda no tempo) que a de stale.
03

Explore os estados

Clique em um estado para ver o que o curador faz com uma skill que está nele — e qual transição (ou não-transição) ele decide. O diagrama acende o nó correspondente.

active

Recém-criada

active stale archived

O painel é alimentado pela mesma lógica de nextState — nada é decorativo.

04

O motor de transição — a ordem dos ramos é load-bearing

A decisão é uma função pura sobre uma âncora de inatividade e duas linhas de corte. A ordem dos ramos — archive primeiro, depois stale, depois reativar — espelha apply_automatic_transitions exatamente:

packages/hermes/src/curator/curator.ts:123–136 — nextState
// 0 significa nunca-ativa, ancorada a +Infinity (mais nova que qualquer corte)
const nextState = (record, staleCutoff, archiveCutoff): SkillState => {
  const anchor = record.lastActivityAt > 0
    ? record.lastActivityAt
    : Number.POSITIVE_INFINITY;          // nunca-ativa ⇒ nunca velha
  const current = record.state;
  if (anchor <= archiveCutoff && current !== 'archived') return 'archived';
  if (anchor <= staleCutoff   && current === 'active')   return 'stale';
  if (anchor >  staleCutoff   && current === 'stale')    return 'active'; // reativa
  return current;
};
A âncora nunca-ativa — um detalhe preciso

Uma skill recém-criada tem lastActivityAt = 0 (criação não é atividade). Se 0 fosse usado literalmente como âncora, ele seria ≤ toda linha de corte e a skill seria arquivada já na primeira passada. Por isso o código ancora um registro nunca-ativo a Number.POSITIVE_INFINITY — mais novo que qualquer corte — garantindo que ela não seja stale nem archived até ter sido de fato usada e depois ficado ociosa. O teste "never archives a brand-new, never-active skill (lastActivityAt = 0)" prova isso. Honestidade de fonte: o comentário do cabeçalho descreve isso como "tratada como agora"; a implementação usa +Infinity, que tem o mesmo efeito e é independente da ordem.

0 (ingênuo)≤ tudo → arquiva! archiveCutoff staleCutoff now +∞> tudo → fica active mais antigo ← tempo → mais recente

A âncora +∞ fica à direita de toda linha de corte; por isso uma skill nunca-ativa nunca dispara um corte.

Preveja antes de revelar

Uma skill está active e ociosa há 120 dias (passou tanto da linha de stale quanto da de archive). Para qual estado nextState a leva nesta passada — stale ou archived?

archived, direto. Como a linha de archive é checada primeiro, e anchor ≤ archiveCutoff, ela retorna 'archived' antes de o ramo de stale sequer ser avaliado. Se stale viesse primeiro, ela só daria um passo (para stale) nesta passada — um bug. O teste "jumps active → archived directly (skips stale)" guarda essa ordem.

Por que archive-primeiro?

Uma skill active ociosa além da linha de archive deve cair em archived diretamente — pulando stale — então o ramo de archive precisa vencer antes do ramo de stale. Inverter a ordem não é "mais lento": é incorreto, porque atrasaria o arquivamento por uma passada inteira.

05

Os dois portões: proveniência e pin

Antes de qualquer transição, runCurator aplica dois opt-outs ortogonais. Só skills autoradas pelo agente são geridas pelo curador, e uma skill pinned é isenta em todo caminho:

packages/hermes/src/curator/curator.ts:86–108 (condensado)
for (const name of names) {
  const record = sidecar[name];
  if (record.createdBy !== 'agent') { skipped.push({ name, reason: 'not-agent-created' }); continue; }
  if (record.pinned)             { skipped.push({ name, reason: 'pinned' });           continue; }
  const to = nextState(record, staleCutoff, archiveCutoff);
  if (to === record.state)       { skipped.push({ name, reason: 'no-change' });        continue; }
  const persisted = await deps.usage.put(name, { ...record, state: to });
  if (!persisted.ok) return persisted;     // fail-closed: não reporta trabalho não-salvo
  transitioned.push({ name, from: record.state, to });
}

Os skips são reportados, não escondidos — o CuratorReport carrega tanto transitioned[] quanto skipped[] (cada um com um motivo tipado: pinned / not-agent-created / no-change), ambos em ordem estável por nome. E a persistência é por transição: se um put falha, a passada aborta com esse err em vez de reportar uma mudança de estado que não aconteceu de forma durável.

Fluxograma vertical com quatro decisões em losango — agent, pinned, no-change, put — levando a skips tipados ou a transitioned, e um relatório CuratorReport à direita

Fig. 7 — Os dois portões e o relatório honesto: cada decisão desvia para um skipped tipado ou avança para transitioned; put falho é fail-closed (gerada à parte).

Portão de proveniência. createdBy !== 'agent'skipped: not-agent-created. Skills do usuário, empacotadas ou do hub nunca são tocadas pelo curador. Ele só poda o que o próprio agente fabricou.
Portão de pin. record.pinnedskipped: pinned. Um pin é um "não mexa nisso" explícito que vence toda transição, mesmo de uma skill de agente há muito ociosa.
CondiçãoO que aconteceMotivo no relatório
createdBy !== 'agent'pulanot-agent-created
pinned === truepulapinned
nextState = estado atualpulano-change
transição válida + put okaplicaentra em transitioned[]
transição válida + put falhaabortaretorna err (fail-closed)
Exemplo resolvido — uma passada do curador sobre 3 skills
1
buscar-cep · createdBy:'user', ociosa há 1 ano → portão de proveniência dispara. Resultado: skipped: not-agent-created. Nada muda.
2
resumir-pr · createdBy:'agent', pinned:true, ociosa há 200 dias → passa pela proveniência mas o portão de pin dispara. Resultado: skipped: pinned.
3
migrar-schema · createdBy:'agent', não-pinned, active, ociosa há 95 dias → nextState = archived; put ok. Resultado: entra em transitioned[] como {from:'active', to:'archived'}.
4
Agora você: uma 4ª skill gerar-nota é agent, não-pinned, stale, e foi usada ontem. Em que lista ela cai, e como? (Dica: ramo 3 de nextState.)

Resposta da "agora você": transitioned[] como {from:'stale', to:'active'} — reativação, porque anchor > staleCutoff e o estado era stale.

06

O sidecar de telemetria — leituras best-effort, escritas atômicas

O UsageStore registra eventos incrementando um contador e carimbando lastActivityAt = clock(). Ele carrega a mesma assimetria leitura/escrita do store de memória — um sidecar corrompido lê como vazio (nunca quebra um hot path), mas uma escrita falha aparece como err:

packages/hermes/src/curator/usage-store.ts:153–166 — load best-effort
private async load(): Promise<UsageSidecar> {
  const stat = await tryCatchAsync(() => this.fs.stat(this.sidecarPath));
  if (!stat.ok || !stat.value) return {};        // ausente ⇒ vazio
  const read = await tryCatchAsync(() => this.fs.readText(this.sidecarPath));
  if (!read.ok) return {};                         // erro de IO ⇒ vazio
  const parsed = tryParseJson(read.value);
  if (!parsed.ok) return {};                       // JSON ruim ⇒ vazio
  const valid = usageSidecarSchema.safeParse(parsed.value);
  if (!valid.success) return {};                   // forma errada ⇒ vazio
  return valid.data;
}

Quatro modos de falha — ausente, erro de IO, JSON malformado, forma errada — todos colapsam para um mapa vazio {}. O objetivo é que um arquivo de telemetria quebrado nunca quebre a chamada da skill anfitriã. A telemetria é um sidecar: secundária por design.

A escrita (save, linhas 168–206) é serializada com chaves ordenadas + indentação de 2 espaços, espelhando json.dump(..., sort_keys=True, indent=2) do Python. Isso torna as escritas byte-estáveis e amigáveis a diff. E só uma falha explícita de escrita é propagada como err — leitura tolera, escrita não.

Um relógio, dois leitores. O mesmo Clock é injetado no UsageStore e no runCurator (curator.ts:53–59 nomeia isso explicitamente). Então "um evento registrado agora" e "uma transição decidida agora" concordam — não há skew entre quando a atividade foi carimbada e quando a ociosidade é julgada.
07

Confusões comuns

Mito 1 "Archived significa deletado." Não — archive é recuperável e é o estado terminal por invariante ("ação máxima = arquivar"). O core portável nunca remove nada; o movimento para o diretório .archive/ é uma preocupação de transporte fora de escopo. Um caminho de reativação até traz uma skill stale de volta para active se ela for usada de novo antes da linha de archive.
Mito 2 "Um sidecar corrompido vai derrubar as chamadas de skill." Não — as leituras são best-effort: um sidecar ausente, ilegível, com JSON malformado ou de forma errada, todos colapsam para um mapa vazio. O ponto inteiro é que um arquivo de telemetria quebrado nunca quebre a chamada da skill anfitriã. Só uma falha de escrita explícita aparece como err.
Mito 3 "A ordem dos ramos é só estilo." Não — inverter "archive primeiro" para "stale primeiro" muda o resultado: uma skill active muito velha daria só um passo (para stale) em vez de pular para archived. É correção, não performance.
retrieval
Por que lastActivityAt = 0 não arquiva uma skill nova?
clique para virar
Porque nextState ancora um registro nunca-ativo a Number.POSITIVE_INFINITY — mais novo que qualquer corte. Criação não é atividade.
retrieval
O que o curador faz com uma skill createdBy:'user'?
clique para virar
Pula com skipped: not-agent-created. Só skills de agente são geridas — e nada é nunca deletado.
retrieval
Por que put falho aborta a passada?
clique para virar
Fail-closed: não se reporta uma transição que não foi salva de forma durável. A passada retorna o err.
retrieval
O que torna o sidecar byte-estável?
clique para virar
Serialização com chaves ordenadas + indentação de 2 espaços, espelhando json.dump(sort_keys=True, indent=2).
08

Recapitulando

Cinco cartas que resumem a metade de descarte do loop. Use as setas do teclado ou os botões.

Carta 1 · A função

O curador descarta; o store autora

O SkillStore cria memória procedural; o runCurator poda o que cai em desuso. Juntos, a memória cresce e encolhe — é o que a torna sustentável.

SkillStoreautora ↑ runCuratordescarta ↓
1

Carta 2 · Os estados

active → stale → archived

Ociosa ≥ 30d vira stale; ≥ 90d vira archived (terminal). Usada de novo enquanto stalereativa. Nada é nunca deletado.

active stale archived 30d 90d
2

Carta 3 · A ordem

Archive primeiro é load-bearing

A cascata de nextState checa archive antes de stale, para que uma active muito velha pule direto para archived. Inverter = bug, não lentidão.

1 · ≤ archiveCutoff?archived 2 · ≤ staleCutoff?stale a 1ª verdadeira ganha
3

Carta 4 · Os portões

Proveniência + pin, skips honestos

Só skills createdBy:'agent' e não-pinned transicionam. Os skips entram em skipped[] com motivo tipado — reportados, nunca escondidos.

agent?proveniência pinned?pin transição não → skip sim → skip
4

Carta 5 · A assimetria

Lê tolerante, escreve fail-closed

Sidecar corrompido lê como {} (4 modos de falha); só uma escrita falha vira err. Um Clock injetado alimenta store e curador — zero skew.

read → {} write → err tolera 4 falhas fail-closed Clock
5
Carta 1 de 5 use
Em uma frase: o curador é a passada determinística que arquiva (nunca deleta) skills de agente há muito ociosas, com a ordem dos ramos como correção e o tempo como um Clock injetado.
09

Verifique

Revisão pontuada

Três questões. A pontuação corre conforme você responde.

1. Uma skill tem createdBy: 'user' e está sem toque há um ano. O que runCurator faz?
Correta: c. O portão de proveniência toca só skills autoradas pelo agente; skills do usuário/empacotadas/do hub são reportadas em skipped com not-agent-created. (E nada é nunca deletado — archive é o estado terminal.)
2. Uma skill de agente recém-criada tem lastActivityAt = 0. Na primeira passada do curador ela fica…
Correta: b. Criação não é atividade, então lastActivityAt fica 0; nextState ancora isso a Number.POSITIVE_INFINITY para que a skill não seja stale nem archived até ter sido genuinamente usada e depois ficado ociosa.
3. Por que a ordem dos ramos em nextState (archive → stale → reativar) é load-bearing?
Correta: d. Se stale fosse checado primeiro, uma active velha só daria um passo para stale nesta passada em vez de pular para archived. O teste "jumps active → archived directly" guarda a ordem, espelhando apply_automatic_transitions.
Acertos: 0/3
As cinco coisas para levar
  1. O curador é a metade de descarte do loop de skills — o complemento do SkillStore.
  2. Três estados, duas linhas de corte: active → (30d) → stale → (90d) → archived.
  3. Nunca deleta: archive é terminal e recuperável; reativação traz stale de volta.
  4. A ordem dos ramos (archive → stale → reativar) é correção, não estilo; a âncora nunca-ativa é +Infinity.
  5. Lê tolerante, escreve fail-closed; um Clock injetado dá zero skew entre carimbo e julgamento.
Pergunta para levar adiante. Se o veredito humano de aprovação (Lição 18) realimentasse o UsageStore — uma skill aprovada conta como "usada" —, o curador pararia de arquivar boas ferramentas raramente usadas? Esse é exatamente o tipo de costura entre subsistemas que a Parte 4 (método de fusão) ensina a desenhar.