Curso / Lição 07
Lição 07 · Mergulho profundo · subsistema 1 de 7

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).

Leia primeiro (fonte primária no repo)
packages/hermes/src/memory/memory-store.ts — o MemoryStore, lido linha a linha

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.

Objetivos desta lição
  • 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+action sã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.
0
stores · MEMORY.md + USER.md
0
teto de chars · MEMORY.md
0
teto de chars · USER.md
0
casos de teste no clone

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.

A METÁFORA · a foto fica parada, o caderno continua sendo escrito
caderno (disco) MEMORY.md USER.md rabiscos novos entram na hora load() tira a foto 📸 a foto (snapshot) congelada · não muda colada em a cabeça do agente (system prompt) A próxima foto só é tirada no próximo load() — quando a sessão recomeça. Aí caderno e foto voltam a coincidir.
Guarde esta frase: “escrita é durável já; a foto só atualiza no próximo load()”. Tudo nesta lição é uma consequência dela.

02 · A API pública

O subsistema inteiro é uma classe e algumas constantes, todos exports nomeados em packages/hermes/src/index.ts:

ExportPapel
MemoryStorea classe — load() / renderSnapshot() / entries() / apply()
ENTRY_DELIMITER'\n§\n' — o delimitador (section sign) entre entradas
DEFAULT_MEMORY_CHAR_LIMIT2200 — teto de chars para MEMORY.md
DEFAULT_USER_CHAR_LIMIT1375 — teto de chars para USER.md
MemoryOpa união discriminada de operações: add / replace / remove
MemoryOpOutcomepayload 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:

MEMORY.md vs USER.md · mesma mecânica, propósitos e tetos distintos (comparativo)
MEMORY.md as notas do PRÓPRIO agente (tarefas, fatos que ele decidiu lembrar) teto 2200 chars USER.md o que ele sabe SOBRE VOCÊ (preferências, contexto pessoal) teto 1375 chars (menor) A classe é a mesma; muda só o arquivo e o teto. O target ('memory' | 'user') roteia cada apply() ao store certo.

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:

Pense em 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.

DUAS FRONTEIRAS, DUAS DEFESAS · runtime (Zod) vs. compilação (a união)
o que vem de FORA (não tipado) target = 'memory' | 'user' action = 'add' | 'replace' | 'remove' validado por Zod · em RUNTIME z.enum(...) — falha fechado se vier lixo o resto do payload content / oldText a união discriminada MemoryOp garantido em COMPILAÇÃO forma ilegal nem chega a ser escrita Regra: valide em runtime SÓ o que cruza uma fronteira não tipada; o resto, deixe o compilador provar.

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):

ANATOMIA do MemoryOpOutcome · o relatório terminal de um apply() que passou
target qual store message confirmação humana entryCount nº de entradas vivas usedChars chars juntados em uso charLimit o teto do store usedChars + charLimit juntos deixam o agente raciocinar sobre quanto orçamento sobra — base da auto-curadoria (seção 06). Caso de falha (over-budget / match ambíguo) NÃO devolve isto: devolve um err com a mensagem de orientação.

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:

SNAPSHOT CONGELADO no load() enquanto o estado vivo avança a cada apply()
início da sessão load() ler · deduplicar · congelar snapshot (renderSnapshot) — embutido no system prompt, NUNCA mutado no meio da sessão estável pela 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()

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:

dedupe() · [...new Set(entries)] — colapsa repetidas, preserva a 1ª ordem
disco (com repetida) A B A C ↑ 2ª ocorrência: descartada dedupe memoryEntries (vivo) A B C A ordem de A (1ª posição) é preservada — não vai para o fim. É por isso que duplicatas no disco não viram entradas distintas no matcher… …mas duplicatas idênticas ainda podem coexistir num arquivo semeado à mão — daí o ramo "todas idênticas" do locateUnique (seção 05).

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:

renderBlock('memory', …) · cabeçalho + indicador de uso + separador de 46 ═
══════════════════════════════════════════════ ← separador (46× ═) MEMORY (your personal notes) [42% — 924/2200 chars] ← indicador de uso ══════════════════════════════════════════════ entrada 1 § ← entradas juntadas pelo ENTRY_DELIMITER entrada 2 … USER.md usa o cabeçalho "USER PROFILE (who the user is) […]"; a % e os números são calculados no render. Bloco vazio ⇒ renderSnapshot devolve undefined.
Preveja antes de continuar
Um agente chama 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.
Por que congelar? Modelos de fronteira cacheiam o prefixo do prompt. Se o bloco de memória embutido no system prompt mudasse toda vez que o agente escrevesse 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 imediatamente (disco), mas o snapshot no prompt fica parado até o 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:

SE O PROMPT SEGUISSE O ESTADO VIVO (ingênuo)
prefixo do prompt (muda a cada escrita) cache ✗ cache ✗ cache ✗ Toda nota re-processa o prompt inteiro. ⇒ mais lento e mais caro a cada turno.
COM O SNAPSHOT CONGELADO (o real)
prefixo estável a sessão inteira cache ✓ vale o tempo todo Escritas vão pro disco sem mexer no prefixo. ⇒ rápido e barato; reconcilia no próximo load().

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:

DUAS SESSÕES · a escrita da sessão 1 entra no snapshot só no load() da sessão 2
SESSÃO 1 SESSÃO 2 load() · foto v1 apply() → disco disco muda; foto v1 NÃO load() · foto v2 ✓ lê o disco já com a escrita a nota da sessão 1 atravessa o disco e reaparece aqui Moral: o snapshot é "eventualmente consistente" com o disco, e o ponto de reconciliação é sempre um load() — normalmente o início da próxima sessão. (Você pode forçar a reconciliação no meio de uma sessão longa re-chamando load() — ao custo de re-quebrar o cache de prefixo uma vez.)

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:

FLUXOGRAMA · apply() despacha por action e cada ramo falha fechado
apply(target, op) despacha por op.action qual action? add já existe igual? no-op · "already exists" joined > limit? err: "Consolidate now…" replace / remove locateUnique(oldText) match único? (0 ou >1 distinto?) não → ✗ err · nada muta "No entry…" / "Be more specific." sim ↓ passa ↓ ✓ save() → disco + MemoryOpOutcome
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:

locateUnique('needle') · 3 desfechos — só o do meio (distintos) falha
0 matches nenhuma entrada contém ✗ err "No entry matched…" nada muda >1 DISTINTOS "task: rever PR" "task: pagar conta" ✗ err "Be more specific." nada muta >1 IDÊNTICOS "nota X" "nota X" ✓ opera na 1ª distinct.size === 1 seguro (regra da fonte) Chave: a decisão é por quantos textos distintos casam (Set), não por quantos matches — é o que separa o meio da direita.

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;
Exemplo resolvido · por que limite 9 passa e 8 falha para duas entradas de 3 chars
1
Liste as entradas. Duas entradas, cada uma com 3 chars: 'aaa' e 'bbb'. Cru, somam 6 chars.
2
Não some cru — junte. O disco guarda as entradas juntadas pelo ENTRY_DELIMITER = '\n§\n', que tem 3 chars (uma quebra de linha + § + outra quebra de linha).
3
Conte o juntado. 'aaa' + '\n§\n' + 'bbb'3 + 3 + 3 = 9 chars. É exatamente isso que joinedLength mede.
4
Compare com o teto. Um limite de 99 > 9 é falso ⇒ passa ✓. Um limite de 89 > 8 é verdadeiro ⇒ falha ✗. O teste "counts the delimiter against the budget" fixa esse degrau de 1 char.
Agora você: três entradas de 4 chars cada. Qual o menor limite que ainda passa? Faça antes de revelar.
São 3 entradas2 delimitadores entre elas. Conta: 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:

0 teto 9 9 chars

Por que chars e não tokens? A mesma string vira um número diferente de tokens em cada modelo (o tokenizer muda). Caracteres são estáveis e iguais em qualquer lugar — então o orçamento é reprodutível e não depende de qual LLM está rodando.

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.

A assimetria leitura/escrita — leia mais

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.

LER vs. ESCREVER · falham de jeitos opostos, de propósito (comparativo)
LEITURA falha arquivo ausente / corrompido ok([]) · store vazio caminho quente nunca quebra o host ESCRITA falha disco recusa o save() err · falha visível o chamador pediu durabilidade — não minta Regra: degrade silencioso onde a perda é tolerável; grite onde alguém contou com o sucesso.

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:

ROUND-TRIP · uma entrada com § solto volta como UMA entrada, não duas
1 entrada (com § no meio) "preço: R$ 5 § 6 por unidade" save disco: bytes literais (§ é só um char) load ✓ 1 entrada intacta splitEntries divide em '\n§\n' (quebra + § + quebra) — não em '§'. Um § cercado de texto/espaços não é o delimitador. Se dividisse no § solto, a entrada acima viraria DUAS ("preço: R$ 5 " e " 6 por unidade") — o bug que o teste de round-trip impede. Caso de teste dedicado: round-trip de § literal (memory-store.test.ts 137–147).

Confusões comuns

"Snapshot congelado significa que as escritas são perdidas." Não — as escritas vão para o disco imediatamente e são refletidas em 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.
"Um § 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 no início e a escreve no fim:

ONDE ESTA PEÇA VIVE · o loop fechado lê a memória ao iniciar e a sedimenta ao fim (a engrenagem destacada)

Clique para acender cada etapa em sequência e seguir o caminho da memória pelo ciclo.

início da sessão chama load() — lê o disco ESTA PEÇA · MemoryStore congela o snapshot (load) apply() escreve na hora system prompt + loop renderSnapshot → contexto o agente raciocina e age passe de aprendizado sedimenta memória durável (--learn) a próxima sessão dá load() e relê tudo ↺ ↑ a montante quem inicia a sessão ↑ esta lição guarda e serve o contexto ↓ a jusante quem usa e re-alimenta A montante: o loop fechado (Lição 04) que abre a sessão e chama load(). Esta peça: a memória que sobrevive ao fim da sessão. A jusante: o passe de aprendizado (Lição 08) sedimenta o que ficou; a próxima sessão relê — fechando o ciclo de continuidade.

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.

A que esta peça se conecta
  • Lição 04 · O loop fechadoo ciclo que esta memória ao abrir a sessão e a re-alimenta ao fim; o MemoryStore é a sua continuidade entre voltas.
  • Lição 08 · reviewAndLearno 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 + curadoro store irmão que reusa a mesma assimetria leitura/escrita (seção 07): leitura degrada para ok([]), escrita grita err. Mesma postura de design.
  • Lição 05 · Ports & injeçãoo MemoryStore lê e escreve pelo FsPort injetado, nunca por fs direto; é o que torna os 23 testes determinísticos e rápidos em loop.
  • Lição 23 · Lab: o passe de aprendizadoo 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.

Experimente · grave uma memória e leia de volta
1
Entre no repositório. cd para a raiz do monorepo Alembic e garanta o build atual: pnpm -r build (para o CLI enxergar o @alembic/hermes compilado).
2
Prove o store desta lição. 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.
3
Grave uma memória pela CLI. 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.
4
Leia de volta — a continuidade em ação. 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.
Por que dois caminhos? O passo 2 prova a mecânica do snapshot congelado (o foco desta lição). O passo 3–4 mostra a memória persistente que o agente realmente carrega entre sessões. São peças-irmãs do mesmo pacote; juntas, são o que dá ao Alembic continuidade — o tema de toda a Lição.
Linha de base de build (o contexto maior). Toda mudança de código no Alembic precisa manter verde a tríade completa: 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.

Snapshot
Quando o snapshot é capturado e quando muda?
clique pra virar ↻
Resposta
Congelado uma vez em load(); nunca muda no meio da sessão — só reconcilia no próximo load(). Protege o cache de prefixo do prompt.
Tipos
O que é validado por Zod e o que é por compilação?
clique pra virar ↻
Resposta
target e action (cruzam fronteira não tipada) via z.enum; o resto do payload é a união discriminada MemoryOp, garantida em compilação.
Matcher
Quando >1 match é erro?
clique pra virar ↻
Resposta
Só quando os matches são distintos (distinct.size > 1 → "Be more specific."). Se forem duplicatas idênticas, opera na primeira com segurança.
Orçamento
Como contar o orçamento de N entradas?
clique pra virar ↻
Resposta
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.

1. Um agente chama apply('memory', {action:'add', …}) no meio da sessão. O que renderSnapshot('memory') retorna logo depois?
Correto: b. O snapshot é capturado uma vez em 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.
2. replace é chamado com oldText: 'task:' e duas entradas distintas contêm 'task:'. O que acontece?
Correto: c. 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.)
3. Por que os limites são medidos em caracteres e não em tokens, e por que incluir o delimitador?
Correto: d. Tokenização difere por modelo; caracteres são estáveis. E como as entradas são persistidas juntadas por '\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.
4. O save() de uma escrita falha (disco recusa). O que o MemoryStore faz — e por que diferente de uma leitura que falha?
Correto: a. É a assimetria deliberada: leitura num caminho quente degrada para um store vazio (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.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que o snapshot e o estado vivo não são a mesma coisa?", "Quando eu chamaria load() de novo no meio de uma sessão longa?", "Como o curador reusa a mesma assimetria leitura/escrita?". É só dizer.