transcribe / analyzeImage — portas de mídia
Duas ferramentas — fala-em-texto e compreensão de imagem — construídas no exato mesmo padrão de portas que você já viu seis vezes. Cada uma é um kernel de despacho minúsculo: validar o pedido (Zod), chamar o backend injetado, re-validar o resultado não confiável (Zod), retornar um Result. Esta é a prova de encerramento de que a disciplina escala: uma capacidade nova é uma porta + um kernel + uma costura fetch fina — nada mais.
CLONE da estrutura do transcription_tools.py (1.798 LOC) + vision_tools.py do Hermes — só o caminho CLOUD. Por que importa pra missão: é o sétimo subsistema entregue do @alembic/hermes, e ele fecha a tese de que toda capacidade do Hermes vira o mesmo desenho. Fonte de proveniência: docs/hermes-complete-map.md §3.5 (transcrição) e §3.6 (visão).
- Reconhecer a forma de 4 estágios do kernel (validar pedido → backend → validar resultado →
Result) e por quetranscribeeanalyzeImagesão idênticos nela. - Ler a refinação Zod "exatamente uma fonte de áudio" como um XOR e prever as quatro entradas.
- Explicar o colapso do envelope: por que
success/errorviram oResulte não campos do payload. - Justificar a decisão da matriz de IGNORAR o caminho local
faster-whispere CLONAR só os cinco provedores cloud numa única porta.
01 · Duas ferramentas, uma única forma
Imagine um balcão de atendimento: você entrega um pedido, um funcionário terceirizado (que você pode trocar) faz o trabalho lá fora, e o balcão confere o que voltou antes de te devolver. O cliente do balcão nunca fala direto com o terceirizado. As duas ferramentas de mídia são exatamente isso — e a "pessoa terceirizada" é uma porta injetada: uma única função que retorna um Result. O módulo não importa nenhum SDK.
TranscriptionBackend e VisionBackend são ambos (req) => Promise<Result<…, Error>>. Em produção, uma impl baseada em fetch; em teste, um fake. O kernel depende do tipo, nunca de um backend concreto — ports-and-injection (Lição 05).// packages/hermes/src/media/types.ts:133-148 export type TranscriptionBackend = ( req: TranscriptionRequest, ) => Promise<Result<TranscriptionResult, Error>>; export type VisionBackend = ( req: VisionRequest, ) => Promise<Result<VisionResult, Error>>;
A simetria é a lição
As duas ferramentas não são "parecidas" — são a mesma forma, diferindo só nos schemas. Veja lado a lado: troque os nomes dos tipos e um kernel vira o outro.
Há, porém, uma assimetria que vale isolar — e ela está só no estágio 1. O pedido de transcribe é um XOR ("exatamente uma" de duas fontes); o de analyzeImage é o oposto, um AND: precisa de imageUrl (URL válida) e prompt (não-vazio), os dois obrigatórios. Mesma forma de kernel, portões de entrada de polaridades opostas.
02 · O kernel de transcribe, linha a linha
Aqui está o balcão inteiro, sem pular nada. São quatro movimentos e três pontos onde o caminho pode virar um err — e em nenhum deles a função lança uma exceção. Repare que a falha do backend é repassada como está (return transcribed), preservando o erro original.
// packages/hermes/src/media/media.ts:51-68 — transcribe const parsedReq = transcriptionRequestSchema.safeParse(req); if (!parsedReq.success) { return err(new Error(`Invalid transcription request: ${parsedReq.error.message}`)); } const transcribed = await deps.backend(parsedReq.data); if (!transcribed.ok) return transcribed; // falha do backend ⇒ err (repassado) const parsed = transcriptionResultSchema.safeParse(transcribed.value); if (!parsed.success) { return err(new Error(`Invalid transcription result: ${parsed.error.message}`)); // saída não confiável } return ok(parsed.data);
analyzeImage (media.ts:76-93) é byte-a-byte a mesma forma — valida imageUrl + prompt, chama o backend, valida {analysis}, retorna Result. A simetria não é coincidência: é o padrão da Lição 05 aplicado mais uma vez.
03 · Validação cruzada: exatamente uma fonte de áudio
O pedido de transcrição modela uma fonte de áudio portável — uma URL que o backend busca, ou base64 inline — e impõe "exatamente uma" com uma refinação Zod. Pense num formulário com duas caixas: a regra não é "preencha pelo menos uma", é "preencha exatamente uma". Isso é um XOR (ou-exclusivo).
// packages/hermes/src/media/types.ts:62-74 — transcriptionRequestSchema z.object({ audioUrl: z.string().url('audioUrl must be a valid URL').optional(), audioBase64: z.string().min(1, 'audioBase64 cannot be empty').optional(), mimeType: z.string().min(1).optional(), }).refine( (req) => (req.audioUrl === undefined) !== (req.audioBase64 === undefined), { message: 'exactly one of audioUrl or audioBase64 is required' }, );
O truque é o !== sobre dois testes === undefined: comparar dois booleanos com "diferente" é precisamente um XOR — verdadeiro só quando exatamente um dos lados é verdadeiro. Veja as quatro entradas possíveis:
audioUrl quanto audioBase64. O que acontece — o backend escolhe um? Lança exceção? Chute antes de revelar.
err, na fronteira, antes do backend. O XOR vê os dois lados presentes (linha 4 da tabela), a refinação devolve false, e o kernel retorna err("exactly one of audioUrl or audioBase64 is required"). Nada de "o backend decide": ele nunca é chamado — o teste em media.test.ts:62-70 prova exatamente isso. E nunca lança: é um Result, não uma exceção.04 · O colapso do envelope
A fonte Python retorna dicts planos: {success, transcript, provider, error} para STT e {success, analysis} para vision. O port não copia esses campos cegamente — ele colapsa o envelope. success/error viram o ok/err do Result; o núcleo de sucesso é aparado e renomeado para o idioma do motor.
success/error fora do payload, um transcript vazio (silêncio real) chega como ok({text:''}) — legítimo — enquanto uma falha é err. São estruturalmente distintos. Num dict plano {success:true, transcript:''} versus {success:false, error:'…'} você teria de ler o campo success a cada chamada e torcer para ninguém esquecer; aqui o tipo te obriga a tratar os dois ramos.05 · Trace um pedido na mão (passo a passo → agora você)
Você viu o kernel e o XOR. Agora execute-os na cabeça, devagar, para um pedido concreto — depois um caso é seu. Recuperar o procedimento (não só ver o resultado) é o que fixa.
transcriptionRequestSchema.safeParse: audioUrl presente, audioBase64 ausente → XOR = true. parsedReq.success é true; segue.await deps.backend(parsedReq.data) devolve ok({text:'hello world', provider:'groq'}). Como transcribed.ok é true, não repassa erro.transcriptionResultSchema.safeParse: text é string ✓, provider é string não-vazia ✓ → parsed.success é true.return ok(parsed.data) → ok({text:'hello world', provider:'groq'}). É exatamente o que o teste media.test.ts:38-42 afirma com toEqual.transcribe({ provider:'groq' }) — sem audioUrl e sem audioBase64, e o backend (se chamado) devolveria ok({text:'x'}). Onde para e o que retorna? Trace antes de revelar.
undefined) → false → parsedReq.success é false → return err("Invalid transcription request: …"). O backend nunca é chamado (o teste media.test.ts:50-60 verifica called === false). Lição: a validação de pedido é uma porta fechada — saída malformada nem alcança o trabalhador terceirizado.06 · Os backends fetch — mapeamento defensivo, fallbacks de campo
Como o backend web (Lição 11), os backends de mídia são impls fetch finas sobre o fetch global (Node 18+, sem node-fetch, sem SDK, sem nova dependência). O fetch é um campo de config injetável, então os testes usam um fake e nunca abrem socket. Como o kernel re-valida tudo, o mapeador pode ser tolerante:
// packages/hermes/src/media/fetch-backends.ts:163-178 — mapeamento defensivo const mapTranscriptionRow = (payload) => { const provider = readField(payload, 'provider'); return { text: asString(readField(payload, 'text') ?? readField(payload, 'transcript')), // fallback ...(typeof provider === 'string' && provider.length > 0 ? { provider } : {}), }; }; const mapVisionRow = (payload) => ({ analysis: asString(readField(payload, 'analysis') ?? readField(payload, 'content')), // fallback });
text→transcript, analysis→content). Qualquer coisa estranha vira string vazia. Ele não precisa ser rígido porque o kernel confere de novo depois.readField só lê de objetos planos (senão undefined); ?? faz o fallback de campo; asString coage não-strings a ''. O provider só entra no objeto se for string não-vazia (spread condicional). É defesa em profundidade: o mapeador tolera, o Zod do kernel garante.O transporte em si — postJson — é o ponto onde falhas viram err. Três modos de falha, um destino:
Os três modos de falha têm testes dedicados: não-2xx → err (207–216), throw de rede → err (218–227), JSON não parseável → err (229–246). Nenhum abre socket; todos injetam um fetch fake.
analysis é o número 12345 (não uma string). O kernel re-valida com Zod, que exige analysis: z.string(). O resultado final é err de schema? Chute antes de revelar.
ok({analysis:''}). Sutil: o mapeador (asString) coage o número a '' antes de o kernel ver o valor. Então o Zod recebe uma string válida (vazia) e aprova. O teste media.test.ts:248-262 afirma exatamente {analysis:''}. A lição é a defesa em profundidade: o não-string nunca alcança o portão Zod — o mapeador já o neutralizou. (O err de "Invalid … result" é reservado para faltar o campo inteiro, como nos testes 86–93 e 131–137.)07 · Por que o ML local é IGNORADO — uma decisão deliberada da matriz
A fonte suporta seis caminhos de transcrição: cinco provedores cloud (Groq, OpenAI Whisper, Mistral Voxtral, xAI Grok STT, ElevenLabs Scribe) e um caminho local faster-whisper (ML Python, baixa ~150 MB de modelo, território de GPU). A matriz de fusão não trata os seis igual. Diante de cada capacidade do Hermes, ela escolhe uma disposição — e aqui o veredito do docs/hermes-complete-map.md é "ADAPT cloud, IGNORE/sidecar local".
Os cinco provedores cloud são, no fundo, a mesma operação — "POST de áudio para um endpoint". Por isso colapsam numa única TranscriptionBackend. Já o faster-whisper é amarrado a uma lib de ML Python sem equivalente limpo em Node: um port TS ou abandonaria STT local ou faria shell-out para um sidecar Python. A matriz escolhe IGNORE/sidecar e diz isso explicitamente. Clonar a estrutura portável, ignorar o que não traduz, e registrar a decisão — é o método de fusão (Lições 03 e 21) operando linha a linha.
08 · As 20 provas — o que cada teste fixa
A confiança neste kernel não é fé: são 20 casos em três blocos describe, todos offline (fake backend ou fake fetch), todos sem socket. Veja como eles se distribuem — e repare que cada modo de falha do desenho tem um teste correspondente.
| Bloco describe | Casos | O que prova |
|---|---|---|
| transcribe (37–94) | 7 | sucesso por URL e por base64; rejeita nenhuma fonte / ambas / URL inválida na fronteira; repassa err do backend; rejeita resultado sem text |
| analyzeImage (96–138) | 5 | sucesso; rejeita prompt vazio (sem chamar backend) / URL inválida; repassa err do backend; rejeita resultado sem analysis |
| fetch backends (146–263) | 8 | mapeia {text,provider} e {analysis}; fallbacks transcript/content; não-2xx / throw de rede / JSON inválido → err; campo numérico coagido a '' |
@alembic/hermes obedece à mesma disciplina: injetar as portas, retornar Result, validar entrada não confiável com Zod, nunca lançar, sem Date.now()/Math.random(). É a Lição 5 tornada concreta, sete vezes. Releia a Lição 5 agora e ela deve soar como um resumo de tudo acima.09 · Como isso se encaixa
Estas duas ferramentas não vivem sozinhas — elas são dois dos sentidos do agente no mundo: ouvidos (transcribe) e olhos (analyzeImage). Quando o motor precisa virar uma fala em texto ou descrever uma imagem, são elas que atendem, sempre pela porta injetada. Veja a peça (destacada) no lugar dela na máquina: o que a alimenta à esquerda, o que ela alimenta à direita — e, embaixo, o caminho que a matriz deliberadamente deixou de fora.
err a pipeline de gates (Lição 17) falha fechado em vez de raciocinar sobre lixo. Tudo isso só funciona porque ela segue a disciplina de portas & injeção (Lição 05) — o mesmo gabarito que o subsistema web (Lição 11) também encarna, e o mesmo critério de portabilidade que decide o que a fusão (Lição 12 · skills) clona ou ignora. Veja o trajeto inteiro no mapa interativo da metodologia.Por que conecta: é o gabarito desta peça — as portas
TranscriptionBackend/VisionBackend são exatamente "injeta a costura, mantém o kernel puro". Sem essa disciplina, o kernel importaria um SDK de STT.
Lição 11 · webSearch / webExtractPor que conecta: o subsistema-gêmeo — o mesmíssimo padrão (porta injetada → kernel fino → Result), só que para busca/extract em vez de áudio/imagem. Aprender um é aprender o outro. Lição 12 · SkillStore
Por que conecta: é o sexto dos sete subsistemas do
@alembic/hermes — mídia é o sétimo que fecha a fileira. A mesma forma de porta + Result aplicada uma vez antes desta.
Lição 26 · Proveniência & segurançaPor que conecta: a porta busca URLs de áudio/imagem; a borda de egress (SSRF, URL-secreta) é uma camada à parte. O
vision-index também nunca escreve no corpus — a disciplina de proveniência em ação.
Lição 07 · MemoryStorePor que conecta: o destino natural rio abaixo — a transcrição que
transcribe valida (e a análise de analyzeImage) é o que vira memória semântica para o agente reusar depois.
10 · Na prática
alembic transcribe nem alembic analyze-image. O kernel desta lição (media.ts) é uma biblioteca dentro de @alembic/hermes — o motor o chama por dentro. Mas o repositório tem comandos de mídia reais, em pacotes irmãos, que exercitam exatamente a mesma forma (porta injetada → kernel → Result): ocr (texto em imagem), vision-index (descrição de imagem) e notes (transcrição → notas de reunião). Todos offline e $0 por padrão; --online liga um backend real. [uncertain] nenhum deles é o transcribe/analyzeImage de @alembic/hermes em si — para isolar este kernel, o caminho direto é a suíte do pacote (mais abaixo).Leia texto dentro de uma imagem — offline, determinístico, sem rede (o gêmeo de OCR do balcão desta lição):
# @alembic/ocr · offline $0 por padrão; --online precisa de docs/ocr-setup.md alembic ocr ./fixtures/recibo.png # ocr: ./fixtures/recibo.png # <texto reconhecido, determinístico offline> # # --online liga o backend SGLang (ALEMBIC_OCR_BASEURL, ex. http://127.0.0.1:10000); # sem essa env ele FALHA FECHADO com mensagem acionável — nenhuma rede é tentada.
Descreva as imagens de uma família do wiki — append-only, deduplicado por imagem, e nunca escrevendo no corpus (a disciplina de proveniência da Lição 26):
# @alembic/vision · descreve cada imagem do pacote → <data-dir>/vision-index/<family>.jsonl alembic vision-index ~/Documents/Resources/Bookmarks # vision-index: Bookmarks (~/Documents/Resources/Bookmarks) # packages: 128, images: 412, described: 412, rows written: 397, backend: offline # index: ~/.alembic/vision-index/Bookmarks.jsonl # # offline = determinístico ($0). --online usa o MLX-VLM local (docs/vision-setup.md). # Descrições vazias (falha por-imagem) NÃO são gravadas → retentáveis no próximo run.
Transcrição → notas estruturadas. O notes consome uma transcrição já em texto e extrai título, decisões e action items — o passo rio abaixo do transcribe desta lição:
# @alembic/hermes meeting-notes · offline; a data padrão vem do relógio do CLI alembic notes ./fixtures/reuniao.txt # notes: Sync de produto (2026-06-27) # summary: Offline deterministic meeting-notes placeholder (no model called). # participants: (none) # decisions: 0 | action items: 0 # # offline é um placeholder determinístico (nenhum modelo chamado); --online preenche de verdade.
As três saídas acima são o formato real impresso por apps/cli/src/commands.ts (runOcr, runVisionIndex, runNotes); os números/textos são ilustrativos. Para a baseline do monorepo (CLAUDE.md): pnpm -r typecheck && pnpm -r build && pnpm -w test.
E para isolar exatamente este kernel — transcribe/analyzeImage de @alembic/hermes — rode a suíte do pacote. Os 20 casos da seção 08 injetam um backend (ou fetch) fake e nunca abrem socket:
# roda só o pacote @alembic/hermes — onde vive src/media/ (media.ts, fetch-backends.ts) pnpm --filter @alembic/hermes test # … # ✓ src/media/media.test.ts > transcribe ok por URL e por base64 (fake backend) # ✓ src/media/media.test.ts > nenhuma/ambas fonte → err, backend não chamado (XOR na fronteira) # ✓ src/media/media.test.ts > analyzeImage rejeita prompt vazio (AND na fronteira) # ✓ src/media/media.test.ts > non-2xx / throw de rede / JSON inválido → err (fetch FALSO) # ✓ src/media/media.test.ts > campo numérico coagido a '' antes do Zod (defesa em profundidade) # Test Files 1 passed # Tests 20 passed ← as 20 provas desta lição, todas sem rede
cd na raiz do monorepo e pnpm --filter @alembic/hermes test. Termina em segundos e não abre rede — os testes de packages/hermes/src/media/media.test.ts injetam um backend (ou fetch) fake.media.test.ts:50-60): o teste afirma called === false — o backend nunca é chamado porque a refinação Zod já reprovou o pedido. É a tabela-verdade da seção 03 virando prova executável.alembic ocr para qualquer PNG no disco (ou alembic notes para um .txt com uma transcrição). Sem --online a saída é determinística e $0; observe o formato ocr: <caminho> seguido do texto — o mesmo desenho "valida → backend → valida → Result" desta lição, num pacote irmão.pnpm -r typecheck && pnpm -r build && pnpm -w test na raiz. O typecheck é a metade silenciosa: ele recusa compilar se você ler result.value sem ter tratado !result.ok antes.Result — roda idêntica em mídia, sem nunca abrir um socket; e os comandos irmãos (ocr/vision-index/notes) provam que o mesmo gabarito vira CLI real e offline. E quando o motor usa o kernel de verdade?
transcribe/analyzeImage por dentro de uma unidade da missão — você dispara o motor com alembic run --goal GOAL.md --plan alembic.plan.ts --yes, e quando uma unidade precisa entender áudio/imagem, esta peça atende pela porta injetada. [uncertain] não há um comando alembic transcribe que isole só este kernel; para isolá-lo, o caminho direto é o pnpm --filter @alembic/hermes test acima — e os comandos ocr/vision-index/notes são as portas de CLI de mídia que o repositório expõe hoje.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
Result. Três caminhos para err, nunca lança.(a===undefined) !== (b===undefined) impõe?audioUrl/audioBase64. É um XOR: nenhuma e ambas falham com err na fronteira.success/error somem do payload?Result (ok/err). Assim ok({text:''}) (silêncio) ≠ err (falha) — estruturalmente distintos.faster-whisper é IGNORADO?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.
audioUrl e audioBase64. O que acontece?(audioUrl===undefined) !== (audioBase64===undefined) é verdadeiro só com exatamente uma fonte; ambas → false → err. a/b erram porque o backend nunca é chamado (teste 62–70 confirma) — a decisão é da fronteira, não do provedor. d erra a forma de falha: o kernel retorna err (um Result), nunca lança.success/error não aparecem como campos em TranscriptionResult?success/error do dict plano viram o ok/err do Result; um transcript vazio (silêncio) é ok({text:''}), nunca confundido com falha. a inverte: é uma escolha de design, não um esquecimento. c é factualmente falso — a fonte tem success/error (é justamente o que se colapsa). d erra o motivo: o ganho é semântico (silêncio ≠ falha), não de tamanho.faster-whisper foi marcado IGNORE na fusão?TranscriptionBackend. a erra o critério (não é velocidade, é portabilidade — o local pode até ser mais rápido). b é falso: transcrição e vision são portas distintas. c é falso: é uma decisão deliberada e registrada, não um descuido.analyzeImage versus transcribe?". É só dizer.