primocode 9.8.0-beta.2 → 9.8.0-beta.21

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 (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
@@ -61,6 +61,28 @@ function argsDoModelo() {
61
61
  return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
62
62
  }
63
63
 
64
+ /* O Claude Code ENXUTO, como motor. Sem isto cada passo do agente abria o
65
+ * Claude Code da pessoa inteiro: hooks, plugins, skills, CLAUDE.md dela e as
66
+ * ferramentas nativas — 36 mil tokens e ~6 s por chamada, e a resposta
67
+ * chegava com recado de hook pessoal ("context-sync pendente"). Medido em
68
+ * 25/09: com estes argumentos, a mesma chamada leva 473 tokens e ~3,5 s. Um
69
+ * pedido de 100 passos pagava aquilo 100 vezes — é a demora de 20 minutos.
70
+ *
71
+ * Nada disto tira o que o motor precisa: o protocolo vai inteiro no prompt
72
+ * (corpoDoProtocolo), e as ferramentas que valem são as do PrimoCode, não as
73
+ * do Claude Code. */
74
+ const ARGS_ENXUTOS = [
75
+ '--setting-sources', 'project',
76
+ '--strict-mcp-config',
77
+ '--disable-slash-commands',
78
+ '--tools', '',
79
+ '--no-session-persistence',
80
+ '--system-prompt', 'Você é o motor de decisão do PrimoCode. Siga à risca o protocolo que vem na mensagem e responda só com o bloco pedido.',
81
+ ];
82
+ function argsDoMotor() {
83
+ return [...ARGS_ENXUTOS, ...argsDoModelo()];
84
+ }
85
+
64
86
  /**
65
87
  * Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
66
88
  * ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
@@ -567,7 +589,7 @@ async function requestActViaHeadless(payload, onEvent) {
567
589
  '-p',
568
590
  '--output-format', 'json',
569
591
  '--dangerously-skip-permissions',
570
- ...argsDoModelo(),
592
+ ...argsDoMotor(),
571
593
  ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
572
594
  env: process.env, ...SEM_APARECER });
573
595
  const r = JSON.parse(saida);
@@ -577,7 +599,7 @@ async function requestActViaHeadless(payload, onEvent) {
577
599
  const pedirCorrecao = (aviso) => {
578
600
  const s = execFileSync('claude', [
579
601
  '-p', '--output-format', 'json', '--dangerously-skip-permissions',
580
- ...argsDoModelo(),
602
+ ...argsDoMotor(),
581
603
  ], {
582
604
  encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
583
605
  env: process.env,
@@ -622,7 +644,7 @@ module.exports = {
622
644
  // o que veio torto. Reimplementar aquilo no outro motor seria manter dois
623
645
  // protocolos que divergem na primeira correção de um deles.
624
646
  linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
625
- MODELO_CLAUDE, argsDoModelo,
647
+ MODELO_CLAUDE, argsDoModelo, argsDoMotor,
626
648
  SKILL_DIR, SKILL_PATH, garantirSkill,
627
649
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
628
650
  garantirEngine, requestAct, emitirDecisao,
@@ -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
+ };
@@ -0,0 +1,104 @@
1
+ /**
2
+ * comandos.js — o pedido que a pessoa repete vira um comando dela.
3
+ *
4
+ * Quem pede "revisa o projeto e me explica os bugs sem jargão" toda semana
5
+ * não deveria digitar isso toda semana. Um arquivo `revisar.md` com o pedido
6
+ * dentro vira `/revisar`, e `/revisar o login` troca `$ARGUMENTOS` por
7
+ * "o login". É o mesmo desenho dos comandos do Claude Code.
8
+ *
9
+ * ── POR QUE NÃO É UMA SKILL ──────────────────────────────────────────────
10
+ * Skill entra sozinha quando o pedido casa com o `quando:` dela. Comando só
11
+ * entra quando a pessoa chama pelo nome. Um é conhecimento de fundo, o outro
12
+ * é um atalho de digitação — e misturar os dois faria um atalho disparar
13
+ * sem ninguém ter pedido.
14
+ *
15
+ * ── ONDE MORAM, E QUEM GANHA ─────────────────────────────────────────────
16
+ * 1. `comandos/` do pacote — os três que vêm de fábrica ("primocode")
17
+ * 2. `~/.primocode/comandos/` — os da pessoa, em qualquer projeto ("global")
18
+ * 3. `<projeto>/.primocode/comandos/` — os daquele projeto ("projeto")
19
+ * O mais perto do trabalho ganha: o do projeto sabe coisas do projeto que o
20
+ * global não sabe. E o de fábrica perde para qualquer um, para a pessoa poder
21
+ * reescrever o `/revisar` do jeito dela sem mexer no pacote.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+ const { separar } = require('./skills.js');
30
+
31
+ const PASTA_DO_PACOTE = path.join(__dirname, '..', 'comandos');
32
+ // O nome vira `/nome` na caixa de digitar: sem espaço, sem acento estranho,
33
+ // sem barra — senão o comando existe no disco e não há como digitá-lo.
34
+ const NOME_VALIDO = /^[a-z0-9][a-z0-9_-]*$/;
35
+
36
+ /** Um arquivo .md → { nome, descricao, corpo, arquivo }. */
37
+ function ler(arquivo) {
38
+ const { cabecalho, corpo } = separar(fs.readFileSync(arquivo, 'utf-8'));
39
+ return {
40
+ nome: path.basename(arquivo, '.md').toLowerCase(),
41
+ descricao: cabecalho.descricao || cabecalho.description || '',
42
+ corpo,
43
+ arquivo,
44
+ };
45
+ }
46
+
47
+ function daPasta(pasta, origem) {
48
+ let nomes = [];
49
+ try { nomes = fs.readdirSync(pasta); } catch { return []; } // pasta que não existe é lista vazia
50
+ const fora = [];
51
+ for (const n of nomes.filter((x) => x.endsWith('.md')).sort()) {
52
+ try {
53
+ const c = ler(path.join(pasta, n));
54
+ if (NOME_VALIDO.test(c.nome) && c.corpo) fora.push({ ...c, origem });
55
+ } catch { /* um arquivo ilegível não derruba os outros */ }
56
+ }
57
+ return fora;
58
+ }
59
+
60
+ /** A lista final, um por nome, já com a precedência aplicada. */
61
+ function carregar({ projectDir, home = os.homedir(), pastaDoPacote = PASTA_DO_PACOTE } = {}) {
62
+ const porNome = new Map();
63
+ // Em ordem crescente de prioridade: quem vem depois sobrescreve.
64
+ const camadas = [
65
+ [pastaDoPacote, 'primocode'],
66
+ [path.join(home, '.primocode', 'comandos'), 'global'],
67
+ ];
68
+ if (projectDir) camadas.push([path.join(projectDir, '.primocode', 'comandos'), 'projeto']);
69
+ for (const [pasta, origem] of camadas) {
70
+ for (const c of daPasta(pasta, origem)) porNome.set(c.nome, c);
71
+ }
72
+ return [...porNome.values()].sort((a, b) => a.nome.localeCompare(b.nome));
73
+ }
74
+
75
+ /** O prompt que vai ao agente. */
76
+ function expandir(comando, argumentos) {
77
+ const args = String(argumentos || '').trim();
78
+ const corpo = comando.corpo;
79
+ if (corpo.includes('$ARGUMENTOS')) return corpo.split('$ARGUMENTOS').join(args).trim();
80
+ // Sem a marca, o que foi digitado não pode sumir: vai no fim.
81
+ return args ? `${corpo}\n\n${args}` : corpo;
82
+ }
83
+
84
+ /** Cria ~/.primocode/comandos/<nome>.md com um esqueleto. Devolve o caminho. */
85
+ function criar(nome, { home = os.homedir() } = {}) {
86
+ const n = String(nome || '').trim().toLowerCase().replace(/^\//, '');
87
+ if (!NOME_VALIDO.test(n)) return { ok: false, error: 'Nome só com letras minúsculas, números, - e _.' };
88
+ const pasta = path.join(home, '.primocode', 'comandos');
89
+ const arquivo = path.join(pasta, n + '.md');
90
+ if (fs.existsSync(arquivo)) return { ok: false, error: `Já existe: ${arquivo}`, arquivo };
91
+ fs.mkdirSync(pasta, { recursive: true });
92
+ fs.writeFileSync(arquivo, [
93
+ '---',
94
+ `descricao: o que o /${n} faz, numa linha`,
95
+ '---',
96
+ 'Escreva aqui o pedido, do jeito que você digitaria.',
97
+ '',
98
+ 'O que vier depois do comando entra no lugar desta palavra: $ARGUMENTOS',
99
+ '',
100
+ ].join('\n'));
101
+ return { ok: true, arquivo };
102
+ }
103
+
104
+ module.exports = { PASTA_DO_PACOTE, NOME_VALIDO, ler, carregar, expandir, criar };