Curso / Lição 7  ·  EN
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).

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

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

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()
// 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;
}
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 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.

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.

O orçamento de chars — e uma assimetria deliberada

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.

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.

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 escrita É durável no disco e visível via entries(), mas o snapshot no prompt só renova no load() da próxima sessão.
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. (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.

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.