MemoryStore de snapshot congeladoUma 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.
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.
replace e remove localizam uma entrada por substring única — e quando isso falha fechado.ENTRY_DELIMITER entre as entradas.ok([])) e escrever (devolve err) — e justificá-la.Result<T, Error> do Alembic (um ok(valor) ou um err(erro), sem exceções).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.
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.
memory, três ações — o subsistema inteiro cabe nesta figura.FsPort.O subsistema todo é uma classe e algumas constantes, todas exportações nomeadas de packages/hermes/src/index.ts:
| Exportação | Papel |
|---|---|
MemoryStore | a classe — load() / renderSnapshot() / entries() / apply() |
ENTRY_DELIMITER | '\n§\n' — o delimitador de seção (o sinal §) entre entradas |
DEFAULT_MEMORY_CHAR_LIMIT | 2200 — teto de caracteres do MEMORY.md |
DEFAULT_USER_CHAR_LIMIT | 1375 — teto de caracteres do USER.md |
MemoryOp | a união discriminada de operações: add / replace / remove |
MemoryOpOutcome | o 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.
{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.add() deduplica exato no carregamento — é por isso que, para testar o ramo de duplicatas, o teste precisa semeá-las direto no disco.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.
Um agente chama apply('memory', {action:'add', …}) no meio da sessão. Logo depois, o que renderSnapshot('memory') devolve?
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.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().
// 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; }
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.
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:
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.
Toque numa ação para ver quais entradas ela afeta — e o que ela devolve.
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;
'aaa' (3 caracteres) e 'bbb' (3 caracteres).ENTRY_DELIMITER = '\n§\n', que tem 3 caracteres (uma quebra de linha, o §, outra quebra de linha).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.
replace ou apagar entradas velhas com remove, depois repetir o add.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));
load()?replace recebe para achar a entrada?oldText), não um índice nem id. Se casar várias entradas distintas, devolve err e nada muda.ENTRY_DELIMITER conta no orçamento?join(ENTRY_DELIMITER)), porque é isso que vai ao disco. Dois textos de 3 = 9 caracteres.ok([]) — um store vazio. Já uma escrita que falha devolve err. Assimetria deliberada: ler perdoa, escrever não.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.'\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.'\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.
Cinco ideias para levar — passe pelos slides e teste se você consegue reproduzir cada uma de memória antes de seguir.
Três perguntas. As respostas têm o mesmo comprimento de propósito — o formato não entrega a certa.
apply('memory', {action:'add', …}) no meio da sessão. O que renderSnapshot('memory') devolve logo depois?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.replace é chamado com oldText: 'task:' e duas entradas distintas contêm 'task:'. O que acontece?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.)'\n§\n', o orçamento inclui esses 3 caracteres por junção — o teste prova 9 passa, 8 falha para duas entradas de 3.