primocode 9.8.0-beta.2 → 9.8.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/act.js CHANGED
@@ -21,7 +21,9 @@ const repeticao = require('./repeticao');
21
21
  const diff = require('./diff');
22
22
  const registro = require('./registro');
23
23
  const permissao = require('./permissao');
24
+ const perigo = require('./perigo');
24
25
  const skills = require('./skills');
26
+ const checkpoint = require('./checkpoint');
25
27
 
26
28
  // 40, não 15: com o freio de repetição (repeticao.js) e o de rodada parada,
27
29
  // volta a mais não é volta em círculo — é trabalho. Quinze acabavam no meio
@@ -475,6 +477,11 @@ function mapaDoProjeto(dir, limite = 80) {
475
477
 
476
478
  function buildMemoryContext(projectDir, projectName) {
477
479
  const parts = [];
480
+ // As instruções vêm primeiro: são regra de quem manda no projeto, e a
481
+ // memória é o que o agente anotou. Entram aqui (e não no bin) para valer
482
+ // igual no terminal, no modo voz e em cada passo de um plano.
483
+ const instrucoes = require('./mencoes.js').instrucoesDoProjeto(projectDir);
484
+ if (instrucoes) parts.push(instrucoes);
478
485
  const geral = cfg.readGeneralMemory();
479
486
  if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
480
487
  const projeto = cfg.readProjectMemory(projectDir);
@@ -493,6 +500,10 @@ async function runAct(userPrompt, opts) {
493
500
  quem sabe lembrar é a SESSÃO — um subagente não pode começar do
494
501
  zero e pedir de novo o que já foi autorizado. */
495
502
  autorizar = null, controle = null,
503
+ /* O modo do /permissoes (auto · perguntar · livre). Vem da config pela
504
+ CLI e desce para os subagentes pelo ctx: eles recusam o perigoso,
505
+ mas só sabem que é "livre" se alguém disser. */
506
+ permissoes = 'auto',
496
507
  /* O cliente HTTP, injetável. NÃO é mais um comutador de motor — o
497
508
  PrimoCode tem um motor só, o do servidor, e não há provedor para o
498
509
  usuário escolher. Continua um parâmetro porque é por aqui que o
@@ -962,7 +973,7 @@ async function runAct(userPrompt, opts) {
962
973
  fala.paralelo(juntos.length, profundidade);
963
974
  const rs = await Promise.all(juntos.map((tc) =>
964
975
  runSubagent(tc.arguments || {}, {
965
- server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
976
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, permissoes,
966
977
  }).catch((e) => `Subagente falhou: ${e && e.message}`)));
967
978
  juntos.forEach((tc, i) => resultadosSpawn.set(tc.id, rs[i]));
968
979
  }
@@ -1005,7 +1016,7 @@ async function runAct(userPrompt, opts) {
1005
1016
  const r = resultadosSpawn.has(tc.id)
1006
1017
  ? resultadosSpawn.get(tc.id)
1007
1018
  : await runSubagent(tc.arguments || {}, {
1008
- server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
1019
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, permissoes,
1009
1020
  });
1010
1021
  repeticao.executou(jaChamado, tc.name, tc.arguments);
1011
1022
  messages.push({ role: 'tool', tool_call_id: tc.id, content: r });
@@ -1018,7 +1029,7 @@ async function runAct(userPrompt, opts) {
1018
1029
  algoRodou = true;
1019
1030
  fala.acao(tc.name, tc.arguments, profundidade);
1020
1031
  const r = await rodarDiretor(tc.arguments || {}, {
1021
- server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
1032
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, permissoes,
1022
1033
  });
1023
1034
  logResult(tc.name, r, profundidade);
1024
1035
  messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(r) });
@@ -1036,7 +1047,7 @@ async function runAct(userPrompt, opts) {
1036
1047
  garantirPasta();
1037
1048
  fala.acao(tc.name, tc.arguments, profundidade);
1038
1049
  const r = await rodarPipeline(tc.arguments || {}, {
1039
- server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, livro, autorizar, controle: portaoControle,
1050
+ server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, permissoes, livro, autorizar, controle: portaoControle,
1040
1051
  });
1041
1052
  logResult(tc.name, r, profundidade);
1042
1053
  repeticao.executou(jaChamado, tc.name, tc.arguments);
@@ -1087,9 +1098,30 @@ async function runAct(userPrompt, opts) {
1087
1098
  }
1088
1099
  }
1089
1100
 
1101
+ /* O PORTÃO do que não tem volta (lib/perigo.js). No mesmo lugar do
1102
+ portão do desktop e pelo mesmo motivo: antes, não depois. A
1103
+ resposta mora no mesmo objeto da sessão — quem lembra é ela. */
1104
+ if (!portaoControle.perigo) portaoControle.perigo = perigo.novo();
1105
+ const risco = await perigo.portao(portaoControle.perigo, {
1106
+ modo: permissoes, nome: tc.name, args: tc.arguments,
1107
+ projectDir: pastaAtual, subagent, perguntar: autorizar,
1108
+ });
1109
+ if (!risco.ok) {
1110
+ fala.acao(tc.name, tc.arguments, profundidade);
1111
+ console.log(fala.resultado(profundidade) + c.warn(`não autorizado — ${risco.motivo}`));
1112
+ registro.anotar(livro, tc.name, tc.arguments, { ok: false, error: risco.error });
1113
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ ok: false, error: risco.error }) });
1114
+ continue;
1115
+ }
1116
+
1090
1117
  algoRodou = true;
1091
1118
  rodouTool = true;
1092
1119
  fala.acao(tc.name, tc.arguments, profundidade);
1120
+ /* Antes de mexer no arquivo, guarda o original — é o que o /desfazer
1121
+ devolve. Caminho inválido não é problema nosso: a tool recusa. */
1122
+ if (checkpoint.MUDAM_ARQUIVO.has(tc.name)) {
1123
+ try { checkpoint.antesDeMudar(tools.resolveInProject(tc.arguments && tc.arguments.path, pastaAtual), pastaAtual); } catch {}
1124
+ }
1093
1125
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
1094
1126
  logResult(tc.name, result, profundidade);
1095
1127
  repeticao.executou(jaChamado, tc.name, tc.arguments);
@@ -1232,7 +1264,7 @@ async function runAct(userPrompt, opts) {
1232
1264
  }
1233
1265
 
1234
1266
  async function runSubagent(args, ctx) {
1235
- const { server, model, token, projectDir, projectName, profundidade, motor } = ctx;
1267
+ const { server, model, token, projectDir, projectName, profundidade, motor, permissoes } = ctx;
1236
1268
  const descricao = args.description || 'sub-tarefa';
1237
1269
 
1238
1270
  if (profundidade + 1 >= MAX_PROFUNDIDADE) {
@@ -1251,6 +1283,7 @@ async function runSubagent(args, ctx) {
1251
1283
  profundidade: profundidade + 1,
1252
1284
  subagent: true,
1253
1285
  motor,
1286
+ permissoes,
1254
1287
  autorizar: ctx.autorizar,
1255
1288
  controle: ctx.controle, // o mesmo objeto: a resposta vale para todos
1256
1289
  });
@@ -1294,7 +1327,7 @@ async function runSubagent(args, ctx) {
1294
1327
  * inventar — e inventar é onde ele erra.
1295
1328
  */
1296
1329
  async function rodarDiretor(args, ctx) {
1297
- const { server, model, token, projectDir, projectName, profundidade, motor } = ctx;
1330
+ const { server, model, token, projectDir, projectName, profundidade, motor, permissoes } = ctx;
1298
1331
  if (profundidade > 0) {
1299
1332
  return { ok: false, error: 'Recusado: o diretor não roda dentro de um subagente. '
1300
1333
  + 'Produza com o plano que você já recebeu.' };
@@ -1305,7 +1338,7 @@ async function rodarDiretor(args, ctx) {
1305
1338
  fala.agente('Diretor', rotulo, profundidade);
1306
1339
  const r = await runAct(prompt, {
1307
1340
  server, model, token, projectDir, projectName,
1308
- history: [], profundidade: profundidade + 1, subagent: true, motor,
1341
+ history: [], profundidade: profundidade + 1, subagent: true, motor, permissoes,
1309
1342
  });
1310
1343
  return (r && r.assistantMessage && r.assistantMessage.content) || '';
1311
1344
  };
@@ -1338,7 +1371,7 @@ async function rodarDiretor(args, ctx) {
1338
1371
  }
1339
1372
 
1340
1373
  async function rodarPipeline(args, ctx) {
1341
- const { server, model, token, projectDir, projectName, profundidade, motor } = ctx;
1374
+ const { server, model, token, projectDir, projectName, profundidade, motor, permissoes } = ctx;
1342
1375
  if (profundidade > 0) {
1343
1376
  return { ok: false, error: 'Recusado: o pipeline não roda dentro de um subagente — '
1344
1377
  + 'os papéis dele já são subagentes. Faça a parte que te cabe.' };
@@ -1361,7 +1394,7 @@ async function rodarPipeline(args, ctx) {
1361
1394
  fala.agente('Agente', rotulo, profundidade);
1362
1395
  const r = await runAct(prompt, {
1363
1396
  server, model, token, projectDir: pasta, projectName,
1364
- history: [], profundidade: profundidade + 1, subagent: true, motor,
1397
+ history: [], profundidade: profundidade + 1, subagent: true, motor, permissoes,
1365
1398
  });
1366
1399
  return (r && r.assistantMessage && r.assistantMessage.content) || '';
1367
1400
  };
@@ -1376,5 +1409,5 @@ async function rodarPipeline(args, ctx) {
1376
1409
  });
1377
1410
  }
1378
1411
 
1379
- module.exports = { runAct, interromper, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1412
+ module.exports = { runAct, buildMemoryContext, interromper, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1380
1413
  anotacaoDoQueJaFoi, encolherParaContinuar, MAX_ITERATIONS };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * checkpoint.js — toda mudança do agente num arquivo tem volta.
3
+ *
4
+ * Quem usa o PrimoCode quase nunca sabe git. Uma edição ruim ("ele apagou
5
+ * metade do meu site") não pode ser o fim da história: antes de o agente
6
+ * escrever, editar ou apagar um arquivo, guardamos o conteúdo ORIGINAL dele,
7
+ * uma vez por pedido. O `/desfazer` devolve tudo como estava.
8
+ *
9
+ * Por que o checkpoint é do PEDIDO e não do passo: um pedido vira vários
10
+ * runAct (passos do plano, continuações, subagentes). Para a pessoa, "desfaz"
11
+ * quer dizer "desfaz o que eu pedi", não "desfaz o terceiro passo interno".
12
+ * Por isso o CLI chama `iniciar(pedido)` antes do laço de passos, e o loop do
13
+ * agente só avisa `antesDeMudar(arquivo, pasta)` — o estado fica aqui, no
14
+ * módulo, e o subagente (mesmo processo) cai no mesmo checkpoint.
15
+ *
16
+ * Onde fica: ~/.primocode/checkpoints/<projeto>-<hash>/<id>/
17
+ * manifest.json o pedido, a hora e cada arquivo (existia ou não)
18
+ * 0, 1, 2… a cópia original de cada arquivo que existia
19
+ * Só os últimos MAXIMO por projeto: é rede de segurança, não arquivo morto.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const os = require('os');
26
+ const path = require('path');
27
+ const crypto = require('crypto');
28
+
29
+ const MAXIMO = 20;
30
+ /** As tools de arquivo que mudam o disco. run_shell fica de fora: não dá para
31
+ saber, antes de rodar, que arquivos um comando vai tocar. */
32
+ const MUDAM_ARQUIVO = new Set(['write_file', 'edit_file', 'multi_edit']);
33
+
34
+ let atual = null; // o pedido em andamento: { pedido, dir, manifest }
35
+
36
+ /* os.homedir() lido na hora, não no require: o teste troca o HOME. */
37
+ function raiz() { return path.join(os.homedir(), '.primocode', 'checkpoints'); }
38
+
39
+ /** Uma pasta por projeto: o nome para a pessoa reconhecer, o hash para dois
40
+ projetos "site" em lugares diferentes não se misturarem. */
41
+ function pastaDoProjeto(projectDir) {
42
+ const abs = path.resolve(projectDir);
43
+ const nome = path.basename(abs).replace(/[^\w.-]+/g, '-').slice(0, 40) || 'raiz';
44
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
45
+ return path.join(raiz(), `${nome}-${hash}`);
46
+ }
47
+
48
+ /** Começa o checkpoint de um pedido. Nada vai ao disco ainda: pedido que não
49
+ mexe em arquivo (uma pergunta, uma pesquisa) não deixa checkpoint vazio. */
50
+ function iniciar(pedido) {
51
+ atual = { pedido: String(pedido || '').replace(/\s+/g, ' ').trim(), dir: null, manifest: null };
52
+ }
53
+
54
+ function encerrar() { atual = null; }
55
+
56
+ function gravarManifest() {
57
+ fs.writeFileSync(path.join(atual.dir, 'manifest.json'), JSON.stringify(atual.manifest, null, 2));
58
+ }
59
+
60
+ /** Chamado pelo loop ANTES de uma tool de arquivo rodar. Guarda o original só
61
+ na primeira vez que o arquivo é tocado no pedido — a segunda edição do
62
+ mesmo arquivo não pode sobrescrever o original com a versão já editada. */
63
+ function antesDeMudar(caminhoAbs, projectDir) {
64
+ if (!atual || !caminhoAbs) return;
65
+ try {
66
+ if (!atual.dir) {
67
+ const base = pastaDoProjeto(projectDir);
68
+ fs.mkdirSync(base, { recursive: true });
69
+ // Sempre maior que o anterior: a ordem dos ids É a ordem dos pedidos.
70
+ const ultimo = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).reduce((m, n) => Math.max(m, Number(n)), 0);
71
+ const id = String(Math.max(Date.now(), ultimo + 1));
72
+ atual.dir = path.join(base, id);
73
+ fs.mkdirSync(atual.dir);
74
+ atual.manifest = { pedido: atual.pedido, quando: new Date().toISOString(),
75
+ projectDir: path.resolve(projectDir), arquivos: [] };
76
+ podar(base);
77
+ }
78
+ const abs = path.resolve(caminhoAbs);
79
+ if (atual.manifest.arquivos.some((a) => a.caminho === abs)) return;
80
+ const existia = fs.existsSync(abs) && fs.statSync(abs).isFile();
81
+ const item = { caminho: abs, existia };
82
+ if (existia) {
83
+ item.copia = String(atual.manifest.arquivos.length);
84
+ fs.copyFileSync(abs, path.join(atual.dir, item.copia));
85
+ }
86
+ atual.manifest.arquivos.push(item);
87
+ gravarManifest();
88
+ } catch { /* checkpoint nunca derruba o trabalho do agente */ }
89
+ }
90
+
91
+ function podar(base) {
92
+ const ids = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)).sort((a, b) => Number(b) - Number(a));
93
+ for (const velho of ids.slice(MAXIMO)) fs.rmSync(path.join(base, velho), { recursive: true, force: true });
94
+ }
95
+
96
+ /** Os checkpoints do projeto, o mais novo primeiro. */
97
+ function lista(projectDir) {
98
+ const base = pastaDoProjeto(projectDir);
99
+ let ids = [];
100
+ try { ids = fs.readdirSync(base).filter((n) => /^\d+$/.test(n)); } catch { return []; }
101
+ return ids.sort((a, b) => Number(b) - Number(a)).map((id) => {
102
+ try {
103
+ const m = JSON.parse(fs.readFileSync(path.join(base, id, 'manifest.json'), 'utf8'));
104
+ return { id, dir: path.join(base, id), ...m };
105
+ } catch { return null; }
106
+ }).filter(Boolean);
107
+ }
108
+
109
+ /** Desfaz os N pedidos mais recentes, do mais novo para o mais velho — assim,
110
+ se dois pedidos mexeram no mesmo arquivo, o que sobra é o original do mais
111
+ antigo. Cada checkpoint desfeito é apagado: o próximo /desfazer volta mais
112
+ um pedido, em vez de repetir o mesmo. */
113
+ function desfazer(projectDir, n = 1) {
114
+ const feitos = [];
115
+ for (const cp of lista(projectDir).slice(0, Math.max(1, n))) {
116
+ for (const a of cp.arquivos) {
117
+ try {
118
+ if (a.existia) {
119
+ fs.mkdirSync(path.dirname(a.caminho), { recursive: true });
120
+ fs.copyFileSync(path.join(cp.dir, a.copia), a.caminho);
121
+ feitos.push({ caminho: a.caminho, acao: 'restaurado' });
122
+ } else {
123
+ fs.rmSync(a.caminho, { force: true });
124
+ feitos.push({ caminho: a.caminho, acao: 'apagado' });
125
+ }
126
+ } catch (e) { feitos.push({ caminho: a.caminho, acao: 'falhou', erro: e.message }); }
127
+ }
128
+ fs.rmSync(cp.dir, { recursive: true, force: true });
129
+ if (atual && atual.dir === cp.dir) atual = null;
130
+ }
131
+ return feitos;
132
+ }
133
+
134
+ module.exports = { MAXIMO, MUDAM_ARQUIVO, iniciar, encerrar, antesDeMudar, lista, desfazer, pastaDoProjeto };
@@ -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 };
package/lib/config.js CHANGED
@@ -129,15 +129,25 @@ function listProjects() {
129
129
  } catch { return []; }
130
130
  }
131
131
 
132
+ /* Palavras que dizem o TIPO da coisa, não QUAL coisa. "landing da barbearia"
133
+ e "landing-nova" (de outra empresa) dividiam "landing", e o pedido novo caía
134
+ dentro do projeto antigo — medido em 24/09: a landing da Navalha de Ouro foi
135
+ escrita em landing-nova/. Só o assunto conta como parecido. */
136
+ const GENERICAS = new Set(['landing', 'lading', 'page', 'pagina', 'site', 'app', 'aplicativo', 'projeto',
137
+ 'novo', 'nova', 'sistema', 'web', 'html', 'teste', 'versao', 'final', 'the', 'para', 'pra']);
138
+ function tokensDoAssunto(nome) {
139
+ return slugify(nome).split('-').filter((t) => t.length > 2 && !GENERICAS.has(t) && !/^v?\d+$/.test(t));
140
+ }
141
+
132
142
  // Projeto parecido com o que o usuário pediu? Compara os pedaços do slug.
133
143
  // "jogo-de-carro" vs "jogo-carro-2d" -> compartilham jogo+carro -> 0.5
134
144
  function findSimilarProject(projectName, minScore = 0.4) {
135
- const wanted = new Set(slugify(projectName).split('-').filter((t) => t.length > 2));
145
+ const wanted = new Set(tokensDoAssunto(projectName));
136
146
  if (!wanted.size) return null;
137
147
 
138
148
  let best = null;
139
149
  for (const existing of listProjects()) {
140
- const tokens = new Set(existing.split('-').filter((t) => t.length > 2));
150
+ const tokens = new Set(tokensDoAssunto(existing));
141
151
  if (!tokens.size) continue;
142
152
  let shared = 0;
143
153
  for (const t of wanted) if (tokens.has(t)) shared++;
@@ -288,6 +298,9 @@ function loadConfig() {
288
298
  theme: file.theme || 'primo',
289
299
  // Modo escolhido dura entre sessões: quem liga o auto quer o auto amanhã.
290
300
  mode: process.env.PRIMOCODE_MODE || file.mode || 'auto',
301
+ // O /permissoes: quando ele pergunta antes de agir. Dura entre sessões
302
+ // pelo mesmo motivo do modo — quem escolheu "perguntar" quer amanhã também.
303
+ permissoes: file.permissoes || 'auto',
291
304
  rootDir: ROOT_DIR,
292
305
  _file: file,
293
306
  };
@@ -297,8 +310,8 @@ function saveConfig(partial) {
297
310
  ensureDir(CONFIG_DIR);
298
311
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
299
312
  // Só persiste o que faz sentido guardar — nada de campos derivados.
300
- const { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode } = { ...cur, ...partial };
301
- const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode };
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 };
302
315
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
303
316
  return next;
304
317
  }
@@ -338,6 +351,7 @@ function esquecerProjeto() {
338
351
  }
339
352
 
340
353
  module.exports = {
354
+ tokensDoAssunto, GENERICAS,
341
355
  lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
342
356
  loadConfig, saveConfig, appendHistory,
343
357
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
package/lib/kits.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * kits.js — o modelo grátis começa de uma página pronta, e não do zero.
3
+ *
4
+ * "A IA free fez essa página, ficou boa, mas quero que ela seja mais
5
+ * rápida." · "Como chegar nesse nível com os modelos free? Nem terminou,
6
+ * demora 10 anos." (24/09/2026)
7
+ *
8
+ * ── A CONTA ──────────────────────────────────────────────────────────────
9
+ * Uma landing boa são ~30 KB de HTML+CSS+JS — uns 9 mil tokens de SAÍDA. No
10
+ * plano grátis (6 mil tokens por minuto, entrada e saída juntas) isso não
11
+ * cabe numa resposta: o servidor manda "uma tool por vez", e a página sai em
12
+ * pedaços, com espera a cada minuto. E o modelo fraco, desenhando do zero,
13
+ * erra justamente o que dá o nível: tipografia, espaço, animação.
14
+ *
15
+ * Com o kit, o design já está resolvido (a landing que o Claude fez, com o
16
+ * conteúdo trocado por marcadores). O agente só TROCA texto e três cores com
17
+ * edit_file: ~1–2 mil tokens de saída em vez de 9 mil. Mais rápido E melhor,
18
+ * pela mesma razão — o trabalho difícil já foi feito uma vez, por quem faz
19
+ * bem.
20
+ *
21
+ * Só entra em pasta NOVA (ou vazia): num projeto que já tem página, copiar
22
+ * um kit por cima seria apagar o trabalho da pessoa.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const PASTA_KITS = path.join(__dirname, '..', 'kits');
31
+
32
+ /* O pedido de landing. Estreito de propósito: "crie um app de tarefas" não é
33
+ landing, e um kit errado é pior que kit nenhum. */
34
+ const RE_LANDING = /\b(landing|lading|p[áa]gina (de vendas|de captura|inicial|institucional|de apresenta[çc][ãa]o|pr[ao]|do meu|da minha|da empresa|do neg[óo]cio)|site (institucional|da empresa|do meu|da minha|pr[ao] (minha|meu|a|o))|one ?page|home ?page)\b/i;
35
+ // Pedido que não quer a cara do kit: nada de puxar o kit por cima disso.
36
+ const RE_SEM_KIT = /\b(do zero|sem modelo|sem template|sem kit|react|next(\.?js)?|vue|svelte|angular|tailwind|wordpress)\b/i;
37
+
38
+ const KITS = {
39
+ landing: {
40
+ nome: 'landing',
41
+ arquivos: ['index.html', 'style.css', 'script.js'],
42
+ instrucao: 'A PASTA JÁ TEM UMA LANDING PRONTA (index.html, style.css, script.js), com design profissional, '
43
+ + 'animações e responsiva. NÃO reescreva os arquivos com write_file: é lento e piora o design. '
44
+ + 'ADAPTE com edit_file: (1) leia o index.html; (2) troque cada bloco marcado com <!-- TROQUE --> pelo '
45
+ + 'conteúdo do pedido — textos reais, específicos do negócio, sem inventar fato que você não sabe; '
46
+ + '(3) no topo do style.css troque só as cores --blue, --orange, --cyan (e as -deep) pelas cores que '
47
+ + 'combinam com o negócio; (4) apague os comentários TROQUE. Pode remover uma seção que não faz '
48
+ + 'sentido para o negócio (e o link dela no menu). Não mexa no script.js.',
49
+ },
50
+ };
51
+
52
+ /** Qual kit este pedido quer, ou null. */
53
+ function escolher(pedido) {
54
+ const t = String(pedido || '');
55
+ if (!RE_LANDING.test(t) || RE_SEM_KIT.test(t)) return null;
56
+ return KITS.landing;
57
+ }
58
+
59
+ /** A pasta está livre para receber o kit? (não existe, ou só tem memory.md) */
60
+ function pastaLivre(dir) {
61
+ try {
62
+ const tem = fs.readdirSync(dir).filter((n) => !n.startsWith('.') && n !== 'memory.md');
63
+ return tem.length === 0;
64
+ } catch { return true; }
65
+ }
66
+
67
+ /** Copia o kit para `dir`. Devolve os arquivos copiados, ou [] se não copiou. */
68
+ function aplicar(kit, dir) {
69
+ if (!kit || !pastaLivre(dir)) return [];
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ const feitos = [];
72
+ for (const nome of kit.arquivos) {
73
+ const destino = path.join(dir, nome);
74
+ if (fs.existsSync(destino)) continue;
75
+ fs.copyFileSync(path.join(PASTA_KITS, kit.nome, nome), destino);
76
+ feitos.push(nome);
77
+ }
78
+ return feitos;
79
+ }
80
+
81
+ module.exports = { KITS, PASTA_KITS, escolher, pastaLivre, aplicar };
package/lib/mencoes.js ADDED
@@ -0,0 +1,98 @@
1
+ // O que a pessoa TRAZ para o pedido, além do texto: as instruções do projeto
2
+ // (PRIMO.md, AGENTS.md, CLAUDE.md) e os arquivos citados com @.
3
+ //
4
+ // Ideia tirada do Claude Code (CLAUDE.md, @arquivo, /init) e do Codex
5
+ // (AGENTS.md). Quem já escreveu um AGENTS.md para outro agente não deveria ter
6
+ // de escrever de novo para o PrimoCode — então os três nomes valem.
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ /* ── INSTRUÇÕES DO PROJETO ───────────────────────────────────────────────
12
+ * Só o PRIMEIRO que existir, na ordem abaixo: um repo com AGENTS.md e
13
+ * CLAUDE.md quase sempre tem o mesmo texto nos dois (um aponta pro outro), e
14
+ * mandar os dois é pagar duas vezes pela mesma regra. O PRIMO.md vem antes
15
+ * porque é o que a pessoa escreveu PARA nós. */
16
+ const NOMES_INSTRUCOES = ['PRIMO.md', 'AGENTS.md', 'CLAUDE.md'];
17
+ const LIMITE_INSTRUCOES = 6000;
18
+
19
+ // A cabeça fica, o rabo sai: é no começo que se escreve o que mais importa.
20
+ function cortar(texto, limite) {
21
+ if (texto.length <= limite) return texto;
22
+ return texto.slice(0, limite).trimEnd() + '\n(cortado)';
23
+ }
24
+
25
+ function lerSeExiste(arquivo) {
26
+ try {
27
+ if (!fs.statSync(arquivo).isFile()) return '';
28
+ return fs.readFileSync(arquivo, 'utf-8');
29
+ } catch { return ''; }
30
+ }
31
+
32
+ /** O texto que entra no contexto, ou '' se não há instrução nenhuma. */
33
+ function instrucoesDoProjeto(projectDir, limite = LIMITE_INSTRUCOES) {
34
+ const partes = [];
35
+ // HOME lido na hora, não na carga do módulo: o teste troca a casa.
36
+ const global = lerSeExiste(path.join(os.homedir(), '.primocode', 'PRIMO.md'));
37
+ if (global.trim()) partes.push(`[~/.primocode/PRIMO.md — suas instruções para todo projeto]\n${cortar(global, limite)}`);
38
+ if (projectDir) {
39
+ for (const nome of NOMES_INSTRUCOES) {
40
+ const texto = lerSeExiste(path.join(projectDir, nome));
41
+ if (!texto.trim()) continue;
42
+ partes.push(`[${nome} — instruções deste projeto: siga]\n${cortar(texto, limite)}`);
43
+ break;
44
+ }
45
+ }
46
+ return partes.join('\n\n');
47
+ }
48
+
49
+ /* ── @ARQUIVO ────────────────────────────────────────────────────────────
50
+ * O @ só conta no começo de uma palavra: "joel@x.com" e "@" solto passam
51
+ * direto. E só vira anexo o que existe DENTRO da pasta do projeto — "@../../
52
+ * .ssh/id_rsa" não pode mandar a chave de ninguém para o modelo. O que não
53
+ * resolve fica como está no texto: pode ser um @usuario de verdade. */
54
+ const LIMITE_ARQUIVO = 40 * 1024;
55
+ const LIMITE_TOTAL = 120 * 1024;
56
+ const RE_MENCAO = /(^|[\s(])@(?:"([^"]+)"|([^\s"]+))/g;
57
+
58
+ function pareceBinario(abs) {
59
+ let fd;
60
+ try {
61
+ fd = fs.openSync(abs, 'r');
62
+ const buf = Buffer.alloc(8000);
63
+ const lidos = fs.readSync(fd, buf, 0, buf.length, 0);
64
+ return buf.subarray(0, lidos).includes(0);
65
+ } catch { return true; }
66
+ finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* já fechado */ } }
67
+ }
68
+
69
+ function mencoes(texto, projectDir, limites = {}) {
70
+ const porArquivo = limites.porArquivo || LIMITE_ARQUIVO;
71
+ const total = limites.total || LIMITE_TOTAL;
72
+ const arquivos = [];
73
+ const ignorados = [];
74
+ if (!texto || !projectDir) return { arquivos, ignorados };
75
+ const raiz = path.resolve(projectDir);
76
+ const vistos = new Set();
77
+ let soma = 0;
78
+ for (const m of String(texto).matchAll(RE_MENCAO)) {
79
+ // Pontuação colada ("veja @index.html.") é da frase, não do nome.
80
+ const bruto = m[2] || m[3].replace(/[.,;:!?)\]]+$/, '');
81
+ if (!bruto) continue;
82
+ const abs = path.resolve(raiz, bruto);
83
+ const rel = path.relative(raiz, abs);
84
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
85
+ let st;
86
+ try { st = fs.statSync(abs); } catch { continue; }
87
+ if (!st.isFile() || vistos.has(abs)) continue;
88
+ vistos.add(abs);
89
+ if (st.size > porArquivo) { ignorados.push({ rel, motivo: `grande demais (limite ${Math.round(porArquivo / 1024)} KB)` }); continue; }
90
+ if (pareceBinario(abs)) { ignorados.push({ rel, motivo: 'arquivo binário' }); continue; }
91
+ if (soma + st.size > total) { ignorados.push({ rel, motivo: `passou do total de ${Math.round(total / 1024)} KB` }); continue; }
92
+ soma += st.size;
93
+ arquivos.push({ rel, abs, bytes: st.size });
94
+ }
95
+ return { arquivos, ignorados };
96
+ }
97
+
98
+ module.exports = { mencoes, instrucoesDoProjeto, NOMES_INSTRUCOES, LIMITE_INSTRUCOES, LIMITE_ARQUIVO, LIMITE_TOTAL };