primocode 11.1.0 → 11.2.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.
@@ -0,0 +1,685 @@
1
+ // App-server local: JSON-RPC 2.0 que expõe o motor às telas (docs/estrutura.md, seção 3).
2
+ // Dois transportes: stdio (uma mensagem JSON por linha) e WebSocket em 127.0.0.1
3
+ // com token de sessão. Com `rede: true` (primocode server --ws --rede) escuta na rede local e aceita
4
+ // também tokens de PAREAMENTO (um por celular, revogável, com poderes limitados). Node puro, sem dependência.
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const http = require('http');
11
+ const crypto = require('crypto');
12
+ const P = require('./protocolo');
13
+ const WS = require('./websocket');
14
+ const { criarPareamentos } = require('./pareamento');
15
+ const { criarPortao } = require('../motor/aprovacao');
16
+
17
+ const { ERRO, erro } = P;
18
+ const MAX_LEITURA = 1024 * 1024;
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'];
21
+ const PILOTO_MS = Number(process.env.PRIMOCODE_PILOTO_MS) || 15 * 60000;
22
+
23
+ const RAIZ_TELA = path.join(__dirname, '..', '..', 'app', 'renderer');
24
+ const TIPOS_ESTATICOS = {
25
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
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
+ };
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. */
31
+ const PAREADO_PODE = new Set([
32
+ 'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'turn/start', 'turn/interrupt', 'turn/queue',
33
+ 'approval/respond', 'question/respond', 'notice/list', 'notice/read', 'studio/history', 'project/list', 'agent/list', 'config/read',
34
+ 'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
35
+ ]);
36
+ const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
37
+
38
+ const texto = (v) => (typeof v === 'string' ? v : '');
39
+
40
+ /* Abre um arquivo ou pasta no programa do sistema. Só caminho já validado chega aqui; sem shell. */
41
+ function abrirNoSistema(abs) {
42
+ // Teste de tela: PRIMOCODE_ABRIR_LOG troca o programa do sistema por uma linha no arquivo.
43
+ if (process.env.PRIMOCODE_ABRIR_LOG) { try { fs.appendFileSync(process.env.PRIMOCODE_ABRIR_LOG, `${abs}\n`); } catch { /* sem log */ } return; }
44
+ const { spawn } = require('child_process');
45
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [abs]] : process.platform === 'win32' ? ['explorer.exe', [abs]] : ['xdg-open', [abs]];
46
+ try { const f = spawn(cmd, args, { detached: true, stdio: 'ignore' }); f.on('error', () => {}); f.unref(); } catch { /* sem programa para abrir */ }
47
+ }
48
+ 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
+
50
+ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {} } = {}) {
51
+ const cfg = require('../config/config');
52
+ const m = motor || require('../motor');
53
+ const base = casa || cfg.CONFIG_DIR;
54
+ const pastaThreads = path.join(base, 'threads');
55
+ const arquivoInfo = path.join(base, 'app-server.json');
56
+ const token = crypto.randomBytes(24).toString('hex');
57
+ const Pareamentos = criarPareamentos({ arquivo: path.join(base, 'pareamentos.json') });
58
+ let modoRede = !!rede;
59
+ const permitidas = (raizes || [os.homedir()]).map((r) => path.resolve(r));
60
+
61
+ const dirAgentes = path.join(base, 'agentes');
62
+ const arqAvisos = path.join(base, 'avisos.json');
63
+ const Conta = require('../config/conta');
64
+ const Agentes = require('../agentes-pessoais');
65
+ const Avisos = require('../avisos');
66
+ const Projetos = require('../projetos-meta');
67
+ const Marca = require('../plugin/marca-kit');
68
+ const Piloto = require('../piloto');
69
+ const Voz = voz || require('../motor/voz').criarVoz({ pasta: path.join(base, 'voz') });
70
+ let loginNavegador = null;
71
+ const statusConta = () => {
72
+ const a = Conta.atual();
73
+ return a ? { logado: true, email: a.email, plano: a.plano, planoAte: a.planoAte, pro: !!a.pro }
74
+ : { logado: false, email: null, plano: null, planoAte: null, pro: false };
75
+ };
76
+ let timerPiloto = null;
77
+ let pilotoRodando = false;
78
+
79
+ const pastaTela = telaDir === undefined ? RAIZ_TELA : telaDir;
80
+ const conexoes = new Set();
81
+ const threads = new Map(); // id → { dados, dono, assinantes, rodando }: dono = quem mandou por último; assinantes = todos que abriram a conversa (recebem os eventos)
82
+ let http_ = null;
83
+ let porta = null;
84
+
85
+ /* ── Conversas: um JSON por thread em ~/.primocode/threads ── */
86
+ const arquivoDe = (id) => path.join(pastaThreads, `${id}.json`);
87
+ function salvar(t) {
88
+ try {
89
+ fs.mkdirSync(pastaThreads, { recursive: true, mode: 0o700 });
90
+ fs.writeFileSync(arquivoDe(t.dados.id), JSON.stringify(t.dados), { mode: 0o600 });
91
+ } catch { /* falhar em guardar não derruba o turno */ }
92
+ }
93
+ function carregar(id) {
94
+ if (!/^[\w-]{1,64}$/.test(String(id || ''))) throw erro(ERRO.PARAMS, 'thread inválida');
95
+ if (threads.has(id)) return threads.get(id);
96
+ let dados;
97
+ try { dados = JSON.parse(fs.readFileSync(arquivoDe(id), 'utf8')); } catch { throw erro(ERRO.NAO_ACHEI, `não achei a conversa ${id}`); }
98
+ const t = { dados, dono: null, assinantes: new Set(), rodando: false };
99
+ threads.set(id, t);
100
+ return t;
101
+ }
102
+ const resumoDe = (d) => ({
103
+ id: d.id, titulo: d.titulo, criada: d.criada, atualizada: d.atualizada,
104
+ projeto: d.projeto || null, arquivada: !!d.arquivada, mensagens: (d.historico || []).length,
105
+ });
106
+ function listarDoDisco() {
107
+ let nomes = [];
108
+ try { nomes = fs.readdirSync(pastaThreads).filter((n) => n.endsWith('.json')); } catch { /* ainda não há */ }
109
+ const out = [];
110
+ for (const n of nomes) {
111
+ const id = n.slice(0, -5);
112
+ try { out.push(resumoDe((threads.get(id) || carregar(id)).dados)); } catch { /* arquivo quebrado: pula */ }
113
+ }
114
+ return out.sort((a, b) => (b.atualizada || 0) - (a.atualizada || 0));
115
+ }
116
+
117
+ /* ── Avisos: gravam em avisos.json e chegam a todas as telas abertas ── */
118
+ const difundir = (metodo, params) => { for (const c of conexoes) { try { c.enviar(P.notificacao(metodo, params)); } catch { /* conexão caiu */ } } };
119
+ const contagemAvisos = () => Avisos.contagem(Avisos.ler({ arquivo: arqAvisos }));
120
+ function novoAviso(dados) {
121
+ const av = Avisos.criar(dados, { arquivo: arqAvisos });
122
+ difundir('notice/created', { ...av, ...contagemAvisos(), estado: av.nivel === 'erro' ? 'erro' : 'ok', aviso: av });
123
+ return av;
124
+ }
125
+ const pilotoLigado = () => cfg.loadConfig().piloto === true;
126
+ /* O emissor de um turno: o que o motor emite vai à tela dona da conversa. Com o piloto ligado
127
+ (ou num turno do piloto) a aprovação vira aviso pendente, em vez de cartão no compositor;
128
+ todo notice/created do motor é gravado em avisos.json. */
129
+ function emissorDe(t) {
130
+ return (ev) => {
131
+ if (ev.evento === 'notice/created') {
132
+ novoAviso({ tipo: 'aviso', nivel: ev.estado === 'erro' ? 'erro' : 'ok', texto: ev.texto || (ev.erro && ev.erro.message) || '', thread: t.dados.id, agente: t.agente });
133
+ return;
134
+ }
135
+ if (ev.evento === 'approval/requested' && (t.agente || pilotoLigado())) {
136
+ novoAviso({ tipo: 'aprovacao', texto: ev.texto, thread: t.dados.id, aprovacao: ev.id, agente: t.agente });
137
+ return;
138
+ }
139
+ // Desktop e celular acompanham o MESMO turno: todo assinante da conversa (quem fez thread/resume ou mandou) recebe.
140
+ const msg = P.notificacao(ev.evento, { thread: t.dados.id, ...ev });
141
+ const para = new Set(t.assinantes);
142
+ if (t.dono) para.add(t.dono);
143
+ for (const c of para) { try { c.enviar(msg); } catch { /* conexão caiu */ } }
144
+ };
145
+ }
146
+
147
+ /* ── Caminhos: só dentro das raízes permitidas, sem segredos ── */
148
+ function caminhoSeguro(p) {
149
+ const abs = path.resolve(texto(p));
150
+ if (!texto(p)) throw erro(ERRO.PARAMS, 'falta path');
151
+ const dentro = permitidas.some((r) => abs === r || abs.startsWith(r + path.sep));
152
+ if (!dentro) throw erro(ERRO.NEGADO, 'caminho fora das pastas permitidas');
153
+ if (BLOQUEADOS.test(abs)) throw erro(ERRO.NEGADO, 'arquivo protegido');
154
+ return abs;
155
+ }
156
+
157
+ /* ── Métodos. Cada um: (params, conn) → resultado, ou lança erro(). ── */
158
+ const metodos = {
159
+ 'thread/start'(p, conn) {
160
+ const agora = Date.now();
161
+ const dados = {
162
+ id: crypto.randomBytes(8).toString('hex'),
163
+ titulo: texto(p.titulo) || 'Nova conversa',
164
+ criada: agora, atualizada: agora,
165
+ projeto: texto(p.projeto) || null, arquivada: false,
166
+ historico: [],
167
+ };
168
+ const t = { dados, dono: conn, assinantes: new Set(conn ? [conn] : []), rodando: false };
169
+ threads.set(dados.id, t);
170
+ salvar(t);
171
+ return resumoDe(dados);
172
+ },
173
+ 'thread/list'(p) {
174
+ const todas = listarDoDisco();
175
+ return { threads: p.arquivadas ? todas : todas.filter((x) => !x.arquivada) };
176
+ },
177
+ 'thread/resume'(p, conn) {
178
+ const t = carregar(p.thread);
179
+ if (conn) { t.assinantes.add(conn); if (!t.rodando) t.dono = conn; }
180
+ return { ...resumoDe(t.dados), mensagens: t.dados.historico, rodando: t.rodando };
181
+ },
182
+ 'thread/rename'(p) {
183
+ const t = carregar(p.thread);
184
+ const titulo = texto(p.titulo).trim();
185
+ if (!titulo) throw erro(ERRO.PARAMS, 'falta titulo');
186
+ t.dados.titulo = titulo.slice(0, 120);
187
+ salvar(t);
188
+ return resumoDe(t.dados);
189
+ },
190
+ 'thread/archive'(p) {
191
+ const t = carregar(p.thread);
192
+ t.dados.arquivada = p.arquivar !== false;
193
+ salvar(t);
194
+ return resumoDe(t.dados);
195
+ },
196
+
197
+ async 'turn/start'(p, conn) {
198
+ const pedido = texto(p.texto).trim();
199
+ if (!pedido) throw erro(ERRO.PARAMS, 'falta texto');
200
+ if (conn && conn.pareado) {
201
+ // O celular não lê arquivos nem escolhe pasta: só usa um projeto já registrado, sem menções nem anexos.
202
+ if (p.projeto && !Projetos.listar().some((x) => x.caminho === p.projeto)) throw erro(ERRO.NEGADO, 'projeto desconhecido');
203
+ p = { ...p, mencoes: undefined, anexos: undefined };
204
+ }
205
+ let t;
206
+ if (p.thread) t = carregar(p.thread);
207
+ else { const novo = metodos['thread/start']({ projeto: p.projeto }, conn); t = threads.get(novo.id); }
208
+ if (t.rodando) throw erro(ERRO.OCUPADO, 'já há um turno rodando nesta conversa; use turn/queue');
209
+ if (pilotoRodando) throw erro(ERRO.OCUPADO, 'o piloto automático está trabalhando; espere ou desligue-o');
210
+ t.dono = conn;
211
+ if (conn) t.assinantes.add(conn);
212
+ t.rodando = true;
213
+ const dados = t.dados;
214
+ const c = cfg.loadConfig();
215
+ const projeto = texto(p.projeto) || dados.projeto || undefined;
216
+ if (projeto) dados.projeto = projeto;
217
+ if (dados.titulo === 'Nova conversa' && !dados.historico.length) dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60);
218
+
219
+ // Menções (@arquivo) e anexos entram como instrução: não viram a fala da pessoa.
220
+ const partes = [];
221
+ if (Array.isArray(p.mencoes) && p.mencoes.length && projeto) {
222
+ const { arquivos } = require('../mencoes').mencoes(p.mencoes.map((x) => `@"${x}"`).join(' '), projeto);
223
+ for (const a of arquivos) {
224
+ try { partes.push(`Arquivo mencionado ${a.rel}:\n${fs.readFileSync(a.abs, 'utf8')}`); } catch { /* sumiu */ }
225
+ }
226
+ }
227
+ if (Array.isArray(p.anexos) && p.anexos.length) partes.push(`Anexos do pedido: ${p.anexos.map(String).join(', ')}`);
228
+ if (p.tipo) partes.push(require('../plugin/estudio/criar').instrucaoDoTipo(texto(p.tipo)));
229
+ // @agente chama na hora; os ligados vão de cardápio (a IA escolhe, não a regex).
230
+ const ag = Agentes.instrucao(pedido, p.agentes, { dir: dirAgentes });
231
+ if (ag.texto) partes.push(ag.texto);
232
+
233
+ const emitir = emissorDe(t);
234
+ t.portao = criarPortao({ emitir });
235
+ const memoria = require('../session/memoria-conversa');
236
+ const opts = {
237
+ texto: pedido, emitir, portao: t.portao,
238
+ autorizar: t.portao.autorizar, perguntar: t.portao.perguntar,
239
+ server: c.server, model: c.model, effort: texto(p.esforco) || c.effort, token: c.token,
240
+ history: memoria.paraEnviar(memoria.nova(), dados.historico),
241
+ projectDir: projeto, projectName: projeto ? path.basename(projeto) : undefined,
242
+ permissoes: c.permissoes, semTerminal: false,
243
+ instrucao: partes.join('\n\n'),
244
+ };
245
+ // O turno roda solto: o cliente recebe turn/* por notificação, não pela resposta.
246
+ Promise.resolve().then(() => m.iniciarTurno(opts)).then((r) => {
247
+ if (r && r.userMessage && !r.parado) {
248
+ dados.historico.push(r.userMessage);
249
+ if (r.assistantMessage) dados.historico.push(r.assistantMessage);
250
+ }
251
+ }).catch((e) => {
252
+ // O motor já emite turn/interrupted com o erro; aqui só garante que a tela saiba.
253
+ emitir({ evento: 'notice/created', estado: 'erro', texto: e && e.message });
254
+ }).finally(() => {
255
+ t.rodando = false;
256
+ if (t.portao) t.portao.cancelar();
257
+ dados.atualizada = Date.now();
258
+ salvar(t);
259
+ });
260
+ return { thread: dados.id };
261
+ },
262
+ 'turn/interrupt'(p) {
263
+ const t = p.thread ? carregar(p.thread) : null;
264
+ if (t && !t.rodando) return { interrompido: false };
265
+ // Quem espera uma resposta é solto como NÃO antes de parar o turno.
266
+ for (const x of t ? [t] : threads.values()) if (x.portao) x.portao.cancelar();
267
+ m.interromper();
268
+ return { interrompido: true };
269
+ },
270
+ 'turn/queue'(p) {
271
+ const pedido = texto(p.texto).trim();
272
+ if (!pedido) throw erro(ERRO.PARAMS, 'falta texto');
273
+ return { enfileirado: !!m.mandarDuranteATarefa(pedido) };
274
+ },
275
+
276
+ // Respostas ao que o motor pediu (approval/requested, question/requested).
277
+ 'approval/respond'(p) {
278
+ const id = texto(p.id);
279
+ if (!id) throw erro(ERRO.PARAMS, 'falta id');
280
+ if (!p.aceitar && !p.recusar && !p.sempre) throw erro(ERRO.PARAMS, 'diga aceitar, recusar ou sempre');
281
+ for (const t of p.thread ? [carregar(p.thread)] : threads.values()) {
282
+ if (t.portao && t.portao.responderAprovacao(id, p)) {
283
+ Avisos.resolverAprovacao(id, p.recusar ? 'nao' : p.sempre ? 'sessao' : 'agora', { arquivo: arqAvisos });
284
+ difundir('notice/changed', contagemAvisos());
285
+ return { ok: true, ...contagemAvisos() };
286
+ }
287
+ }
288
+ throw erro(ERRO.NAO_ACHEI, `não há aprovação pendente ${id}`);
289
+ },
290
+ 'question/respond'(p) {
291
+ const id = texto(p.id);
292
+ if (!id) throw erro(ERRO.PARAMS, 'falta id');
293
+ if (!Array.isArray(p.respostas)) throw erro(ERRO.PARAMS, 'falta respostas (lista)');
294
+ for (const t of p.thread ? [carregar(p.thread)] : threads.values()) {
295
+ if (t.portao && t.portao.responderPergunta(id, p.respostas)) return { ok: true };
296
+ }
297
+ throw erro(ERRO.NAO_ACHEI, `não há pergunta pendente ${id}`);
298
+ },
299
+
300
+ /* ── Projetos (pasta + ícone + cor + descrição) ── */
301
+ 'project/list'(p) { return { projetos: Projetos.listar({ arquivados: !!p.arquivados }) }; },
302
+ 'project/create'(p) {
303
+ try { const r = Projetos.criar(p); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(ERRO.PARAMS, e.message); }
304
+ },
305
+ 'project/update'(p) {
306
+ try { const r = Projetos.atualizar(p.id, p); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(/não achei/.test(e.message) ? ERRO.NAO_ACHEI : ERRO.PARAMS, e.message); }
307
+ },
308
+ 'project/delete'(p) {
309
+ try { const r = Projetos.arquivar(p.id); difundir('project/changed', { id: r.id }); return r; } catch (e) { throw erro(ERRO.NAO_ACHEI, e.message); }
310
+ },
311
+
312
+ /* ── Kit da marca (lib/marca-kit.js) ── */
313
+ 'brand/read'(p) {
314
+ const vista = (k) => ({
315
+ 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 || {}),
317
+ });
318
+ const ativa = (() => { try { return fs.readFileSync(path.join(Marca.raiz(), '.ativa'), 'utf8').trim() || null; } catch { return null; } })();
319
+ 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 }; }
320
+ return { kits: Marca.listar().map(vista), ativa };
321
+ },
322
+ 'brand/write'(p) {
323
+ const nome = texto(p.nome).trim();
324
+ if (!nome) throw erro(ERRO.PARAMS, 'falta nome');
325
+ if (p.apagar) { const r = Marca.apagar(nome); if (!r.ok) throw erro(ERRO.NAO_ACHEI, r.error); return { apagado: true }; }
326
+ const atual = Marca.ler(nome) || {};
327
+ const m = { ...atual, nome };
328
+ for (const k of ['tom', 'slogan', 'site', 'redes']) if (k in p) m[k] = texto(p[k]).slice(0, 300);
329
+ 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
+ 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);
332
+ if (!r.ok) throw erro(ERRO.PARAMS, r.error);
333
+ if (p.ativar) Marca.usar(nome);
334
+ return metodos['brand/read']({ nome });
335
+ },
336
+
337
+ /* ── Agentes ── */
338
+ 'agent/list'() { return { agentes: Agentes.listar({ dir: dirAgentes }), piloto: pilotoEstado() }; },
339
+ 'agent/create'(p) { return agenteOp(() => Agentes.criar(p, { dir: dirAgentes })); },
340
+ 'agent/update'(p) { return agenteOp(() => Agentes.atualizar(p.nome, p, { dir: dirAgentes })); },
341
+ 'agent/delete'(p) { return agenteOp(() => Agentes.apagar(p.nome, { dir: dirAgentes })); },
342
+ 'agent/toggle'(p) { return agenteOp(() => Agentes.alternar(p.nome, typeof p.ligado === 'boolean' ? p.ligado : undefined, { dir: dirAgentes })); },
343
+
344
+ /* ── Piloto automático e avisos ── */
345
+ 'autopilot/set'(p) {
346
+ const mudar = {};
347
+ if ('ligado' in p) mudar.piloto = p.ligado === true;
348
+ if ('grupo' in p) mudar.grupo = p.grupo === true;
349
+ if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, 'diga ligado e/ou grupo');
350
+ cfg.saveConfig(mudar);
351
+ agendarPiloto();
352
+ const r = pilotoEstado();
353
+ difundir('agent/changed', { piloto: r });
354
+ return r;
355
+ },
356
+ async 'autopilot/run'() {
357
+ if (pilotoRodando) throw erro(ERRO.OCUPADO, 'o piloto já está num ciclo');
358
+ return rodarCiclo();
359
+ },
360
+ 'notice/list'() { return Avisos.listar({ arquivo: arqAvisos }); },
361
+ 'notice/read'(p) {
362
+ if (!p.id && !p.todos) throw erro(ERRO.PARAMS, 'diga id ou todos');
363
+ try { const r = Avisos.marcarLido({ id: p.id, todos: !!p.todos }, { arquivo: arqAvisos }); difundir('notice/changed', contagemAvisos()); return r; } catch (e) { throw erro(ERRO.NAO_ACHEI, e.message); }
364
+ },
365
+
366
+ /* ── Pareamento do celular (só o computador, nunca um celular pareado) ── */
367
+ 'pairing/info'() { return { rede: modoRede, porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
368
+ 'pairing/create'(p) {
369
+ 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
+ const ips = enderecosLocais();
371
+ if (!ips.length) throw erro(ERRO.NEGADO, 'não achei um endereço de rede local neste computador');
372
+ const r = Pareamentos.criar(p.nome);
373
+ const urls = ips.map((ip) => `ws://${ip}:${porta}`);
374
+ const codigo = `${urls[0]}/?token=${r.token}`;
375
+ const q = require('../qr').gerarQR(codigo, { ecc: 'M' });
376
+ // A tela desenha o QR com estas linhas ('1' = módulo escuro); nenhum HTML vem do motor.
377
+ return { ...r, urls, codigo, qr: { tamanho: q.tamanho, linhas: q.modulos.map((l) => l.map((b) => (b ? '1' : '0')).join('')) } };
378
+ },
379
+ 'pairing/list'() { return { pareamentos: Pareamentos.listar() }; },
380
+ 'pairing/revoke'(p) {
381
+ const id = texto(p.id);
382
+ if (!Pareamentos.revogar(id)) throw erro(ERRO.NAO_ACHEI, 'não existe esse pareamento');
383
+ for (const c of [...conexoes]) if (c.pareado && c.pareado.id === id && c.fechar) c.fechar();
384
+ return { revogado: id, pareamentos: Pareamentos.listar() };
385
+ },
386
+
387
+ 'config/read'(p, conn) {
388
+ const c = cfg.loadConfig();
389
+ const out = {};
390
+ for (const k of CAMPOS_CONFIG) out[k] = c[k];
391
+ out.servidor = c.server;
392
+ out.temToken = !!c.token;
393
+ out.projetoAtual = c.currentProject;
394
+ if (conn && conn.pareado) out.pareado = true;
395
+ return out;
396
+ },
397
+ 'config/write'(p) {
398
+ const mudar = {};
399
+ for (const k of CAMPOS_CONFIG) if (k in p) mudar[k] = p[k];
400
+ if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, `nada para gravar (campos: ${CAMPOS_CONFIG.join(', ')})`);
401
+ cfg.saveConfig(mudar);
402
+ return metodos['config/read']();
403
+ },
404
+
405
+ /* ── Conta (login do app): só as funções de lib/config/conta.js. Nunca devolve token. ── */
406
+ 'account/status'() { return statusConta(); },
407
+ async 'account/login'(p) {
408
+ const r = await Conta.entrar({ email: texto(p.email), senha: texto(p.senha) }, { buscar: contaOp.buscar });
409
+ if (!r.ok) return { ok: false, error: r.error };
410
+ difundir('account/changed', statusConta());
411
+ return { ok: true, ...statusConta() };
412
+ },
413
+ async 'account/login-browser'() {
414
+ if (loginNavegador) return { ok: true, emAndamento: true, url: loginNavegador.url };
415
+ let urlPronta;
416
+ const aUrl = new Promise((res) => { urlPronta = res; });
417
+ const estado = { url: null };
418
+ loginNavegador = estado;
419
+ Conta.entrarPeloNavegador({ abrir: contaOp.abrir, buscar: contaOp.buscar, aviso: (u) => { estado.url = u; urlPronta(u); } })
420
+ .then((r) => difundir('account/changed', { ...statusConta(), ...(r && r.ok ? {} : { erro: (r && r.error) || 'o login não concluiu.' }) }))
421
+ .catch((e) => difundir('account/changed', { ...statusConta(), erro: e.message }))
422
+ .finally(() => { loginNavegador = null; urlPronta(null); });
423
+ const url = await aUrl;
424
+ return { ok: true, iniciado: true, url };
425
+ },
426
+ 'account/logout'() {
427
+ Conta.sair();
428
+ difundir('account/changed', statusConta());
429
+ return { ok: true, ...statusConta() };
430
+ },
431
+
432
+ /* ── 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 }); },
434
+ 'voice/stop'() { return Voz.parar(); },
435
+
436
+ /* ── Studio: histórico do que o Estúdio gerou e abrir no sistema ── */
437
+ 'studio/history'(p) {
438
+ const r = require('../plugin/estudio/historico').historico({ busca: texto(p.busca), limite: p.limite });
439
+ return { ...r, tipos: Object.keys(require('../plugin/estudio/criar').QUADRADOS) };
440
+ },
441
+ 'studio/open'(p) {
442
+ let abs = caminhoSeguro(p.path);
443
+ if (!fs.existsSync(abs)) throw erro(ERRO.NAO_ACHEI, 'não achei o arquivo');
444
+ if (p.pasta && !fs.statSync(abs).isDirectory()) abs = path.dirname(abs);
445
+ (abrir || abrirNoSistema)(abs);
446
+ return { aberto: abs };
447
+ },
448
+
449
+ 'fs/list'(p) {
450
+ const abs = caminhoSeguro(p.path);
451
+ let itens;
452
+ try { itens = fs.readdirSync(abs, { withFileTypes: true }); } catch (e) { throw erro(ERRO.NAO_ACHEI, `não consegui listar: ${e.code || e.message}`); }
453
+ return {
454
+ path: abs,
455
+ itens: itens.filter((d) => !BLOQUEADOS.test(path.join(abs, d.name)))
456
+ .map((d) => ({ nome: d.name, tipo: d.isDirectory() ? 'pasta' : 'arquivo' }))
457
+ .sort((a, b) => (a.tipo === b.tipo ? a.nome.localeCompare(b.nome) : a.tipo === 'pasta' ? -1 : 1)),
458
+ };
459
+ },
460
+ 'fs/read'(p) {
461
+ const abs = caminhoSeguro(p.path);
462
+ let st;
463
+ try { st = fs.statSync(abs); } catch (e) { throw erro(ERRO.NAO_ACHEI, `não achei: ${e.code || e.message}`); }
464
+ if (!st.isFile()) throw erro(ERRO.PARAMS, 'não é arquivo');
465
+ if (st.size > MAX_LEITURA) throw erro(ERRO.PARAMS, `grande demais (limite ${MAX_LEITURA / 1024} KB)`);
466
+ return { path: abs, bytes: st.size, conteudo: fs.readFileSync(abs, 'utf8') };
467
+ },
468
+ };
469
+
470
+
471
+ /* ── Agentes e piloto: helpers ── */
472
+ function agenteOp(fn) {
473
+ try { const r = fn(); difundir('agent/changed', { nome: r.nome }); return r; } catch (e) { throw erro(/não achei/.test(e.message) ? ERRO.NAO_ACHEI : ERRO.PARAMS, e.message); }
474
+ }
475
+ const pilotoEstado = () => { const c = cfg.loadConfig(); return { ligado: c.piloto === true, grupo: c.grupo === true, rodando: pilotoRodando }; };
476
+ const algumRodando = () => [...threads.values()].some((x) => x.rodando);
477
+
478
+ /* Um agente do piloto roda como uma conversa própria, com o ofício na frente. Aprovações viram aviso. */
479
+ async function executarAgente(agente, pedido) {
480
+ const c = cfg.loadConfig();
481
+ const dados = { id: `piloto-${agente.nome}-${Date.now().toString(36)}`, titulo: `@${agente.nome} (piloto)`, criada: Date.now(), atualizada: Date.now(), projeto: null, arquivada: true, historico: [] };
482
+ const t = { dados, dono: null, assinantes: new Set(), rodando: true, agente: agente.nome };
483
+ threads.set(dados.id, t);
484
+ const emitir = emissorDe(t);
485
+ t.portao = criarPortao({ emitir });
486
+ const proj = agente.onde && agente.onde !== 'fora' ? Projetos.pastaDe(agente.onde) : null;
487
+ try {
488
+ const r = await m.iniciarTurno({
489
+ texto: pedido, emitir, portao: t.portao, autorizar: t.portao.autorizar, perguntar: t.portao.perguntar,
490
+ server: c.server, model: c.model, effort: c.effort, token: c.token, history: [],
491
+ projectDir: proj || undefined, projectName: proj ? path.basename(proj) : undefined,
492
+ permissoes: 'perguntar', semTerminal: false,
493
+ instrucao: `Seu ofício:\n${agente.oficio}`,
494
+ });
495
+ return r && r.assistantMessage ? textoDe(r.assistantMessage.content) : '';
496
+ } finally {
497
+ t.rodando = false;
498
+ if (t.portao) t.portao.cancelar();
499
+ threads.delete(dados.id);
500
+ }
501
+ }
502
+ const textoDe = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && x.text) || '').join('') : '');
503
+
504
+ async function rodarCiclo() {
505
+ pilotoRodando = true;
506
+ difundir('agent/changed', { piloto: pilotoEstado() });
507
+ try {
508
+ const c = cfg.loadConfig();
509
+ const r = await Piloto.ciclo({
510
+ agentes: Agentes.listar({ dir: dirAgentes }), grupo: c.grupo === true,
511
+ executar: executarAgente, ocupado: () => [...threads.values()].some((x) => x.rodando && !x.agente),
512
+ freio: opcoesPiloto.freio,
513
+ });
514
+ for (const e of r.executados) novoAviso({ tipo: 'aviso', nivel: e.ok ? 'ok' : 'erro', texto: `@${e.agente}: ${e.resumo || (e.ok ? 'terminou' : 'falhou')}`, agente: e.agente });
515
+ return r;
516
+ } finally { pilotoRodando = false; difundir('agent/changed', { piloto: pilotoEstado() }); }
517
+ }
518
+ const opcoesPiloto = { freio: undefined };
519
+ function agendarPiloto() {
520
+ if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
521
+ if (!pilotoLigado()) return;
522
+ timerPiloto = setInterval(() => { if (!pilotoRodando && !algumRodando()) rodarCiclo().catch(() => {}); }, PILOTO_MS);
523
+ timerPiloto.unref();
524
+ }
525
+ agendarPiloto();
526
+
527
+ /* ── Despacho: uma mensagem de uma conexão ── */
528
+ async function tratar(linha, conn) {
529
+ const r = P.interpretar(linha);
530
+ if (r.vazio || r.resposta) return;
531
+ if (r.erro) { conn.enviar(r.erro); return; }
532
+ const { msg } = r;
533
+ const temId = 'id' in msg;
534
+ const fn = Object.prototype.hasOwnProperty.call(metodos, msg.method) ? metodos[msg.method] : null;
535
+ if (fn && conn.pareado && !PAREADO_PODE.has(msg.method)) {
536
+ if (temId) conn.enviar(P.respostaErro(msg.id, ERRO.NEGADO, `um celular pareado não pode usar ${msg.method}`));
537
+ return;
538
+ }
539
+ if (fn && conn.pareado) Pareamentos.marcarUso(conn.pareado.id);
540
+ if (!fn) { if (temId) conn.enviar(P.respostaErro(msg.id, ERRO.METODO, `método não existe: ${msg.method}`)); return; }
541
+ try {
542
+ const res = await fn(msg.params || {}, conn);
543
+ if (temId) conn.enviar(P.resposta(msg.id, res));
544
+ } catch (e) {
545
+ if (!temId) return;
546
+ if (e && e.codigo) conn.enviar(P.respostaErro(msg.id, e.codigo, e.message, e.data));
547
+ else conn.enviar(P.respostaErro(msg.id, ERRO.INTERNO, e && e.message));
548
+ }
549
+ }
550
+
551
+ function soltar(conn) {
552
+ conexoes.delete(conn);
553
+ for (const t of threads.values()) { if (t.dono === conn) t.dono = null; t.assinantes.delete(conn); }
554
+ }
555
+
556
+ /* ── Transporte stdio ── */
557
+ function stdio(entrada = process.stdin, saida = process.stdout) {
558
+ const conn = { enviar: (obj) => { try { saida.write(P.serializar(obj)); } catch { /* pipe fechou */ } } };
559
+ conexoes.add(conn);
560
+ const corta = P.cortador();
561
+ entrada.setEncoding && entrada.setEncoding('utf8');
562
+ entrada.on('data', (d) => { for (const l of corta.push(d)) tratar(l, conn); });
563
+ entrada.on('end', () => { const s = corta.sobra(); if (s) tratar(s, conn); soltar(conn); });
564
+ return conn;
565
+ }
566
+
567
+ /* ── Transporte WebSocket: token na URL (?token=) ou em Authorization: Bearer ──
568
+ O token de sessão só vale vindo do próprio computador; da rede só entra token de pareamento. */
569
+ const iguais = (a, b) => { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && crypto.timingSafeEqual(x, y); };
570
+ function autenticar(req) {
571
+ let dado = '';
572
+ try { dado = new URL(req.url, 'http://127.0.0.1').searchParams.get('token') || ''; } catch { /* url torta */ }
573
+ const auth = String(req.headers.authorization || '');
574
+ if (!dado && /^Bearer /i.test(auth)) dado = auth.slice(7).trim();
575
+ if (!dado) return null;
576
+ if (iguais(dado, token)) return LOOPBACK.has(req.socket.remoteAddress) ? { tipo: 'mestre' } : null;
577
+ const r = Pareamentos.validar(dado);
578
+ return r ? { tipo: 'pareado', pareado: r } : null;
579
+ }
580
+ const tokenBate = (req) => !!autenticar(req);
581
+ 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;
586
+ }
587
+ /* 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
+ function hostPermitido(cabecalho) {
589
+ const host = String(cabecalho || '').replace(/:\d+$/, '');
590
+ if (host === '127.0.0.1' || host === 'localhost') return true;
591
+ return modoRede && /^\d{1,3}(\.\d{1,3}){3}$/.test(host) && enderecosLocais().includes(host);
592
+ }
593
+
594
+ /* GET / e arquivos de app/renderer: só 127.0.0.1 (Host conferido contra rebind). O token
595
+ continua exigido no WebSocket; a página só entrega o HTML e o JS. */
596
+ function estatico(req, res) {
597
+ const nega = (c, m) => { res.writeHead(c, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end(m); };
598
+ if (!hostPermitido(req.headers.host)) return nega(403, 'host não permitido');
599
+ if (req.method !== 'GET' && req.method !== 'HEAD') return nega(426, 'use WebSocket');
600
+ if (/^\/midia(\?|$)/.test(req.url)) return midia(req, res, nega);
601
+ // O app do celular pergunta se o token ainda vale (revogado?). Só diz sim ou não ao dono do token; CORS aberto porque a página do celular é de outra origem.
602
+ if (/^\/pareamento\/valido(\?|$)/.test(req.url)) {
603
+ const q = autenticar(req);
604
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*' });
605
+ return res.end(JSON.stringify({ valido: !!(q && q.tipo === 'pareado') }));
606
+ }
607
+ if (!pastaTela) return nega(426, 'use WebSocket');
608
+ let rel;
609
+ try { rel = decodeURIComponent(new URL(req.url, 'http://127.0.0.1').pathname); } catch { return nega(400, 'url torta'); }
610
+ if (rel === '/') rel = '/index.html';
611
+ const abs = path.join(pastaTela, rel);
612
+ if (abs !== pastaTela && !abs.startsWith(pastaTela + path.sep)) return nega(403, 'fora da pasta');
613
+ fs.readFile(abs, (e, dados) => {
614
+ if (e) return nega(404, 'não achei');
615
+ res.writeHead(200, {
616
+ 'Content-Type': TIPOS_ESTATICOS[path.extname(abs)] || 'application/octet-stream',
617
+ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
618
+ });
619
+ res.end(req.method === 'HEAD' ? undefined : dados);
620
+ });
621
+ }
622
+
623
+ /* GET /midia?path=…&token=…: a prévia do que o Estúdio gerou (vídeo, PDF, imagem). Exige o token,
624
+ só serve dentro das raízes permitidas e só as extensões de mídia; vídeo aceita Range. */
625
+ function midia(req, res, nega) {
626
+ if (!tokenBate(req)) return nega(401, 'sem token');
627
+ let abs;
628
+ try { abs = caminhoSeguro(new URL(req.url, 'http://127.0.0.1').searchParams.get('path')); } catch { return nega(403, 'caminho não permitido'); }
629
+ const tipo = MIDIAS[path.extname(abs).toLowerCase()];
630
+ if (!tipo) return nega(403, 'tipo não permitido');
631
+ let st; try { st = fs.statSync(abs); if (!st.isFile()) throw new Error('x'); } catch { return nega(404, 'não achei'); }
632
+ const cab = { 'Content-Type': tipo, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' };
633
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
634
+ let ini = 0; let fim = st.size - 1; let codigo = 200;
635
+ if (m && (m[1] || m[2])) {
636
+ if (m[1]) { ini = Number(m[1]); if (m[2]) fim = Math.min(fim, Number(m[2])); } else { ini = Math.max(0, st.size - Number(m[2])); }
637
+ if (ini > fim || ini >= st.size) { res.writeHead(416, { 'Content-Range': `bytes */${st.size}` }); return res.end(); }
638
+ codigo = 206; cab['Content-Range'] = `bytes ${ini}-${fim}/${st.size}`;
639
+ }
640
+ cab['Content-Length'] = st.size ? fim - ini + 1 : 0;
641
+ res.writeHead(codigo, cab);
642
+ if (req.method === 'HEAD' || !st.size) return res.end();
643
+ fs.createReadStream(abs, { start: ini, end: fim }).on('error', () => res.destroy()).pipe(res);
644
+ }
645
+
646
+ function ws({ porta: pedida = 0, rede: pedirRede } = {}) {
647
+ if (pedirRede !== undefined) modoRede = !!pedirRede;
648
+ 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
+ });
661
+ http_.on('error', reject);
662
+ http_.listen(pedida, modoRede ? '0.0.0.0' : '127.0.0.1', () => {
663
+ porta = http_.address().port;
664
+ try {
665
+ fs.mkdirSync(base, { recursive: true });
666
+ fs.writeFileSync(arquivoInfo, JSON.stringify({ porta, token, pid: process.pid, rede: modoRede }), { mode: 0o600 });
667
+ fs.chmodSync(arquivoInfo, 0o600);
668
+ } catch { /* sem gravar, o cliente ainda recebe porta e token pelo retorno */ }
669
+ resolve({ porta, token });
670
+ });
671
+ });
672
+ }
673
+
674
+ function fechar() {
675
+ if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
676
+ for (const c of [...conexoes]) if (c.fechar) c.fechar();
677
+ conexoes.clear();
678
+ try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
679
+ return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
680
+ }
681
+
682
+ return { stdio, ws, fechar, tratar, metodos: Object.keys(metodos), token, arquivoInfo, pastaThreads, opcoesPiloto };
683
+ }
684
+
685
+ module.exports = { criarServidor };