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).
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 |
A operação em si é uma união discriminada em tempo de compilação — não há um saco de "params" tipado por string:
// 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 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:
// 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; }
load() da próxima sessão. O teste "snapshot reflects load-time disk state, not mid-session writes" prova: depois de um apply(), renderSnapshot() é byte-idêntico ao de antes, mas o disco já contém a nova entrada.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 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;
O teste "counts the delimiter against the budget" fixa isso: 'aaa' + '\n§\n' (3 chars) + 'bbb' = 9 chars, 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.
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.
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 escrita É durável no disco e visível via entries(), mas o snapshot no prompt só renova no load() da próxima sessão.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. (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.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.