UsageStore + runCurator — a metade da disposição
O ciclo fechado tem duas metades: o SkillStore autora memória procedural, e o curador dispõe do que fica sem uso. UsageStore é um sidecar de telemetria que conta use/view/patch de skills; runCurator é um passo determinístico que move skills de agente há muito ociosas active → stale → archived. Dois invariantes o definem: nunca deletar (archive é o estado terminal) e TEMPO é um Clock injetado — nunca Date.now(). Um CLONE do skill_usage.py + curator.py do Hermes.
Esta lição destila esses arquivos e o docs/hermes-complete-map.md §3.3 + §1.9/§1.10. Todo número e toda linha de código têm fonte (rodapé). Por que importa pra missão: sem disposição, a memória procedural do agente só cresce — o curador é o que mantém o conjunto de skills enxuto e auditável, sem nunca destruir nada.
- Explicar por que o loop precisa de uma metade de disposição e como o curador a cumpre sem deletar.
- Percorrer o ciclo
active → stale → archivede o caminho de reativação de cabeça. - Defender por que a âncora
Number.POSITIVE_INFINITYe a ordem dos ramos (archive → stale → reativar) são estruturais. - Distinguir os dois portões (proveniência e
pinned) e a assimetria leitura-best-effort/escrita-fail-closed do sidecar.
01 · As duas metades do loop
Pense numa oficina: alguém fabrica ferramentas novas conforme a necessidade aparece, e alguém arruma a bancada, recolhendo o que ninguém pega há meses. Se só houvesse o fabricante, a bancada viraria um caos em semanas. O ciclo fechado de skills do Alembic tem exatamente esses dois papéis — e esta lição é sobre o arrumador.
Dois pedaços, dois deveres distintos: o UsageStore é puro registro — toda vez que uma skill é usada, vista ou corrigida, ele incrementa um contador e carimba o horário. O runCurator nunca observa o vivo; ele só lê esse registro frio e decide, por skill, se ela já ficou ociosa o bastante para mudar de estado. Nenhum dos dois jamais apaga uma skill — esse é o invariante central, e nós voltamos a ele na seção 10.
runCurator a coloca — e o que acontece com o conteúdo dela?
archived — e o conteúdo continua intacto. 120d > 90d, então a âncora de inatividade cruza o corte de archive; como esse ramo vence primeiro (seção 04), a skill vai direto para archived mesmo que estivesse active. Mas nada é deletado: archive é o estado terminal e recuperável por invariante ("max action = archive"). Se você esperava "deletada para liberar espaço", esse é justamente o erro que o subsistema foi desenhado para nunca cometer.02 · O ciclo de vida
Toda skill gerida pelo curador vive numa máquina de estados de três posições. Ela nasce active, escorrega para stale se ficar ociosa além do corte de stale, e cai em archived se ficar ociosa além do corte de archive. Duas setas extras tornam a máquina honesta: uma skill stale usada de novo reativa, e uma active velha o bastante pula stale e vai direto para archived.
stale é uma zona de carência. Uma skill que caiu de moda por um mês pode voltar à moda — e o curador a traz de volta para active de graça se ela for usada antes do corte de archive. Só depois de 90 dias inteiros de silêncio o subsistema desiste e move para archived. Sem o degrau stale, não haveria janela de reativação.As setas extras, em palavras simples
stale, a skill volta a active. É a recompensa por voltar a ser útil.active está parada há mais que o corte de archive, ela não dá o passinho para stale — vai direto para archived. Faz sentido: já passou muito do ponto de carência.As três faixas de ociosidade, numa linha do tempo
Os dois cortes recortam a linha do tempo de inatividade em três faixas. Onde a âncora cai decide o estado:
03 · A âncora +Infinity — o detalhe que evita um arquivamento absurdo
Aqui mora a parte mais sutil do subsistema. A decisão de estado é uma função pura sobre uma âncora de inatividade (o último horário de atividade) e os dois cortes. Mas e uma skill que nunca foi usada? Ela tem lastActivityAt = 0, porque criar não conta como atividade. Se 0 fosse usado literalmente como âncora, ele seria ≤ todo corte — e a skill arquivaria no primeiro passo do curador. Absurdo: ela acabou de nascer.
A correção é de uma linha: um registro nunca-ativo é ancorado a Number.POSITIVE_INFINITY — mais novo que qualquer corte concebível — garantindo que ele não fique nem stale nem archived até ter sido de fato usado e depois ficado ocioso. Veja a função inteira:
// packages/hermes/src/curator/curator.ts:123-136 — nextState const nextState = (record, staleCutoff, archiveCutoff): SkillState => { const anchor = record.lastActivityAt > 0 ? record.lastActivityAt : Number.POSITIVE_INFINITY; // nunca-ativo ⇒ nunca velho 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; };
O comentário do cabeçalho fraseia isto como "tratado como agora"; a implementação usa +Infinity. Os dois têm o mesmo efeito — manter a skill recém-criada longe de qualquer corte — mas +Infinity é independente da ordem: não importa qual valor de "agora" você usaria, +∞ é sempre maior. O teste "never archives a brand-new, never-active skill (lastActivityAt = 0)" prova exatamente este comportamento.
04 · A ordem dos ramos é estrutural — archive primeiro
Repare nos três if de nextState: eles são testados em ordem fixa — archive, depois stale, depois reativar. Essa ordem não é estética; ela decide o resultado para uma skill active muito velha. Se o teste de stale viesse primeiro, uma active parada há 120 dias só daria o passinho para stale neste passo — em vez de pular direto para archived, como deve.
Para a skill active de 120 dias, o ramo ① (archive) já é verdadeiro, então a função retorna 'archived' e nunca chega ao ramo ② (stale). Se a ordem fosse invertida, o ramo de stale capturaria a skill primeiro e o resultado seria 'stale' — errado. O teste "jumps active → archived directly when old enough (skips stale)" protege essa ordem, espelhando o apply_automatic_transitions do Hermes exatamente.
Simples vs Técnico
stale para quem passou pouco do prazo." A ordem dos if codifica exatamente essa prioridade — o caso mais severo (archive) é checado antes do mais brando (stale).active com anchor ≤ archiveCutoff também satisfaz anchor ≤ staleCutoff (pois archiveCutoff < staleCutoff na linha do tempo de "instante de corte"). A desambiguação é puramente posicional: o primeiro if que casa retorna. Por isso a sequência archive → stale → reativar é parte do contrato, e não um detalhe de estilo.05 · Rode nextState na mão (passo a passo → agora você)
Você viu o fluxograma. Agora execute a função na mão para um caso, devagar — depois um é seu. Recuperar o procedimento fixa mais que ver o retorno pronto.
lastActivityAt > 0 (já foi usada), então anchor = lastActivityAt — não cai no caso +Infinity. Em termos de inatividade, ela está parada há 45 dias.anchor ≤ archiveCutoff é falso. Pula.current === 'active', mas o estado atual é stale ⇒ falso. Pula. (Mesmo que 45d > 30d, o ramo só promove quem está active.)anchor > staleCutoff (atividade recente) e current === 'stale'. Aqui a âncora é mais antiga que o corte de stale (segue ociosa), então anchor > staleCutoff é falso. Pula.return current ⇒ permanece stale. No runCurator isso vira um skip com a razão no-change (seção 06).stale que foi usada ontem (atividade muito recente). Qual ramo casa e qual o retorno? Faça antes de revelar.
anchor > staleCutoff é verdadeiro, e current === 'stale' também ⇒ retorna 'active' — a skill reativa. Ramos ① e ② falham (a âncora recente não é ≤ corte algum). Dica: o procedimento é sempre o mesmo — testar os três if em ordem; o primeiro que casa decide.06 · Os dois portões: proveniência e pin
Antes de sequer chamar nextState, o runCurator aplica dois opt-outs ortogonais. Só skills autoradas pelo agente são geridas pelo curador (proveniência), e uma skill pinned é isenta em todo caminho. Os dois são portões de guarda: quem não passa é registrado como skip, com uma razão tipada — e nunca silenciosamente ignorado.
// 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 }); }
CuratorReport carrega tanto transitioned[] quanto skipped[] (cada um com razão tipada: pinned / not-agent-created / no-change), ambos em ordem estável de nome. E a persistência é por-transição: se um put falha, o passo aborta com aquele err em vez de reportar uma mudança de estado que não aconteceu duravelmente. É o mesmo princípio fail-closed do resto do harness.O relatório, em duas listas
07 · O sidecar de telemetria — leituras best-effort, escritas atômicas
Agora a outra metade do par: o UsageStore. Ele registra eventos incrementando um contador e carimbando lastActivityAt = clock(). O ponto fino é a assimetria leitura/escrita, herdada do memory store: um sidecar corrompido lê como vazio (nunca quebra um caminho quente), mas uma escrita que falha emerge como err. Telemetria quebrada nunca pode derrubar a chamada de skill do host.
// 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; }
{}. Só o quinto caso (tudo válido) devolve dados reais. É essa convergência que garante: nenhuma falha de leitura jamais propaga uma exceção para a chamada de skill.O lado da escrita: record() incrementa e carimba
Quando uma skill é usada, vista ou corrigida, o record faz duas coisas simples sobre o registro: bate o contador do evento e carimba o horário com o clock() injetado. É esse carimbo que vira a âncora lida pelo curador depois.
A assimetria, lado a lado
É a mesma assimetria do memory store, e vale tornar explícita: leitura e escrita tratam o erro de formas opostas — de propósito.
08 · Um clock, dois leitores — e a escrita byte-estável
O determinismo do subsistema depende de um detalhe de fiação: o mesmo Clock é injetado no UsageStore e no runCurator (curator.ts:53-59 diz isto explicitamente). Assim, "um evento registrado agora" e "uma transição decidida agora" concordam — não há skew entre quando a atividade foi carimbada e quando a obsolescência é julgada.
A escrita do sidecar fecha o ciclo de determinismo: ela é serializada com chaves ordenadas + indent de 2 espaços, espelhando o json.dump(..., sort_keys=True, indent=2) do Python. O resultado é byte-estável e amigável a diff — rodar o curador duas vezes sobre o mesmo estado produz exatamente o mesmo arquivo.
09 · A linha do tempo — arraste a inatividade
Junte tudo numa única alavanca. Arraste os dias ociosos de uma skill active de agente e veja a âncora cruzar os cortes: até 30d fica active, entre 30 e 90 vira stale, e a partir de 90 cai em archived (terminal). O ponto na régua é a âncora; a etiqueta mostra o estado que nextState retornaria.
+Infinity, que cairia infinitamente à esquerda da régua (o instante mais novo possível), e por isso permanece active. É o mesmo mecanismo da seção 03, agora visível na alavanca.10 · Archive vs delete — o invariante "nunca deletar"
Toda a lição converge para um invariante: o curador nunca deleta. archived é o estado terminal — recuperável, auditável, presente no disco. Compare as duas filosofias lado a lado; a diferença é a coluna inteira da reversibilidade:
.archive/ é uma preocupação de transporte fora de escopo. Um caminho de reativação até traz uma skill stale de volta a active se ela for usada de novo antes do corte de archive.err.O CLONE: de onde isto veio no Hermes
11 · Como isso se encaixa
Recue um passo e olhe a máquina inteira. O curador não é uma engrenagem solta: ele é a metade de disposição do loop fechado de aprendizado do Alembic. Cada turno de agente que dá certo pode autorar uma skill procedural; sem nada que retire o ocioso, o conjunto de skills só inflaria. O curador é o contrapeso — lê a telemetria fria do UsageStore e move o esquecido active → stale → archived, sem nunca destruir. É o que mantém a memória procedural enxuta e auditável ao longo de muitas sessões.
As peças que tocam esta — e por que conectam
Lição 04 · O loop fechado — o curador é literalmente a metade de disposição desse loop; sem ele, a metade de autoria cresce sem limite.
Lição 07 · MemoryStore — o UsageStore herda a mesma assimetria leitura-best-effort / escrita-fail-closed e o append-only/never-delete do memory store.
Lição 08 · reviewAndLearn — é o passe que autora a skill procedural que o curador depois governa; um produz, o outro dispõe.
Lição 12 · SkillStore — a outra metade do par desta lição: o SkillStore escreve as skills createdBy:'agent' que o portão de proveniência do curador filtra.
Lição 28 · Determinismo & replay — o Clock injetado e o save de chaves ordenadas que tornam o curador idempotente são o mesmo princípio de determinismo replicável.
12 · Na prática
O curador é um subsistema interno do @alembic/hermes — ele ainda não tem um verbo alembic de primeira classe próprio. Mas você pode tocar com as mãos os mesmos invariantes que esta lição ensina por dois caminhos reais: inspecionar a memória procedural (skills são memória procedural — a categoria exata que o curador governa) pela CLI, e rodar a suíte que exercita o runCurator de ponta a ponta. Os dois compartilham a fiação do pacote: Clock injetado, append-only, nada deletado.
Inspecionar a memória procedural que o curador governa
alembic memory é o verbo real sobre os multi-stores do @alembic/hermes (episodic | semantic | procedural | decision | transcript). A skill é memória procedural; listar esse store mostra os registros append-only com o carimbo de tempo (vindo do mesmo tipo de Clock que o curador usa para a âncora). Repare: list nunca remove nada — é a face de leitura do mesmo invariante "nunca deletar".
# lista os registros do store procedural (newest-first), offline e hermético $ alembic memory procedural list # saída esperada (vazio numa máquina nova; o caminho é sempre impresso): # memory procedural: 0 record(s) under ~/.alembic/memory/procedural.jsonl # grave um registro de exemplo (carimbado pelo Clock injetado + id-factory): $ alembic memory procedural add --record '{"content":"prefira early-return em guards"}' # memory procedural: appended mem_… (agent cli) # liste de novo — o registro aparece com id, agente e horário do Clock: $ alembic memory procedural list # memory procedural: 1 record(s) under ~/.alembic/memory/procedural.jsonl # mem_… [agent cli] at 2025-01-01T00:00:00.000Z
[uncertain] o caminho exato do --dir padrão depende da resolução de dataDir da sua máquina; o store usa <dataDir>/memory/<substore>.jsonl (passe --dir <path> para fixar). O timestamp acima é ilustrativo — ele vem do Clock injetado, não de Date.now().
Rodar o curador de ponta a ponta (a suíte real)
Para ver o runCurator e o UsageStore de verdade — as transições active → stale → archived, a âncora +Infinity, os dois portões, o fail-closed na escrita — rode a suíte do pacote. São os 21 casos de curator.test.ts que esta lição destilou; eles exercitam cada ramo de nextState com um Clock falso (tempo controlado, zero Date.now()).
# roda só os testes do pacote @alembic/hermes (curador + memory + clarify + …) $ pnpm --filter @alembic/hermes test # saída esperada (recorte): # ✓ src/curator/curator.test.ts (21 tests) # ✓ never archives a brand-new, never-active skill (lastActivityAt = 0) # ✓ jumps active → archived directly when old enough (skips stale) # ✓ reactivates a stale skill used again before the archive cutoff # ✓ skips a pinned skill / skips a non-agent-created skill
cd /Users/acf/Documents/Projects/appfy/alembic e garanta o baseline verde uma vez: pnpm -r typecheck && pnpm -r build && pnpm -w test.alembic memory procedural list. Olhe a linha … record(s) under …: é o caminho do JSONL append-only. Em máquina nova vem 0 record(s) — e nada foi deletado para chegar a esse zero.alembic memory procedural add --record '{"content":"…"}' e depois list de novo. Procure o id novo, o agent e o at — o carimbo veio do Clock, não do relógio do SO.pnpm --filter @alembic/hermes test. Na saída, ache o arquivo src/curator/curator.test.ts e os nomes de caso citados acima — cada um é uma transição (ou um portão) desta lição provada na fronteira real.archived, o terminal). É o invariante central, agora visível na sua máquina.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
Number.POSITIVE_INFINITY. lastActivityAt=0 (criação não é atividade); +∞ > todo corte ⇒ a skill nova não fica stale/archived até ser usada e ficar ociosa.active ociosa além do corte de archive pular stale e ir direto a archived. O 1º if que casa vence; ramos não são exclusivos.createdBy === 'agent') e pinned. Quem não passa vira skip com razão tipada: not-agent-created / pinned / no-change.{} (best-effort, nunca lança). Na escrita: a falha emerge como err (fail-closed). Telemetria nunca quebra a chamada de skill.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.
createdBy: 'user' e não foi tocada em um ano. O que runCurator faz?not-agent-created, antes de qualquer cálculo de estado. a ignora o portão — a ociosidade nem chega a ser avaliada. b viola o invariante central: nada é jamais deletado, archive é o terminal. d também pressupõe que nextState rodou, mas o portão corta antes — e mesmo se rodasse, um ano levaria a archived, não stale.lastActivityAt = 0. No primeiro passo do curador ela é…lastActivityAt fica 0; nextState ancora isso a Number.POSITIVE_INFINITY para que a skill não fique nem stale nem archived até ter sido genuinamente usada e depois ficado ociosa. a descreve exatamente o bug que a âncora evita — usar 0 literalmente arquivaria a skill recém-nascida. c falha pela mesma razão: nenhum ramo de obsolescência dispara com âncora +∞. d reincide no invariante quebrado: o subsistema não tem caminho de delete.nextState (archive → stale → reativar) é estrutural?active velha só daria um passo para stale neste passo em vez de pular para archived. O teste "jumps active → archived directly" protege a ordem, espelhando apply_automatic_transitions. a é falso porque os ramos não são exclusivos: uma âncora ≤ archiveCutoff também é ≤ staleCutoff, logo a posição decide. b inverte tudo — reativar primeiro nunca impediria o archive, mas confundiria os casos ociosos. c trata como otimização o que é, na verdade, uma diferença de resultado.put falha no meio de runCurator. O que acontece com o relatório?if (!persisted.ok) return persisted; — o passo aborta com o err antes de empurrar para transitioned[], então o relatório nunca afirma uma mudança que não aconteceu duravelmente. b é o anti-padrão exato que o código evita: reportar trabalho não salvo. c viola o invariante "nunca deletar". d descreveria um best-effort tolerante, mas o subsistema escolhe parar no primeiro erro de escrita para manter o relatório fiel ao disco.stale existe se o destino é archived?", "O que muda se eu não injetar o mesmo Clock nos dois?", "Como o .archive/ se relaciona com o estado archived?". É só dizer.