UsageStore + runCurator, a metade do descarteO 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.
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.
active → stale → archived com as duas linhas de corte (30d / 90d) e o caminho de reativação.nextState (archive → stale → reativar) é load-bearing.+Infinity para uma skill nunca-ativa (lastActivityAt = 0).UsageStore.Result<T, Error> em vez de lançar exceções (ok / err).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 é.
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.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.
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.
Clock é o ponto de verdade temporal compartilhado.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).
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.
active → archived (tracejado).DEFAULT_STALE_AFTER_MSDEFAULT_ARCHIVE_AFTER_MSactive / stale / archivedstale (30–90 dias) é a única janela em que usar a skill a reativa; passou de 90, arquiva.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.
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.
O painel é alimentado pela mesma lógica de nextState — nada é decorativo.
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:
// 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; };
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.
A âncora +∞ fica à direita de toda linha de corte; por isso uma skill nunca-ativa nunca dispara um corte.
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?
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.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.
nextState: lida de cima para baixo, a primeira condição verdadeira retorna. Por isso a ordem é load-bearing.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:
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.
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).
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.record.pinned → skipped: 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ção | O que acontece | Motivo no relatório |
|---|---|---|
createdBy !== 'agent' | pula | not-agent-created |
pinned === true | pula | pinned |
nextState = estado atual | pula | no-change |
transição válida + put ok | aplica | entra em transitioned[] |
transição válida + put falha | aborta | retorna err (fail-closed) |
buscar-cep · createdBy:'user', ociosa há 1 ano → portão de proveniência dispara. Resultado: skipped: not-agent-created. Nada muda.resumir-pr · createdBy:'agent', pinned:true, ociosa há 200 dias → passa pela proveniência mas o portão de pin dispara. Resultado: skipped: pinned.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'}.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.
transitioned[] o que o put realmente salvou; um put falho retorna err antes de qualquer report.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:
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.
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.err..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.
err.
active muito velha daria só um passo (para stale) em vez de pular para archived. É correção, não performance.
lastActivityAt = 0 não arquiva uma skill nova?nextState ancora um registro nunca-ativo a Number.POSITIVE_INFINITY — mais novo que qualquer corte. Criação não é atividade.createdBy:'user'?skipped: not-agent-created. Só skills de agente são geridas — e nada é nunca deletado.put falho aborta a passada?err.json.dump(sort_keys=True, indent=2).Cinco cartas que resumem a metade de descarte do loop. Use as setas do teclado ou os botões.
Clock injetado.Três questões. A pontuação corre conforme você responde.
createdBy: 'user' e está sem toque há um ano. O que runCurator faz?skipped com not-agent-created. (E nada é nunca deletado — archive é o estado terminal.)lastActivityAt = 0. Na primeira passada do curador ela fica…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.nextState (archive → stale → reativar) é load-bearing?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.SkillStore.active → (30d) → stale → (90d) → archived.stale de volta.+Infinity.Clock injetado dá zero skew entre carimbo e julgamento.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.