primocode 9.8.0-beta.4 → 9.8.0-beta.6

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.
Files changed (52) hide show
  1. package/README.md +15 -4
  2. package/app/ansi.js +55 -0
  3. package/app/ide.css +345 -0
  4. package/app/ide.html +183 -0
  5. package/app/ide.js +639 -0
  6. package/bin/primocode.js +315 -83
  7. package/kits/acolhedor/index.html +130 -0
  8. package/kits/acolhedor/script.js +25 -0
  9. package/kits/acolhedor/style.css +146 -0
  10. package/kits/editorial/index.html +115 -0
  11. package/kits/editorial/script.js +19 -0
  12. package/kits/editorial/style.css +112 -0
  13. package/kits/premium/index.html +83 -0
  14. package/kits/premium/script.js +11 -0
  15. package/kits/premium/style.css +87 -0
  16. package/kits/vitrine/index.html +108 -0
  17. package/kits/vitrine/script.js +20 -0
  18. package/kits/vitrine/style.css +117 -0
  19. package/lib/app.js +41 -6
  20. package/lib/browser.js +14 -1
  21. package/lib/checkpoint-git.js +218 -0
  22. package/lib/checkpoint.js +159 -44
  23. package/lib/comandos-extra.js +481 -0
  24. package/lib/config.js +5 -2
  25. package/lib/ide.js +337 -0
  26. package/lib/kits.js +165 -54
  27. package/lib/skills.js +6 -1
  28. package/lib/ui.js +171 -17
  29. package/package.json +2 -2
  30. package/skills/apresentacao-pptx/SKILL.md +52 -0
  31. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  32. package/skills/cartaz-de-arte/SKILL.md +47 -0
  33. package/skills/criar-skill/LICENSE.txt +202 -0
  34. package/skills/criar-skill/SKILL.md +49 -0
  35. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  36. package/skills/depurar-de-verdade/SKILL.md +14 -5
  37. package/skills/documento-word/SKILL.md +57 -0
  38. package/skills/interface-revisada/SKILL.md +47 -0
  39. package/skills/me-ensina/LICENSE.txt +21 -0
  40. package/skills/me-ensina/SKILL.md +27 -0
  41. package/skills/me-entrevista/LICENSE.txt +21 -0
  42. package/skills/me-entrevista/SKILL.md +27 -0
  43. package/skills/pdf-bonito/SKILL.md +48 -0
  44. package/skills/planilha-excel/SKILL.md +47 -0
  45. package/skills/react-sem-dor/SKILL.md +48 -0
  46. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  47. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  48. package/skills/temas-prontos/LICENSE.txt +202 -0
  49. package/skills/temas-prontos/SKILL.md +50 -0
  50. package/skills/testar-o-site/LICENSE.txt +202 -0
  51. package/skills/testar-o-site/SKILL.md +46 -0
  52. package/skills/video-no-remotion/SKILL.md +48 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * comandos-extra.js — a lógica dos comandos pequenos do terminal.
3
+ *
4
+ * "Mais mascotes, mais comandos." (dono, 24/09/2026)
5
+ *
6
+ * /novo, /projetos, /abrir, /doctor, /custo, /copiar, /historico e /atalhos.
7
+ * Cada um é pequeno e serve a quem nunca programou: saber onde estão os
8
+ * projetos, abrir o que foi feito, descobrir por que algo não funciona, copiar
9
+ * a resposta sem selecionar com o mouse.
10
+ *
11
+ * O desenho na tela mora no bin/primocode.js; aqui fica o que dá para medir
12
+ * sem terminal. Por isso quase tudo recebe as dependências de fora (quem
13
+ * roda comando, quem sonda porta, quem fala com o servidor): o teste troca
14
+ * por falsos e confere a regra sem abrir navegador nem tocar a rede.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const http = require('http');
22
+ const { spawnSync, execFileSync } = require('child_process');
23
+
24
+ // ── tamanho e tempo, do jeito que se fala ────────────────────────────────
25
+
26
+ function fmtTamanho(bytes) {
27
+ const b = Number(bytes) || 0;
28
+ if (b < 1024) return `${b} B`;
29
+ if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
30
+ if (b < 1024 ** 3) return `${(b / 1024 / 1024).toFixed(1)} MB`;
31
+ return `${(b / 1024 ** 3).toFixed(1)} GB`;
32
+ }
33
+
34
+ /* "há 3 h" diz mais que "24/09/2026 14:02" para quem procura o projeto de
35
+ ontem. Passou de uma semana, a data volta: "há 23 dias" obriga a fazer
36
+ conta. */
37
+ function quandoFoi(ms, agora = Date.now()) {
38
+ const s = Math.max(0, Math.round((agora - ms) / 1000));
39
+ if (s < 60) return 'agora';
40
+ if (s < 3600) return `há ${Math.floor(s / 60)} min`;
41
+ if (s < 86400) return `há ${Math.floor(s / 3600)} h`;
42
+ const dias = Math.floor(s / 86400);
43
+ if (dias === 1) return 'ontem';
44
+ if (dias < 7) return `há ${dias} dias`;
45
+ return new Date(ms).toLocaleDateString('pt-BR');
46
+ }
47
+
48
+ // ── /projetos ────────────────────────────────────────────────────────────
49
+
50
+ /* O que não é do projeto: dependência baixada e histórico do git. Entrar em
51
+ node_modules para somar tamanho custa segundos numa pasta grande, e o
52
+ número que a pessoa quer é o do que ELA (ou o agente) escreveu. */
53
+ const PULAR = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.cache', '__pycache__', '.venv', 'venv']);
54
+ const TETO_ENTRADAS = 4000;
55
+
56
+ /** Tamanho e última mudança de uma pasta, olhando os arquivos de dentro. */
57
+ function medirPasta(dir) {
58
+ let bytes = 0, arquivos = 0, modificado = 0, vistos = 0, cortado = false;
59
+ const fila = [dir];
60
+ try { modificado = fs.statSync(dir).mtimeMs; } catch { /* sumiu */ }
61
+ while (fila.length) {
62
+ const atual = fila.pop();
63
+ let itens;
64
+ try { itens = fs.readdirSync(atual, { withFileTypes: true }); } catch { continue; }
65
+ for (const it of itens) {
66
+ // Teto: uma pasta com cem mil arquivos não pode segurar o /projetos.
67
+ if (++vistos > TETO_ENTRADAS) { cortado = true; fila.length = 0; break; }
68
+ if (PULAR.has(it.name)) continue;
69
+ const p = path.join(atual, it.name);
70
+ if (it.isDirectory()) { fila.push(p); continue; }
71
+ if (!it.isFile()) continue;
72
+ try {
73
+ const st = fs.statSync(p);
74
+ bytes += st.size;
75
+ arquivos++;
76
+ if (st.mtimeMs > modificado) modificado = st.mtimeMs;
77
+ } catch { /* arquivo que sumiu no meio */ }
78
+ }
79
+ }
80
+ return { bytes, arquivos, modificado, cortado };
81
+ }
82
+
83
+ /**
84
+ * Os projetos de ~/primocode, do mais recente para o mais antigo.
85
+ * "Mais recente" é o último ARQUIVO mexido lá dentro — a data da pasta só
86
+ * muda quando nasce ou some um item, e editar o index.html não conta.
87
+ */
88
+ function listarProjetos(raiz) {
89
+ let nomes = [];
90
+ try {
91
+ nomes = fs.readdirSync(raiz, { withFileTypes: true })
92
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
93
+ .map((d) => d.name);
94
+ } catch { return []; }
95
+ return nomes
96
+ .map((nome) => ({ nome, dir: path.join(raiz, nome), ...medirPasta(path.join(raiz, nome)) }))
97
+ .sort((a, b) => b.modificado - a.modificado);
98
+ }
99
+
100
+ // ── /novo ────────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * O nome da pasta do /novo. O do modelo (lib/nome-projeto.js) quando veio; o
104
+ * `deriveProjectName` quando não veio. Nunca o de uma pasta que já existe —
105
+ * /novo é justamente "não continue aquele": cair dentro de outro projeto
106
+ * por coincidência de nome é o defeito que ele existe para evitar.
107
+ */
108
+ function nomeDoNovo({ descricao, nomes, sugerido, derivar, slugify, semColisao }) {
109
+ const base = slugify(sugerido || (derivar ? derivar(descricao) : '') || 'projeto') || 'projeto';
110
+ return semColisao(base, nomes || []);
111
+ }
112
+
113
+ /** O pedido que vai ao agente: a descrição, com a ordem de começar do zero. */
114
+ function pedidoDoNovo(descricao, nome) {
115
+ return `Projeto NOVO, do zero, na pasta ${nome} (acabou de ser criada e está vazia). `
116
+ + `Não reaproveite nem abra outros projetos. O pedido: ${descricao}`;
117
+ }
118
+
119
+ // ── /abrir ───────────────────────────────────────────────────────────────
120
+
121
+ /* As portas que o projeto diz usar. Script com --port/-p/PORT= manda; sem
122
+ isso, a porta padrão do framework que ele instala. */
123
+ const PORTA_PADRAO = [
124
+ ['vite', 5173], ['next', 3000], ['react-scripts', 3000], ['astro', 4321],
125
+ ['@angular/core', 4200], ['nuxt', 3000], ['@sveltejs/kit', 5173], ['express', 3000],
126
+ ['parcel', 1234], ['gatsby', 8000], ['@remix-run/dev', 3000],
127
+ ];
128
+ /* Sem framework conhecido, as portas de sempre — mas SÓ se o projeto tem cara
129
+ de servidor (script dev/start, app.py…). Sondar 8000 às cegas abria o
130
+ servidor de OUTRO programa que estivesse de pé na máquina: medido aqui,
131
+ um projeto só com memory.md abriu um localhost:8000 que não era dele. */
132
+ const PORTAS_NODE = [3000, 5173, 8080];
133
+ const PORTAS_PYTHON = [5000, 8000];
134
+ const SERVIDOR_PYTHON = ['app.py', 'main.py', 'server.py', 'manage.py'];
135
+
136
+ function portasDoProjeto(dir) {
137
+ let pkg;
138
+ try { pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { pkg = null; }
139
+ if (!pkg) return SERVIDOR_PYTHON.some((f) => fs.existsSync(path.join(dir, f))) ? PORTAS_PYTHON.slice() : [];
140
+ const portas = [];
141
+ const scripts = Object.values(pkg.scripts || {}).join(' ');
142
+ for (const m of scripts.matchAll(/(?:--port[= ]|-p |PORT=)(\d{2,5})\b/g)) portas.push(Number(m[1]));
143
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
144
+ for (const [dep, porta] of PORTA_PADRAO) if (deps[dep]) portas.push(porta);
145
+ if (!portas.length && pkg.scripts && (pkg.scripts.dev || pkg.scripts.start)) portas.push(...PORTAS_NODE);
146
+ return [...new Set(portas)];
147
+ }
148
+
149
+ /** A porta responde HTTP? Um GET curto em 127.0.0.1, sem esperar o corpo. */
150
+ function sondarPorta(porta, ms = 600) {
151
+ return new Promise((ok) => {
152
+ const req = http.get({ host: '127.0.0.1', port: porta, path: '/', timeout: ms }, (res) => {
153
+ res.resume();
154
+ ok(true);
155
+ });
156
+ req.on('timeout', () => { req.destroy(); ok(false); });
157
+ req.on('error', () => ok(false));
158
+ });
159
+ }
160
+
161
+ /* Onde procurar a página pronta: a raiz, e as pastas em que o build costuma
162
+ deixar o resultado. */
163
+ const PAGINAS = ['index.html', 'public/index.html', 'dist/index.html', 'build/index.html', 'src/index.html', 'docs/index.html'];
164
+
165
+ /**
166
+ * O que o /abrir deve abrir.
167
+ * 1. um servidor do projeto que esteja de pé (a porta que ele diz usar);
168
+ * 2. a index.html, se houver.
169
+ * Nessa ordem porque a página servida é a de verdade (com rota, API, build);
170
+ * o arquivo aberto direto às vezes nem carrega o JavaScript.
171
+ */
172
+ async function acharAlvoAbrir(dir, { sondar = sondarPorta } = {}) {
173
+ for (const porta of portasDoProjeto(dir)) {
174
+ if (await sondar(porta)) return { tipo: 'url', url: `http://localhost:${porta}`, porque: 'o servidor do projeto está de pé' };
175
+ }
176
+ for (const rel of PAGINAS) {
177
+ const p = path.join(dir, rel);
178
+ if (fs.existsSync(p)) return { tipo: 'arquivo', caminho: p, porque: rel };
179
+ }
180
+ return null;
181
+ }
182
+
183
+ // ── /doctor ──────────────────────────────────────────────────────────────
184
+
185
+ function versaoMinima(atual, minima) {
186
+ const a = String(atual).replace(/^v/, '').split('.').map(Number);
187
+ const m = String(minima).replace(/^[^\d]*/, '').split('.').map(Number);
188
+ for (let i = 0; i < 3; i++) {
189
+ if ((a[i] || 0) > (m[i] || 0)) return true;
190
+ if ((a[i] || 0) < (m[i] || 0)) return false;
191
+ }
192
+ return true;
193
+ }
194
+
195
+ /* O Python com Quartz/AppKit é o cursor do Primo (lib/cursor.js). A mesma
196
+ pergunta que ele faz, sem instalar nada: o doctor só olha. */
197
+ function pythonComQuartz() {
198
+ let py = null;
199
+ try { py = require('./voz.js').python(); } catch { /* sem voz.js */ }
200
+ if (!py) return { py: null, tem: false };
201
+ try {
202
+ execFileSync(py, ['-c', 'import Quartz, AppKit'], { stdio: 'ignore', timeout: 20000 });
203
+ return { py, tem: true };
204
+ } catch { return { py, tem: false }; }
205
+ }
206
+
207
+ function espacoLivre(dir) {
208
+ // statfs é do Node 18.15+; o engines pede 18.17, mas sem ele não quebra.
209
+ if (typeof fs.statfsSync !== 'function') return null;
210
+ try { const s = fs.statfsSync(dir); return s.bavail * s.bsize; } catch { return null; }
211
+ }
212
+
213
+ function comPrazo(promessa, ms) {
214
+ let t;
215
+ return Promise.race([
216
+ promessa,
217
+ new Promise((_, nao) => { t = setTimeout(() => nao(new Error(`não respondeu em ${Math.round(ms / 1000)}s`)), ms); }),
218
+ ]).finally(() => clearTimeout(t));
219
+ }
220
+
221
+ const UM_GB = 1024 ** 3;
222
+
223
+ /**
224
+ * A lista do /doctor. Cada linha: `ok` true (✓), false (✗) ou null (·, não se
225
+ * aplica aqui), o que foi olhado, o detalhe, e — quando ✗ — como consertar em
226
+ * uma linha. Consertar é a parte que importa: "✗ Chrome" sozinho manda a
227
+ * pessoa pesquisar; "instale em google.com/chrome" resolve.
228
+ *
229
+ * Tudo injetável (`dep`), para o teste passar os casos sem rede nem Chrome.
230
+ */
231
+ async function diagnosticar(dep = {}) {
232
+ const d = {
233
+ versaoNode: process.version,
234
+ minimaNode: '18.17.0',
235
+ plataforma: process.platform,
236
+ saude: null, // async () => objeto do /health
237
+ prazoServidor: 15000,
238
+ provedor: () => null, // provedor do /api, ou null
239
+ conferirChave: null, // async (id, chave) => { ok, error }
240
+ cliInstalado: () => false, // (id) => bool
241
+ acharChrome: () => null,
242
+ pythonQuartz: pythonComQuartz,
243
+ raiz: null,
244
+ espaco: espacoLivre,
245
+ arquivoConfig: null,
246
+ ...dep,
247
+ };
248
+ const linhas = [];
249
+ const poe = (ok, item, detalhe, conserto) => linhas.push({ ok, item, detalhe, conserto: ok === false ? conserto : undefined });
250
+
251
+ // Node
252
+ const nodeOk = versaoMinima(d.versaoNode, d.minimaNode);
253
+ poe(nodeOk, 'Node', d.versaoNode, `atualize para ${d.minimaNode} ou mais em nodejs.org`);
254
+
255
+ // Servidor
256
+ if (d.saude) {
257
+ try {
258
+ const h = await comPrazo(d.saude(), d.prazoServidor);
259
+ poe(true, 'Servidor', `${h && h.status || 'ok'}${h && h.version ? ' · v' + h.version : ''}`);
260
+ } catch (e) {
261
+ poe(false, 'Servidor', e.message, 'confira a internet; se acabou de ligar, ele acorda em até 1 min — rode /doctor de novo');
262
+ }
263
+ }
264
+
265
+ // /api
266
+ const p = d.provedor();
267
+ if (!p) {
268
+ poe(null, '/api', 'não configurado — os pedidos vão pelo servidor (normal)');
269
+ } else if (p.cli) {
270
+ const ok = !!d.cliInstalado(p.id);
271
+ poe(ok, '/api', p.nome, `o ${p.nome} não está instalado ou logado — instale e faça login, ou /api off`);
272
+ } else if (d.conferirChave) {
273
+ let r;
274
+ try { r = await comPrazo(d.conferirChave(p.id, p.chave), d.prazoServidor); } catch (e) { r = { ok: false, error: e.message }; }
275
+ poe(!!r.ok, '/api', `${p.nome} · ${p.modelo}${r.ok ? '' : ' · ' + r.error}`, `cole a chave de novo com /api, ou /api off para voltar ao servidor`);
276
+ } else {
277
+ poe(true, '/api', `${p.nome} · ${p.modelo}`);
278
+ }
279
+
280
+ // Chrome
281
+ let chrome = null;
282
+ try { chrome = d.acharChrome(); } catch { /* sem módulo */ }
283
+ poe(!!chrome, 'Navegador', chrome || 'nenhum Chrome/Edge/Brave encontrado',
284
+ 'instale o Google Chrome (google.com/chrome), ou aponte PRIMOCODE_CHROME para o seu');
285
+
286
+ // Cursor (Python + PyObjC) — só existe no macOS
287
+ if (d.plataforma !== 'darwin') {
288
+ poe(null, 'Cursor do Primo', 'só no macOS');
289
+ } else {
290
+ const q = d.pythonQuartz();
291
+ if (!q.py) poe(false, 'Cursor do Primo', 'sem Python 3', 'instale o Python 3 em python.org e abra o terminal de novo');
292
+ else poe(!!q.tem, 'Cursor do Primo', q.tem ? `${q.py} com PyObjC` : `${q.py} sem PyObjC`,
293
+ `ele tenta instalar sozinho no primeiro uso; se falhar: ${q.py} -m pip install pyobjc-framework-Quartz pyobjc-framework-Cocoa`);
294
+ }
295
+
296
+ // Disco
297
+ if (d.raiz) {
298
+ const livre = d.espaco(d.raiz);
299
+ if (livre == null) poe(null, 'Disco', 'não consegui medir aqui');
300
+ else poe(livre >= UM_GB, 'Disco', `${fmtTamanho(livre)} livres`, 'menos de 1 GB livre: apague projetos velhos em ~/primocode ou esvazie a lixeira');
301
+ }
302
+
303
+ // Config
304
+ if (d.arquivoConfig) {
305
+ if (!fs.existsSync(d.arquivoConfig)) poe(true, 'Config', 'ainda não existe (usa o padrão)');
306
+ else {
307
+ try { JSON.parse(fs.readFileSync(d.arquivoConfig, 'utf8')); poe(true, 'Config', d.arquivoConfig); }
308
+ catch (e) { poe(false, 'Config', `não consegui ler: ${e.message}`, `apague ${d.arquivoConfig} — ele nasce de novo no padrão`); }
309
+ }
310
+ }
311
+ return linhas;
312
+ }
313
+
314
+ // ── /custo ───────────────────────────────────────────────────────────────
315
+
316
+ /* O contador da sessão. Cada pedido ao modelo passa por lib/api.js; em vez
317
+ de mexer lá dentro, as funções exportadas são embrulhadas uma vez na
318
+ abertura. O act.js chama `motor.requestAct` com `motor = api` (o próprio
319
+ objeto do módulo), então o embrulho pega o agente, o chat, o resumo e o
320
+ plano — todo mundo que conversa com o modelo.
321
+
322
+ Token exato só quando o provedor manda `usage`. Sem isso, a conta é a de
323
+ bolso: 4 caracteres ≈ 1 token. O /custo diz qual das duas está mostrando. */
324
+ function novoContador() { return { porProvedor: {}, desde: Date.now() }; }
325
+
326
+ const tam = (x) => { try { return typeof x === 'string' ? x.length : JSON.stringify(x || '').length; } catch { return 0; } };
327
+
328
+ function registrar(cont, provedor) {
329
+ const b = cont.porProvedor[provedor] || (cont.porProvedor[provedor] = {
330
+ pedidos: 0, charsEnviados: 0, charsRecebidos: 0, tokEnviados: 0, tokRecebidos: 0, exatos: 0,
331
+ });
332
+ b.pedidos++;
333
+ return b;
334
+ }
335
+
336
+ function medirEnvio(b, payload) {
337
+ // O token da conta e o sinal de abortar não são conteúdo: não vão pro modelo.
338
+ const { token, sinal, ...corpo } = payload || {};
339
+ b.charsEnviados += tam(corpo);
340
+ }
341
+
342
+ function medirEvento(b, evt) {
343
+ if (!evt) return;
344
+ if (evt.delta) b.charsRecebidos += String(evt.delta).length;
345
+ if (evt.tool_call) b.charsRecebidos += tam(evt.tool_call);
346
+ const u = evt.usage;
347
+ if (u && (u.prompt_tokens || u.completion_tokens || u.input_tokens || u.output_tokens)) {
348
+ b.tokEnviados += Number(u.prompt_tokens || u.input_tokens || 0);
349
+ b.tokRecebidos += Number(u.completion_tokens || u.output_tokens || 0);
350
+ b.exatos++;
351
+ }
352
+ }
353
+
354
+ function instalarContador(api, cont, quemAtende) {
355
+ if (!api || api.__contador) return;
356
+ api.__contador = cont;
357
+ for (const nome of ['requestAct', 'requestChat']) {
358
+ const original = api[nome];
359
+ if (typeof original !== 'function') continue;
360
+ api[nome] = function (servidor, payload, onEvent) {
361
+ const b = registrar(cont, quemAtende());
362
+ medirEnvio(b, payload);
363
+ return original.call(this, servidor, payload, (evt) => {
364
+ medirEvento(b, evt);
365
+ return onEvent ? onEvent(evt) : undefined;
366
+ });
367
+ };
368
+ }
369
+ for (const nome of ['requestResumo', 'requestPlano', 'requestRefino']) {
370
+ const original = api[nome];
371
+ if (typeof original !== 'function') continue;
372
+ api[nome] = function (servidor, payload) {
373
+ const b = registrar(cont, quemAtende());
374
+ medirEnvio(b, payload);
375
+ return Promise.resolve(original.call(this, servidor, payload)).then((r) => {
376
+ b.charsRecebidos += tam(r);
377
+ if (r && r.usage) medirEvento(b, { usage: r.usage });
378
+ return r;
379
+ });
380
+ };
381
+ }
382
+ }
383
+
384
+ /** As linhas do /custo: por provedor, e o total. */
385
+ function resumoCusto(cont) {
386
+ const linhas = [];
387
+ let total = { pedidos: 0, enviados: 0, recebidos: 0 };
388
+ for (const [provedor, b] of Object.entries(cont.porProvedor)) {
389
+ // Com `usage` em todos os pedidos, vale o número do provedor; senão a
390
+ // estimativa inteira, para não somar exato com chute.
391
+ const exato = b.exatos > 0 && b.exatos === b.pedidos;
392
+ const enviados = exato ? b.tokEnviados : Math.ceil(b.charsEnviados / 4);
393
+ const recebidos = exato ? b.tokRecebidos : Math.ceil(b.charsRecebidos / 4);
394
+ linhas.push({ provedor, pedidos: b.pedidos, enviados, recebidos, exato });
395
+ total.pedidos += b.pedidos; total.enviados += enviados; total.recebidos += recebidos;
396
+ }
397
+ return { linhas, total };
398
+ }
399
+
400
+ function fmtNumero(n) { return Number(n || 0).toLocaleString('pt-BR'); }
401
+
402
+ // ── /copiar ──────────────────────────────────────────────────────────────
403
+
404
+ /* Quem copia em cada sistema. No Linux há dois mundos (Wayland e X11) e três
405
+ programas; tenta na ordem e diz qual instalar se nenhum existir. */
406
+ function copiadores(plataforma) {
407
+ if (plataforma === 'darwin') return [['pbcopy', []]];
408
+ if (plataforma === 'win32') return [['clip', []]];
409
+ return [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]];
410
+ }
411
+
412
+ function copiar(texto, { plataforma = process.platform, rodar = spawnSync } = {}) {
413
+ if (!texto) return { ok: false, error: 'ainda não há resposta para copiar' };
414
+ for (const [cmd, args] of copiadores(plataforma)) {
415
+ let r;
416
+ // O clip do Windows lê UTF-16 com BOM; em UTF-8 os acentos viram lixo.
417
+ const entrada = plataforma === 'win32' ? Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(texto, 'utf16le')]) : texto;
418
+ try { r = rodar(cmd, args, { input: entrada, timeout: 5000 }); } catch { continue; }
419
+ if (r && !r.error && r.status === 0) return { ok: true, via: cmd };
420
+ }
421
+ return {
422
+ ok: false,
423
+ error: plataforma === 'linux'
424
+ ? 'não achei wl-copy, xclip nem xsel — instale um (ex.: sudo apt install xclip)'
425
+ : 'a área de transferência não respondeu neste computador',
426
+ };
427
+ }
428
+
429
+ // ── /historico ───────────────────────────────────────────────────────────
430
+
431
+ /* A busca é sem acento e sem caixa: quem procura "botao" quer achar "Botão". */
432
+ const sem = (x) => String(x || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
433
+
434
+ function buscarHistorico(arquivo, termo, limite = 20) {
435
+ let texto = '';
436
+ try { texto = fs.readFileSync(arquivo, 'utf8'); } catch { return []; }
437
+ const palavras = sem(termo).split(/\s+/).filter(Boolean);
438
+ const achados = [];
439
+ const linhas = texto.split('\n');
440
+ // Do fim para o começo: o mais recente é o que a pessoa quase sempre quer.
441
+ for (let i = linhas.length - 1; i >= 0 && achados.length < limite; i--) {
442
+ if (!linhas[i].trim()) continue;
443
+ let e;
444
+ try { e = JSON.parse(linhas[i]); } catch { continue; }
445
+ if (!e || !e.prompt) continue;
446
+ const alvo = sem(`${e.prompt} ${e.project || ''}`);
447
+ if (palavras.every((p) => alvo.includes(p))) achados.push(e);
448
+ }
449
+ return achados;
450
+ }
451
+
452
+ // ── /atalhos ─────────────────────────────────────────────────────────────
453
+
454
+ /* Tirado do lib/entrada.js (a caixa de digitar) e do main() do CLI — não de
455
+ memória. Se uma tecla mudar lá, é aqui que tem de mudar também. */
456
+ const ATALHOS = [
457
+ ['Enter', 'manda o pedido'],
458
+ ['Alt+Enter · Ctrl+J', 'quebra a linha sem mandar (ou \\ no fim da linha)'],
459
+ ['Esc Esc', 'para o agente no meio do trabalho'],
460
+ ['Esc', 'fecha o menu de comandos'],
461
+ ['!comando', 'roda direto no shell, sem passar pelo agente'],
462
+ ['@arquivo', 'anexa um arquivo do projeto só a este pedido'],
463
+ ['/', 'abre o menu de comandos'],
464
+ ['Tab · →', 'completa o comando sugerido'],
465
+ ['↑ ↓', 'pedidos anteriores (ou anda no menu, se aberto)'],
466
+ ['Ctrl+C', 'limpa o que foi digitado; com a caixa vazia, sai'],
467
+ ['Ctrl+D', 'sai (com a caixa vazia)'],
468
+ ['Ctrl+L', 'limpa a tela'],
469
+ ['Ctrl+A · Ctrl+E', 'vai para o começo · o fim da linha'],
470
+ ['Ctrl+U · Ctrl+K', 'apaga antes · depois do cursor'],
471
+ ['Ctrl+W', 'apaga a palavra antes do cursor'],
472
+ ];
473
+
474
+ module.exports = {
475
+ fmtTamanho, quandoFoi, medirPasta, listarProjetos,
476
+ nomeDoNovo, pedidoDoNovo,
477
+ portasDoProjeto, sondarPorta, acharAlvoAbrir,
478
+ diagnosticar, versaoMinima, espacoLivre,
479
+ novoContador, instalarContador, resumoCusto, fmtNumero,
480
+ copiar, copiadores, buscarHistorico, ATALHOS,
481
+ };
package/lib/config.js CHANGED
@@ -295,6 +295,9 @@ function loadConfig() {
295
295
  currentProject: file.currentProject || null,
296
296
  userName: file.userName || null,
297
297
  mascot: file.mascot || 'primo',
298
+ // O rostinho do mascote depois da resposta. Ligado por padrão; quem acha
299
+ // que é enfeite demais desliga com /mascot reacoes off.
300
+ reacoes: file.reacoes !== false,
298
301
  theme: file.theme || 'primo',
299
302
  // Modo escolhido dura entre sessões: quem liga o auto quer o auto amanhã.
300
303
  mode: process.env.PRIMOCODE_MODE || file.mode || 'auto',
@@ -310,8 +313,8 @@ function saveConfig(partial) {
310
313
  ensureDir(CONFIG_DIR);
311
314
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
312
315
  // Só persiste o que faz sentido guardar — nada de campos derivados.
313
- const { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode, permissoes } = { ...cur, ...partial };
314
- const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode, permissoes };
316
+ const { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes } = { ...cur, ...partial };
317
+ const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, reacoes, theme, mode, permissoes };
315
318
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
316
319
  return next;
317
320
  }