Mais de metade daquele state.db eram cópias de mensagens que o prune já tinha removido


A ventoinha do portátil começou a ligar-se em trabalho idle. Os backups demoram mais a cada semana. O hermes sessions stats reporta uma base de dados com vários gigabytes — mas a sua lista de sessions não parece ter vários gigabytes. A maioria das conversas tem uma dúzia de turnos, e o histórico delas foi comprimido há muito. Então para onde foi o espaço?

A resposta é um pouco contra-intuitiva: foi para as mensagens antigas que o prune já tinha removido. Os corpos completos delas foram copiados para gémeos “arquivados”, vezes sem conta. Numa instalação com o prune opcional ligado, um único profile acumulou 31.930 dessas cópias; um segundo profile tinha 48.737 — 55% de todas as suas linhas.

O que o prune faz na realidade

O Hermes tem uma funcionalidade opt-in chamada prune proativo (compression.proactive_prune_tokens, desligada por padrão). É deliberadamente modesto: quando o histórico reenviado passa do seu limite de tokens, substitui tool results antigos e volumosos — dumps de terminal, leituras de ficheiros, web extracts — por resumos curtos. Sem chamadas ao modelo.

A parte importante: um prune edita o corpo de algumas mensagens que já existem. Nunca acrescenta uma mensagem, remove uma ou muda-lhes a ordem. Do ponto de vista dos dados, é uma edição local.

O caminho de commit antigo não concordava. Cada commit passava pelo escritor completo de arquivar-e-comprimir: arquivar todas as linhas ativas, depois reinserir a lista toda.

Muda três mensagens; a base de dados ganha uma cópia do transcript inteiro.

Uma ou duas vezes não é nada. Mas o prune dispara repetidamente numa session longa, e as cópias acumulam-se: cada uma é “o histórico completo como estava naquele momento”, e a maior parte desse conteúdo torna-se matéria-prima da cópia seguinte. É por isso que o ficheiro cresce muito mais depressa do que as suas conversas.

Os números medidos

Não são estimativas — vêm da issue de tracking:

Observação Valor
Cópias expiradas, primeiro profile 31.930 linhas
Cópias expiradas, segundo profile 48.737 linhas
Fatia das linhas desse profile 55%
Gatilho prune proativo ligado (config de exemplo proactive_prune_tokens: 12000)

Metade daquela base de dados não fazia nada além de tornar mais lentas as leituras, as escritas, os backups e o índice full-text.

O que mudou: reescrever no lugar

A nova primitiva de commit é SessionDB.rewrite_pruned_rows(session_id, changes, model_config_patch=...) e corre numa única transação:

  1. Nomear a linha. Resolve primeiro por _row_id; senão por tool_call_id (tool results) ou pela lista de ids de tool call que um turno de assistant transporta — único dentro de uma session; senão apenas pela única linha ativa com o mesmo role e o mesmo conteúdo guardado.
  2. Provar que ainda tem o original. Mesmo role, mesmo tool call id, mesmos call ids e, para linhas de tool, o mesmo corpo guardado. Qualquer divergência aborta o caminho no lugar.
  3. Deixar um gémeo arquivado do seu eu anterior ao prune (active=0, compacted=1), para que o corpo completo despromovido continue pesquisável exatamente como antes. Compactar continua a não significar perda de dados.
  4. Assumir as colunas alteradas sob o seu próprio id e fundir o patch de rearme no model_config.

Linhas inalteradas, ordem do modelo, row ids e linhas acrescentadas em paralelo nunca são tocadas. Os triggers existentes messages_fts_update e messages_display_identity_update mantêm o full-text e os índices de display consistentes, e o message_count não muda porque nenhuma linha entra ou sai do conjunto ativo.

Um detalhe em que vale a pena confiar: quando a linha não pode ser nomeada com exatidão — _absorbed_row_ids fundidos, zero ou várias correspondências, uma linha inativa, uma linha de user, um corpo que já mudou — ele não adivinha. Lança PruneRowUnresolvedError antes de qualquer escrita, regista-o em INFO e recai no caminho antigo de arquivar-e-comprimir. Mais lento, mas correto.

Usar isto, e verificar a sua própria base

1. Descobrir se o prune está ligado.

hermes config get compression.proactive_prune_tokens

0 (ou “Config key not set”) significa desligado, e este mecanismo não é o que fez crescer a sua base de dados. Para o ligar, as três chaves vivem em compression::

compression:
  proactive_prune_tokens: 48000        # 0 = off; 48000 is a reasonable start
  proactive_prune_min_result_chars: 8000   # results smaller than this are left alone
  proactive_prune_min_reclaim_tokens: 4096 # do not commit unless it reclaims this much

2. Medir a base.

hermes sessions stats
du -sh ~/.hermes/state.db

(Profiles não padrão guardam os dados no seu próprio diretório de profile.)

3. Recuperar espaço — duas operações diferentes, não as confunda.

hermes sessions optimize          # merge FTS5 segments + VACUUM; no session data changes
hermes sessions optimize-storage  # migrate the full-text index to the compact v23 layout

O optimize recupera páginas livres; o optimize-storage muda o layout do índice e pode recuperar uma fração grande em bases de dados grandes. Nenhum deles apaga cópias antigas por si — o que arquivar ou apagar continua a ser a sua decisão de retenção. A correção impede a criação de novas cópias; não limpa as que já lá estão. As linhas existentes ficam até arquivar ou apagar sessions ao seu próprio ritmo.

Uma nota de estado honesta: a alteração foi mesclada na main a 18 de outubro de 2026, depois de a v0.22.2 ter saído a 15 de outubro, por isso a release atual não a inclui. Os novos prunes começam a usar o caminho no lugar depois do seu próximo hermes update.

Porque é que este tipo de crescimento fica invisível

Três razões que se somam.

  • É um subproduto de algo correto. Arquivar-e-reinserir garantia que cada commit era um snapshot completo e consistente. Não havia bug de lógica — o custo era apenas debitado no disco.
  • As métricas não o mostram. Contagem de sessions igual, contagem de mensagens igual, UI bem. Só o tamanho do ficheiro e o I/O degradam devagar.
  • Escala com o uso. Quanto mais sessions longas e outputs de tool volumosos correr, mais depressa cresce — e os utilizadores intensivos são os que menos provavelmente reparam, porque as máquinas deles estão ocupadas de qualquer forma.

Isto não é corrupção da base de dados, e as duas coisas não devem ser confundidas: um schema partido ou uma session desaparecida tem o seu próprio caminho de reparação, coberto em recuperar um state.db danificado. Aqui os dados estavam perfeitamente saudáveis — havia simplesmente demasiados.

A lição geral

“Atualizar no lugar” e “reescrever tudo” são funcionalmente equivalentes e completamente diferentes em custo. Muitos sistemas escolhem a reescrita porque é simples e não pode deixar um estado a meio; o preço é copiar todos os dados em cada pequena edição. Meta isso num hot path — a cada poucos turnos — e o preço acumula-se mais depressa do que esperaria.

O teste é direto: se mudou uma linha, devia estar a escrever N? Se não, esse caminho acaba por se tornar um problema de capacidade. A correção raramente precisa de um motor de armazenamento novo; normalmente só precisa que o chamador reconheça uma atualização no lugar — aqui, nomeando a linha exata pela sua identidade de tool call e verificando que nada mais se mexeu.

Para controlar o contexto e o tamanho do histórico com mais intenção, há dois artigos relacionados que vão mais fundo: a estratégia de compressão lean-tail cobre o que sobrevive a uma compactação, e reconstruir o prompt depois da compactação cobre o que o modelo vê a seguir. Se gere sessions a partir de uma shell, a referência do hermes sessions lista todos os subcomandos e filtros.

Resumo

  • Sintoma: state.db muito maior do que o volume das suas conversas; backups lentos, I/O pesado, contagens de linhas estranhas no hermes sessions stats.
  • Causa: cada commit do prune proativo arquivava todas as linhas ativas e reinseria a lista toda — 48.737 cópias substituídas num profile, 55% das suas linhas.
  • Correção: rewrite_pruned_rows() atualiza apenas as colunas alteradas, mantém um gémeo arquivado pesquisável e recai em segurança quando uma linha não pode ser nomeada com exatidão.
  • Hoje: veja o interruptor com hermes config get compression.proactive_prune_tokens, meça a base com hermes sessions stats, recupere com hermes sessions optimize e atualize para ter o novo caminho de commit.