Lição 7 · Curso de Fusão · Mergulhos profundos · Memória profunda
Alembic × Hermes · O Curso de Fusão · Parte 2 — Mergulhos profundos

Memória profunda: o MemoryStore de snapshot congelado

Uma memória limitada e gravada em arquivo que o agente carrega entre sessões — dois stores (MEMORY.md para as notas do próprio agente, USER.md para o que ele sabe sobre você), editados por uma única operação memory que localiza entradas por uma substring curta e única. Seu truque definidor é o snapshot congelado: o que o system prompt vê é capturado uma vez no carregamento e nunca se move, mesmo enquanto escritas no meio da sessão chegam ao disco. É um CLONE fiel do tools/memory_tool.py do Hermes.

Leia primeiro (fonte primária)
packages/hermes/src/memory/memory-store.ts — o arquivo inteiro, lido verbatim

Tudo nesta lição sai de arquivo real do monorepo: a classe e as constantes em memory-store.ts, os enums em memory/schema.ts e os casos em memory-store.test.ts. A proveniência do CLONE está em docs/hermes-complete-map.md §3.2 e docs/alembic-hermes-fusion-matrix.md §2.1. Nada aqui é inventado — onde o número exato importa, ele foi conferido na fonte atual.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
O que você vai conseguir fazer
  • Explicar o que é o snapshot congelado e por que ele protege o cache de prefixo do prompt sem perder durabilidade.
  • Descrever como replace e remove localizam uma entrada por substring única — e quando isso falha fechado.
  • Calcular o orçamento de caracteres contando o ENTRY_DELIMITER entre as entradas.
  • Nomear a assimetria deliberada entre ler (devolve ok([])) e escrever (devolve err) — e justificá-la.
Suposições tolas (o que presumimos de você — bem pouco)
  • Você já viu nas lições anteriores o padrão Result<T, Error> do Alembic (um ok(valor) ou um err(erro), sem exceções).
  • Você sabe ler um trecho de TypeScript simples. Vamos traduzir cada linha que aparecer.
  • Você não precisa saber o que é KV cache nem cache de prefixo — a gente constrói esses termos aqui, do zero.
1

A grande ideia


Um agente sem memória recomeça do zero a cada conversa. Dar memória a ele parece simples — "é só escrever num arquivo". O difícil é dar memória sem quebrar o cache do modelo e sem mentir sobre o que foi salvo. O MemoryStore resolve os dois com um único truque e uma única assimetria.

São dois cadernos. O MEMORY.md guarda as notas que o próprio agente faz para si (decisões, fatos do projeto). O USER.md guarda o que o agente aprendeu sobre você (preferências, contexto pessoal). Uma só operação — memory — mexe nos dois, com três ações possíveis: adicionar, substituir e remover.

Pense como… um caderno de bolso que você consulta no começo do dia: você fotografa a página de manhã e leva a foto no bolso (o snapshot). Durante o dia você ainda anota coisas novas no caderno de verdade — mas a foto no bolso continua a mesma até você tirar outra foto amanhã. A analogia quebra num ponto: aqui a "foto" é o que entra no prompt do modelo, e mantê-la imóvel é o que faz o modelo continuar barato e rápido.

Por baixo do capô

O subsistema inteiro é uma classe (MemoryStore) mais algumas constantes, todas exportações nomeadas de packages/hermes/src/index.ts. É um CLONE fiel do tools/memory_tool.py do Hermes, reescrito do zero no estilo Alembic: Result em vez de exceções, união discriminada em vez de um saco de parâmetros tipados por string, e FsPort em vez de acesso direto ao disco (para ser testável).

O ponto de design que vale gravar: durabilidade (escrever no disco) e estabilidade do prompt (o que o modelo vê) são preocupações independentes, e o store as mantém separadas de propósito. Quase tudo nesta lição é uma consequência dessa separação.

dois cadernos, uma operação, três ações MEMORY.md notas do próprio agente limite 2200 caracteres · decisão de arquitetura · fato fixo do projeto USER.md o que sabe sobre você limite 1375 caracteres · prefere respostas curtas · fuso, idioma, contexto memory() add replace · remove o alvo (memory|user) e a ação são validados por Zod; o resto é união em tempo de compilação
Dois stores, uma operação memory, três ações — o subsistema inteiro cabe nesta figura.
CLONE fiel: reescrito do zero no estilo Alembic (não copiado linha a linha) Hermes (origem) tools/memory_tool.py exceções · params por string reescrito do zero Alembic (CLONE) memory-store.ts · Result união discriminada · FsPort
O CLONE preserva o comportamento da fonte, mas adota as convenções do Alembic: nada de exceções, nada de saco de parâmetros, IO testável via FsPort.
2

A API pública


O subsistema todo é uma classe e algumas constantes, todas exportações nomeadas de packages/hermes/src/index.ts:

ExportaçãoPapel
MemoryStorea classe — load() / renderSnapshot() / entries() / apply()
ENTRY_DELIMITER'\n§\n' — o delimitador de seção (o sinal §) entre entradas
DEFAULT_MEMORY_CHAR_LIMIT2200 — teto de caracteres do MEMORY.md
DEFAULT_USER_CHAR_LIMIT1375 — teto de caracteres do USER.md
MemoryOpa união discriminada de operações: add / replace / remove
MemoryOpOutcomeo resultado terminal: target, message, entryCount, usedChars, charLimit

A operação em si é uma união discriminada em tempo de compilação — não existe um saco de "params" tipado por string:

// packages/hermes/src/memory/memory-store.ts (MemoryOp)
export type MemoryOp =
  | { readonly action: 'add'; readonly content: string }
  | { readonly action: 'replace'; readonly oldText: string; readonly content: string }
  | { readonly action: 'remove'; readonly oldText: string };

Só os dois valores que cruzam uma fronteira sem tipo na fonte Python — target e action — são validados por Zod em tempo de execução (memory/schema.ts: z.enum(['memory','user']) e z.enum(['add','replace','remove'])). O payload da operação é uma união em tempo de compilação, então uma forma ilegal nem chega a ser escrita.

Por que isso importaUma união discriminada deixa o compilador fazer o trabalho de um validador: se você escrever {action:'remove', content:'x'} (faltando oldText, sobrando content), o código nem compila. O Zod fica só onde o compilador não alcança — na borda onde dados externos entram como strings cruas.
duas fronteiras de validação, dois mecanismos BORDA EXTERNA · Zod (runtime) target: memory|user action: add|replace|… strings cruas de fora → z.enum rejeita o inválido INTERIOR · compilador (compile-time) MemoryOp = união discriminada forma ilegal (campo errado) → não compila, nunca é escrita
Validação em duas camadas: o Zod guarda a borda; o compilador guarda o interior. Cada um onde é mais forte.
dedupe no load: [...new Set(entries)] — paridade com dict.fromkeys do Python "tier T2" "PT-BR" "tier T2" (dup) new Set "tier T2" "PT-BR" duplicata exata some; por isso add() nunca cria entradas repetidas.
O add() deduplica exato no carregamento — é por isso que, para testar o ramo de duplicatas, o teste precisa semeá-las direto no disco.
3

A invariante definidora: snapshot vs. estado vivo


O store guarda duas realidades paralelas. O load() lê o disco, deduplica e congela um snapshot; o apply() muta as entradas vivas e as persiste — mas nunca toca no snapshot.

Preveja antes de revelar

Um agente chama apply('memory', {action:'add', …}) no meio da sessão. Logo depois, o que renderSnapshot('memory') devolve?

Exatamente o que devolvia antes. O snapshot foi capturado uma única vez no load() e não muda no meio da sessão. A escrita é durável no disco e aparece em entries(), mas o bloco que está dentro do prompt só se atualiza no load() da próxima sessão.
Linha do tempo de uma sessão: o load() lê, deduplica e congela uma faixa larga rotulada snapshot embarcada no system prompt que nunca muda; abaixo, apply() #1 e apply() #2 escrevem no disco imediatamente; uma seta tracejada indica que snapshot e disco só reconciliam no próximo load().

O snapshot congelado fica imóvel no prompt durante toda a sessão; as escritas vão ao disco na hora. Eles só se reconciliam no próximo load().

a mesma ideia, como diagrama de fluxo
início da sessão load() ler · deduplicar · congelar snapshot (renderSnapshot) — embarcado no system prompt, NUNCA muta no meio da sessão estável a sessão inteira ⇒ o cache de prefixo do prompt se mantém apply() #1 apply() #2 → disco (durável já) as entradas vivas avançam a cada apply(); o snapshot congelado acima não — só reconciliam no PRÓXIMO load()
// packages/hermes/src/memory/memory-store.ts (load + renderSnapshot, condensado)
async load(): Promise<Result<void, Error>> {
  // … lê MEMORY.md + USER.md via FsPort …
  this.memoryEntries = dedupe(memory.value);   // paridade com dict.fromkeys do Python
  this.userEntries   = dedupe(user.value);
  this.snapshot = {                            // congelado UMA vez, aqui
    memory: this.renderBlock('memory', this.memoryEntries),
    user:   this.renderBlock('user',   this.userEntries),
  };
  return ok(undefined);
}
renderSnapshot(target: MemoryTarget): string | undefined {
  const block = this.snapshot[target];   // estado do load, não o vivo
  return block.length > 0 ? block : undefined;
}

Por que congelar?

Modelos de fronteira fazem cache do prefixo do prompt. Se o bloco de memória embarcado no system prompt mudasse toda vez que o agente faz uma nota, o prefixo mudaria e o cache seria invalidado pelo resto da sessão — mais lento e mais caro. Então as escritas são duráveis na hora (no disco), mas o snapshot dentro do prompt fica parado até o load() da próxima sessão.

O teste "snapshot reflects load-time disk state, not mid-session writes" prova exatamente isso: depois de um apply(), o renderSnapshot() é byte-a-byte idêntico ao de antes, mas o disco já contém a nova entrada. Durabilidade e estabilidade do prefixo são preocupações independentes, e o design as mantém separadas.

O que é "cache de prefixo"? O modelo guarda o trabalho já feito sobre o começo do prompt (o prefixo) para reusar na próxima chamada da mesma sessão. Se o começo não muda, ele reaproveita; se muda, joga fora e recalcula. Manter o snapshot imóvel é o que mantém esse reaproveitamento vivo.
4

Localizar por substring única — sem IDs


replace e remove não recebem um índice nem um id. Eles recebem um trecho curto e o store acha a única entrada que o contém. O matcher falha fechado na ambiguidade:

Fluxograma: uma lupa sobre três cartões de entrada de memória ramifica em três decisões — uma entrada casa, opera; várias entradas distintas casam, devolve err e nada muda; várias casam mas são duplicatas idênticas, seguro operar na primeira.

A regra de localização por substring: opera com um casamento, falha fechado em casamentos distintos, e trata duplicatas idênticas como seguras.

// packages/hermes/src/memory/memory-store.ts — locateUnique
const matches = entries.flatMap((entry, index) =>
  entry.includes(needle) ? [{ index, entry }] : [],
);
if (matches.length === 0) return err(new Error(`No entry matched '${needle}'.`));
if (matches.length > 1) {
  const distinct = new Set(matches.map((m) => m.entry));
  if (distinct.size > 1) {
    return err(new Error(`Multiple entries matched '${needle}'. Be more specific.`));
  }
  // Todas idênticas — seguro operar na primeira.
}

A sutileza: vários casamentos só são erro quando são entradas distintas. Se todos os casamentos são exatamente o mesmo texto (duplicatas verdadeiras), agir na primeira é seguro — a regra de fidelidade da fonte. Duplicatas não conseguem entrar via add() (ele deduplica), então o teste as semeia no disco e carrega para exercitar esse ramo.

add

Adicionar uma entrada

decisão: tier T2 por padrão prefere PT-BR nas respostas nova entrada (aparece no add)

Toque numa ação para ver quais entradas ela afeta — e o que ela devolve.

o que o matcher devolve para cada situação includes(needle)? 0 casamentos err: "No entry matched" 1 casamento opera ✓ vários DISTINTOS err: "Be more specific" vários IDÊNTICOS opera na primeira ✓
Quatro situações, quatro respostas. Só "vários distintos" falha fechado — as outras três operam ou são impossíveis de confundir.
Cuidado"Vários casamentos" não é sempre erro. Se as entradas casadas forem idênticas entre si, o store age na primeira — porque não há ambiguidade real sobre o que mudar. O erro só dispara quando os casamentos são textos diferentes.
5

O orçamento de caracteres — e uma assimetria deliberada


Os limites são contados em caracteres, não tokens, porque a contagem de caracteres é independente do modelo. E o crucial: o ENTRY_DELIMITER conta contra o orçamento — o store mede o comprimento já unido, exatamente o que vai para o disco:

// packages/hermes/src/memory/memory-store.ts — joinedLength
const joinedLength = (entries: readonly string[]): number =>
  entries.length === 0 ? 0 : entries.join(ENTRY_DELIMITER).length;
Exemplo resolvido — contando o orçamento
1
Duas entradas: 'aaa' (3 caracteres) e 'bbb' (3 caracteres).
2
Entre elas entra um ENTRY_DELIMITER = '\n§\n', que tem 3 caracteres (uma quebra de linha, o §, outra quebra de linha).
3
Total unido = 3 + 3 + 3 = 9 caracteres. Logo um limite de 9 passa e um de 8 falha.
4
Agora você: três entradas de 2 caracteres cada. Quantos caracteres no total? → 2 + 3 + 2 + 3 + 2 = 12 (dois delimitadores entre três entradas).
Entradas: 2 · cada uma com 3 caracteres · delimitadores: 1 × 3 = 3
Total unido = 9 caracteres · limite MEMORY.md = 2200 · uso: 0.4%

O teste "counts the delimiter against the budget" fixa isso: 'aaa' + '\n§\n' (3 caracteres) + 'bbb' = 9 caracteres, então um limite de 9 passa e 8 falha. Quando um add estouraria, o erro não é uma falha seca — ele diz ao agente para consolidar ("use 'replace' to merge … or 'remove' stale entries, then retry"), transformando um teto rígido num convite à curadoria.

o delimitador faz parte da conta — é o que vai para o disco aaa 3 caracteres \n§\n +3 (delimitador) bbb 3 caracteres = 9 limite 9 ✓ · 8 ✗
3 + 3 (delimitador) + 3 = 9. Por isso 9 passa e 8 falha: a conta é do que de fato chega ao disco.
estourar o teto não é beco sem saída — o erro vira um convite à curadoria add() estouraria joinedLength > limite err orienta: "use 'replace' to merge / 'remove'" replace/remove retry → ok
O teto rígido vira um loop de curadoria: consolidar entradas sobrepostas com replace ou apagar entradas velhas com remove, depois repetir o add.
6

A assimetria ler/escrever


A assimetria — ler é mais permissivo

Um arquivo corrompido ou ausente na leitura devolve ok([]) (um store vazio) — um caminho quente, parecido com telemetria, nunca pode quebrar o host. Mas uma escrita que falha devolve err — o chamador escolheu explicitamente persistir, então uma perda silenciosa seria uma mentira. A mesma assimetria reaparece no store de uso do curador; é uma postura de design deliberada e repetida, não um acidente.

// packages/hermes/src/memory/memory-store.ts — leitura tolerante
if (!exists.value) return ok([]);       // arquivo ausente → store vazio, nunca quebra
// … lê e divide em entradas …
return ok(splitEntries(read.value));
duas operações, duas posturas opostas diante da falha LER arquivo some ou corrompe → ok([]) · segue vivo caminho quente nunca derruba o host ESCREVER gravação falha → err · avisa em alto e bom som o chamador pediu para persistir; perder em silêncio seria mentir
Ler perdoa (a memória é um luxo, não pode quebrar o turno); escrever não perdoa (uma perda silenciosa seria uma mentira ao chamador).
Retrieval
Por que o snapshot é congelado no load()?
toque para virar
Para preservar o cache de prefixo do prompt: se o bloco de memória mudasse a cada nota, o prefixo mudaria e o cache seria invalidado pelo resto da sessão.
Retrieval
O que replace recebe para achar a entrada?
toque para virar
Uma substring curta e única (oldText), não um índice nem id. Se casar várias entradas distintas, devolve err e nada muda.
Retrieval
O ENTRY_DELIMITER conta no orçamento?
toque para virar
Sim. O orçamento mede o comprimento unido (join(ENTRY_DELIMITER)), porque é isso que vai ao disco. Dois textos de 3 = 9 caracteres.
Retrieval
Ler um arquivo corrompido devolve o quê?
toque para virar
ok([]) — um store vazio. Já uma escrita que falha devolve err. Assimetria deliberada: ler perdoa, escrever não.
7

Confusões comuns


"Snapshot congelado significa que as escritas se perdem." Não — as escritas vão ao disco imediatamente e aparecem em entries(). Só o snapshot dentro do prompt é congelado, e só até o próximo load(). Durabilidade e estabilidade do prefixo são preocupações independentes que o design mantém separadas.
"Um § na minha nota vai corromper o arquivo." Não — o store divide na sequência completa '\n§\n', não num § solto. Uma entrada cujo corpo contém um § isolado faz round-trip como uma só entrada; há um teste dedicado exatamente para isso.
"Os limites são em tokens." Não — são em caracteres, de propósito: a tokenização muda de modelo para modelo, mas a contagem de caracteres é estável. E inclui o delimitador, porque ele é parte do que de fato é escrito.
splitEntries divide só na sequência COMPLETA '\n§\n' — não num § solto nota A: "preço é R$ 9 §90" · nota B: "outra coisa" o § solto dentro da nota A NÃO é separador → A inteira ("…R$ 9 §90") = 1 entrada B = 1 entrada · total: 2, não 3
Um § isolado no corpo de uma nota faz round-trip intacto — há um teste dedicado para exatamente esse caso. Só '\n§\n' completo separa.

Resumo simples: o agente tem dois cadernos, mexe neles por trechos de texto (não por número de linha), respeita um limite contado em caracteres, e a "foto" que ele leva no prompt só atualiza no começo de cada sessão. Quando algo dá errado na leitura, ele segue vazio; quando dá errado na escrita, ele avisa.

Resumo técnico: MemoryStore mantém memoryEntries/userEntries vivos mais um snapshot: Readonly<Record<MemoryTarget, string>> capturado em load(). apply() roteia para add/replace/remove, todos passando por joinedLength (orçamento com delimitador) e locateUnique (substring, falha fechado em distintos). dedupe = [...new Set(entries)]; splitEntries divide no '\n§\n' completo. Leitura ausente → ok([]); escrita falha → err. Enums (memory|user, add|replace|remove) por Zod na borda; o resto por união em tempo de compilação.

8

Recapitulando


Cinco ideias para levar — passe pelos slides e teste se você consegue reproduzir cada uma de memória antes de seguir.

1 / 5 · O truque

O snapshot é congelado no carregamento

O que o system prompt vê é capturado uma vez no load() e não muda no meio da sessão — para preservar o cache de prefixo do modelo.

snapshot · imóvel a sessão toda
i

2 / 5 · Durabilidade

As escritas continuam duráveis

Congelar o snapshot não perde nada: apply() grava no disco na hora e aparece em entries(). Só o bloco no prompt espera o próximo load().

apply()disco (já)
ii

3 / 5 · Localização

Acha entradas por substring única

Nada de índices ou ids: replace/remove recebem um trecho curto. Vários casamentos distintoserr "Be more specific". Nada é mutado.

includes(needle) · falha fechado
iii

4 / 5 · Orçamento

Limites em caracteres, com delimitador

2200 (MEMORY.md) e 1375 (USER.md), contados sobre o texto unido — o '\n§\n' entra na conta. Estourar vira um convite a consolidar.

3+3+3=9
iv

5 / 5 · Postura

Ler perdoa, escrever não

Leitura corrompida → ok([]) (nunca quebra o turno). Escrita falha → err (perder em silêncio seria mentir). A mesma assimetria reaparece no curador.

ler → ok([])escrever → err
v
Slide 1 / 5 navegam
Pergunta de fixação: sem reler, em uma frase — por que congelar o snapshot economiza dinheiro?
9

Verifique seu entendimento


Quiz de revisão

Três perguntas. As respostas têm o mesmo comprimento de propósito — o formato não entrega a certa.

1. Um agente chama apply('memory', {action:'add', …}) no meio da sessão. O que renderSnapshot('memory') devolve logo depois?
Correta: b. O snapshot é capturado uma vez no load() e nunca muta no meio da sessão, preservando o cache de prefixo. A escrita É durável no disco e visível por entries(), mas o snapshot no prompt só atualiza no load() da próxima sessão.
2. replace é chamado com oldText: 'task:' e duas entradas distintas contêm 'task:'. O que acontece?
Correta: c. locateUnique devolve err quando os casamentos são distintos, e o store deixa todas as entradas intactas. (Se os casamentos fossem duplicatas exatas, agir na primeira é a regra de fidelidade da fonte — mas casamentos distintos sempre falham fechado.)
3. Por que os limites são medidos em caracteres e não em tokens, e por que incluir o delimitador?
Correta: d. A tokenização muda por modelo; a contagem de caracteres é estável. E como as entradas são persistidas unidas por '\n§\n', o orçamento inclui esses 3 caracteres por junção — o teste prova 9 passa, 8 falha para duas entradas de 3.
Eu sou seu professor — me devolva a pergunta. Qual destes você ainda não conseguiria reconstruir sem reler: o congelamento do snapshot, a localização por substring, a conta do orçamento, ou a assimetria ler/escrever? Diga qual e eu aprofundo essa antes da Lição 8 (Aprendizado profundo), que constrói o outro subsistema vindo do Hermes.