vibeeditor-render 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -182,6 +182,35 @@ async function versaoNova() {
182
182
  return null;
183
183
  }
184
184
 
185
+ /**
186
+ * Atualização ao abrir: se saiu versão nova no npm, troca por ela na hora (npx com o número exato, então a cópia
187
+ * guardada do npx não segura a antiga) e esta fica só esperando a nova fechar. Não roda em desenvolvimento (com o
188
+ * código-fonte do host ao lado) nem duas vezes seguidas (VIBE_RENDER_ATUALIZADO), e nunca trava a abertura:
189
+ * sem internet ou com o npm fora, segue na versão atual.
190
+ */
191
+ async function atualizarSeTiver(argv) {
192
+ if (process.env.VIBE_RENDER_ATUALIZADO || process.env.VIBE_RENDER_SEM_ATUALIZAR) return false;
193
+ if (fs.existsSync(path.join(RAIZ, "host", "build.mjs"))) return false;
194
+ const nova = await versaoNova();
195
+ if (!nova) return false;
196
+ dizer(`${azul("›")} Saiu a versão ${negrito(nova)} (esta é a ${VERSAO}). Atualizando e abrindo a nova…`);
197
+ // no Windows o npx é um .cmd e precisa do shell: argumento com espaço (--pasta "C:\Meus Vídeos") vai entre aspas
198
+ const args = process.platform === "win32" ? argv.map((a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : argv;
199
+ const filho = spawn("npx", ["-y", `${PACOTE.name}@${nova}`, ...args], {
200
+ stdio: "inherit", shell: process.platform === "win32", env: {...process.env, VIBE_RENDER_ATUALIZADO: "1"},
201
+ });
202
+ // o Ctrl+C chega nos dois: quem cuida é a versão nova; esta só repassa o código de saída
203
+ process.on("SIGINT", () => {});
204
+ await new Promise((ok) => {
205
+ filho.on("error", (e) => {
206
+ dizer(`${ambar("!")} Não consegui atualizar (${e.message}). Sigo na ${VERSAO}.`);
207
+ ok(false);
208
+ });
209
+ filho.on("exit", (code) => process.exit(code == null ? 0 : code));
210
+ });
211
+ return false;
212
+ }
213
+
185
214
  /** "1.10.0" é mais nova que "1.9.3"? (só números; pré-lançamentos não entram) */
186
215
  function maisNova(a, b) {
187
216
  const x = String(a).split(".").map(Number), y = String(b).split(".").map(Number);
@@ -255,7 +284,8 @@ async function principal() {
255
284
  }
256
285
 
257
286
  dizer(`\n${negrito(azul("▶ Vibe Render"))} ${cinza(`v${VERSAO}`)} ${cinza("· exporta os vídeos do Editor IA neste computador")}\n`);
258
- versaoNova().then((v) => v && dizer(`${ambar("!")} Saiu a versão ${v}. Feche e abra de novo com ${azul("npx vibeeditor-render@latest")}.`));
287
+ // versão nova no npm: abre direto nela (esta só espera); sem internet, segue nesta
288
+ await atualizarSeTiver(process.argv.slice(2));
259
289
 
260
290
  const {PORTAS} = require("../core/config");
261
291
  const outra = await jaAberto(PORTAS);
package/core/ia.js ADDED
@@ -0,0 +1,375 @@
1
+ // Motor de IA "Claude Code" do Editor IA: em vez de o site pagar a API por token, o Vibe Render roda o Claude Code
2
+ // instalado neste computador (`claude -p`, binário original da Anthropic), logado na assinatura da própria pessoa
3
+ // (Pro/Max): o gasto sai do limite do plano dela. Nada de login passa por aqui: o Claude Code usa o login que a
4
+ // pessoa fez nele, e o Vibe Render tira ANTHROPIC_API_KEY do ambiente para não cair na cobrança da API.
5
+ //
6
+ // As ferramentas continuam no site (os projetos moram no navegador, no OPFS): o Vibe Render expõe um servidor MCP
7
+ // local por conversa com as definições que o site mandou; cada chamada vira um evento "chamada" para o site, que
8
+ // roda a ferramenta e devolve o resultado (POST .../resultado). A pesquisa na internet usa o WebSearch/WebFetch
9
+ // do próprio Claude Code. Rotas (sessão do site conferida pelo servidor):
10
+ // GET /v1/ia/status {instalado, versao, logado, plano, email, erro}
11
+ // POST /v1/ia/conversas {sistema, mensagem, ferramentas, modelo, esforco, web, sessao?} → {id}
12
+ // GET /v1/ia/conversas/<id>/eventos?desde=N eventos a partir do N (espera até ~25 s por novidade)
13
+ // POST /v1/ia/conversas/<id>/resultado {chamada, conteudo, erro}
14
+ // POST /v1/ia/conversas/<id>/cancelar
15
+ // POST /v1/ia/mcp/<id> o servidor MCP (só o processo do claude, com o token da conversa)
16
+ const fs = require("node:fs");
17
+ const os = require("node:os");
18
+ const path = require("node:path");
19
+ const crypto = require("node:crypto");
20
+ const {spawn, execFile} = require("node:child_process");
21
+
22
+ const MAX_SIMULTANEAS = 3;
23
+ const ESPERA_EVENTOS = 25000;
24
+ const GUARDA_DEPOIS_DO_FIM = 10 * 60000; // o site pode reconectar e ler o fim depois
25
+ const PREFIXO_MCP = "mcp__vibe__";
26
+ const MODELOS = new Set(["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]);
27
+ const ESFORCOS = new Set(["low", "medium", "high", "xhigh", "max"]);
28
+ const WEB = ["WebSearch", "WebFetch"];
29
+
30
+ const erro = (status, mensagem) => Object.assign(new Error(mensagem), {status});
31
+
32
+ /** onde está o executável do Claude Code (o .cmd do npm só chama o .exe: chamamos direto, sem shell) */
33
+ function acharClaude(env = process.env, plataforma = process.platform, casa = os.homedir()) {
34
+ const existe = (p) => {
35
+ try {
36
+ return fs.statSync(p).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ };
41
+ if (env.VIBE_RENDER_CLAUDE && existe(env.VIBE_RENDER_CLAUDE)) return env.VIBE_RENDER_CLAUDE;
42
+ const pastas = String(env.PATH || env.Path || "").split(path.delimiter).filter(Boolean);
43
+ if (plataforma === "win32") {
44
+ pastas.push(path.join(casa, ".local", "bin"));
45
+ for (const d of pastas) {
46
+ const exe = path.join(d, "claude.exe");
47
+ if (existe(exe)) return exe;
48
+ const cmd = path.join(d, "claude.cmd");
49
+ if (existe(cmd)) {
50
+ const m = /"%dp0%\\([^"]+\.exe)"/i.exec(fs.readFileSync(cmd, "utf8"));
51
+ if (m && existe(path.join(d, m[1]))) return path.join(d, m[1]);
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ pastas.push(path.join(casa, ".local", "bin"), path.join(casa, ".claude", "local"));
57
+ for (const d of pastas) if (existe(path.join(d, "claude"))) return path.join(d, "claude");
58
+ return null;
59
+ }
60
+
61
+ /** ambiente do claude: sem chave de API (senão cobra a API em vez da assinatura) e sem o contexto do computador */
62
+ function ambiente(env = process.env) {
63
+ const e = {...env};
64
+ delete e.ANTHROPIC_API_KEY;
65
+ delete e.ANTHROPIC_AUTH_TOKEN;
66
+ delete e.CLAUDECODE; // se o Vibe Render subiu de dentro de um Claude Code, o filho não pode achar que é aninhado
67
+ e.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
68
+ e.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
69
+ e.MCP_TOOL_TIMEOUT = String(2 * 60 * 60000); // preparar/render de vídeo longo roda no site e pode demorar
70
+ e.MCP_TIMEOUT = "30000";
71
+ return e;
72
+ }
73
+
74
+ /** argumentos do claude -p para uma volta da conversa */
75
+ function argumentos({modelo, esforco, web, sessao, arqSistema, arqMcp}) {
76
+ const a = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--input-format", "stream-json",
77
+ "--model", modelo, "--effort", esforco, "--system-prompt-file", arqSistema,
78
+ "--mcp-config", arqMcp, "--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands",
79
+ "--permission-mode", "dontAsk", "--allowedTools", "mcp__vibe", ...(web ? WEB : []),
80
+ "--tools", ...(web ? WEB : [""])];
81
+ if (sessao) a.push("--resume", sessao);
82
+ return a;
83
+ }
84
+
85
+ /** blocos da Messages API (o que as ferramentas do site devolvem) → conteúdo do MCP */
86
+ function paraMcp(conteudo) {
87
+ if (conteudo == null || conteudo === "") return [{type: "text", text: "ok"}];
88
+ if (typeof conteudo === "string") return [{type: "text", text: conteudo}];
89
+ const blocos = Array.isArray(conteudo) ? conteudo : [conteudo];
90
+ return blocos.map((b) => {
91
+ if (b && b.type === "image" && b.source && b.source.type === "base64") return {type: "image", data: b.source.data, mimeType: b.source.media_type};
92
+ if (b && b.type === "text") return {type: "text", text: String(b.text || "")};
93
+ return {type: "text", text: typeof b === "string" ? b : JSON.stringify(b)};
94
+ });
95
+ }
96
+
97
+ /** valida o pedido do site; o system prompt e as ferramentas vêm do site, mas os limites são daqui */
98
+ function validarConversa(d) {
99
+ if (!d || typeof d !== "object") throw erro(400, "corpo inválido");
100
+ if (typeof d.sistema !== "string" || !d.sistema.trim()) throw erro(400, "sistema: texto obrigatório");
101
+ if (!d.mensagem || typeof d.mensagem !== "object" || !Array.isArray(d.mensagem.content)) throw erro(400, "mensagem: {content: [...]}");
102
+ const ferramentas = Array.isArray(d.ferramentas) ? d.ferramentas : [];
103
+ if (ferramentas.length > 80) throw erro(400, "ferramentas demais");
104
+ for (const f of ferramentas) {
105
+ if (!f || !/^[a-z0-9_]{1,60}$/.test(String(f.name || ""))) throw erro(400, `nome de ferramenta inválido: ${f && f.name}`);
106
+ }
107
+ const modelo = MODELOS.has(d.modelo) ? d.modelo : "claude-opus-5-5";
108
+ const esforco = ESFORCOS.has(d.esforco) ? d.esforco : "high";
109
+ const sessao = d.sessao == null ? null : String(d.sessao);
110
+ if (sessao && !/^[0-9a-f-]{36}$/i.test(sessao)) throw erro(400, "sessao inválida");
111
+ return {sistema: d.sistema, mensagem: {role: "user", content: d.mensagem.content}, ferramentas, modelo, esforco, web: d.web === true, sessao};
112
+ }
113
+
114
+ /** texto do erro que o claude devolveu, em português quando é o limite da assinatura */
115
+ function erroDoClaude(texto) {
116
+ const t = String(texto || "").trim();
117
+ if (/usage limit|limit reached|rate.?limit|5-hour|weekly limit|out of extra usage/i.test(t)) {
118
+ return `O limite da sua assinatura do Claude acabou por agora (${t.slice(0, 200)}). Espere renovar ou troque o motor para API em Configurações → Claude.`;
119
+ }
120
+ if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return "O Claude Code deste computador não está logado. Abra um terminal, rode `claude` e faça o login com a sua conta.";
121
+ return t.slice(0, 1500) || "O Claude Code parou sem dizer o motivo.";
122
+ }
123
+
124
+ /** encerra o processo e os filhos (no Windows o kill não leva a árvore junto) */
125
+ function encerrar(p) {
126
+ if (!p || p.exitCode !== null) return;
127
+ if (process.platform === "win32") execFile("taskkill", ["/pid", String(p.pid), "/T", "/F"], {windowsHide: true}, () => {});
128
+ else p.kill("SIGTERM");
129
+ }
130
+
131
+ /**
132
+ * opc: {dirDados, porta() (do servidor, para o MCP), env, log, spawn (injetável), acharClaude (injetável), versao}
133
+ */
134
+ function criarIa(opc) {
135
+ const env = opc.env || process.env;
136
+ const log = opc.log || (() => {});
137
+ const rodar = opc.spawn || spawn;
138
+ const dir = path.join(opc.dirDados, "ia");
139
+ const trabalho = path.join(dir, "trabalho"); // cwd fixo: o --resume procura a sessão pela pasta
140
+ const conversas = new Map();
141
+ let statusCache = null;
142
+
143
+ const bin = () => (opc.acharClaude || acharClaude)(env);
144
+
145
+ function executar(binario, args, ms = 20000) {
146
+ return new Promise((ok) => {
147
+ execFile(binario, args, {env: ambiente(env), timeout: ms, windowsHide: true}, (e, out, err) => ok({e, out: String(out || ""), err: String(err || "")}));
148
+ });
149
+ }
150
+
151
+ /** o Claude Code está instalado e logado? (cache de 30 s: a tela pergunta sempre que abre) */
152
+ async function status(forcar = false) {
153
+ if (!forcar && statusCache && Date.now() - statusCache.em < 30000) return statusCache.v;
154
+ const b = bin();
155
+ let v;
156
+ if (!b) v = {instalado: false, erro: "Não encontrei o Claude Code neste computador."};
157
+ else {
158
+ const [ver, auth] = await Promise.all([executar(b, ["--version"]), executar(b, ["auth", "status"])]);
159
+ let a = {};
160
+ try {
161
+ a = JSON.parse(auth.out);
162
+ } catch {
163
+ /* versão antiga sem JSON: fica sem os detalhes */
164
+ }
165
+ v = {instalado: true, versao: ver.out.trim().split(/\s/)[0] || null, logado: a.loggedIn === true,
166
+ metodo: a.authMethod || null, plano: a.subscriptionType || null, email: a.email || null};
167
+ if (v.logado && v.metodo !== "claude.ai") v.aviso = "O Claude Code deste computador está logado com chave de API: o gasto vai para a API, não para a assinatura.";
168
+ }
169
+ statusCache = {em: Date.now(), v};
170
+ return v;
171
+ }
172
+
173
+ function emitir(c, ev) {
174
+ c.eventos.push({...ev, n: c.eventos.length});
175
+ for (const f of c.ouvintes) f();
176
+ c.ouvintes.clear();
177
+ }
178
+
179
+ function terminar(c, fim) {
180
+ if (c.fim) return;
181
+ c.fim = fim;
182
+ for (const [, p] of c.pendentes) p.ok({erro: true, conteudo: "a conversa terminou"});
183
+ c.pendentes.clear();
184
+ emitir(c, {tipo: "fim", ...fim});
185
+ setTimeout(() => {
186
+ conversas.delete(c.id);
187
+ fs.rm(c.pasta, {recursive: true, force: true}, () => {});
188
+ }, GUARDA_DEPOIS_DO_FIM).unref();
189
+ }
190
+
191
+ /** uma linha do stream-json do claude → eventos para o site */
192
+ function lerLinha(c, linha) {
193
+ let j;
194
+ try {
195
+ j = JSON.parse(linha);
196
+ } catch {
197
+ return;
198
+ }
199
+ if (j.type === "system" && j.subtype === "init") {
200
+ c.sessao = j.session_id || c.sessao;
201
+ emitir(c, {tipo: "inicio", sessao: c.sessao, modelo: j.model});
202
+ return;
203
+ }
204
+ if (j.type === "stream_event" && j.event) {
205
+ const ev = j.event;
206
+ if (ev.type === "content_block_delta" && ev.delta && ev.delta.type === "text_delta") emitir(c, {tipo: "texto", delta: ev.delta.text});
207
+ if (ev.type === "content_block_start" && ev.content_block && ev.content_block.type === "tool_use") {
208
+ const nome = String(ev.content_block.name || "");
209
+ const nosso = nome.startsWith(PREFIXO_MCP);
210
+ emitir(c, {tipo: "ferramenta", nome: nosso ? nome.slice(PREFIXO_MCP.length) : nome, id: ev.content_block.id, servidor: !nosso});
211
+ }
212
+ return;
213
+ }
214
+ if (j.type === "assistant" && j.message) {
215
+ for (const b of j.message.content || []) {
216
+ if (b.type === "tool_use" && WEB.includes(b.name)) c.web.set(b.id, b);
217
+ if (b.type === "text" && b.text) c.ultimoTexto = b.text;
218
+ }
219
+ if (j.message.usage) emitir(c, {tipo: "uso", uso: j.message.usage, modelo: j.message.model});
220
+ return;
221
+ }
222
+ if (j.type === "user" && j.message && Array.isArray(j.message.content)) {
223
+ for (const b of j.message.content) {
224
+ const pedido = b.type === "tool_result" && c.web.get(b.tool_use_id);
225
+ if (!pedido) continue;
226
+ const alvo = String((pedido.input && (pedido.input.query || pedido.input.url)) || "").slice(0, 160);
227
+ const nome = pedido.name === "WebSearch" ? "web_search" : "web_fetch";
228
+ emitir(c, {tipo: "ferramenta_fim", nome, id: pedido.id, entrada: pedido.input, alvo, servidor: true, erro: !!b.is_error, resumo: b.is_error ? "erro" : (nome === "web_search" ? "resultados" : "página lida")});
229
+ }
230
+ return;
231
+ }
232
+ if (j.type === "result") {
233
+ c.resultado = j;
234
+ }
235
+ }
236
+
237
+ async function criar(corpo) {
238
+ const d = validarConversa(corpo);
239
+ const ativas = [...conversas.values()].filter((c) => !c.fim).length;
240
+ if (ativas >= MAX_SIMULTANEAS) throw erro(429, `Já há ${ativas} execuções da IA neste computador. Espere uma terminar.`);
241
+ const b = bin();
242
+ if (!b) throw erro(409, "Não encontrei o Claude Code neste computador. Instale (code.claude.com) e faça o login com a sua conta.");
243
+
244
+ const id = crypto.randomUUID();
245
+ const token = crypto.randomBytes(24).toString("hex");
246
+ const pasta = path.join(dir, "conversas", id);
247
+ fs.mkdirSync(pasta, {recursive: true});
248
+ fs.mkdirSync(trabalho, {recursive: true});
249
+ const arqSistema = path.join(pasta, "sistema.md");
250
+ const arqMcp = path.join(pasta, "mcp.json");
251
+ fs.writeFileSync(arqSistema, d.sistema);
252
+ fs.writeFileSync(arqMcp, JSON.stringify({mcpServers: {vibe: {type: "http", url: `http://127.0.0.1:${opc.porta()}/v1/ia/mcp/${id}`, headers: {Authorization: `Bearer ${token}`}}}}));
253
+
254
+ const c = {id, token, pasta, ferramentas: d.ferramentas, eventos: [], ouvintes: new Set(), pendentes: new Map(), web: new Map(),
255
+ sessao: d.sessao, ultimoTexto: "", resultado: null, fim: null, proc: null, saidaErro: ""};
256
+ conversas.set(id, c);
257
+
258
+ const p = rodar(b, argumentos({...d, arqSistema, arqMcp}), {cwd: trabalho, env: ambiente(env), windowsHide: true, stdio: ["pipe", "pipe", "pipe"]});
259
+ c.proc = p;
260
+ let resto = "";
261
+ p.stdout.on("data", (buf) => {
262
+ resto += buf.toString("utf8");
263
+ const linhas = resto.split("\n");
264
+ resto = linhas.pop();
265
+ for (const l of linhas) if (l.trim()) lerLinha(c, l);
266
+ });
267
+ p.stderr.on("data", (buf) => {
268
+ c.saidaErro = (c.saidaErro + buf.toString("utf8")).slice(-4000);
269
+ });
270
+ p.on("error", (e) => terminar(c, {motivo: "erro", erro: `Não consegui abrir o Claude Code: ${e.message}`}));
271
+ p.on("close", (code) => {
272
+ if (resto.trim()) lerLinha(c, resto);
273
+ const r = c.resultado;
274
+ if (c.cancelado) return terminar(c, {motivo: "parado", sessao: c.sessao});
275
+ if (r && !r.is_error && r.subtype === "success") {
276
+ return terminar(c, {motivo: "fim", sessao: r.session_id || c.sessao, texto: String(r.result || c.ultimoTexto || ""), custo: r.total_cost_usd, uso: r.usage, voltas: r.num_turns});
277
+ }
278
+ if (r && r.subtype === "error_max_turns") return terminar(c, {motivo: "limite", sessao: c.sessao});
279
+ const texto = (r && (r.result || (r.errors || []).join(" "))) || c.saidaErro || `saiu com código ${code}`;
280
+ log("ia", `claude terminou com erro: ${String(texto).slice(0, 300)}`);
281
+ terminar(c, {motivo: "erro", sessao: c.sessao, erro: erroDoClaude(texto)});
282
+ });
283
+ // a mensagem da pessoa vai pelo stdin (stream-json aceita imagens e texto longo sem passar pela linha de comando)
284
+ p.stdin.on("error", () => {});
285
+ p.stdin.end(`${JSON.stringify({type: "user", message: d.mensagem})}\n`);
286
+ log("ia", `conversa ${id.slice(0, 8)} · ${d.modelo} · ${d.ferramentas.length} ferramentas${d.sessao ? " · continuando" : ""}`);
287
+ return {id};
288
+ }
289
+
290
+ /** eventos a partir de `desde`; se ainda não há nada novo, espera até ESPERA_EVENTOS */
291
+ function eventos(id, desde, sinal) {
292
+ const c = conversas.get(id);
293
+ if (!c) return Promise.resolve(null);
294
+ const pegar = () => ({eventos: c.eventos.slice(desde), fim: !!c.fim});
295
+ if (c.eventos.length > desde || c.fim) return Promise.resolve(pegar());
296
+ return new Promise((ok) => {
297
+ const pronto = () => {
298
+ clearTimeout(t);
299
+ ok(pegar());
300
+ };
301
+ const t = setTimeout(() => {
302
+ c.ouvintes.delete(pronto);
303
+ ok(pegar());
304
+ }, ESPERA_EVENTOS);
305
+ c.ouvintes.add(pronto);
306
+ if (sinal) sinal.once("close", () => {
307
+ clearTimeout(t); c.ouvintes.delete(pronto);
308
+ });
309
+ });
310
+ }
311
+
312
+ function resultado(id, corpo) {
313
+ const c = conversas.get(id);
314
+ if (!c) throw erro(404, "conversa não encontrada");
315
+ const p = c.pendentes.get(String(corpo && corpo.chamada));
316
+ if (!p) throw erro(404, "chamada não encontrada (já respondida ou a conversa terminou)");
317
+ c.pendentes.delete(String(corpo.chamada));
318
+ p.ok({erro: corpo.erro === true, conteudo: corpo.conteudo});
319
+ return {ok: true};
320
+ }
321
+
322
+ function cancelar(id) {
323
+ const c = conversas.get(id);
324
+ if (!c) throw erro(404, "conversa não encontrada");
325
+ if (!c.fim) {
326
+ c.cancelado = true;
327
+ encerrar(c.proc);
328
+ }
329
+ return {ok: true};
330
+ }
331
+
332
+ /** o servidor MCP da conversa (JSON-RPC por HTTP, respostas em JSON) */
333
+ async function mcp(id, autorizacao, mensagem) {
334
+ const c = conversas.get(id);
335
+ const tk = String(autorizacao || "").replace(/^Bearer\s+/i, "");
336
+ if (!c || !tk || tk.length !== c.token.length || !crypto.timingSafeEqual(Buffer.from(tk), Buffer.from(c.token))) throw erro(401, "token");
337
+ if (Array.isArray(mensagem)) return (await Promise.all(mensagem.map((m) => responder(c, m)))).filter(Boolean);
338
+ return responder(c, mensagem);
339
+ }
340
+
341
+ async function responder(c, m) {
342
+ if (!m || typeof m !== "object") return {jsonrpc: "2.0", id: null, error: {code: -32600, message: "pedido inválido"}};
343
+ if (m.id === undefined || m.id === null) return null; // notificação: nada a responder
344
+ const ok = (result) => ({jsonrpc: "2.0", id: m.id, result});
345
+ const p = m.params || {};
346
+ if (m.method === "initialize") {
347
+ return ok({protocolVersion: p.protocolVersion || "2025-06-18", capabilities: {tools: {}}, serverInfo: {name: "vibe", version: opc.versao || "0"}});
348
+ }
349
+ if (m.method === "ping") return ok({});
350
+ if (m.method === "tools/list") {
351
+ return ok({tools: c.ferramentas.map((f) => ({name: f.name, description: f.description || "", inputSchema: f.input_schema || {type: "object"}}))});
352
+ }
353
+ if (m.method === "tools/call") {
354
+ if (c.fim) return ok({content: [{type: "text", text: "a conversa terminou"}], isError: true});
355
+ const chamada = crypto.randomUUID();
356
+ const r = await new Promise((resolver) => {
357
+ c.pendentes.set(chamada, {ok: resolver});
358
+ emitir(c, {tipo: "chamada", chamada, nome: String(p.name || ""), entrada: p.arguments || {}});
359
+ });
360
+ return ok({content: paraMcp(r.conteudo), isError: !!r.erro});
361
+ }
362
+ return {jsonrpc: "2.0", id: m.id, error: {code: -32601, message: `método desconhecido: ${m.method}`}};
363
+ }
364
+
365
+ function cancelarTodas() {
366
+ for (const c of conversas.values()) if (!c.fim) {
367
+ c.cancelado = true;
368
+ encerrar(c.proc);
369
+ }
370
+ }
371
+
372
+ return {status, criar, eventos, resultado, cancelar, mcp, cancelarTodas, _conversas: conversas};
373
+ }
374
+
375
+ module.exports = {criarIa, acharClaude, argumentos, paraMcp, validarConversa, erroDoClaude, ambiente};
package/core/jobs.js CHANGED
@@ -45,7 +45,9 @@ const NOMES_NORMALIZAVEIS = ["original"];
45
45
  /** quantas vezes um job tenta renderizar quando a falha é passageira (do compositor, não do código do vídeo) */
46
46
  const TENTATIVAS = 2;
47
47
  /** erros do compositor do Remotion que não dependem do código nem dos arquivos: vale tentar de novo */
48
- const falhaPassageira = (e) => /No frame found at position|Compositor exited|Compositor quit|Request closed/i.test(String((e && e.message) || e));
48
+ // delayRender/timeout: quadro que demorou a carregar (vídeo-base pesado, máquina ocupada) — repete com 1 aba, como o
49
+ // hv render do editor local (ee515e1)
50
+ const falhaPassageira = (e) => /No frame found at position|Compositor exited|Compositor quit|Request closed|delayRender|timed out|timeout/i.test(String((e && e.message) || e));
49
51
 
50
52
  /** valida o corpo do POST /v1/jobs; devolve a especificação limpa ou lança {status, message} */
51
53
  function validarJob(d, cache) {
@@ -223,12 +225,14 @@ function criarJobs(opc) {
223
225
  ffmpeg = await normalizarOriginais(j, ffmpeg, cancel);
224
226
  const ativos = Object.fromEntries(Object.entries(j.arquivos).map(([nome, sha]) => [nome, opc.urlAtivo(j.id, sha, j.token)]));
225
227
  const inputProps = {codigo: j.codigo, props: j.props, ativos, composicao: j.composicao};
226
- const comum = {browserExecutable: opc.navegador || null, binariesDirectory: opc.binarios || null, chromeMode: "headless-shell", logLevel: "warn"};
228
+ // 3D (three.js / kit Motion explicativo) precisa de WebGL de verdade: "angle" usa a placa de vídeo pelo ANGLE
229
+ const gl = usaWebGL(j.codigo) ? "angle" : null;
230
+ const comum = {browserExecutable: opc.navegador || null, binariesDirectory: opc.binarios || null, chromeMode: "headless-shell", logLevel: "warn", ...(gl ? {chromiumOptions: {gl}} : {})};
227
231
  const p = PRESETS[j.preset];
228
232
  fs.mkdirSync(opc.dirTmp, {recursive: true});
229
233
  for (let tentativa = 1; ; tentativa++) {
230
234
  try {
231
- browser = await R.openBrowser("chrome", {browserExecutable: comum.browserExecutable, chromeMode: "headless-shell", logLevel: "warn"});
235
+ browser = await R.openBrowser("chrome", {browserExecutable: comum.browserExecutable, chromeMode: "headless-shell", logLevel: "warn", ...(gl ? {chromiumOptions: {gl}} : {})});
232
236
  const composition = await R.selectComposition({...comum, serveUrl: opc.hostDir, id: "VibeHost", inputProps, puppeteerInstance: browser});
233
237
  mudar(j, {status: "renderizando", etapa: "Renderizando quadros"});
234
238
  // na repetição vai com 1 aba: vídeo com GOP longo / fps variável (ex.: WhatsApp, 1 quadro-chave a cada 10 s)
@@ -307,4 +311,10 @@ function criarJobs(opc) {
307
311
  return {criar, obter, interno, cancelar, ouvir, ativos, emUso: () => new Set([...jobs.values()].filter((j) => j.status !== "pronto").flatMap((j) => [...Object.values(j.arquivos || {}), ...Object.values(j.originais || {})]))};
308
312
  }
309
313
 
310
- module.exports = {criarJobs, validarJob, escalaDo, concorrencia, nomeSaida, falhaPassageira, TENTATIVAS, NOMES_NORMALIZAVEIS};
314
+ /** o código usa 3D (kit Motion explicativo, three.js)? Então o render precisa de WebGL de verdade (gl "angle") */
315
+ function usaWebGL(codigo) {
316
+ const c = String(codigo || "");
317
+ return ["@vibe/explicativo", "@remotion/three", "@react-three/fiber", 'require("three")', "require('three')"].some((m) => c.includes(m));
318
+ }
319
+
320
+ module.exports = {criarJobs, validarJob, escalaDo, concorrencia, nomeSaida, falhaPassageira, usaWebGL, TENTATIVAS, NOMES_NORMALIZAVEIS};
package/core/nucleo.js CHANGED
@@ -7,6 +7,7 @@ const {criarPareamento} = require("./pareamento");
7
7
  const {criarServidor} = require("./servidor");
8
8
  const {criarTranscricoes} = require("./transcricao");
9
9
  const {criarVoz} = require("./voz");
10
+ const {criarIa} = require("./ia");
10
11
  const {LIMITES, origensPermitidas, STATUS_INTERVALO} = require("./config");
11
12
 
12
13
  /**
@@ -25,7 +26,8 @@ async function criarNucleo(opc) {
25
26
  });
26
27
  const transcricoes = criarTranscricoes({cache, dirDados: opc.dirDados, dirTmp: path.join(opc.dirDados, "tmp"), whisper: opc.whisper});
27
28
  const voz = criarVoz({dirDados: opc.dirDados, env, urlInicial: opc.voiceStudio, log: opc.log});
28
- servidor = criarServidor({versao: opc.versao, origens: origensPermitidas(env), cache, jobs, transcricoes, voz, pareamento, mostrarArquivo: opc.mostrarArquivo, log: opc.log});
29
+ const ia = criarIa({dirDados: opc.dirDados, env, versao: opc.versao, porta: () => servidor.porta(), log: opc.log});
30
+ servidor = criarServidor({versao: opc.versao, origens: origensPermitidas(env), cache, jobs, transcricoes, voz, ia, pareamento, mostrarArquivo: opc.mostrarArquivo, log: opc.log});
29
31
  const porta = await servidor.iniciar(opc.portas);
30
32
  pareamento.conferir();
31
33
  voz.aoIniciar(); // abre o VoiceStudio junto, se a pessoa pediu (Configurações → VoiceStudio)
@@ -37,6 +39,7 @@ async function criarNucleo(opc) {
37
39
  clearInterval(relogio);
38
40
  for (const j of jobs.ativos()) jobs.cancelar(j.id);
39
41
  transcricoes.cancelarTodas();
42
+ ia.cancelarTodas();
40
43
  await servidor.parar();
41
44
  },
42
45
  };
package/core/servidor.js CHANGED
@@ -99,6 +99,24 @@ function criarServidor(opc) {
99
99
  return enviarArquivo(req, res, opc.cache.caminho(sha), {"Access-Control-Allow-Origin": "*", "Cache-Control": "no-store"});
100
100
  }
101
101
 
102
+ // servidor MCP da conversa de IA: quem chama é o processo do claude deste computador (sem Origin), com o token
103
+ // aleatório da conversa; nenhum navegador tem esse token (core/ia.js)
104
+ if (partes[0] === "v1" && partes[1] === "ia" && partes[2] === "mcp" && partes.length === 4 && opc.ia) {
105
+ if (req.headers.origin) return json(403, {erro: "origem não permitida"});
106
+ if (req.method !== "POST") {
107
+ res.writeHead(405, {Allow: "POST"}); return res.end();
108
+ }
109
+ try {
110
+ const r = await opc.ia.mcp(partes[3], req.headers.authorization, await lerJson(req, LIMITES.jsonBytes));
111
+ if (r === null || (Array.isArray(r) && !r.length)) {
112
+ res.writeHead(202); return res.end();
113
+ }
114
+ return json(200, r);
115
+ } catch (e) {
116
+ return json(e.status || 500, {erro: e.status ? e.message : "erro interno"});
117
+ }
118
+ }
119
+
102
120
  const origin = req.headers.origin;
103
121
  if (!origemPermitida(origin, opc.origens)) return json(403, {erro: "origem não permitida"});
104
122
  const cors = {
@@ -116,7 +134,7 @@ function criarServidor(opc) {
116
134
  const p = opc.pareamento.visao();
117
135
  return json(200, {app: "vibe-render", versao: opc.versao, pareado: p.pareado, dispositivoId: p.dispositivoId, email: p.email,
118
136
  plataforma: process.platform, porta, acessoPlataforma: p.plataforma,
119
- recursos: [...(opc.transcricoes ? ["transcricao"] : []), ...(opc.voz ? ["voz", "voz-abrir"] : [])]}, cors);
137
+ recursos: [...(opc.transcricoes ? ["transcricao"] : []), ...(opc.voz ? ["voz", "voz-abrir"] : []), ...(opc.ia ? ["ia"] : [])]}, cors);
120
138
  }
121
139
 
122
140
  // daqui para baixo: sessão válida deste aparelho
@@ -161,6 +179,21 @@ function criarServidor(opc) {
161
179
  if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
162
180
  if (await opc.voz.rota(req, res, partes.slice(2), {json, cors})) return;
163
181
  }
182
+ // motor de IA "Claude Code" (assinatura da pessoa): core/ia.js
183
+ if (partes[0] === "v1" && partes[1] === "ia" && opc.ia) {
184
+ if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
185
+ if (partes[2] === "status" && partes.length === 3 && req.method === "GET") return json(200, await opc.ia.status(u.searchParams.get("forcar") === "1"), cors);
186
+ if (partes[2] === "conversas" && partes.length === 3 && req.method === "POST") return json(201, await opc.ia.criar(await lerJson(req, LIMITES.jsonBytes)), cors);
187
+ if (partes[2] === "conversas" && partes[3] && partes.length === 5) {
188
+ const id = partes[3];
189
+ if (partes[4] === "eventos" && req.method === "GET") {
190
+ const r = await opc.ia.eventos(id, Math.max(0, Number(u.searchParams.get("desde")) || 0), req);
191
+ return r ? json(200, r, cors) : json(404, {erro: "conversa não encontrada"}, cors);
192
+ }
193
+ if (partes[4] === "resultado" && req.method === "POST") return json(200, opc.ia.resultado(id, await lerJson(req, LIMITES.jsonBytes)), cors);
194
+ if (partes[4] === "cancelar" && req.method === "POST") return json(200, opc.ia.cancelar(id), cors);
195
+ }
196
+ }
164
197
  if (partes[0] === "v1" && partes[1] === "jobs" && partes[2]) {
165
198
  const id = partes[2];
166
199
  const j = opc.jobs.obter(id);