O MemoryStore de snapshot congelado
Uma memória limitada, persistida 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 única e curta. Seu truque definidor é o snapshot congelado: o que o system prompt vê é capturado uma vez no load() e nunca se move, mesmo quando escritas no meio da sessão vão para o disco. É um CLONE fiel do tools/memory_tool.py do Hermes (1089 LOC).
Esta lição destila esse arquivo + schema.ts + os 23 casos de teste em memory-store.test.ts. Todos os números têm fonte (rodapé). Por que importa pra missão: é a peça que dá ao agente continuidade — o que ele sabe sobre você sobrevive ao fim da sessão, sem quebrar o cache de prefixo que mantém o custo baixo.
- Explicar o invariante do snapshot congelado e por que ele protege o cache de prefixo do prompt.
- Reconhecer a união discriminada
MemoryOp(add/replace/remove) e por que sótarget+actionsão validados com Zod. - Aplicar a regra de localizar por substring única e prever quando ela falha fechado.
- Calcular o orçamento de chars contando o
ENTRY_DELIMITER, e explicar a assimetria leitura/escrita.
01 · A intuição: o caderno congelado
Antes do código, a imagem. Pense num agente que tem um caderno com tudo o que precisa lembrar sobre você. No começo da conversa ele tira uma foto da página e cola essa foto no início da cabeça dele (o system prompt). Durante a conversa, ele pode rabiscar coisas novas no caderno — e esses rabiscos vão pro papel na hora. Mas a foto colada na cabeça não muda: continua mostrando o caderno como estava quando a sessão começou. Só na próxima conversa ele tira uma foto nova.
Essa é a tensão inteira da lição: a foto (snapshot) precisa ficar parada para o modelo não recalcular o prompt toda hora — mas os rabiscos (disco) precisam ser duráveis na hora, senão você perde a nota. O MemoryStore mantém as duas verdades ao mesmo tempo, de propósito.
02 · A API pública
O subsistema inteiro é uma classe e algumas constantes, todos exports nomeados em packages/hermes/src/index.ts:
| Export | Papel |
|---|---|
MemoryStore | a classe — load() / renderSnapshot() / entries() / apply() |
ENTRY_DELIMITER | '\n§\n' — o delimitador (section sign) entre entradas |
DEFAULT_MEMORY_CHAR_LIMIT | 2200 — teto de chars para MEMORY.md |
DEFAULT_USER_CHAR_LIMIT | 1375 — teto de chars para USER.md |
MemoryOp | a união discriminada de operações: add / replace / remove |
MemoryOpOutcome | payload terminal: target, message, entryCount, usedChars, charLimit |
Dois stores, dois propósitos
São dois arquivos com papéis diferentes — e tetos diferentes. O target de toda operação escolhe entre eles:
O tipo central, em duas linguagens
A operação em si é uma união discriminada em tempo de compilação — não há um saco de "params" tipado por string. Alterne entre a explicação simples e o tipo real:
MemoryOp como um formulário com três versões, e cada versão só tem os campos que faz sentido preencher: o formulário “add” pede só o content; o “replace” pede oldText (o que trocar) e content (pelo quê); o “remove” pede só oldText. Você nunca consegue mandar um add com um oldText — o tipo não deixa. Preencher o formulário errado é um erro de compilação, não um bug que estoura em produção.// packages/hermes/src/memory/memory-store.ts:85-88 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 não tipada na fonte Python — target e action — são validados em runtime com Zod (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 pode ser escrita.
O que volta de um apply() bem-sucedido
Toda mutação que dá certo devolve um MemoryOpOutcome — não um booleano, mas um relatório que diz ao agente o estado pós-escrita (quantas entradas, quantos chars usados, qual o teto):
03 · O invariante definidor: snapshot vs. estado vivo
O store mantém duas realidades paralelas. load() lê o disco, deduplica e congela um snapshot; apply() muta as entradas vivas e as persiste — mas nunca toca no snapshot. Esta linha do tempo é o coração da lição:
O código que congela
load() é o único lugar que escreve this.snapshot. renderSnapshot() só lê desse campo congelado — nunca do estado vivo:
// packages/hermes/src/memory/memory-store.ts:119-147 (condensado) async load(): Promise<Result<void, Error>> { // … ler 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; }
No load, a deduplicação preserva a ordem
Repare na linha dedupe(memory.value): ao ler do disco, entradas repetidas são colapsadas, mas a primeira ocorrência mantém sua posição — paridade exata com o dict.fromkeys do Python:
O formato do bloco que entra no prompt
O que renderBlock produz não é a lista crua — é um bloco com cabeçalho, separador e um indicador de uso embutido ([NN% — usados/teto chars]). Assim o próprio agente, ao ler seu system prompt, já vê quão cheio está o store e pode se auto-curar antes de estourar:
apply('memory', {action:'add', …}) no meio da sessão. Logo depois, ele compara dois retornos: renderSnapshot('memory') e entries('memory'). Um deles enxerga a nova entrada, o outro não. Qual é qual? Decida antes de revelar.
entries() vê a nova entrada; renderSnapshot() não. entries() devolve a lista viva, que apply() acabou de mutar (e já persistiu no disco). renderSnapshot() devolve o bloco congelado no load() — byte-idêntico ao de antes da escrita. O teste "snapshot reflects load-time disk state, not mid-session writes" prova exatamente isso: depois do apply(), renderSnapshot() não mudou, mas o disco já contém a entrada nova. Se você apostou que os dois mudariam, caiu na confusão clássica — durabilidade e estabilidade-do-prompt são coisas separadas.load() da próxima sessão.O custo de NÃO congelar — lado a lado
Para sentir por que isso importa, compare os dois mundos. À esquerda, um store ingênuo que injeta o estado vivo no prompt; à direita, o MemoryStore real:
Onde as duas verdades voltam a coincidir
A defasagem entre snapshot e disco não é permanente — ela dura exatamente uma sessão. Veja duas sessões em sequência: o que foi escrito na sessão 1 só aparece no snapshot da sessão 2, porque é o load() dela que tira a foto nova:
04 · As três operações: add / replace / remove (fluxograma)
Uma única apply() recebe a MemoryOp e decide o caminho pela action. Cada caminho tem sua própria checagem de fail-closed. Siga as setas — cada losango é uma pergunta que escolhe a rota:
add deduplica antes de tudo: uma entrada idêntica vira no-op com a mensagem "already exists" (sem escrita), exatamente como o Python. É por isso que duplicatas nunca chegam ao disco por add().replace e remove compartilham o mesmo passo: localizar a entrada-alvo por oldText via locateUnique. Se não há match único, ambos falham fechado antes de qualquer mutação — o disco fica intacto.05 · Localizar por substring única — sem IDs
replace e remove não recebem índice nem id. Recebem uma substring curta e o store acha a única entrada que a contém. O matcher falha fechado em ambiguidade:
// packages/hermes/src/memory/memory-store.ts:369-392 — 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: múltiplos matches só são erro quando são entradas distintas. Se todos os matches são exatamente o mesmo texto (duplicatas verdadeiras), agir na primeira é seguro — a regra de fidelidade da fonte. Duplicatas não entram via add() (ele deduplica), então o teste as semeia no disco e dá load para exercer esse ramo.
Os três desfechos do matcher
Toda chamada a locateUnique cai em um de três casos. Repare que distinto e duplicata têm a mesma contagem de matches (>1) mas desfechos opostos:
06 · O orçamento de chars — e por que o delimitador conta (worked)
Os limites são contados em caracteres, não tokens, porque contagens de chars são independentes de modelo. Crucialmente, o ENTRY_DELIMITER conta no orçamento — o store mede o comprimento juntado, exatamente o que vai para o disco:
// packages/hermes/src/memory/memory-store.ts:344-346 const joinedLength = (entries: readonly string[]): number => entries.length === 0 ? 0 : entries.join(ENTRY_DELIMITER).length;
'aaa' e 'bbb'. Cru, somam 6 chars.ENTRY_DELIMITER = '\n§\n', que tem 3 chars (uma quebra de linha + § + outra quebra de linha).'aaa' + '\n§\n' + 'bbb' → 3 + 3 + 3 = 9 chars. É exatamente isso que joinedLength mede.9 > 9 é falso ⇒ passa ✓. Um limite de 8 ⇒ 9 > 8 é verdadeiro ⇒ falha ✗. O teste "counts the delimiter against the budget" fixa esse degrau de 1 char.4+4+4 + (2 × 3) = 12 + 6 = 18 chars ⇒ o menor limite que passa é 18 (em 17 falha). Regra geral: soma_das_entradas + (n−1) × 3 — o delimitador entra entre entradas, então N entradas têm N−1 separações.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.
Sinta o orçamento — calculadora
Arraste os dois sliders. A barra cresce e troca de cor — verde = cabe, vermelho = estoura o teto. Repare que o total sempre inclui os delimitadores:
07 · A assimetria leitura/escrita — uma postura de design
A última peça é uma decisão sutil que reaparece pelo código todo: leitura e escrita falham de jeitos opostos, de propósito.
Um arquivo corrompido ou ausente na leitura retorna ok([]) (um store vazio) — um caminho quente tipo telemetria nunca pode quebrar o host. Mas uma escrita que falha retorna err — quem chamou escolheu explicitamente persistir, então uma perda silenciosa seria uma mentira. A mesma assimetria reaparece no usage store do curador; é uma postura de design deliberada e repetida, não acidente.
O round-trip de um § solto
O delimitador é a sequência completa '\n§\n' — não um § qualquer. splitEntries separa só nessa sequência, então uma entrada que contém um § no meio do texto sobrevive inteira ao salvar-e-recarregar:
Confusões comuns
entries(). Só o snapshot no prompt está congelado, e só até o próximo load(). Durabilidade e estabilidade do cache de prefixo são preocupações independentes que o design mantém separadas.§ na minha nota vai corromper o arquivo." Não — o store separa pela sequência completa '\n§\n', não por um § isolado. Uma entrada cujo corpo contém um § solto faz round-trip como uma entrada; há um teste dedicado exatamente para isso.08 · Como isso se encaixa
O MemoryStore não é uma peça solta — é o que dá continuidade entre sessões ao loop fechado da metodologia. Toda execução do agente começa lendo a memória persistida e termina (opcionalmente) escrevendo de volta o que aprendeu. Sem este store, cada sessão recomeçaria do zero. Veja onde a peça vive no fluxo real, e como o loop a lê no início e a escreve no fim:
Clique para acender cada etapa em sequência e seguir o caminho da memória pelo ciclo.
Onde você está na metodologia. O Alembic é uma máquina que se constrói rodando o loop learn → analyze → execute → verify → improve. O MemoryStore é o que torna esse loop cumulativo: cada passagem pode deixar uma nota durável que a próxima encontra já no system prompt — sem re-explicar tudo, sem quebrar o cache de prefixo. É a diferença entre um agente que esquece tudo a cada turno e um que acumula contexto sobre você e sobre a tarefa. Para ver o quadro inteiro em movimento — como cada peça se liga às outras — abra o mapa interativo da metodologia.
- Lição 04 · O loop fechado — o ciclo que lê esta memória ao abrir a sessão e a re-alimenta ao fim; o
MemoryStoreé a sua continuidade entre voltas. - Lição 08 · reviewAndLearn — o passe a jusante que decide o que vira nota durável; ele escreve no store que esta lição congela, sedimentando o aprendizado.
- Lição 09 · UsageStore + curador — o store irmão que reusa a mesma assimetria leitura/escrita (seção 07): leitura degrada para
ok([]), escrita gritaerr. Mesma postura de design. - Lição 05 · Ports & injeção — o
MemoryStorelê e escreve peloFsPortinjetado, nunca porfsdireto; é o que torna os 23 testes determinísticos e rápidos em loop. - Lição 23 · Lab: o passe de aprendizado — o laboratório onde você roda o ciclo de ponta a ponta e vê a memória ser lida, usada e sedimentada na prática.
09 · Na prática
Chega de teoria — toque o subsistema. Há dois caminhos reais, e vale distinguir um do outro com honestidade. O primeiro exercita exatamente o MemoryStore desta lição (o de MEMORY.md/USER.md, snapshot congelado) rodando os 23 testes do pacote. O segundo é o comando de CLI que você usa no dia a dia para a memória persistente do @alembic/hermes:
# 1) exercita ESTE store (o de snapshot congelado): roda os 23 casos de memory-store.test.ts pnpm --filter @alembic/hermes test # … vitest roda a suíte do pacote … # ✓ src/memory/memory-store.test.ts (23 tests) # Test Files N passed # Tests N passed ← inclui "snapshot reflects load-time disk state, not mid-session writes"
[uncertain] os números de arquivos/testes acima são ilustrativos do formato do vitest; o total varia com o estado do repositório. O invariante real é a suíte do @alembic/hermes verde — incluindo o caso que prova snapshot ≠ estado vivo (seção 03).
O segundo caminho é o comando de CLI alembic memory. Atenção a uma distinção importante: ele opera os multi-stores append-only do @alembic/hermes (episodic · semantic · procedural · decision · transcript) — a memória persistente que o harness usa. É a peça-irmã do MemoryStore desta lição (mesma casa, packages/hermes), não o store de MEMORY.md/USER.md em si. Ele é o jeito real de gravar e consultar memória pela linha de comando — offline e hermético:
# grava uma nota na memória semântica (offline, append-only JSONL) alembic memory semantic add --record '{"text":"o usuário prefere PT-BR nas respostas"}' # memory semantic: appended <id> (agent cli) # ~/.alembic/memory/semantic.jsonl # consulta o que está gravado (mais recente primeiro) alembic memory semantic list # memory semantic: 1 record(s) under ~/.alembic/memory/semantic.jsonl # <id> [agent cli] at <timestamp>
O add exige --record <json> (um objeto JSON); sem ele o comando falha fechado com "memory add requires --record <json>". O --agent é opcional: no add ele recai para cli (um registro precisa de dono); no list, quando dado, filtra por aquele agente. O store fica em ~/.alembic/memory/<substore>.jsonl por padrão, ou onde --dir <path> apontar.
cd para a raiz do monorepo Alembic e garanta o build atual: pnpm -r build (para o CLI enxergar o @alembic/hermes compilado).pnpm --filter @alembic/hermes test. O que procurar: a suíte verde, incluindo o caso "snapshot reflects load-time disk state, not mid-session writes" — a prova viva da seção 03.alembic memory semantic add --record '{"text":"prefiro PT-BR"}'. O que procurar: a linha memory semantic: appended <id> (agent cli) e o caminho do arquivo logo abaixo.alembic memory semantic list. O que procurar: o registro que você acabou de gravar, com seu id, o agent cli e o timestamp. Inspecione o arquivo cru: ele é JSONL append-only em ~/.alembic/memory/semantic.jsonl.pnpm -r typecheck && pnpm -r build && pnpm -w test. Para mexer só neste subsistema, isole com pnpm --filter @alembic/hermes test — a mesma suíte de 23 casos que esta lição destila, agora 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.
load(); nunca muda no meio da sessão — só reconcilia no próximo load(). Protege o cache de prefixo do prompt.target e action (cruzam fronteira não tipada) via z.enum; o resto do payload é a união discriminada MemoryOp, garantida em compilação.distinct.size > 1 → "Be more specific."). Se forem duplicatas idênticas, opera na primeira com segurança.soma_das_entradas + (N−1) × 3 — o ENTRY_DELIMITER ('\n§\n', 3 chars) conta porque é o que vai pro disco. Limites em chars, não tokens.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.
apply('memory', {action:'add', …}) no meio da sessão. O que renderSnapshot('memory') retorna logo depois?load() e nunca mutado no meio da sessão, preservando o cache de prefixo do prompt. a descreve o store ingênuo que NÃO existe aqui — anexar ao snapshot mudaria o prefixo e quebraria o cache. c inverte a regra: snapshot defasado é o objetivo, não um erro; ele reconcilia no próximo load(). d inventa um estado vazio — a escrita É durável no disco e visível via entries(), só o snapshot fica parado.replace é chamado com oldText: 'task:' e duas entradas distintas contêm 'task:'. O que acontece?locateUnique retorna err quando os matches são distintos, e o store deixa toda entrada intacta. a nunca acontece: o store opera em uma entrada por vez, jamais em duas. b é justo o que o fail-closed evita — escolher uma de duas distintas em silêncio mutaria a errada. d erra o mecanismo: o código de biblioteca devolve Result/err, não lança. (Se os matches fossem duplicatas exatas entre si, agir na primeira é a regra de fidelidade da fonte — mas matches distintos sempre falham fechado.)'\n§\n', o orçamento inclui esses 3 chars por separação — o teste prova 9 passa, 8 falha para duas entradas de 3 chars. a confunde causa: a razão é reprodutibilidade entre modelos, não velocidade. b é falso — dá para contar tokens em JS, mas o número varia por tokenizer. c contradiz joinedLength, que mede exatamente o texto juntado que vai pro disco.save() de uma escrita falha (disco recusa). O que o MemoryStore faz — e por que diferente de uma leitura que falha?ok([])); escrita grita (err) porque o chamador contou com a persistência. b aplica a regra da leitura à escrita — exatamente o erro que a assimetria evita. c inventa um loop de retry que não existe e travaria o agente. d mistura conceitos: o snapshot é congelado de propósito e uma falha de escrita não o invalida.load() de novo no meio de uma sessão longa?", "Como o curador reusa a mesma assimetria leitura/escrita?". É só dizer.