primocode 10.5.0 → 10.6.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,161 @@
1
+ /**
2
+ * outra-pasta.js — a pessoa falou de OUTRA pasta: olhar lá, e trocar só com o sim dela.
3
+ *
4
+ * "Quando a IA perceber que eu perguntei sobre algo de outra pasta, ela
5
+ * tem que ir procurar lá. E se ela estiver aberta numa pasta e eu estiver
6
+ * trabalhando em outra, ela muda de pasta — com permissão, claro."
7
+ * (11/10/2026)
8
+ *
9
+ * Duas coisas diferentes, de propósito:
10
+ *
11
+ * · LER lá não pede licença: foi a própria pessoa que citou a pasta. O
12
+ * agente ganha leitura (read_file, list_dir, grep, glob) naquela pasta,
13
+ * sem sair de onde está. Escrever continua só na pasta de trabalho.
14
+ * · TRABALHAR lá é trocar a pasta da sessão — e isso SEMPRE pergunta. Ela
15
+ * muda onde os arquivos nascem, e o "sim" é da pessoa, não da heurística.
16
+ *
17
+ * Antes, citar "pasta loja" trocava sozinho (lib/pasta.js), e um projeto só
18
+ * era achado com a palavra "pasta" na frente. Agora "olha o landing-padaria"
19
+ * também acha — pelo nome dos projetos em ~/primocode e das pastas recentes.
20
+ *
21
+ * `detectar` e `pedirTroca` são puros (quem toca disco e terminal entra por
22
+ * parâmetro), para o teste medir a regra sem disco e sem ninguém digitando.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+ const pasta = require('./pasta.js');
31
+
32
+ // Nome que é palavra comum: "app", "site", "teste" aparecem em qualquer
33
+ // frase e não querem dizer a pasta de mesmo nome.
34
+ const COMUNS = new Set(['app', 'site', 'teste', 'test', 'projeto', 'project', 'src', 'docs', 'novo',
35
+ 'video', 'videos', 'web', 'api', 'lib', 'dist', 'build', 'public', 'assets', 'dados', 'data', 'tmp']);
36
+
37
+ function semAcento(s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); }
38
+
39
+ function ehPasta(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
40
+
41
+ /**
42
+ * As pastas que a pessoa conhece pelo NOME: os projetos de ~/primocode e as
43
+ * recentes (projeto-ativo.json). Mesmo nome em dois lugares: fica a recente.
44
+ */
45
+ function conhecidas({ raiz, recentes = [] } = {}) {
46
+ const lista = [];
47
+ const vistos = new Set();
48
+ const incluir = (caminho) => {
49
+ const abs = path.resolve(caminho);
50
+ if (vistos.has(abs) || !ehPasta(abs)) return;
51
+ vistos.add(abs);
52
+ lista.push({ nome: path.basename(abs), caminho: abs });
53
+ };
54
+ for (const r of recentes) incluir(r);
55
+ if (raiz) {
56
+ try {
57
+ for (const d of fs.readdirSync(raiz, { withFileTypes: true })) {
58
+ if (d.isDirectory() && !d.name.startsWith('.')) incluir(path.join(raiz, d.name));
59
+ }
60
+ } catch { /* sem raiz ainda */ }
61
+ }
62
+ return lista;
63
+ }
64
+
65
+ /**
66
+ * O pedido cita uma pasta conhecida pelo nome?
67
+ *
68
+ * Só nome com PELO MENOS DUAS palavras (`landing-padaria`, `jogo_carro`) casa
69
+ * solto na frase — escrito com hífen, sublinhado ou espaço. Nome de uma
70
+ * palavra só ("loja") casaria com "faz uma loja" e trocaria de pasta por
71
+ * acaso; para ele vale a marca explícita do lib/pasta.js ("pasta loja").
72
+ */
73
+ function porNome(texto, lista) {
74
+ const t = ` ${semAcento(texto).replace(/[^a-z0-9]+/g, ' ').trim()} `;
75
+ const achados = [];
76
+ for (const p of lista || []) {
77
+ const partes = semAcento(p.nome).split(/[^a-z0-9]+/).filter(Boolean);
78
+ if (partes.length < 2 || COMUNS.has(partes.join('-'))) continue;
79
+ if (partes.join('').length < 6) continue;
80
+ if (t.includes(` ${partes.join(' ')} `)) achados.push({ ...p, como: 'nome' });
81
+ }
82
+ // O nome mais comprido primeiro: "jogo-carro-mundo-aberto" ganha de "jogo-carro".
83
+ return achados.sort((a, b) => b.nome.length - a.nome.length);
84
+ }
85
+
86
+ /**
87
+ * As pastas que o pedido cita, fora a atual. Caminho escrito vem primeiro
88
+ * (é o mais certo); depois os nomes conhecidos.
89
+ *
90
+ * `resolver(candidato)` → `{ caminho }` | `{ ambiguo }` | null é o
91
+ * `pasta.resolverPasta` no programa, e um falso no teste.
92
+ */
93
+ function detectar(texto, { conhecidas: lista = [], atual = '', resolver = null } = {}) {
94
+ const fora = (c) => !atual || path.resolve(c) !== path.resolve(atual);
95
+ const saida = [];
96
+ const candidato = pasta.detectarPasta(texto);
97
+ if (candidato && resolver) {
98
+ const achado = resolver(candidato);
99
+ if (achado && achado.ambiguo) return { ambiguo: achado.ambiguo, candidato };
100
+ if (achado && achado.caminho && fora(achado.caminho)) {
101
+ saida.push({ caminho: path.resolve(achado.caminho), nome: candidato, como: 'caminho' });
102
+ }
103
+ }
104
+ for (const p of porNome(texto, lista)) {
105
+ if (fora(p.caminho) && !saida.some((s) => s.caminho === p.caminho)) saida.push(p);
106
+ }
107
+ return { pastas: saida };
108
+ }
109
+
110
+ /**
111
+ * A linha que vai junto do pedido quando a pessoa citou outra pasta e o
112
+ * agente continua onde está. Ela diz as duas portas: ler lá agora, ou pedir
113
+ * para trabalhar lá.
114
+ */
115
+ function notaDeConsulta(caminho, atual) {
116
+ return `[outra pasta] O usuário citou ${caminho}. Você pode LER e PROCURAR lá sem sair de ${atual}: `
117
+ + `read_file e list_dir com o caminho absoluto; glob com o padrão absoluto ("${caminho}/**/*.js"); `
118
+ + `grep com glob absoluto ("${caminho}/**"). `
119
+ + 'Para TRABALHAR lá (criar ou mudar arquivo), chame mudar_pasta — o usuário confirma antes.';
120
+ }
121
+
122
+ /**
123
+ * O pedido de troca do modelo (tool mudar_pasta). Só troca com o SIM.
124
+ *
125
+ * @param {object} args { caminho, motivo }
126
+ * @param {object} ctx { atual, subagent, trocar, resolver }
127
+ * trocar(info) → Promise<{ ok, caminho, memoria? , error? }>: quem PERGUNTA
128
+ * ao usuário e, com o sim, troca a sessão. Sem ele, ninguém pode dizer sim.
129
+ */
130
+ async function pedirTroca(args, { atual = '', subagent = false, trocar = null, resolver = null } = {}) {
131
+ const bruto = String((args && (args.caminho || args.path || args.pasta)) || '').trim();
132
+ const motivo = String((args && args.motivo) || '').trim();
133
+ if (!bruto) return { ok: false, error: 'faltou o caminho da pasta (caminho).' };
134
+ if (subagent) return { ok: false, error: 'subagente não troca a pasta da sessão — devolva ao agente principal o que achou.' };
135
+
136
+ const resolve = resolver || ((c) => pasta.resolverPasta(c, { cwd: atual || process.cwd() }));
137
+ const achado = resolve(bruto);
138
+ if (!achado) return { ok: false, error: `não achei a pasta "${bruto}". Confira o caminho (list_dir) ou peça ao usuário.` };
139
+ if (achado.ambiguo) {
140
+ return { ok: false, error: `há mais de uma pasta "${bruto}"; chame de novo com o caminho completo.`,
141
+ opcoes: achado.ambiguo.map((a) => a.caminho) };
142
+ }
143
+ const destino = path.resolve(achado.caminho);
144
+ if (atual && destino === path.resolve(atual)) return { ok: true, caminho: destino, aviso: 'já está nesta pasta.' };
145
+ if (destino === path.resolve(os.homedir()) || destino === path.parse(destino).root) {
146
+ return { ok: false, error: 'a home e a raiz do disco não são pasta de trabalho. Escolha a pasta do projeto.' };
147
+ }
148
+ if (typeof trocar !== 'function') {
149
+ return { ok: false, error: 'trocar de pasta precisa do sim do usuário, e não há ninguém para responder neste modo. '
150
+ + 'Leia lá se precisar e diga a ele o caminho para usar /dir.' };
151
+ }
152
+ const r = await trocar({ caminho: destino, motivo });
153
+ if (!r || !r.ok) {
154
+ return { ok: false, recusado: true,
155
+ error: (r && r.error) || 'o usuário NÃO quis trocar de pasta. Continue na atual; ler a outra continua liberado.' };
156
+ }
157
+ return { ok: true, caminho: r.caminho || destino, ...(r.memoria ? { memoria: r.memoria } : {}),
158
+ comoUsar: 'a pasta de trabalho agora é esta: caminhos relativos valem a partir dela.' };
159
+ }
160
+
161
+ module.exports = { conhecidas, porNome, detectar, notaDeConsulta, pedirTroca, COMUNS };
package/lib/perigo.js CHANGED
@@ -164,14 +164,39 @@ function analisarTrecho(palavras, estado) {
164
164
  return null;
165
165
  }
166
166
 
167
+ /* O LIVRO DE AÇÕES (lib/auditoria.js) mora fora do projeto, e o `rm` nele já
168
+ pedia licença. Mas `> livro`, `sed -i`, `truncate` e `mv` passavam: o
169
+ agente podia apagar ou reescrever o registro do que ele mesmo fez (review
170
+ do #95, item 3). Ler o livro (cat, grep, tail…) segue livre. */
171
+ const LIVRO = path.join(os.homedir(), '.primocode', 'registro');
172
+ const SO_LE = new Set(['cat', 'head', 'tail', 'less', 'more', 'grep', 'rg', 'wc', 'ls', 'jq', 'nl', 'stat', 'file', 'cd']);
173
+ function mexeNoLivro(palavras, cwd) {
174
+ const p = palavras.filter((x) => !PREFIXOS.has(x) && !/^[A-Za-z_]\w*=/.test(x));
175
+ if (!p.length) return false;
176
+ const noLivro = (x) => {
177
+ const alvo = path.resolve(cwd, expandir(String(x).replace(/^\d?>>?/, '')));
178
+ return alvo === LIVRO || dentro(LIVRO, alvo);
179
+ };
180
+ // Redirecionar para dentro do livro é escrever nele, seja qual for o comando.
181
+ for (let i = 0; i < p.length; i++) {
182
+ const redireciona = /^\d?>>?$/.test(p[i]) ? p[i + 1] : /^\d?>>?./.test(p[i]) ? p[i] : /.>>?./.test(p[i]) ? p[i].split(/>>?/).pop() : null;
183
+ if (redireciona && noLivro(redireciona)) return true;
184
+ }
185
+ if (SO_LE.has(path.basename(p[0]))) return false;
186
+ return p.slice(1).some((x) => !/^-/.test(x) && noLivro(x.split(/>>?/)[0]));
187
+ }
188
+
167
189
  function analisarComando(comando, cwd, projeto) {
168
190
  const s = String(comando || '');
169
191
  // Baixar e rodar na hora: o que roda é o que o site mandar naquele segundo.
170
192
  if (/\b(curl|wget)\b[^;&]*\|\s*(sudo\s+)?(env\s+)?(ba|z|da|k)?sh\b/.test(s)) {
171
193
  return 'baixa um script da internet e roda sem ler';
172
194
  }
195
+ // O mesmo que mcp_conectar, pela porta dos fundos: não pula o portão.
196
+ if (/\bprimocode\s+mcp\s+(conectar|connect|add)\b/.test(s)) return 'instala e roda um servidor MCP de terceiros';
173
197
  const estado = { cwd, projeto, pulandoFlags: false };
174
198
  for (const t of trechos(s)) {
199
+ if (mexeNoLivro(t.palavras, estado.cwd)) return 'mexe no livro de ações do PrimoCode (~/.primocode/registro), o registro do que o agente fez';
175
200
  const r = analisarTrecho(t.palavras, estado);
176
201
  if (r) return r;
177
202
  }
@@ -189,6 +214,12 @@ function classificar(nome, args, { projectDir } = {}) {
189
214
  if (COMANDOS.has(nome)) {
190
215
  const cwd = a.cwd ? path.resolve(projeto, expandir(a.cwd)) : projeto;
191
216
  motivo = analisarComando(a.command, cwd, projeto);
217
+ } else if (nome === 'mcp_conectar') {
218
+ /* Instala e roda código de terceiro (npx/uvx/docker) com as chaves do
219
+ usuário: sempre pergunta, como o curl | sh. O motivo leva o comando,
220
+ então "a sessão inteira" libera AQUELE servidor, não qualquer um. */
221
+ const cmd = [a.comando || a.command, ...(Array.isArray(a.args) ? a.args : [])].filter(Boolean).join(' ');
222
+ motivo = `instala e roda o servidor MCP "${a.nome || '?'}"${cmd ? ` (${cmd.slice(0, 80)})` : ''}`;
192
223
  } else if (ESCRITAS.has(nome) && a.path) {
193
224
  const alvo = path.resolve(projeto, expandir(a.path));
194
225
  const rel = path.relative(projeto, alvo);
@@ -216,12 +247,13 @@ async function portao(estado, { modo, nome, args, projectDir, subagent = false,
216
247
  const m = normalizarModo(modo);
217
248
  if (m === 'livre') return { ok: true };
218
249
  const { perigoso, motivo: motivoPerigo } = classificar(nome, args, { projectDir });
219
- const tipo = perigoso ? 'perigo' : (m === 'perguntar' && COMANDOS.has(nome)) ? 'comando'
250
+ const tipo = perigoso ? 'perigo' : (m === 'perguntar' && (COMANDOS.has(nome) || nome === 'mcp_usar')) ? 'comando'
220
251
  : (m === 'perguntar' && ESCRITAS.has(nome)) ? 'arquivo' : null;
221
252
  if (!tipo) return { ok: true };
222
253
 
223
254
  const motivo = perigoso ? motivoPerigo
224
- : tipo === 'comando' ? `rodar: ${String((args || {}).command || '').slice(0, 80)}`
255
+ : tipo === 'comando' ? (nome === 'mcp_usar' ? `usar ${(args || {}).servidor}.${(args || {}).ferramenta}`
256
+ : `rodar: ${String((args || {}).command || '').slice(0, 80)}`)
225
257
  : `escrever ${String((args || {}).path || 'um arquivo')}`;
226
258
  const recusa = (texto) => ({ ok: false, motivo, error: texto });
227
259
 
package/lib/referencia.js CHANGED
@@ -28,6 +28,7 @@ const http = require('http');
28
28
  const net = require('net');
29
29
  const tls = require('tls');
30
30
  const { URL } = require('url');
31
+ const leitura = require('./leitura');
31
32
 
32
33
  // Achado de segurança: redirect sem checar destino é SSRF — uma fonte de
33
34
  // busca (conteúdo de terceiro, não confiável) pode responder com um Location
@@ -231,6 +232,13 @@ function textoDe(html) {
231
232
  .trim();
232
233
  }
233
234
 
235
+ /* Uma fonte lida, com o passo da cascata que a leu: o terminal mostra, e o
236
+ modelo sabe se a página veio pela busca simples ou pelo navegador. */
237
+ function fonteLida(cand, trecho, passo, chars) {
238
+ return { titulo: cand.titulo, url: cand.url, nota: cand.nota, trecho,
239
+ leitura: { passo, como: passo === 1 ? 'busca simples' : 'navegador', chars, enviados: trecho.length } };
240
+ }
241
+
234
242
  /**
235
243
  * ── FONTE ERRADA É PIOR QUE FONTE NENHUMA ────────────────────────────────
236
244
  * Pedido real: "pesquise sobre a Paris Group e crie um vídeo para anunciar a
@@ -665,7 +673,7 @@ async function docsConhecidas(consulta) {
665
673
  * A pesquisa completa: busca, abre as melhores páginas e devolve o texto
666
674
  * delas para o modelo LER. O modelo escreve o código dele com isso na mão.
667
675
  */
668
- async function pesquisar({ consulta, paginas = 3 }) {
676
+ async function pesquisar({ consulta, paginas = 3, navegador } = {}) {
669
677
  const termo = String(consulta || '').trim();
670
678
  if (!termo) return { ok: false, error: 'diga o que pesquisar' };
671
679
 
@@ -742,6 +750,7 @@ async function pesquisar({ consulta, paginas = 3 }) {
742
750
  // Os chutes de domínio ficam de lado até o fim: só então dá para saber
743
751
  // qual deles fala mais do assunto.
744
752
  const chutesLidos = [];
753
+ const finas = [];
745
754
  // As páginas abrem JUNTAS, não uma depois da outra: dez candidatos a 2 s
746
755
  // cada eram 20 s de espera antes de ler a primeira linha. Quem não abriu
747
756
  // vira null e é pulado na ordem — a ordem de confiança continua valendo.
@@ -752,10 +761,17 @@ async function pesquisar({ consulta, paginas = 3 }) {
752
761
  if (lidas.length >= paginas) break;
753
762
  const html = paginasAbertas[ic];
754
763
  if (!html) continue;
755
- const texto = textoDe(html);
764
+ // Passo 1 da leitura em cascata (lib/leitura.js): a estrutura sai
765
+ // aqui, local, e o modelo nunca vê o HTML cru.
766
+ const est = leitura.estruturaDe(html, cand.url);
767
+ const texto = est.texto;
756
768
  // 400, não 200: uma casca de página que carrega tudo por JavaScript
757
769
  // deixa 200-300 caracteres de menu, e isso passava como "fonte lida".
758
- if (texto.length < 400) continue;
770
+ // Ela não é descartada: fica pro passo 2 (o navegador), lá embaixo.
771
+ if (texto.length < leitura.MINIMO) {
772
+ if (cand.nota !== 'site provável da empresa') finas.push({ cand, html });
773
+ continue;
774
+ }
759
775
  if (pareceCodigo(texto)) continue;
760
776
  // O chute de domínio precisa PROVAR que acertou. "Paris Group" tem
761
777
  // parisgroup.com (um grupo de varejo no exterior) e parisgroup.ai (a
@@ -778,15 +794,28 @@ async function pesquisar({ consulta, paginas = 3 }) {
778
794
  // Passar não basta: entre dois domínios do MESMO nome, fica o que
779
795
  // fala mais do assunto. parisgroup.ai e parisgroup.co os dois
780
796
  // "batiam", e o agente recebia as duas empresas como se fossem uma.
781
- chutesLidos.push({ nota, item: { titulo: cand.titulo, url: cand.url, nota: cand.nota, trecho: texto.slice(0, MAX_TEXTO) } });
797
+ chutesLidos.push({ nota, item: fonteLida(cand, leitura.caber(est, MAX_TEXTO).conteudo, 1, texto.length) });
782
798
  continue;
783
799
  }
784
- lidas.push({
785
- titulo: cand.titulo,
786
- url: cand.url,
787
- nota: cand.nota,
788
- trecho: texto.slice(0, MAX_TEXTO),
789
- });
800
+ lidas.push(fonteLida(cand, leitura.caber(est, MAX_TEXTO).conteudo, 1, texto.length));
801
+ }
802
+
803
+ // Passo 2: faltou fonte e houve página fina (SPA) — o navegador escondido
804
+ // renderiza o JavaScript. Só duas, e as duas dividem UM teto de 20 s.
805
+ // A URL passa pela mesma trava da busca simples antes de o Chrome abrir:
806
+ // redirect e pedido seguinte continuam travados dentro do htmlEscondido.
807
+ const prazoPasso2 = Date.now() + 20000;
808
+ const abrirNoNavegador = navegador === undefined
809
+ ? (u) => require('./browser').htmlEscondido(u) : navegador;
810
+ for (const { cand, html } of finas.slice(0, 2)) {
811
+ if (lidas.length >= paginas || !abrirNoNavegador || Date.now() >= prazoPasso2) break;
812
+ let host = '';
813
+ try { host = new URL(cand.url).hostname; } catch { continue; }
814
+ if (hostBloqueado(host)) continue;
815
+ const resta = prazoPasso2 - Date.now();
816
+ const r = await leitura.ler(cand.url, { html, orcamento: MAX_TEXTO,
817
+ navegador: (u) => Promise.race([abrirNoNavegador(u), new Promise((ok) => setTimeout(ok, resta, null))]) });
818
+ if (r.ok && r.passo === 2 && !pareceCodigo(r.texto)) lidas.push(fonteLida(cand, r.conteudo, 2, r.chars));
790
819
  }
791
820
 
792
821
  // O melhor chute entra na frente: quando a empresa tem site, ele é a
@@ -820,6 +849,6 @@ async function pesquisar({ consulta, paginas = 3 }) {
820
849
  };
821
850
  }
822
851
 
823
- module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG, pegar,
852
+ module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG, pegar, hostBloqueado,
824
853
  relevante, quantoBate, palavrasDaConsulta, sitesProvaveis, chutesVivos, pareceCodigo,
825
854
  destinoBing, destinoYahoo, buscarBing, buscarYahoo, buscarMDN, docsConhecidas, PRAZO_BUSCADOR };
package/lib/repeticao.js CHANGED
@@ -142,6 +142,25 @@ function executou(registro, nome, args) {
142
142
  registro.olhouDesde = false;
143
143
  }
144
144
 
145
+ /**
146
+ * O `ver` sem registrar nada: a chamada SERIA repetição? E a chave dela.
147
+ *
148
+ * Quem usa é o lote de leituras em paralelo (lib/act.js): ele roda antes do
149
+ * laço, e o que o laço depois recusasse como repetido já teria rodado — e o
150
+ * livro (lib/auditoria.js) anotava "recusada" para algo que aconteceu.
151
+ */
152
+ function espiar(registro, nome, args) {
153
+ const busca = consultaDe(nome, args);
154
+ const foraDaEpoca = busca !== null || REFERENCIAS.has(nome);
155
+ const base = assinatura(nome, args);
156
+ const epoca = (base === registro.ultimaMudanca && !registro.olhouDesde)
157
+ ? registro.epocaDaMudanca : (registro.epoca || 0);
158
+ const chave = base + (foraDaEpoca ? '' : `@${epoca}`);
159
+ const repetida = registro.vistos.has(chave)
160
+ || (!!busca && registro.buscas.some((b) => semelhanca(b.texto, busca) >= LIMITE_SEMELHANCA));
161
+ return { repetida, chave };
162
+ }
163
+
145
164
  /**
146
165
  * Registra a chamada e diz se ela é repetição.
147
166
  *
@@ -251,5 +270,5 @@ function referenciasNaConversa(messages) {
251
270
  module.exports = {
252
271
  REFERENCIAS, LIMITE_SEMELHANCA,
253
272
  normalizar, palavras, semelhanca, assinatura, consultaDe,
254
- novo, ver, executou, observa, muda, chamadasPorId, referenciasNaConversa,
273
+ novo, ver, espiar, executou, observa, muda, chamadasPorId, referenciasNaConversa,
255
274
  };
package/lib/tela.js CHANGED
@@ -221,7 +221,7 @@ function quadro(e) {
221
221
  conversa.length = altoConversa;
222
222
  }
223
223
  if (rolagem > 0) {
224
- conversa[0] = ' ' + c.dim(`↑ ${rolagem} linha${rolagem > 1 ? 's' : ''} abaixo · PgDn ou a roda do mouse para voltar`);
224
+ conversa[0] = ' ' + c.dim(`↑ ${rolagem} linha${rolagem > 1 ? 's' : ''} abaixo · PgDn para voltar`);
225
225
  }
226
226
 
227
227
  const esquerda = [...conversa, ...caixa.linhas].map((l) => caber(l, mainW));
package/lib/tools.js CHANGED
@@ -94,6 +94,59 @@ function resolveInProject(rawPath, projectDir) {
94
94
  return target;
95
95
  }
96
96
 
97
+ /* ── LER EM OUTRA PASTA ──────────────────────────────────────────────────
98
+ * A pessoa citou outra pasta ("olha como fiz no landing-padaria"): o agente
99
+ * pode LER e PROCURAR lá, sem trocar a pasta de trabalho (lib/outra-pasta.js).
100
+ * Só leitura, e só nas pastas que a própria pessoa citou nesta sessão —
101
+ * escrever continua preso na pasta de trabalho pelo resolveInProject. */
102
+ const PASTAS_DE_CONSULTA = new Set();
103
+
104
+ function liberarLeitura(dir) {
105
+ if (!dir) return;
106
+ try { if (fs.statSync(dir).isDirectory()) PASTAS_DE_CONSULTA.add(path.resolve(dir)); } catch { /* não existe */ }
107
+ }
108
+
109
+ function pastasDeConsulta() { return [...PASTAS_DE_CONSULTA]; }
110
+ function esquecerConsultas() { PASTAS_DE_CONSULTA.clear(); }
111
+
112
+ /** Caminho para LER: dentro do projeto, ou dentro de uma pasta citada. */
113
+ function resolverParaLer(rawPath, projectDir) {
114
+ try {
115
+ return resolveInProject(rawPath, projectDir);
116
+ } catch (e) {
117
+ const bruto = String(rawPath || '').replace(/^~(?=$|[/\\])/, os.homedir());
118
+ if (path.isAbsolute(bruto)) {
119
+ const alvo = path.resolve(bruto);
120
+ for (const raiz of PASTAS_DE_CONSULTA) {
121
+ const rel = path.relative(raiz, alvo);
122
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return alvo;
123
+ }
124
+ }
125
+ throw new Error(`${e.message}. Ler fora da pasta de trabalho só vale nas pastas que o usuário citou; `
126
+ + 'para trabalhar em outra pasta, use mudar_pasta.');
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Onde procurar (glob/grep): a pasta de trabalho, ou a pasta citada.
132
+ *
133
+ * O schema do servidor não ganhou campo novo (cada token do núcleo conta no
134
+ * teto do minuto): a outra pasta vem no PADRÃO absoluto —
135
+ * "/Users/x/site/src/<estrela>.js" vira a raiz "/Users/x/site/src" e o
136
+ * padrão "<estrela>.js". `pasta` explícito também vale, para quem mandar.
137
+ */
138
+ function raizDaBusca(padrao, pastaPedida, projectDir) {
139
+ if (pastaPedida) return { raiz: resolverParaLer(pastaPedida, projectDir), padrao, outra: true };
140
+ const bruto = String(padrao || '').replace(/^~(?=$|[/\\])/, os.homedir());
141
+ if (!padrao || !path.isAbsolute(bruto)) return { raiz: projectDir, padrao, outra: false };
142
+ const partes = bruto.split(/[/\\]/);
143
+ const i = partes.findIndex((p) => /[*?{[]/.test(p));
144
+ const fixo = (i === -1 ? partes : partes.slice(0, i)).join('/') || '/';
145
+ const resto = i === -1 ? '**/*' : partes.slice(i).join('/');
146
+ const raiz = resolverParaLer(fixo, projectDir);
147
+ return { raiz, padrao: resto, outra: path.resolve(raiz) !== path.resolve(projectDir) };
148
+ }
149
+
97
150
  function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
98
151
  if (currentDepth > maxDepth) return '';
99
152
  let result = '';
@@ -411,7 +464,7 @@ function editFile({ path: rawPath, old_string, new_string, replace_all }, projec
411
464
  }
412
465
 
413
466
  function readFile({ path: rawPath, offset, limit }, projectDir) {
414
- const filePath = resolveInProject(rawPath, projectDir);
467
+ const filePath = resolverParaLer(rawPath, projectDir);
415
468
  const bruto = fs.readFileSync(filePath, 'utf-8');
416
469
  const todas = bruto.split('\n');
417
470
 
@@ -578,13 +631,20 @@ async function despachar(name, args, ctx) {
578
631
  case 'read_file':
579
632
  return readFile(args, projectDir);
580
633
  case 'list_dir':
581
- return { ok: true, tree: treeDir(resolveInProject(args.path, projectDir), args.depth || 2) };
634
+ return { ok: true, tree: treeDir(resolverParaLer(args.path, projectDir), args.depth || 2) };
635
+ /* `pasta` (opcional): procurar numa pasta que o usuário citou, sem
636
+ trocar a de trabalho. Os caminhos voltam relativos a ELA, e a
637
+ resposta diz qual é. */
582
638
  case 'glob': {
583
- const arquivos = globFiles(args.pattern || '**/*', projectDir);
584
- return { ok: true, total: arquivos.length, arquivos: arquivos.slice(0, 200) };
639
+ const b = raizDaBusca(args.pattern || '**/*', args.pasta, projectDir);
640
+ const arquivos = globFiles(b.padrao || '**/*', b.raiz);
641
+ return { ok: true, total: arquivos.length, arquivos: arquivos.slice(0, 200), ...(b.outra ? { pasta: b.raiz } : {}) };
642
+ }
643
+ case 'grep': {
644
+ const b = raizDaBusca(args.glob, args.pasta, projectDir);
645
+ const r = grepFiles({ ...args, glob: b.padrao }, b.raiz);
646
+ return b.outra && r && r.ok !== false ? { ...r, pasta: b.raiz } : r;
585
647
  }
586
- case 'grep':
587
- return grepFiles(args, projectDir);
588
648
  case 'run_shell':
589
649
  return runShell(args.command, args.timeout_ms || 60000, projectDir, ambienteLimpo());
590
650
  case 'run_in_new_terminal':
@@ -630,6 +690,15 @@ async function despachar(name, args, ctx) {
630
690
  return { ok: true, gravado: entry };
631
691
  }
632
692
 
693
+ // ── MCP: o serviço pela API dele, antes do mouse (lib/mcp.js) ──
694
+ case 'mcp_procurar':
695
+ return require('./mcp.js').procurar(args.busca || args.query || args.servico || '');
696
+ // Roda código de terceiro: o portão (lib/perigo.js) pergunta antes de chegar aqui.
697
+ case 'mcp_conectar':
698
+ return require('./mcp.js').conectarNovo({ nome: args.nome, command: args.comando || args.command, args: args.args, env: args.env });
699
+ case 'mcp_usar':
700
+ return require('./mcp.js').usar(args.servidor, args.ferramenta, args.argumentos || args.arguments || {}, projectDir);
701
+
633
702
  // ── Navegador controlado (CDP) — o agente age numa página real ──
634
703
  /* O navegador fala em REFS (lib/snapshot.js): abrir e clicar já
635
704
  devolvem o retrato da página com "@e3 [button] Entrar", e o
@@ -882,4 +951,5 @@ module.exports = {
882
951
  fmtBytes, treeDir, runShell, ambienteLimpo, openInBrowser, runInNewTerminal,
883
952
  execute, uninstall, resolveInProject, globToRegex, globFiles, grepFiles, editFile,
884
953
  multiEdit, writeTodos, getTodos,
954
+ liberarLeitura, pastasDeConsulta, esquecerConsultas, resolverParaLer,
885
955
  };
package/lib/travado.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * travado.js — o agente que anda em círculo para antes de comer a cota.
3
+ *
4
+ * ── O QUE ACONTECEU DE VERDADE ───────────────────────────────────────────
5
+ * 01/10: um subagente passou mais de uma hora relendo o mesmo arquivo, em
6
+ * fatias que se sobrepunham, até acabar a cota grátis. O freio de repetição
7
+ * (repeticao.js) só pega a chamada IDÊNTICA — a fatia 1-200 e a 150-350 do
8
+ * mesmo app.js passavam como coisas novas. O único freio era o teto de 20
9
+ * voltas, e 20 voltas de leitura são muita cota.
10
+ *
11
+ * ── A REGRA (do foreman do thruwire: perguntas fechadas, limite em número) ─
12
+ * 1. a mesma fatia (ou uma que se sobrepõe) do mesmo arquivo, lida
13
+ * LIMITE_RELEITURA vezes sem escrita no meio? → travado
14
+ * 2. a mesma ferramenta com os mesmos argumentos LIMITE_MESMA_CHAMADA
15
+ * vezes sem escrita no meio? → travado
16
+ * 3. VOLTAS_SEM_NOVIDADE voltas seguidas sem escrever nada e sem nenhuma
17
+ * chamada que ainda não tinha sido feita? → travado
18
+ * Na primeira vez, um empurrão curto (o que está repetindo, o que fazer). Se
19
+ * travar de novo depois do empurrão, PARA e diz por quê.
20
+ *
21
+ * Puro e determinístico: sem I/O, sem relógio. O teste mede a regra inteira.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const path = require('path');
27
+
28
+ const LIMITE_RELEITURA = 3;
29
+ const LIMITE_MESMA_CHAMADA = 4;
30
+ const VOLTAS_SEM_NOVIDADE = 6;
31
+ const ESCRITAS = new Set(['write_file', 'edit_file', 'multi_edit']);
32
+
33
+ function novo() {
34
+ return { leituras: new Map(), chamadas: new Map(), vistas: new Set(), novidade: false, voltasSemNovidade: 0, avisos: 0 };
35
+ }
36
+
37
+ /* A faixa que a leitura cobre. Sem offset nem limit, o read_file devolve o
38
+ arquivo inteiro. Com offset e sem limit, devolve LEITURA_PADRAO linhas a
39
+ partir dele (lib/tools.js) — e não "daqui até o fim": era assim, e ler as
40
+ linhas 1, 201 e 401 em sequência, que é o que a dica da read_file manda
41
+ fazer, contava como reler a mesma parte (review do #95, item 1). */
42
+ const LEITURA_PADRAO = 200;
43
+ function faixa(args) {
44
+ const a = args || {};
45
+ if (a.offset == null && a.limit == null) return [1, Infinity];
46
+ const ini = Math.max(1, Number(a.offset) || 1);
47
+ const lim = Number(a.limit) > 0 ? Number(a.limit) : LEITURA_PADRAO;
48
+ return [ini, ini + lim - 1];
49
+ }
50
+ const sobrepoe = (a, b) => a[0] <= b[1] && b[0] <= a[1];
51
+
52
+ /* LER PELO SHELL é ler. O agente que leva o empurrão por reler o app.js
53
+ foge para `cat app.js` ou `sed -n '1,200p' app.js` — e o freio só olhava a
54
+ read_file (item 2). O comando de leitura simples vira a leitura que ele é. */
55
+ function leituraDoShell(comando) {
56
+ const s = String(comando || '').trim();
57
+ const m = /^(?:cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*)?(cat|nl|less|more|head|tail|sed)((?:\s+(?:-n\s*)?(?:'[^']*'|"[^"]*"|-\S+|\d+))*)\s+("[^"]+"|'[^']+'|[^\s|;&><'"]+)\s*$/.exec(s);
58
+ if (!m) return null;
59
+ const tira = (x) => String(x || '').replace(/^["']|["']$/g, '');
60
+ const [, cd, cmd, opcoes] = m;
61
+ const arquivo = cd ? path.join(tira(cd), tira(m[4])) : tira(m[4]);
62
+ if (cmd === 'sed') {
63
+ const r = /-n\s*['"]?(\d+)(?:,(\d+))?p['"]?/.exec(opcoes);
64
+ if (!r) return null; // sed que não é "mostra as linhas A a B"
65
+ const ini = Number(r[1]);
66
+ return { path: arquivo, offset: ini, limit: (r[2] ? Number(r[2]) : ini) - ini + 1 };
67
+ }
68
+ if (cmd === 'head') {
69
+ const n = /(\d+)/.exec(opcoes || '');
70
+ return { path: arquivo, offset: 1, limit: n ? Number(n[1]) : 10 };
71
+ }
72
+ return { path: arquivo }; // cat, tail e companhia: o arquivo todo
73
+ }
74
+
75
+ /* O mesmo arquivo com outro nome é o mesmo arquivo: `app.js`, `./app.js`,
76
+ `/projeto/app.js` e `src/../app.js` (item 2). Sem a pasta, normaliza só o
77
+ texto. Puro: path.resolve não abre nada. */
78
+ function caminhoDe(p, pasta) {
79
+ return pasta ? path.resolve(pasta, String(p)) : path.normalize(String(p));
80
+ }
81
+
82
+ /** Olha UMA chamada, antes de ela rodar. Devolve o motivo se travou, ou null. */
83
+ function observar(estado, nome, args, pasta) {
84
+ const a = args || {};
85
+ const chave = `${nome}:${JSON.stringify(a)}`;
86
+ if (ESCRITAS.has(nome)) {
87
+ estado.leituras.clear();
88
+ estado.chamadas.clear();
89
+ estado.novidade = true;
90
+ }
91
+ if (!estado.vistas.has(chave)) { estado.vistas.add(chave); estado.novidade = true; }
92
+ const leitura = nome === 'read_file' ? a : nome === 'run_shell' ? leituraDoShell(a.command) : null;
93
+ if (leitura && leitura.path) {
94
+ const caminho = caminhoDe(leitura.path, pasta);
95
+ const f = faixa(leitura);
96
+ const antes = estado.leituras.get(caminho) || [];
97
+ antes.push(f);
98
+ estado.leituras.set(caminho, antes);
99
+ const vezes = antes.filter((g) => sobrepoe(f, g)).length;
100
+ if (vezes >= LIMITE_RELEITURA) {
101
+ const nomeCurto = pasta ? path.relative(pasta, caminho) || caminho : caminho;
102
+ return `leu ${nomeCurto} ${vezes} vezes (a mesma parte, ou partes que se sobrepõem) sem mudar nada no meio`;
103
+ }
104
+ }
105
+ const n = (estado.chamadas.get(chave) || 0) + 1;
106
+ estado.chamadas.set(chave, n);
107
+ if (n >= LIMITE_MESMA_CHAMADA) return `chamou ${nome} com os mesmos argumentos ${n} vezes`;
108
+ return null;
109
+ }
110
+
111
+ /** Fecha a volta. Devolve o motivo se já são voltas demais sem nada novo. */
112
+ function fimDaVolta(estado) {
113
+ estado.voltasSemNovidade = estado.novidade ? 0 : estado.voltasSemNovidade + 1;
114
+ estado.novidade = false;
115
+ if (estado.voltasSemNovidade >= VOLTAS_SEM_NOVIDADE) {
116
+ return `${estado.voltasSemNovidade} voltas seguidas sem escrever nada e sem buscar nada novo`;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Travou: a primeira vez empurra; a segunda para. */
122
+ function decidir(estado, motivo) {
123
+ if (!motivo) return null;
124
+ estado.avisos++;
125
+ if (estado.avisos === 1) {
126
+ estado.voltasSemNovidade = 0;
127
+ return {
128
+ acao: 'empurrar',
129
+ texto: `VOCÊ ESTÁ ANDANDO EM CÍRCULO: ${motivo}. O que você já leu está na conversa. `
130
+ + 'Pare de reler. Faça AGORA o próximo passo concreto (edit_file, write_file, rodar o teste) '
131
+ + 'ou chame finish dizendo o que conseguiu e o que falta. Mais uma volta assim e eu paro você.',
132
+ };
133
+ }
134
+ return {
135
+ acao: 'parar',
136
+ texto: `Parei o agente: ele travou (${motivo}), mesmo depois de avisado. `
137
+ + 'Continuar só gastaria cota. O que já foi feito ficou feito — refaça o pedido com um passo mais específico.',
138
+ };
139
+ }
140
+
141
+ module.exports = { LIMITE_RELEITURA, LIMITE_MESMA_CHAMADA, VOLTAS_SEM_NOVIDADE, LEITURA_PADRAO, novo, faixa, leituraDoShell, observar, fimDaVolta, decidir };
package/lib/ui.js CHANGED
@@ -558,6 +558,7 @@ const ROTULOS = {
558
558
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
559
559
  estudio_midia:'Estúdio·mídia', construir_app:'Construir',
560
560
  remember_general:'Lembrar',
561
+ mcp_procurar:'MCP·procurar', mcp_conectar:'MCP·conectar', mcp_usar:'MCP',
561
562
  };
562
563
 
563
564
  /* A linha da AÇÃO: `●` na cor da marca e o rótulo em negrito. O resultado