Lição 18 · Fusão v3 · Conhecimento e ingestão · @alembic/sessions ← índice
Alembic × Hermes — O Curso de Fusão v3 · Conhecimento e ingestão

@alembic/sessions: três leitores, uma memória

O histórico de trabalho do usuário já existe em disco — sessões do Claude Code, rollouts do Codex, o export do ChatGPT — mas em três formatos incompatíveis. O @alembic/sessions os normaliza num único SessionRecord, e o A4b (alembic import) transforma cada turno em registro de memória de transcript com id determinístico e tempo lido da fonte: reimportar é sempre no-op. Nada de Date.now(); nada de fabricação.

1

A grande ideia: leitores, não escritores

O escopo do pacote cabe numa palavra do próprio header: "SCOPE: READERS" (packages/sessions/src/index.ts:4–16). Três funções, cada uma dona de um formato on-disk real: readCodexSession (o rollout-*.jsonl de ~/.codex/sessions/YYYY/MM/DD/), readClaudeSession (o <sessionId>.jsonl de ~/.claude/projects/<cwd-codificado>/) e readChatGptExport (o conversations.json do data export — que devolve MUITOS records de uma vez). Não há escritor nem analytics aqui; as únicas deps são @alembic/contracts e zod, e o filesystem é injetado via deps.readText — o núcleo é puro e testável em memória (index.ts:16–23).

Pense como… um arquivista que recebe fitas de três gravadores diferentes — cada um com seu rótulo, sua velocidade, seu formato de bobina — e transcreve todas para a MESMA ficha de ata: quem falou, o que disse, quando a reunião começou. Onde quebra: o arquivista humano interpreta; estes leitores não — linha que não casa com o schema é pulada ou vira err, nunca "corrigida".

O invariante de tempo: startedAt é lido DOS DADOS — o timestamp da primeira linha (Claude), o timestamp do session_meta (Codex), o create_time em segundos-epoch convertido para ISO (ChatGPT). Nunca Date.now()/new Date() como leitura de relógio, que a plan-VM proíbe (index.ts:18–22). Uma sessão importada hoje ou daqui a um ano carimba o MESMO tempo: o dela.
2

Em uma imagem: três formatos → um record

codex · rollout-*.jsonl linha 1: session_meta {id, timestamp} response_item type=message claude · <sessionId>.jsonl type ∈ {user, assistant} + message content: string OU blocos [text…] chatgpt · conversations.json array; mapping = ÁRVORE de nós create_time em segundos-epoch 3 readers linha ruim → PULA (skip) sem id/timestamp → err readText INJETADO (puro) re-valida com Zod na saída nunca lança SessionRecord id · startedAt (DA FONTE) cwd? · turns[{role, text}] claude/codex ⇒ 1 record chatgpt ⇒ MUITOS records thinking / tool_use / tool_result / image não viram texto (v1) — claude-reader.ts:20–22
Três formatos on-disk, um contrato de saída. Erros fatais só quando falta a identidade (id) ou o tempo (startedAt); o resto degrada linha a linha.

O detalhe de robustez é a assimetria deliberada entre err e skip: um transcript sem sessionId ou sem timestamp algum é err — não dá para ancorar identidade nem tempo (claude-reader.ts:127–134); mas uma linha individual malformada, um tipo que não é turno (ai-title, summary, reasoning, function_call…) é apenas pulada (claude-reader.ts:24–26; codex-reader.ts:11–17). No ChatGPT, a árvore mapping é achatada a partir da raiz seguindo children, com um guard de visited contra ciclos (chatgpt-reader.ts:184–189), e o texto de cada nó é o join dos parts — nó de sistema vazio é descartado (chatgpt-reader.ts:14–17).

3

Inferência de formato pelo path

Quem decide qual leitor usar é a CLI, e a regra é legível de trás para frente (apps/cli/src/commands.ts:6246–6263): --format explícito SEMPRE vence; senão, o path fala — nome começando com rollout- ou um segmento /.codex/codex; um segmento /.claude/claude; conversations.json ou um .json que não é .jsonlchatgpt. Path ambíguo? Fail closed: a mensagem manda passar --format claude|codex|chatgpt, em vez de adivinhar e importar lixo com cara de memória.

apps/cli/src/commands.ts:6250–6257
if (explicit !== undefined) return ok(explicit);          // --format vence
const lower = path.toLowerCase();
const base = basename(lower);
if (base.startsWith('rollout-') || lower.includes('/.codex/')) return ok('codex');
if (lower.includes('/.claude/')) return ok('claude');
if (base === 'conversations.json' || (lower.endsWith('.json') && !lower.endsWith('.jsonl'))) {
  return ok('chatgpt');
}
4

A4b: de SessionRecord a memória de transcript

Ler é metade; a outra metade é o mapper do hermes, sessionToTranscriptRecords (packages/hermes/src/memory/transcript-import.ts:51–86), que transforma cada turno NÃO-VAZIO num TranscriptRecord completo e já validado. As decisões anti-fabricação estão todas no envelope:

SessionRecord turns c/ índice 0..n sessionToTranscriptRecords id: import-<agent>-<sess>-<n> at = Date.parse(startedAt) turno vazio → pulado (índice fica) startedAt ruim → [] (fail-closed) appendNewTranscriptRecords id ∈ existentes? skipped++ novo? append VERBATIM porta que falha → err na hora (nunca sucesso parcial mudo) transcript .jsonl 1ª run --write: appended N · skipped 0 2ª run --write (mesma fonte): appended 0 · skipped N — idempotente o MESMO arquivo que alembic memory transcript list lê
O mapper constrói o registro COMPLETO (id + at derivados); a porta de escrita persiste verbatim e deduplica por id — reimportar a mesma fonte appenda zero.

A escrita idempotente é appendNewTranscriptRecords (transcript-import.ts:122–142): recebe o conjunto de ids já em disco, pula qualquer registro cujo id já exista (ou duplicado dentro do próprio lote) e falha o lote FECHADO na primeira porta que der err — "the caller never reports a partial success it cannot prove". O destino é <memory-dir>/transcript.jsonl (commands.ts:6021) — exatamente o arquivo que alembic memory transcript add|list usa. E o default do comando é dry-run $0: preview de contagens, nada escrito; --write é quem libera o append (commands.ts:5908–5912).

E o --distill? (interpretação nunca vira fato)--distill --online adiciona uma segunda passada A4b: um modelo local destila a sessão em memória semântica + de decisão. Cada registro destilado é MARCADO como model-distilled — proveniência session:<id> + distilled:<modelId> (tags falsificadas pelo modelo são STRIPPED) e confiança ≤ 0.5 — e --write continua sendo o único portão de escrita. Sem --online, --distill falha fechado: sem gasto, sem fabricação.
5

Cheque seu modelo mental

Você roda alembic import ~/.claude/projects/x/abc.jsonl --write duas vezes seguidas. O que a segunda run faz?
Correto: o id é derivado (import-<agent>-<sessionId>-<n>, transcript-import.ts:74) — a mesma fonte produz registros byte-idênticos — e appendNewTranscriptRecords pula todo id já presente (transcript-import.ts:122–142). Reimportar é seguro por construção; nem err, nem duplicata.
Por que o agent faz parte do id do registro importado, e não só import-<sessão>-<n>?
Correto: o transcript é UM arquivo particionado pelo campo agent; se dois agentes importam a mesma sessão com ids iguais, o dedupe-por-id descartaria os registros do segundo — "two agents importing the same source must get DISTINCT ids" (transcript-import.ts:70–74). Ordenação não usa o id, e reimportação repetida continua no-op de propósito.
Camada técnica — comandos copy-paste

Default é dry-run $0 (nada escrito, nada de rede):

# preview: formato inferido do path (.claude/ ⇒ claude)
alembic import ~/.claude/projects/<dir>/<sessionId>.jsonl --agent alex --json

# escrever de verdade (idempotente; rode 2× e compare appended/skipped)
alembic import ~/.claude/projects/<dir>/<sessionId>.jsonl --agent alex --write

# export do ChatGPT (MUITAS sessões de uma vez)
alembic import ~/Downloads/conversations.json --agent alex --json

# conferir o destino — o MESMO arquivo do multi-store
alembic memory transcript list --agent alex --limit 5

# a regra de inferência e o mapper, na fonte
sed -n '6246,6263p' apps/cli/src/commands.ts
sed -n '51,86p' packages/hermes/src/memory/transcript-import.ts

Formato ambíguo (ex.: um .jsonl fora de .claude//.codex/) falha fechado — passe --format.

O que levar desta lição
Pergunta de acompanhamento sugerida: "o que acontece com os blocos de thinking e tool_use na v1 — e o que ganharíamos importando-os?" — Na próxima lição, saímos das sessões e vamos ao acervo: @alembic/ingestion caminha as famílias wiki dir-per-package e emite a ponte package.jsonl que alimenta o funil.