primocode 11.2.0 → 11.3.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v11.2.0
1
+ # PrimoCode v11.3.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v11.2.0
12
+ ▐▛███▜▌ Primo Code v11.3.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const os = require('os');
8
8
  const path = require('path');
9
9
 
10
- const ICONES = ['workflow', 'film', 'palette', 'pen', 'folder', 'shield', 'bell', 'sliders', 'home'];
10
+ const ICONES = ['workflow', 'film', 'palette', 'pen', 'folder', 'shield', 'bell', 'sliders', 'home', 'scan-search', 'flask-conical', 'git-branch', 'pen-line', 'scissors', 'captions', 'trending-up', 'send', 'sparkles'];
11
11
  const hexOk = (h) => /^#[0-9a-f]{6}$/i.test(String(h || ''));
12
12
  const pasta = (dir) => dir || process.env.PRIMOCODE_AGENTES || path.join(os.homedir(), '.primocode', 'agentes');
13
13
 
@@ -44,7 +44,10 @@ function gravar(a, dir) {
44
44
  function listar({ dir } = {}) {
45
45
  let nomes = [];
46
46
  try { nomes = fs.readdirSync(pasta(dir)).filter((n) => n.endsWith('.md')); } catch { /* ainda não há */ }
47
- return nomes.map((n) => ler(n.slice(0, -3), { dir })).filter(Boolean).sort((a, b) => a.nome.localeCompare(b.nome));
47
+ /* Na ordem em que foram criados (o arquivo mais antigo primeiro); empate por nome. */
48
+ const nasc = (n) => { try { return fs.statSync(path.join(pasta(dir), n)).birthtimeMs; } catch { return 0; } };
49
+ return nomes.map((n) => ({ n, t: nasc(n) })).sort((a, b) => a.t - b.t || a.n.localeCompare(b.n))
50
+ .map(({ n }) => ler(n.slice(0, -3), { dir })).filter(Boolean);
48
51
  }
49
52
 
50
53
  function validar(c, atual) {
@@ -39,7 +39,10 @@ async function main(args) {
39
39
  const falso = process.env.PRIMOCODE_MOTOR_FALSO;
40
40
  // PRIMOCODE_CONTA_FALSA=<arquivo.js>: { buscar, abrir } de mentira para o login (sem rede, sem navegador). Só para teste.
41
41
  const contaFalsa = process.env.PRIMOCODE_CONTA_FALSA;
42
+ // PRIMOCODE_TRANSCRITOR_FALSO=<arquivo.js>: async (wav) => ({ ok, texto }) no lugar do Whisper local. Só para teste de tela.
43
+ const transcritorFalso = process.env.PRIMOCODE_TRANSCRITOR_FALSO;
42
44
  const srv = criarServidor({
45
+ ...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
43
46
  ...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
44
47
  ...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
45
48
  });
@@ -0,0 +1,43 @@
1
+ // Endereços do computador para o pareamento do celular, na ordem em que valem a pena tentar.
2
+ // Achado (02/11): com VPN (Tailscale, 100.64.0.0/10) a lista vinha com o IP da VPN primeiro, o QR levava só ele e o celular
3
+ // no Wi-Fi não alcançava. Agora a rede local (192.168/16, 10/8, 172.16/12) vem antes, a VPN e os túneis por último,
4
+ // e o código do QR leva TODOS (o app do celular tenta um por um).
5
+ 'use strict';
6
+
7
+ const partes = (ip) => String(ip).split('.').map(Number);
8
+
9
+ /* 0 = LAN doméstica/escritório (melhor), 1 = outro endereço roteável, 2 = VPN/túnel/link-local (último recurso). */
10
+ function classe(ip, nome = '') {
11
+ const [a, b] = partes(ip);
12
+ if (/^(utun|tun|tap|ppp|ipsec|wg|tailscale|zt|vpn)/i.test(String(nome)) || /tailscale|zerotier|wireguard/i.test(String(nome))) return 2;
13
+ if (a === 100 && b >= 64 && b <= 127) return 2; // CGNAT 100.64.0.0/10: Tailscale e afins
14
+ if (a === 169 && b === 254) return 2; // link-local
15
+ if (a === 192 && b === 168) return 0;
16
+ if (a === 10) return 0;
17
+ if (a === 172 && b >= 16 && b <= 31) return 0;
18
+ return 1;
19
+ }
20
+ const peso = (ip) => { const [a, b] = partes(ip); return a === 192 && b === 168 ? 0 : a === 10 ? 1 : a === 172 ? 2 : 3; };
21
+
22
+ /* `itens`: [{ ip, nome? }] ou ['ip', ...]. Estável e sem repetição. */
23
+ function ordenar(itens) {
24
+ const lista = [];
25
+ const vistos = new Set();
26
+ for (const x of itens || []) {
27
+ const ip = typeof x === 'string' ? x : x && x.ip;
28
+ if (!ip || vistos.has(ip) || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) continue;
29
+ vistos.add(ip);
30
+ lista.push({ ip, nome: typeof x === 'string' ? '' : x.nome || '', i: lista.length });
31
+ }
32
+ lista.sort((p, q) => (classe(p.ip, p.nome) - classe(q.ip, q.nome)) || (peso(p.ip) - peso(q.ip)) || (p.i - q.i));
33
+ return lista.map((x) => x.ip);
34
+ }
35
+
36
+ /* Os IPv4 externos das interfaces, já ordenados. */
37
+ function locais(interfaces) {
38
+ const itens = [];
39
+ for (const [nome, lista] of Object.entries(interfaces || {})) for (const i of lista || []) if (i.family === 'IPv4' && !i.internal) itens.push({ ip: i.address, nome });
40
+ return ordenar(itens);
41
+ }
42
+
43
+ module.exports = { ordenar, locais, classe };
@@ -17,7 +17,7 @@ const { criarPortao } = require('../motor/aprovacao');
17
17
  const { ERRO, erro } = P;
18
18
  const MAX_LEITURA = 1024 * 1024;
19
19
  const BLOQUEADOS = /(^|[\\/])(\.ssh|\.aws|\.gnupg|\.env[^\\/]*|app-server\.json|pareamentos\.json|config\.json)$/;
20
- const CAMPOS_CONFIG = ['effort', 'model', 'mode', 'permissoes', 'userName', 'mascot', 'reacoes', 'theme', 'autoApply', 'piloto', 'grupo', 'inicioFeito'];
20
+ const CAMPOS_CONFIG = ['effort', 'model', 'mode', 'permissoes', 'userName', 'mascot', 'reacoes', 'theme', 'autoApply', 'piloto', 'grupo', 'inicioFeito', 'preferencias'];
21
21
  const PILOTO_MS = Number(process.env.PRIMOCODE_PILOTO_MS) || 15 * 60000;
22
22
 
23
23
  const RAIZ_TELA = path.join(__dirname, '..', '..', 'app', 'renderer');
@@ -26,13 +26,19 @@ const TIPOS_ESTATICOS = {
26
26
  '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
27
27
  };
28
28
 
29
- /* O que um celular pareado pode pedir ao motor: acompanhar, aprovar, mandar pedido, ver entrega e histórico.
30
- Fica de fora: arquivos, configuração, projetos e agentes (escrita), terminal, abrir coisas no computador. */
29
+ /* O que um celular pareado pode pedir ao motor. Desde 02/11 o app do celular tem TODAS as telas do desktop (Início, Studio, Projetos,
30
+ Agentes, Kit da marca, Avisos, Configurações, busca, voz), então as telas ganharam os pedidos delas, com limites:
31
+ config/write só nos campos de preferência (nunca permissões, piloto, modelo ou servidor), voice/start sem texto (não gera fala no
32
+ computador), fs/list e fs/read continuam de fora (arquivo do computador não vai para o celular).
33
+ Fica de fora: arquivos, permissões, piloto automático, abrir coisas no computador, conta (entrar/sair), pareamento, telas do Estúdio. */
31
34
  const PAREADO_PODE = new Set([
32
- 'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'turn/start', 'turn/interrupt', 'turn/queue',
35
+ 'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'thread/archive', 'turn/start', 'turn/interrupt', 'turn/queue',
33
36
  'approval/respond', 'question/respond', 'notice/list', 'notice/read', 'studio/history', 'project/list', 'agent/list', 'config/read',
34
37
  'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
38
+ 'project/create', 'project/update', 'project/delete', 'agent/create', 'agent/update', 'agent/delete', 'agent/toggle',
39
+ 'brand/read', 'brand/write', 'config/write', 'voice/start', 'voice/stop', 'voice/transcribe',
35
40
  ]);
41
+ const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
36
42
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
37
43
 
38
44
  const texto = (v) => (typeof v === 'string' ? v : '');
@@ -47,7 +53,16 @@ function abrirNoSistema(abs) {
47
53
  }
48
54
  const MIDIAS = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
49
55
 
50
- function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {} } = {}) {
56
+ /* O login do GitHub, lido do hosts.yml do `gh` (sem rodar o gh). Devolve o usuário ou null. */
57
+ function githubConectado() {
58
+ try {
59
+ const t = fs.readFileSync(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8');
60
+ const m = /^\s+user:\s*(\S+)/m.exec(t);
61
+ return m ? m[1] : null;
62
+ } catch { return null; }
63
+ }
64
+
65
+ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor } = {}) {
51
66
  const cfg = require('../config/config');
52
67
  const m = motor || require('../motor');
53
68
  const base = casa || cfg.CONFIG_DIR;
@@ -82,6 +97,24 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
82
97
  let http_ = null;
83
98
  let porta = null;
84
99
 
100
+ /* A linha `primocode:abrir-gosto <url>` que o motor escreve no stderr (só com PRIMOCODE_APP=1, que o app liga) vira screen/open
101
+ para as telas do computador. O resto do stderr passa igual. */
102
+ let restoDoStderr = '';
103
+ const escreverStderr = process.stderr.write.bind(process.stderr);
104
+ const olharStderr = process.env.PRIMOCODE_APP === '1' && !process.env.PRIMOCODE_SEM_TELA_NO_APP;
105
+ function filtrarStderr(chunk) {
106
+ restoDoStderr = (restoDoStderr + String(chunk)).slice(-4000);
107
+ const linhas = restoDoStderr.split('\n');
108
+ restoDoStderr = linhas.pop();
109
+ for (const l of linhas) {
110
+ const url = require('../plugin/estudio/gosto').urlDaLinha(l);
111
+ if (url) for (const c of conexoes) { if (!c.pareado) { try { c.enviar(P.notificacao('screen/open', { url })); } catch { /* caiu */ } } }
112
+ }
113
+ }
114
+ if (olharStderr) {
115
+ process.stderr.write = (chunk, ...resto) => { try { filtrarStderr(chunk); } catch { /* nunca quebra o log */ } return escreverStderr(chunk, ...resto); };
116
+ }
117
+
85
118
  /* ── Conversas: um JSON por thread em ~/.primocode/threads ── */
86
119
  const arquivoDe = (id) => path.join(pastaThreads, `${id}.json`);
87
120
  function salvar(t) {
@@ -144,6 +177,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
144
177
  };
145
178
  }
146
179
 
180
+ /* Título da conversa (bug 2): depois do primeiro turno de uma conversa nova, quem leu a troca escreve o título
181
+ (lib/session/titulo-conversa.js) e a tela recebe thread/renamed. Falha ou demora: fica o provisório. */
182
+ function perguntarTitulo(prompt) {
183
+ if (typeof m.titular === 'function') return m.titular(prompt);
184
+ const c = cfg.loadConfig();
185
+ let resposta = '';
186
+ return require('../provider/api').requestChat(c.server, { prompt, history: [], model: c.model, token: c.token }, (evt) => { if (evt.delta) resposta += evt.delta; }).then(() => resposta);
187
+ }
188
+ function batizar(t, pedido, resposta) {
189
+ const provisorio = t.provisorio;
190
+ if (!provisorio) return;
191
+ t.provisorio = null;
192
+ const texto_ = typeof resposta === 'string' ? resposta : Array.isArray(resposta) ? resposta.map((x) => (x && x.text) || '').join('') : '';
193
+ require('../session/titulo-conversa').escolher({ pedido, resposta: texto_, perguntar: perguntarTitulo }).then((titulo) => {
194
+ if (!titulo || t.dados.titulo !== provisorio) return; // veio lixo, ou a pessoa já renomeou
195
+ t.dados.titulo = titulo.slice(0, 120);
196
+ salvar(t);
197
+ difundir('thread/renamed', { thread: t.dados.id, titulo: t.dados.titulo });
198
+ }).catch(() => { /* fica o provisório */ });
199
+ }
200
+
147
201
  /* ── Caminhos: só dentro das raízes permitidas, sem segredos ── */
148
202
  function caminhoSeguro(p) {
149
203
  const abs = path.resolve(texto(p));
@@ -214,7 +268,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
214
268
  const c = cfg.loadConfig();
215
269
  const projeto = texto(p.projeto) || dados.projeto || undefined;
216
270
  if (projeto) dados.projeto = projeto;
217
- if (dados.titulo === 'Nova conversa' && !dados.historico.length) dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60);
271
+ // O título provisório é o começo do pedido; quando o primeiro turno termina, o modelo escreve o de verdade (batizar).
272
+ if (dados.titulo === 'Nova conversa' && !dados.historico.length) { dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60); t.provisorio = dados.titulo; }
218
273
 
219
274
  // Menções (@arquivo) e anexos entram como instrução: não viram a fala da pessoa.
220
275
  const partes = [];
@@ -247,6 +302,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
247
302
  if (r && r.userMessage && !r.parado) {
248
303
  dados.historico.push(r.userMessage);
249
304
  if (r.assistantMessage) dados.historico.push(r.assistantMessage);
305
+ batizar(t, pedido, r.assistantMessage && r.assistantMessage.content);
250
306
  }
251
307
  }).catch((e) => {
252
308
  // O motor já emite turn/interrupted com o erro; aqui só garante que a tela saiba.
@@ -313,7 +369,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
313
369
  'brand/read'(p) {
314
370
  const vista = (k) => ({
315
371
  nome: k.nome, cores: k.cores || {}, fontes: k.fontes || {}, tom: k.tom || '', slogan: k.slogan || '',
316
- site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, contraste: Marca.validarContraste(k.cores || {}),
372
+ site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, logo: k.logo || null, contraste: Marca.validarContraste(k.cores || {}),
317
373
  });
318
374
  const ativa = (() => { try { return fs.readFileSync(path.join(Marca.raiz(), '.ativa'), 'utf8').trim() || null; } catch { return null; } })();
319
375
  if (p.nome) { const k = Marca.ler(p.nome); if (!k) throw erro(ERRO.NAO_ACHEI, `não há kit "${p.nome}"`); return { kit: vista(k), ativa }; }
@@ -328,7 +384,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
328
384
  for (const k of ['tom', 'slogan', 'site', 'redes']) if (k in p) m[k] = texto(p[k]).slice(0, 300);
329
385
  if (p.cores && typeof p.cores === 'object') m.cores = { ...(atual.cores || {}), ...Object.fromEntries(Object.entries(p.cores).map(([k, v]) => [k, Marca.normHex(v) || v])) };
330
386
  if (p.fontes && typeof p.fontes === 'object') m.fontes = { ...(atual.fontes || {}), titulo: texto(p.fontes.titulo || (atual.fontes || {}).titulo), texto: texto(p.fontes.texto || (atual.fontes || {}).texto) };
331
- const r = Marca.salvar(m);
387
+ // Logo vindo da tela: um data URL png ou svg de até 1 MB, gravado num arquivo temporário e copiado para o kit.
388
+ let logoArquivo = null;
389
+ if (p.logo && typeof p.logo.dataUrl === 'string') {
390
+ const mm = /^data:image\/(png|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(p.logo.dataUrl);
391
+ if (!mm) throw erro(ERRO.PARAMS, 'o logo tem que ser png ou svg');
392
+ const bytes = Buffer.from(mm[2], 'base64');
393
+ if (bytes.length > 1048576) throw erro(ERRO.PARAMS, 'o logo passa de 1 MB');
394
+ logoArquivo = path.join(os.tmpdir(), `primo-logo-${process.pid}-${Date.now()}.${mm[1] === 'png' ? 'png' : 'svg'}`);
395
+ fs.writeFileSync(logoArquivo, bytes, { mode: 0o600 });
396
+ }
397
+ let r;
398
+ try { r = Marca.salvar(m, { logoArquivo }); } finally { if (logoArquivo) { try { fs.unlinkSync(logoArquivo); } catch { /* já saiu */ } } }
332
399
  if (!r.ok) throw erro(ERRO.PARAMS, r.error);
333
400
  if (p.ativar) Marca.usar(nome);
334
401
  return metodos['brand/read']({ nome });
@@ -364,18 +431,38 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
364
431
  },
365
432
 
366
433
  /* ── Pareamento do celular (só o computador, nunca um celular pareado) ── */
367
- 'pairing/info'() { return { rede: modoRede, porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
434
+ 'pairing/info'() { return { rede: modoRede, porta: portaRede || porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
368
435
  'pairing/create'(p) {
369
436
  if (!modoRede) throw erro(ERRO.NEGADO, 'o servidor está só em 127.0.0.1. Suba com `primocode server --ws --rede` para parear um celular');
370
437
  const ips = enderecosLocais();
371
438
  if (!ips.length) throw erro(ERRO.NEGADO, 'não achei um endereço de rede local neste computador');
372
439
  const r = Pareamentos.criar(p.nome);
373
- const urls = ips.map((ip) => `ws://${ip}:${porta}`);
374
- const codigo = `${urls[0]}/?token=${r.token}`;
440
+ const urls = ips.map((ip) => `ws://${ip}:${portaRede || porta}`);
441
+ // O código leva TODOS os endereços: o primeiro vai no ws://, os outros em `&h=` (mesma porta). O app do celular tenta um por um.
442
+ const codigo = `${urls[0]}/?token=${r.token}${ips.length > 1 ? `&h=${ips.slice(1).join(',')}` : ''}`;
375
443
  const q = require('../qr').gerarQR(codigo, { ecc: 'M' });
376
444
  // A tela desenha o QR com estas linhas ('1' = módulo escuro); nenhum HTML vem do motor.
377
445
  return { ...r, urls, codigo, qr: { tamanho: q.tamanho, linhas: q.modulos.map((l) => l.map((b) => (b ? '1' : '0')).join('')) } };
378
446
  },
447
+ /* Telas do Estúdio dentro do app (estilos e gosto): o motor escreve a linha de protocolo no stderr (lib/plugin/estudio/gosto.js),
448
+ o servidor a vira `screen/open` e a tela mostra a página num quadro. `screen/dismiss` fecha pelo botão Fechar do quadro. */
449
+ async 'screen/dismiss'(p, conn) {
450
+ if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
451
+ const url = require('../plugin/estudio/gosto').urlDaLinha(`primocode:abrir-gosto ${texto(p.url)}`);
452
+ if (!url) throw erro(ERRO.PARAMS, 'url inválida');
453
+ const u = new URL(url);
454
+ await new Promise((ok) => {
455
+ const rq = http.request({ host: '127.0.0.1', port: u.port, path: '/fechar', method: 'POST', headers: { 'x-gosto-token': u.searchParams.get('t'), 'content-length': 0 } }, (r) => { r.resume(); r.on('end', ok); });
456
+ rq.on('error', ok); rq.setTimeout(3000, () => { rq.destroy(); ok(); }); rq.end();
457
+ });
458
+ return { ok: true };
459
+ },
460
+ /* Liga/desliga o modo rede sem reiniciar (botão "Conectar celular"). Só o computador. */
461
+ async 'pairing/enable'(p, conn) {
462
+ if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
463
+ const r = await mudarRede(p.ligar !== false);
464
+ return { ...r, enderecos: modoRede ? enderecosLocais() : [] };
465
+ },
379
466
  'pairing/list'() { return { pareamentos: Pareamentos.listar() }; },
380
467
  'pairing/revoke'(p) {
381
468
  const id = texto(p.id);
@@ -391,15 +478,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
391
478
  out.servidor = c.server;
392
479
  out.temToken = !!c.token;
393
480
  out.projetoAtual = c.currentProject;
481
+ out.github = githubConectado();
394
482
  if (conn && conn.pareado) out.pareado = true;
395
483
  return out;
396
484
  },
397
- 'config/write'(p) {
485
+ 'config/write'(p, conn) {
398
486
  const mudar = {};
399
- for (const k of CAMPOS_CONFIG) if (k in p) mudar[k] = p[k];
487
+ const campos = conn && conn.pareado ? CAMPOS_CONFIG_PAREADO : CAMPOS_CONFIG;
488
+ for (const k of campos) if (k in p) mudar[k] = p[k];
489
+ if (conn && conn.pareado && !Object.keys(mudar).length && CAMPOS_CONFIG.some((k) => k in p)) throw erro(ERRO.NEGADO, 'o celular só muda as preferências; o resto é do computador');
400
490
  if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, `nada para gravar (campos: ${CAMPOS_CONFIG.join(', ')})`);
401
491
  cfg.saveConfig(mudar);
402
- return metodos['config/read']();
492
+ return metodos['config/read']({}, conn);
403
493
  },
404
494
 
405
495
  /* ── Conta (login do app): só as funções de lib/config/conta.js. Nunca devolve token. ── */
@@ -430,8 +520,15 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
430
520
  },
431
521
 
432
522
  /* ── Voz (6.9): provedor trocável em lib/motor/voz.js; só gera arquivo, nunca toca ── */
433
- async 'voice/start'(p) { return Voz.iniciar({ texto: p && p.texto }); },
523
+ async 'voice/start'(p, conn) { return Voz.iniciar({ texto: conn && conn.pareado ? undefined : p && p.texto }); }, // o celular não manda o computador falar
434
524
  'voice/stop'() { return Voz.parar(); },
525
+ /* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
526
+ async 'voice/transcribe'(p) {
527
+ const T = require('../motor/transcricao');
528
+ const r = await T.transcreverBase64(p && p.audio, { pasta: path.join(base, 'voz'), ...(transcritor ? { transcritor } : {}) });
529
+ if (!r.ok) return { ok: false, error: r.error, comoResolver: r.comoResolver };
530
+ return { ok: true, texto: r.texto };
531
+ },
435
532
 
436
533
  /* ── Studio: histórico do que o Estúdio gerou e abrir no sistema ── */
437
534
  'studio/history'(p) {
@@ -579,10 +676,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
579
676
  }
580
677
  const tokenBate = (req) => !!autenticar(req);
581
678
  function enderecosLocais() {
582
- if (enderecos) return enderecos(); // teste: endereços de mentira
583
- const out = [];
584
- for (const lista of Object.values(os.networkInterfaces())) for (const i of lista || []) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
585
- return out;
679
+ if (enderecos) return require('./enderecos').ordenar(enderecos()); // teste: endereços de mentira
680
+ return require('./enderecos').locais(os.networkInterfaces()); // LAN primeiro, VPN (100.64/10, utun) por último
586
681
  }
587
682
  /* Anti-rebind: no padrão só 127.0.0.1/localhost; com --rede também o IP (literal) de uma interface do computador. Nome de host nunca. */
588
683
  function hostPermitido(cabecalho) {
@@ -643,21 +738,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
643
738
  fs.createReadStream(abs, { start: ini, end: fim }).on('error', () => res.destroy()).pipe(res);
644
739
  }
645
740
 
741
+ /* Um servidor HTTP+WebSocket com as regras do motor. Um só em 127.0.0.1; com a rede ligada, um segundo em 0.0.0.0 (porta própria). */
742
+ function criarHttp() {
743
+ const srv = http.createServer(estatico);
744
+ srv.on('upgrade', (req, socket) => {
745
+ const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
746
+ const quem = autenticar(req);
747
+ if (!quem) return recusar(401, 'Unauthorized');
748
+ if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
749
+ const conn = quem.pareado ? { pareado: quem.pareado } : {};
750
+ const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
751
+ conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
752
+ conn.fechar = sock.fechar;
753
+ conexoes.add(conn);
754
+ });
755
+ return srv;
756
+ }
757
+
646
758
  function ws({ porta: pedida = 0, rede: pedirRede } = {}) {
647
759
  if (pedirRede !== undefined) modoRede = !!pedirRede;
648
760
  return new Promise((resolve, reject) => {
649
- http_ = http.createServer(estatico);
650
- http_.on('upgrade', (req, socket) => {
651
- const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
652
- const quem = autenticar(req);
653
- if (!quem) return recusar(401, 'Unauthorized');
654
- if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
655
- const conn = quem.pareado ? { pareado: quem.pareado } : {};
656
- const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
657
- conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
658
- conn.fechar = sock.fechar;
659
- conexoes.add(conn);
660
- });
761
+ http_ = criarHttp();
661
762
  http_.on('error', reject);
662
763
  http_.listen(pedida, modoRede ? '0.0.0.0' : '127.0.0.1', () => {
663
764
  porta = http_.address().port;
@@ -671,11 +772,30 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
671
772
  });
672
773
  }
673
774
 
775
+ /* Liga ou desliga o modo rede em tempo de execução (o mesmo do --rede), sem reiniciar o app: sobe um segundo servidor em 0.0.0.0
776
+ (porta própria, a do QR). O de 127.0.0.1 não muda. Desligar derruba os celulares conectados. */
777
+ let redeSrv = null;
778
+ let portaRede = null;
779
+ async function mudarRede(ligar) {
780
+ if (ligar && !modoRede) {
781
+ if (!http_) throw erro(ERRO.NEGADO, 'o servidor ainda não está no ar');
782
+ await new Promise((ok, falha) => { const s2 = criarHttp(); s2.once('error', falha); s2.listen(0, '0.0.0.0', () => { redeSrv = s2; portaRede = s2.address().port; ok(); }); });
783
+ modoRede = true;
784
+ } else if (!ligar && modoRede && redeSrv) {
785
+ for (const c of [...conexoes]) if (c.pareado && c.fechar) c.fechar();
786
+ const s2 = redeSrv; redeSrv = null; portaRede = null; modoRede = false;
787
+ await new Promise((ok) => { s2.close(() => ok()); if (s2.closeAllConnections) s2.closeAllConnections(); setTimeout(ok, 500); });
788
+ }
789
+ return { rede: modoRede, porta: portaRede || porta };
790
+ }
791
+
674
792
  function fechar() {
793
+ if (olharStderr) process.stderr.write = escreverStderr;
675
794
  if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
676
795
  for (const c of [...conexoes]) if (c.fechar) c.fechar();
677
796
  conexoes.clear();
678
797
  try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
798
+ if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
679
799
  return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
680
800
  }
681
801
 
package/lib/avisos.js CHANGED
@@ -27,6 +27,7 @@ function criar(dados, { arquivo: a } = {}) {
27
27
  estado: aprov ? 'pendente' : 'novo',
28
28
  nivel: dados.nivel === 'erro' ? 'erro' : 'ok',
29
29
  texto: String(dados.texto || '').slice(0, 600),
30
+ detalhe: dados.detalhe ? String(dados.detalhe).slice(0, 300) : null,
30
31
  agente: dados.agente || null, thread: dados.thread || null, aprovacao: dados.aprovacao || null,
31
32
  criado: Date.now(),
32
33
  };