# HANDOFF — Épico "Inbox Multimodal + Agente de Vendas" (6 ondas)

> ⚠️ **INSTRUÇÃO PERMANENTE (não remover):** ler no INÍCIO de toda sessão que
> trabalhe neste épico; ATUALIZAR + COMMITAR ao final de CADA avanço, teste, bug
> encontrado e correção. Regras do Rafael:
> 1. Progresso só conta com PROVA VISÍVEL — teste real com Playwright em CONTA
>    REAL. É PROIBIDO relatar "funcionou" sem isso.
> 2. O critério não é "funcionou", é "funcionou BEM". UX ruim = bug: corrigir
>    antes de reportar. Medidas de front por ferramenta, não a olho.
> 3. Se precisar: criar contas, usar MCP, pedir credenciais ao Rafael.
> 4. graphify é obrigatório antes de ler código-fonte (`graphify query "..."`).
>    Buscar referências de mercado (WhatsApp Web, Intercom…) pra ancorar UX.

## Contexto fixo

- **Épico:** mídia multimodal ponta a ponta no inbox + agente, composer estilo
  WhatsApp, split de mensagens do agente, toolkit do vendedor.
- **Spec mestre:** `docs/superpowers/specs/2026-07-21-inbox-multimodal-design.md`
  (aprovado 2026-07-21). Execução: ondas 0→5 em sequência, cada onda com plano próprio.
- **Decisões:** transcrição plugável (API BYOK default, mlx-whisper opcional em
  Mac); vídeo atrás de flag por agente (off default); mídia sempre persistida no
  bucket `whatsapp-media` (UI nunca consome URL do WAHA direto).
- **Pontos de código âncora:** ingestão `lib/waha/ingest.ts` (media_url só);
  envio `app/api/v1/messages/_handler.ts` (descarta mídia hoje); UI
  `components/inbox/{MessageBubble,Composer}.tsx` (placeholder/só texto); agente
  `lib/agent-engine/edge/crm/get-lead-context.ts:184` (mídia vira `[image]`);
  abstração futura Meta: `lib/agent-engine/channel-adapter.ts`.
- **Evidências:** `.superpowers/evidence/inbox-multimodal-*.png` (gitignored).

## Estado atual

| Onda | Status | Prova |
|---|---|---|
| 0 — Fundação mídia (Storage + MediaSource + signed URL) | ✅ COMPLETA (7/7 + review final ready-to-merge) | E2E REAL provado 2026-07-21: WhatsApp "Lia" +5511 4863-3324 conectado por QR; Rafael enviou imagem/áudio/vídeo/PDF/figurinha reais → 5/5 ingeridos com tipo certo, worker persistiu no bucket (SQL: `media_status='stored'`, bytes corretos, path `{org}/{conv}/{msg}.{ext}`); endpoint 302→signed URL 5/5 com content-type certo (~800ms), cross-org 404. Evidências: `.superpowers/evidence/inbox-multimodal-onda0-{inbox,endpoint}.png`. T1: bucket via migration **0055** (0054 tomado por branch irmã). |
| 1 — Render real na UI | ✅ COMPLETA (review final ready) | E2E real 2026-07-21: 5/5 mídias RENDERIZADAS na conversa real; áudio tocou (currentTime 1.41s, 1.5x no clique), vídeo 103.9s, lightbox Esc ok; medidas por getBoundingClientRect: img 256×192 (4:3), sticker 160×160, audio 240×44, video 384×216 (16:9), zero overflow; 391+ testes, tsc/lint 0. Evidências: .superpowers/evidence/inbox-multimodal-onda1-{thread,lightbox}.png. Forward Onda 2: alargar hasMedia p/ media_storage_path. |
| 2 — Composer WhatsApp (anexo/áudio/emoji) | ✅ COMPLETA + confirmada no celular do Rafael | T1 upload storage-first (guard DoS); T2 backend via signed URL 600s + wahaSendPlanFor (review matou IDOR cross-tenant); T3 anexos+preview+caption; T4 gravador PTT (mic liberado sempre); T5 emoji-mart lazy pt-BR. Prova CRM→WAHA: 5/5 enviados (imagem+caption, PDF, texto, 👍 do picker, áudio gravado) → Rafael CONFIRMOU no celular: tudo chegou, **áudio como MENSAGEM DE VOZ** (convert:true funcionou no NOWEB Core — contingência ffmpeg NÃO necessária). Bug de ack destravado (ver Decisões). 421 testes, tsc/lint 0. Evidências: `.superpowers/evidence/inbox-multimodal-onda2-*.png`. |
| 3 — Agente multimodal (model-agnóstico) | ✅ CÓDIGO COMPLETO (8/8 + review Ready) + PROVADO AO VIVO em OpenAI | 2 camadas: (universal) worker `media_derive_v1` preenche `media_derived_text` (Whisper/visão/pdf), lido por `get-lead-context` → qualquer modelo lê; (aprimoramento) parte nativa AI SDK capability-gated em `inbound-turn` só p/ a mídia do turno atual. 451 testes, tsc/lint 0. Migration 0058. **PROVA REAL 2026-07-22:** camada universal com APIs reais (GPT-4o visão descreveu imagem CRM Iquira, Whisper transcreveu áudio, pdf-parse extraiu PDF *(registro de 2026-07-22; o pacote `pdf-parse` saiu na issue #238 — hoje quem extrai é o `pdfjs-dist`)* → `media_derived_status=ready`); agente multimodal PROVADO em **OpenAI/gpt-4o** — turno completo descreveu a imagem na tela (evidência `inbox-multimodal-onda3-gpt4o-descreve-imagem.png`). Runtime multi-modelo comprovado (mesmo código, providers trocados por config). **Google**: chave do Rafael é de GATEWAY (lista modelos fictícios gemini-3.1/gpt-5.6; todo gemini real → "does not exist") — precisa chave direta do Google AI Studio. **Anthropic**: credencial ainda é placeholder (last4 1234). Vídeo → Onda 3.1 (ffmpeg). |
| 3.1 — Vídeo (ffmpeg) | ✅ COMPLETA — derivação E resposta do agente provadas (fix pós-crítica do Rafael) | `lib/messaging/media/video-derive.ts`: ffmpeg (instalado 8.0.1) extrai faixa de áudio (→ Whisper) + 4 frames (→ visão GPT-4o) num tmpdir isolado; combina em `media_derived_text`. Atrás da flag `video_frames_enabled` por-agente (0058); worker só deriva vídeo se algum agente publicado da org tem a flag. PROVA REAL: vídeo de 1min43 (Onda 0) → transcreveu o bug report falado (leads no funil errado, scroll horizontal, CRM "Tomik") + descreveu as 4 telas nos frames. Evidência: `.superpowers/evidence/inbox-multimodal-onda31-video-derivado.txt`. 454 testes. Agente leu o derivado (citou "problema com o funil do CRM"). Contingência: transcrição usa a chave da org (OpenAI); ffmpeg precisa estar no worker image de produção (Dockerfile). |
| 4 — Split de mensagens | ✅ COMPLETA + provada ao vivo | Migration 0059 (`split_messages`/`split_max_chars` por-agente); `splitIntoBubbles` puro (parágrafo→sentença→palavra, lossless — review pegou drop silencioso de `R$149.90`); `sendInBubbles` no `send_message` (guardrail 1x no corpo lógico, split só no envio, seq++ por bolha, jitter 1.2-2s no piso anti-ban); instrução no opening quando on. PROVA REAL: agente (split on, max 120) respondeu pergunta longa em **6 bolhas curtas** (todas ≤120, sent, espaçadas ~3-5s, word-split correto de sentença >120). Evidência: `inbox-multimodal-onda4-split-bolhas.png`. 464 testes. Limitação documentada: cap diário anti-ban conta 1/turno (não por bolha) — follow-up. |
| 5 — Toolkit vendedor (templates de script) | ✅ COMPLETA (5 tasks + prova E2E real) — draft IA/notas/snooze = sub-ondas 5.1-5.3 futuras | Migration **0060** (`message_templates`: pessoal `owner_user_id`=user / compartilhado=null; RLS `fn_user_org_ids`+`fn_role_at_least`); `interpolateTemplate` (`{{nome}}`/`{{primeiro_nome}}`, fallback literal); CRUD `/api/v1/message-templates` (Zod, audit, shared-gate por ROLE_RANK); slash-menu `/` no composer (`resolveSlash` puro + `TemplateMenu`, Enter-guard, insere corpo interpolado no cursor); página `/app/templates` (CRUD, badges Pessoal/Compartilhado, nav Sidebar). **PROVA REAL 2026-07-22** (agent e2e-agent, conversa `bcb64692` lid:59782320914646, sessão e2e-wave12 WORKING): template pessoal criado na UI; **gate UI** (agent NÃO vê toggle compartilhar); `/` abriu o menu; escolher inseriu `Oi Rafael!…` (`{{primeiro_nome}}`→"Rafael", zero literal); **ENVIADO+ENTREGUE no WhatsApp real** (`status=delivered`, `ack=2`, `external_id` WAHA, by_user); **isolamento RLS pela UI** (3 no banco → agent vê só 2: próprio+compartilhado, "Privado do Manager" oculto). 476 testes, tsc/lint 0. Evidências: `inbox-multimodal-onda5-{template-criado,slashmenu,interpolado,enviado-whatsapp,isolamento}.png`. |

## Sub-ondas e fixes pós-épico (2026-07-23)

- **5.1 — Rascunho da IA no composer: ✅ COMPLETA + provada.** Botão "Sugerir resposta" (Sparkle) reusa o agente publicado da org em modo rascunho (`generateDraftReply` = `loadPublishedAgentConfig` + `getLeadContext` + `runModelCall` SEM tools; via limpa, sem guardrails de anti-ban/disclosure). Rota `POST /api/v1/conversations/[id]/draft-reply` (pool pg singleton no web via `getRequestPool`, novo padrão). PROVA REAL (conversa bcb64692, agente openai/gpt-4o cred Q1kA): rascunho coerente na persona, SEM "sou assistente virtual", personalizado; editado e ENTREGUE (ack=2). Fix pós-review: erro de CRM vira reason `error`/500 (não `blocked`); rota não engole exceção; `applyDraft` SUBSTITUI o composer (não concatena). Evidência `inbox-multimodal-onda51-rascunho-gerado.png`.
- **Merge origin/main (10 PRs #27-32) na branch:** feito sem tocar `feat/followup-flows` (sessão irmã, fora da main); 2 conflitos resolvidos preservando ambos (audit/actions.ts, package.json). 487 testes.
- **3 bugs de composer corrigidos** (Rafael pegou clicando os botões reais — Onda 2 os passou por ter "provado" via API/replay): (1) **microfone** `Permissions-Policy: microphone=()`→`(self)` (next.config.ts); (2) **botão "+"** `<input type=file>` estava dentro do `PopoverContent` (Radix desmonta ao fechar → picker não abria) → movido pra fora; (3) **rascunho concatenava** → agora substitui. Provados na tela (file picker abre → imagem entregue ack=2; `allowsFeature('microphone')=true`).

- **5.3 — Snooze/lembrete por conversa: ✅ COMPLETA + provada.** Migration **0062** (renumerada de 0061 — followup reivindicou concorrente; colunas `snooze_until`/`snoozed_at`/`snoozed_by_user_id` em conversations + kind `snooze_expired`). Botão "Lembrar" (1h/3h/24h + cancelar) no ConversationHeader; rota `POST/DELETE /api/v1/conversations/[id]/snooze`; cron `snooze-watcher` (Bearer fail-closed) varre `snooze_until<=now`, limpa sempre, e se o lead não respondeu (`last_inbound_at<=snoozed_at`, comparado como Date) reabre (status=open, last_message_at bumpado=topo da fila) + cria aviso interno `snooze_expired` — NADA ao cliente. PROVA REAL (conversa bcb64692): "Em 1 hora"→snooze +60min→botão "Lembrete ativo"; cron `{scanned:1,reopened:1}`→reabriu+aviso; central de avisos 15→16 com copy leiga; caso "lead respondeu"→`reopened:0` sem aviso; auth sem/errado secret→403. Evidências `inbox-multimodal-onda53-{lembrete-ativo,aviso-central}.png`. **DEPLOY: self-host precisa agendar `GET/POST /api/v1/cron/snooze-watcher` no `scheduler` (~5min) com o Bearer — sem isso o snooze não expira.**

- **5.2 — Notas internas de conversa: ✅ COMPLETA + provada.** Migration **0063** (`conversation_notes`: org_id, conversation_id, body, created_by_user_id, created_by_name denormalizado, created_at; RLS select=membro/platform, write=agent+). Rota `GET/POST /notes` + `DELETE /notes/[noteId]` (autor OU manager+). Front: `useConversationNotes` (query+realtime próprio), `useCreateNote`, `useDeleteNote`; `NoteCard` (destaque âmbar/warning + selo "só o time vê" + autor); `ChatThread` faz `mergeThreadItems` (union message|note por timestamp) — notas inline; `Composer` com toggle "Responder | Nota interna" (bifurca submit, esconde anexo/áudio/rascunho, placeholder+destaque âmbar). PROVA REAL (conversa bcb64692): nota criada em `conversation_notes` (autor "E2E Agent") e **CRÍTICO: 0 mensagens com o texto — não vazou pro WhatsApp**; aparece inline no thread com destaque via realtime (sem reload); modo Responder não regrediu (msg entregue ack=2); delete pelo autor some do thread+banco (botão só p/ autor/manager). Evidência `inbox-multimodal-onda52-nota-no-thread.png`.

## Follow-ups de deploy resolvidos (2026-07-23, commit 5e61321)

- **event-log-drain estava ÓRFÃO** (não agendado em nenhum config): sem ele, em self-host o `media_persist`/`media_derive` (+ sentiment/rag/lgpd/automation) NUNCA drenam → a mídia multimodal do épico não funcionaria em prod. **Agendado a cada 1min no scheduler do `docker-compose.prod.yml`.** Provado: endpoint responde 200 + drena + auth fail-closed 403.
- **snooze-watcher** (5.3) agendado a cada 5min no scheduler — lembrete expira sozinho, zero passo manual pro self-hoster.
- **ffmpeg** adicionado ao `Dockerfile` (estágio runner do APP — a derivação de vídeo roda no processo do app via o cron event-log-drain, não no worker). Alpine `apk add ffmpeg`.

### Follow-ups NÃO resolvidos (bloqueio real, não esquecimento)
- ~~Upgrade `@ai-sdk/*` p/ visão nativa no turno~~ **[RESOLVIDO/DESMENTIDO 2026-07-23]** — a visão nativa JÁ FUNCIONA com os pacotes atuais (`ai@7` + `@ai-sdk/*@4`). A conclusão da Onda 3 estava errada. Provado 4× com API real (Anthropic + OpenAI, inclusive end-to-end storage→gpt-4o com tools lendo detalhe fino "Iquira Energy Innovation"); `runModelCall` passa `messages` direto pro `generateText`. **Nenhum upgrade necessário.** Teste de regressão em `lib/agent-engine/agent/media-parts.test.ts`.
- **Confluência com `feat/followup-flows`:** a sessão irmã está ATIVA (61 commits, sem PR, commit de hoje — onda 8). O merge dos 9 arquivos sobrepostos só se resolve quando AMBOS os PRs forem integrar na main; forçar agora é prematuro (a branch deles ainda muda). **Mapa dos 9 p/ o merge futuro:** append-friendly (resolução mecânica) → Sidebar, ui/icons, MANIFEST, register-handlers, package.json, lib/audit/actions.ts; **gerados** (regenerar/reconciliar) → database.types.ts, baseline.sql; **lógica (atenção)** → `lib/agent-engine/agent/inbound-turn.ts` (+46 inbox / +6 followup — checar que o wiring de mídia e o do followup coexistem). Migrations não colidem (inbox 0055/0058/0059/0060/0062/0063; followup 0056/0057/0061).

## Próximo passo exato

**ÉPICO CONCLUÍDO E ENTREGUE.** Todas as ondas (0,1,2,3,3.1,4,5,5.1,5.2,5.3) completas, provadas em conta real, com review final de cada uma. Branch `feat/inbox-multimodal` pushed e **PR #34 aberto pra main** (https://github.com/melgarafael/DeskcommCRM/pull/34). 505 testes, tsc/lint 0. Worktree mantido vivo pra iterar em feedback do PR. **Push exige `DESKCOMM_GOV_PHASE_MERGE=1 git push` (hook do gov-loop em `loop/hooks/pre-push` bloqueia push comum).** Próximo: revisão/merge do PR (decisão do Rafael) + resolver confluência com `feat/followup-flows` no merge. Follow-ups: rotacionar chave Anthropic; ffmpeg no worker de prod; agendar o cron snooze-watcher no deploy; visão nativa no turno (upgrade @ai-sdk/*); limpar dados de prova da org de teste. **Melhorias 5.2 (review, não-bloqueantes):** M2 — a RLS `conversation_notes_write` deixa qualquer agent deletar nota alheia via PostgREST direto (a regra autor/manager+ vive só na rota); defense-in-depth = policy DELETE escopada a `created_by_user_id = auth.uid() OR fn_role_at_least(org,'manager')`. M3 — o modo "Nota interna" fica indisponível em conversa bloqueada (`blockedReason` faz early-return no Composer antes do toggle); anotar sobre contato bloqueado deveria ser permitido.

**Follow-ups abertos (não bloqueiam o épico, dependem do Rafael):**
- **Sub-ondas do toolkit** (5.1 rascunho da IA no composer, 5.2 notas internas de conversa, 5.3 snooze/lembrete de lead) — greenfield, cada uma com plano próprio quando o Rafael priorizar.
- **Segurança:** ROTACIONAR a chave Anthropic (`sk-ant-...VQAA`) colada no chat.
- ~~**Visão nativa no turno:** upgrade dos `@ai-sdk/*`~~ **[DESMENTIDO 2026-07-23: a visão nativa já funciona; não precisa upgrade — ver acima.]**
- **Google:** chave direta do AI Studio (a atual é de gateway).
- **Prod:** `ffmpeg` no worker image (Dockerfile) p/ derivação de vídeo.
- **Limpeza:** org de teste poluída (contato "Prova Multimodal" fake, `ai_agent_versions` clonadas, sessão FAILED órfã) + templates de prova da Onda 5.

## Decisões e problemas

- **Onda 3.1 correção pós-crítica (2026-07-22, commit 5157a66):** Rafael questionou (com razão) a resposta ruim da Lia ao vídeo ("não consigo interpretar vídeos... contate o suporte") — NÃO era "aprovado", eu tinha over-claimed. Causa raiz: o derivado ia CRU no contexto → o agente negava a mídia por reflexo. Fix `frameMediaBody` (get-lead-context) enquadra o derivado como percepção do agente. RE-PROVADO: Lia resumiu o bug real do vídeo (funis errados, rolagem, CRM Tomik) — `inbox-multimodal-onda3-lia-resume-video.png`. **Lição registrada: "derivação funcionou" ≠ "agente respondeu bem"; a experiência depende do enquadramento E do prompt do agente (o agente de teste tinha prompt vazio/vendas que deflectia).**
- **Split provado no WhatsApp REAL (2026-07-22):** além do inbox, disparei o split na conversa real (lead `59782320914646@lid` da sessão "Lia") → 7 bolhas `delivered` (ack=2, external_id do WAHA) no celular do Rafael. A prova anterior (conversa `Prova Multimodal`, número fake +5500000000001) era só no CRM — não chegava a WhatsApp real.
- **Onda 3 prova (2026-07-22): universal layer entrega, parte nativa no turno pende de upgrade.** Prova real com chaves BYOK: (1) **camada universal PROVADA** — GPT-4o visão descreveu imagem (CRM Iquira), Whisper transcreveu áudio, pdf-parse extraiu PDF *(registro de 2026-07-22; o pacote `pdf-parse` saiu na issue #238 — hoje quem extrai é o `pdfjs-dist`)* → `media_derived_text=ready`; (2) **agente entende mídia em OpenAI E Anthropic** (usa o derivado; evidência `inbox-multimodal-onda3-gpt4o-descreve-imagem.png`). **Google**: chave é de GATEWAY (modelos fictícios gemini-3.1/gpt-5.6; gemini real → "does not exist") — precisa chave direta do AI Studio. **Anthropic real** (`sk-ant-...VQAA`) cadastrada e valida; modelos reais desta chave: claude-sonnet-4-6, opus-4-8, sonnet-5 (os antigos claude-3-5 → not_found). **⚠️ ATENÇÃO SEGURANÇA:** a chave Anthropic foi colada no chat — Rafael deve ROTACIONÁ-LA. ~~**Parte NATIVA no turno NÃO chega ao modelo**~~ **[CORRIGIDO 2026-07-23: esta conclusão estava ERRADA.]** A parte nativa (`media-parts.ts`, formato `file`+mediaType, bytes inline) CHEGA ao modelo com `@ai-sdk/*@4.x` sob `ai@7` MESMO com tools+multi-step — provado 4× com API real (Anthropic + OpenAI; end-to-end storage→gpt-4o leu "Iquira Energy Innovation"). A conclusão errada veio de um teste ANTES do fix de formato (commit 2ce59f4) que nunca foi re-verificado. `runModelCall` passa `messages` direto (não transforma parts). Nenhum upgrade de `@ai-sdk` é necessário. Config LLM da org p/ derivação/agente DEVEM alinhar provider (org settings->llm.provider é usado pelos guardrails do turno).
- **org de teste poluída pela prova:** contato/conversa `Prova Multimodal` (11111111.../22222222...) + várias `ai_agent_versions` clonadas via SQL apontando openai/google/anthropic. Agente publicado restável via clone (ver comandos no log). Limpar depois.
- **Bug de ack destravado (2026-07-22, commit 6d807fd):** todo outbound travava em `sent`/ack=0 — o ack do WAHA 2026.x/NOWEB chega como `{fromMe}_{chatId}_{bareId}` mas o envio grava `external_id` = bareId (parseWahaMessageId extrai o id interno); `handleAck` casava por igualdade e nunca achava a linha. Fix: `bareWaMessageId` normaliza o ack à cauda antes do match (só no read-side; NÃO mexi no external_id de inbound, que é full e sustenta o dedup 23505). Pré-existente (afeta texto também), achado na prova E2E da Onda 2. Provado reenviando os 5 acks reais pelo webhook → status `read`/ack=3, UI com 5 checks "Lida".
- **Contingência de voz resolvida:** `sendVoice` com `convert:true` no WAHA NOWEB Core JÁ entrega como mensagem de voz (Rafael confirmou no celular) — a conversão OGG server-side (ffmpeg) NÃO é necessária na Onda 2.

- **T7/bugs reais do WAHA 2026.7.1 (2026-07-21, commits 131bea7+33bfc36):** a prova em conta real revelou que o payload NOWEB mudou: mídia em `payload.media.{url,mimetype}` (não `mediaUrl`), SEM campo `type` (inferir de `_data.message`: stickerMessage/imageMessage/…), `media.url` anuncia porta INTERNA do container (localhost:3000; no host é 3030). Fixes: `mediaUrlOf`/`mediaMimeOf`/`resolveMessageType` no ingest; `fetchWahaMedia` reconstrói a URL sobre `WAHA_API_BASE_URL` (path+query só — SSRF impossível por construção); `application/mp4`→`.mp4`. Mensagens de mídia pré-fix foram ingeridas como text vazio (limpas no dev; clones antigos não têm dados a migrar — mídia nunca funcionou antes).
- **Dev/WAHA auth:** o `.env.local` precisava do HASH sha512 em `WAHA_API_KEY` (o client envia o hash; imagem Core compara literal) — corrigido no worktree. Sessão FAILED `org_6e567068_a3ec82` ficou órfã em Conexões (limpar depois).
- **T4/retry (2026-07-21):** o plano previa retry gerido pelo handler (`status:"retry"` + backoff próprio) — ERRADO contra o contrato real: `drain.ts` não conta attempt em `retry` (reservado a postpone benigno) → loop infinito. Decisão: worker retorna `status:"error"` sempre e o drain é dono de retry/backoff/dead-letter; worker marca `media_status:"failed"` só no último attempt (`row.attempts >= 4`, espelho do MAX_ATTEMPTS=5 do drain). Review pegou isso — o teste original passava porque bypassava o drain.

## Log de sessões

- **2026-07-21** — Brainstorming + design mestre aprovado e commitado (`6e596ac`).
  Handoff criado. Nada implementado ainda.
- **2026-07-21 (exec Onda 0)** — Worktree `.claude/worktrees/feat-inbox-multimodal`
  (branch `feat/inbox-multimodal`, base `a480193`), baseline 335 testes verdes.
  T1 concluída (commit `98d78a2`). Aprendizado: aplicar SQL em `storage.*` no dev
  = `supabase db query --linked` (MCP exige OAuth; role `agent_worker` sem grants).
