primocode 9.8.0-beta.6 → 9.8.0-beta.8

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/fala.js CHANGED
@@ -37,9 +37,95 @@
37
37
 
38
38
  'use strict';
39
39
 
40
+ const { EventEmitter } = require('events');
40
41
  const { c, actionHeader, friendlyError } = require('./ui');
41
42
  const markdown = require('./markdown.js');
42
43
 
44
+ /* ── O CANAL ESTRUTURADO ─────────────────────────────────────────────────
45
+ * A janela do app (lib/app.js) mostra a conversa como CHAT: a fala do Primo
46
+ * em texto e cada ação dele numa linha cinza ("Leu index.html", "Rodou npm
47
+ * test ✓"). Adivinhar isso relendo a saída colorida do terminal seria frágil
48
+ * — um rótulo que muda aqui quebraria a janela calado. Então a MESMA função
49
+ * que desenha no terminal também avisa, por este canal, o que desenhou.
50
+ *
51
+ * Sem ouvinte (terminal puro), `avisar` não faz nada: nenhum custo para quem
52
+ * não abriu a janela. */
53
+ const eventos = new EventEmitter();
54
+ eventos.setMaxListeners(20);
55
+ function avisar(tipo, dados) {
56
+ if (!eventos.listenerCount('evento')) return;
57
+ try { eventos.emit('evento', Object.assign({ tipo }, dados || {})); } catch { /* a janela nunca derruba o agente */ }
58
+ }
59
+
60
+ /* O que a linha de ação da janela diz, por ferramenta. Verbo no passado
61
+ porque a linha fica na conversa depois que a ação acabou — "Lendo" viraria
62
+ mentira um segundo depois. O ícone é uma família, não um desenho por tool:
63
+ a página tem dez ícones, não setenta. */
64
+ const VERBOS = {
65
+ read_file: ['Leu', 'ler'], write_file: ['Escreveu', 'escrever'], edit_file: ['Editou', 'editar'],
66
+ multi_edit: ['Editou', 'editar'], list_dir: ['Listou', 'buscar'], glob: ['Procurou', 'buscar'],
67
+ grep: ['Buscou', 'buscar'], run_shell: ['Rodou', 'rodar'], run_in_new_terminal: ['Abriu um terminal com', 'rodar'],
68
+ open_in_browser: ['Abriu', 'web'], spawn_agent: ['Chamou um agente', 'agente'], write_todos: ['Planejou', 'plano'],
69
+ finish: ['Concluiu', 'plano'], pesquisar_referencia: ['Pesquisou', 'web'], remember_general: ['Guardou na memória', 'plano'],
70
+ planejar_producao: ['Planejou a produção', 'agente'], construir_app: ['Construiu', 'agente'],
71
+ };
72
+
73
+ /** Verbo, alvo e ícone de uma ação — só o que cabe numa linha, nunca o
74
+ * conteúdo inteiro de um write_file. */
75
+ function resumoDaAcao(name, args) {
76
+ const a = args || {};
77
+ let [verbo, icone] = VERBOS[name] || [];
78
+ if (!verbo) {
79
+ if (/^browser_/.test(name)) { verbo = 'Navegador · ' + name.slice(8); icone = 'web'; }
80
+ else if (/^desktop_/.test(name)) { verbo = 'Computador · ' + name.slice(8).replace(/_/g, ' '); icone = 'computador'; }
81
+ else if (/^estudio_/.test(name)) { verbo = 'Estúdio · ' + name.slice(8); icone = 'agente'; }
82
+ else { verbo = name; icone = 'outro'; }
83
+ }
84
+ let alvo = a.path || a.command || a.pattern || a.consulta || a.url || a.target || a.app || a.selector
85
+ || a.titulo || a.description || a.arquivo || (a.todos ? `${a.todos.length} tarefas` : '') || '';
86
+ if (typeof alvo !== 'string') alvo = '';
87
+ alvo = alvo.replace(/\s+/g, ' ').trim();
88
+ if (alvo.length > 120) alvo = alvo.slice(0, 119) + '…';
89
+ return { nome: name, verbo, alvo, icone };
90
+ }
91
+
92
+ /** O resultado de uma ação, resumido para a linha da janela: ok ou não, o
93
+ * "+12 −3" das edições e até 40 linhas de detalhe para quem expandir. */
94
+ function resumoDoResultado(name, r) {
95
+ if (!r || typeof r !== 'object') return { nome: name, ok: true };
96
+ if (r.ok === false) {
97
+ return { nome: name, ok: false, resumo: String(r.error || `exit ${r.code ?? 'erro'}`).replace(/\s+/g, ' ').trim().slice(0, 300) };
98
+ }
99
+ const fora = { nome: name, ok: true };
100
+ if (typeof r._antes === 'string' && typeof r._depois === 'string') {
101
+ try {
102
+ const diff = require('./diff.js');
103
+ const n = r._antes.split('\n').length + r._depois.split('\n').length;
104
+ if (n <= diff.MAX_LINHAS_ARQUIVO * 2) {
105
+ const ops = diff.operacoes(r._antes, r._depois);
106
+ Object.assign(fora, diff.contar(ops));
107
+ fora.detalhe = ops.filter((o) => o.tipo !== ' ').slice(0, 40).map((o) => o.tipo + ' ' + o.texto).join('\n');
108
+ }
109
+ } catch { /* sem diff: a linha sai sem os números */ }
110
+ }
111
+ if (name === 'write_file') fora.criado = !!r.created;
112
+ if (name === 'run_shell' || name === 'run_in_new_terminal') {
113
+ const saida = String(r.stdout || '').trim() + (r.stderr ? '\n' + String(r.stderr).trim() : '');
114
+ fora.detalhe = saida.split('\n').slice(-40).join('\n').trim();
115
+ fora.codigo = r.code ?? 0;
116
+ }
117
+ const plural = (n, um, varios) => `${n} ${n === 1 ? um : varios}`;
118
+ if (name === 'read_file') fora.resumo = plural(r.linhas || 0, 'linha', 'linhas');
119
+ if (name === 'grep') fora.resumo = plural(r.total || 0, 'ocorrência', 'ocorrências');
120
+ if (name === 'glob') fora.resumo = plural(r.total || 0, 'arquivo', 'arquivos');
121
+ if (name === 'list_dir' && r.tree) fora.detalhe = String(r.tree).split('\n').slice(0, 40).join('\n');
122
+ if (name === 'write_todos' && Array.isArray(r.todos)) {
123
+ fora.detalhe = r.todos.map((t) => (t.status === 'completed' ? '✓ ' : '○ ') + t.content).join('\n');
124
+ fora.resumo = `${r.feitos}/${r.total}`;
125
+ }
126
+ return fora;
127
+ }
128
+
43
129
  /* Até este tamanho, o texto que o modelo escreve antes de chamar uma tool é
44
130
  guardado e sai de uma vez, como pensamento. Passou disso sem tool à vista,
45
131
  é resposta — e volta a correr na tela conforme chega. Uma frase de
@@ -58,6 +144,7 @@ function pensamento(texto, profundidade) {
58
144
  if (!ls.length || !ls[0]) return '';
59
145
  const p = ident(profundidade);
60
146
  const saida = ls.map((l, i) => `${p} ${i === 0 ? c.dim('✦') : ' '} ${c.muted(l)}`).join('\n');
147
+ avisar('pensamento', { texto: ls.join('\n'), prof: profundidade || 0 });
61
148
  console.log(saida);
62
149
  return saida;
63
150
  }
@@ -65,6 +152,7 @@ function pensamento(texto, profundidade) {
65
152
  /** A ação (tool) que ele decidiu fazer. */
66
153
  function acao(name, args, profundidade) {
67
154
  const saida = ident(profundidade) + actionHeader(name, args);
155
+ avisar('acao', Object.assign(resumoDaAcao(name, args), { prof: profundidade || 0 }));
68
156
  console.log(saida);
69
157
  return saida;
70
158
  }
@@ -102,6 +190,7 @@ function resposta(texto) {
102
190
  const largura = Math.max(40, (process.stdout.columns || 80) - 4);
103
191
  const linhasMd = markdown.renderizar(texto, largura);
104
192
  const saida = '\n' + linhasMd.map((l, i) => (i === 0 ? `${c.brand('●')} ${l}` : ` ${l}`)).join('\n');
193
+ avisar('resposta', { texto: String(texto) });
105
194
  console.log(saida);
106
195
  return saida;
107
196
  }
@@ -111,6 +200,7 @@ function resposta(texto) {
111
200
  function aviso(texto) {
112
201
  if (!String(texto || '').trim()) return '';
113
202
  const saida = '\n' + formatar(texto, c.warn('●'));
203
+ avisar('aviso', { texto: String(texto) });
114
204
  console.log(saida);
115
205
  return saida;
116
206
  }
@@ -154,9 +244,11 @@ function fluxo() {
154
244
  escrever(delta) {
155
245
  const t = String(delta || '');
156
246
  if (!t) return;
247
+ avisar('pedaco', { texto: t });
157
248
  sair(desenho.pedaco(t));
158
249
  },
159
250
  fechar() {
251
+ avisar('fim', {});
160
252
  sair(desenho.fim());
161
253
  if (escreveu) process.stdout.write('\n');
162
254
  },
@@ -174,6 +266,7 @@ function paralelo(quantos, profundidade) {
174
266
  /** Um agente delegado começando: "◆ Agente·frontend a folha de estilo". */
175
267
  function agente(rotulo, descricao, profundidade) {
176
268
  const saida = `${ident(profundidade)} ${c.brand('◆')} ${c.bold(rotulo || 'Agente')}${descricao ? ' ' + c.muted(descricao) : ''}`;
269
+ avisar('acao', { nome: 'agente', verbo: rotulo || 'Agente', alvo: String(descricao || '').slice(0, 120), icone: 'agente', prof: profundidade || 0 });
177
270
  console.log(saida);
178
271
  return saida;
179
272
  }
@@ -182,8 +275,9 @@ function agente(rotulo, descricao, profundidade) {
182
275
  function agenteVoltou(resumo, profundidade) {
183
276
  const texto = String(resumo || 'sem resultado').replace(/\s+/g, ' ').trim();
184
277
  const saida = resultado(profundidade) + c.muted(texto.length > 140 ? texto.slice(0, 139) + '…' : texto);
278
+ avisar('resultado', { nome: 'agente', ok: true, detalhe: texto.slice(0, 2000) });
185
279
  console.log(saida);
186
280
  return saida;
187
281
  }
188
282
 
189
- module.exports = { LIMIAR_PENSAMENTO, pensamento, acao, resultado, recuo, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
283
+ module.exports = { eventos, avisar, resumoDaAcao, resumoDoResultado, LIMIAR_PENSAMENTO, pensamento, acao, resultado, recuo, resposta, aviso, problema, frase, fluxo, paralelo, agente, agenteVoltou };
@@ -1,27 +1,30 @@
1
1
  /**
2
- * ide.js — a PrimoCode IDE: árvore, editor, prévia e chat, na mesma janela.
2
+ * painel.js — o painel da direita da janela: a prévia do site e os arquivos.
3
3
  *
4
- * "Um app ao invés de CLI, aí fica uma IDE e uma CLI."
4
+ * "Remove a IDE ou deixa idêntico ao Codex."
5
5
  *
6
- * ── POR QUE MORA DENTRO DO SERVIDOR DO /rc ───────────────────────────────
7
- * A janela do /rc já resolveu o difícil: servidor em 127.0.0.1 com chave
8
- * sorteada, fluxo de eventos (SSE) ao vivo e o espelho da saída do terminal.
9
- * Uma IDE com servidor próprio seria uma segunda chave, uma segunda porta e um
10
- * segundo fluxo — e as duas janelas mostrariam agentes que parecem diferentes
11
- * sendo o mesmo. Aqui só entram as rotas que a IDE precisa a mais (árvore,
12
- * ler, salvar, prévia) e o vigia da pasta, que empurra eventos pelo MESMO
13
- * fluxo do /rc.
6
+ * Antes isto era a PrimoCode IDE: árvore, EDITOR de código, prévia e chat. O
7
+ * editor saiu. Quem usa o PrimoCode não programa — um editor de código na
8
+ * cara dessa pessoa é uma porta para quebrar o que o agente fez, sem saber
9
+ * como voltar. O que ficou é o que ela usa de verdade: ver o site que está
10
+ * nascendo (Prévia) e olhar o que tem na pasta (Arquivos), só leitura.
11
+ *
12
+ * ── POR QUE MORA DENTRO DO SERVIDOR DA JANELA ────────────────────────────
13
+ * A janela (lib/app.js) já resolveu o difícil: 127.0.0.1, chave sorteada e o
14
+ * fluxo de eventos (SSE). Aqui só entram as rotas do painel (árvore, ler,
15
+ * prévia) e o vigia da pasta, que empurra "arquivo mudou" pelo MESMO fluxo —
16
+ * é o que faz a prévia recarregar enquanto o agente escreve.
14
17
  *
15
18
  * ── SEGURANÇA ────────────────────────────────────────────────────────────
16
- * Estas rotas leem e ESCREVEM no disco do usuário. Três travas:
17
19
  * · a chave do app.js vale aqui também (tudo mora em /api/);
18
20
  * · todo caminho é resolvido DENTRO da pasta do projeto — "../", caminho
19
21
  * absoluto e link simbólico que aponta para fora são recusados;
22
+ * · nada aqui escreve no disco: sem editor, não há rota de salvar;
20
23
  * · a prévia roda num iframe sandbox SEM allow-same-origin, e o servidor
21
24
  * manda o mesmo sandbox no cabeçalho. O código da prévia foi escrito pelo
22
- * agente (ou pela pessoa); se ele herdasse a origem da IDE, poderia chamar
23
- * /api/enviar e dar ordens ao agente. Com origem opaca, o fetch dele sai
24
- * com `Origin: null` e o app.js recusa.
25
+ * agente; se ele herdasse a origem da janela, poderia chamar /api/enviar
26
+ * e dar ordens ao agente. Com origem opaca, o fetch dele sai com
27
+ * `Origin: null` e o app.js recusa.
25
28
  */
26
29
 
27
30
  'use strict';
@@ -36,9 +39,9 @@ const IGNORADOS = new Set(['node_modules', '.git', '.DS_Store', '.next', '.cache
36
39
  '__pycache__', '.venv', 'venv', '.turbo', '.parcel-cache']);
37
40
  const MAX_ITENS = 4000;
38
41
  const MAX_PROFUNDIDADE = 12;
39
- // 1 MB: acima disso um <textarea> engasga a cada tecla, e arquivo desse
40
- // tamanho num projeto de iniciante é quase sempre gerado (bundle, dado).
41
- const MAX_EDITOR = 1024 * 1024;
42
+ // 1 MB: acima disso o visualizador engasga, e arquivo desse tamanho num
43
+ // projeto de iniciante é quase sempre gerado (bundle, dado).
44
+ const MAX_LEITURA = 1024 * 1024;
42
45
 
43
46
  // ── caminhos ─────────────────────────────────────────────────────────────
44
47
 
@@ -46,12 +49,12 @@ const MAX_EDITOR = 1024 * 1024;
46
49
  *
47
50
  * `path.relative` pega o "../" e o caminho absoluto; o `realpath` pega o link
48
51
  * simbólico, que passa pelo primeiro teste com cara de arquivo de dentro e
49
- * aponta para ~/.ssh. Arquivo que ainda não existe (salvar um novo) é checado
52
+ * aponta para ~/.ssh. Caminho que ainda não existe é checado
50
53
  * pela pasta onde vai nascer. */
51
54
  function dentro(raiz, relativo) {
52
55
  if (typeof relativo !== 'string' || relativo.includes('\0')) return null;
53
56
  // Absoluto é recusado de cara, e não "reinterpretado" como relativo:
54
- // quem manda /etc/hosts quer o /etc/hosts, e salvar em <projeto>/etc/hosts
57
+ // quem manda /etc/hosts quer o /etc/hosts, e ler <projeto>/etc/hosts
55
58
  // no lugar seria uma surpresa. `C:\` e `\\servidor` também contam.
56
59
  if (path.isAbsolute(relativo) || path.win32.isAbsolute(relativo)) return null;
57
60
  const base = path.resolve(raiz);
@@ -113,11 +116,10 @@ function arvore(raiz) {
113
116
  return { itens, cortada };
114
117
  }
115
118
 
116
- // ── ler e salvar ─────────────────────────────────────────────────────────
119
+ // ── ler ──────────────────────────────────────────────────────────────────
117
120
 
118
121
  /* Binário é o que tem byte zero nos primeiros 8 KB — o mesmo teste do git.
119
- Mostrar um PNG como texto no editor e deixar a pessoa salvar por cima
120
- destruiria a imagem sem aviso. */
122
+ Mostrar um PNG como texto é uma tela de lixo que parece arquivo quebrado. */
121
123
  function ehBinario(buf) {
122
124
  const n = Math.min(buf.length, 8000);
123
125
  for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
@@ -131,35 +133,12 @@ function ler(raiz, rel) {
131
133
  try { st = fs.statSync(abs); } catch { return { status: 404, corpo: { ok: false, error: 'não existe' } }; }
132
134
  if (!st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
133
135
  const base = { ok: true, caminho: relativoDe(raiz, abs), tamanho: st.size, mtime: st.mtimeMs };
134
- if (st.size > MAX_EDITOR) return { status: 200, corpo: Object.assign(base, { grande: true }) };
136
+ if (st.size > MAX_LEITURA) return { status: 200, corpo: Object.assign(base, { grande: true }) };
135
137
  const buf = fs.readFileSync(abs);
136
138
  if (ehBinario(buf)) return { status: 200, corpo: Object.assign(base, { binario: true }) };
137
139
  return { status: 200, corpo: Object.assign(base, { conteudo: buf.toString('utf8') }) };
138
140
  }
139
141
 
140
- /* `mtimeBase` é o mtime que a página viu ao abrir o arquivo. Se o disco está
141
- mais novo, alguém (quase sempre o agente) mexeu no meio — e salvar por cima
142
- apagaria o trabalho dele sem ninguém perceber. A página decide: recarregar
143
- ou mandar de novo com `forcar`. */
144
- function salvar(raiz, dados) {
145
- const rel = dados && dados.caminho;
146
- const conteudo = dados && dados.conteudo;
147
- if (typeof conteudo !== 'string') return { status: 400, corpo: { ok: false, error: 'conteúdo faltando' } };
148
- if (Buffer.byteLength(conteudo) > MAX_EDITOR) return { status: 413, corpo: { ok: false, error: 'maior que 1 MB' } };
149
- const abs = dentro(raiz, rel);
150
- if (!abs || abs === path.resolve(raiz)) return { status: 403, corpo: { ok: false, error: 'fora da pasta do projeto' } };
151
- if (ignorado(relativoDe(raiz, abs))) return { status: 403, corpo: { ok: false, error: 'pasta protegida' } };
152
- let st = null;
153
- try { st = fs.statSync(abs); } catch { /* arquivo novo */ }
154
- if (st && !st.isFile()) return { status: 400, corpo: { ok: false, error: 'não é arquivo' } };
155
- if (st && dados.mtimeBase && !dados.forcar && st.mtimeMs > Number(dados.mtimeBase) + 1) {
156
- return { status: 409, corpo: { ok: false, conflito: true, error: 'mudou no disco depois que você abriu', mtime: st.mtimeMs } };
157
- }
158
- fs.mkdirSync(path.dirname(abs), { recursive: true });
159
- fs.writeFileSync(abs, conteudo);
160
- return { status: 200, corpo: { ok: true, caminho: relativoDe(raiz, abs), mtime: fs.statSync(abs).mtimeMs } };
161
- }
162
-
163
142
  // ── prévia ───────────────────────────────────────────────────────────────
164
143
 
165
144
  const TIPOS = {
@@ -193,7 +172,7 @@ function servirPrevia(raiz, rel, res) {
193
172
  'Content-Type': TIPOS[path.extname(alvo).toLowerCase()] || 'application/octet-stream',
194
173
  'Cache-Control': 'no-store',
195
174
  // O mesmo sandbox do iframe, dito pelo servidor: vale até se alguém
196
- // abrir o endereço da prévia fora da IDE.
175
+ // abrir o endereço da prévia fora da janela.
197
176
  'Content-Security-Policy': 'sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock',
198
177
  });
199
178
  fs.createReadStream(alvo).pipe(res);
@@ -237,7 +216,7 @@ function vigiar(raiz, aoMudar, espera = 150) {
237
216
  // ── as rotas ─────────────────────────────────────────────────────────────
238
217
 
239
218
  /**
240
- * Monta a IDE num servidor do app.js.
219
+ * Monta o painel num servidor do app.js.
241
220
  *
242
221
  * @param {object} op
243
222
  * @param {()=>string} op.pasta a pasta do projeto AGORA (o /dir muda ela)
@@ -247,9 +226,6 @@ function vigiar(raiz, aoMudar, espera = 150) {
247
226
  function montar(op) {
248
227
  let pastaVigiada = null;
249
228
  let pararVigia = () => {};
250
- // O que a própria IDE acabou de salvar, para a página não pintar como
251
- // "o agente mexeu" a mudança que a pessoa fez.
252
- const salvosAgora = new Map();
253
229
 
254
230
  function conferirPasta() {
255
231
  const atual = op.pasta && op.pasta();
@@ -257,19 +233,14 @@ function montar(op) {
257
233
  pararVigia();
258
234
  pastaVigiada = atual;
259
235
  if (!fs.existsSync(atual)) { pararVigia = () => {}; return atual; }
260
- pararVigia = vigiar(atual, (mudados) => {
261
- const agora = Date.now();
262
- const deVoce = mudados.filter((m) => (salvosAgora.get(m) || 0) > agora - 1500);
263
- op.emitir('arquivos', { pasta: atual, mudados, deVoce });
264
- });
236
+ pararVigia = vigiar(atual, (mudados) => op.emitir('arquivos', { pasta: atual, mudados }));
265
237
  op.emitir('arquivos', { pasta: atual, mudados: [], trocou: true });
266
238
  return atual;
267
239
  }
268
- // O /dir troca a pasta pelo terminal, sem passar por aqui: sem esta volta
269
- // a IDE continuaria vigiando o projeto anterior.
270
- /* E só depois que a IDE foi aberta de verdade: quem abriu só o chat do
271
- /rc não precisa de ninguém vigiando o ~/primocode inteiro — no Linux
272
- o fs.watch recursivo custa um aviso do sistema por pasta. */
240
+ /* O /dir e os projetos da barra lateral trocam a pasta sem passar por
241
+ aqui: sem esta ronda o painel vigiaria o projeto anterior. E só depois
242
+ que o painel foi aberto de verdade — no Linux o fs.watch recursivo custa
243
+ um aviso do sistema por pasta, e quem só conversa não precisa disso. */
273
244
  let usada = false;
274
245
  const ronda = setInterval(() => { if (usada) conferirPasta(); }, 2000);
275
246
  if (ronda.unref) ronda.unref();
@@ -279,48 +250,31 @@ function montar(op) {
279
250
  res.end(JSON.stringify(corpo));
280
251
  }
281
252
 
282
- function lerCorpo(req) {
283
- return new Promise((ok) => {
284
- let corpo = '';
285
- let grande = false;
286
- req.on('data', (c) => { corpo += c; if (corpo.length > MAX_EDITOR * 2) { grande = true; req.destroy(); } });
287
- req.on('end', () => { try { ok(grande ? null : JSON.parse(corpo)); } catch { ok(null); } });
288
- });
289
- }
290
-
291
- /* Devolve true se a rota era da IDE. A chave já foi conferida pelo
292
- app.js antes de chegar aqui — só as rotas com /api/ passam por ela, e
293
- por isso a prévia também mora debaixo de /api/. */
253
+ /* Devolve true se a rota era do painel. A chave já foi conferida pelo
254
+ app.js antes de chegar aqui — por isso a prévia também mora em /api/. */
294
255
  async function atender(req, res, url) {
295
256
  const rota = url.pathname;
296
- if (!rota.startsWith('/api/ide/')) return false;
257
+ const ehArquivos = rota.startsWith('/api/arquivos/');
258
+ const m = rota.match(/^\/api\/previa\/[0-9a-f]+\/?(.*)$/);
259
+ if (!ehArquivos && !m) return false;
297
260
  usada = true;
298
261
  const raiz = conferirPasta();
299
262
  if (!raiz || !fs.existsSync(raiz)) { json(res, 404, { ok: false, error: 'sem pasta de projeto' }); return true; }
300
263
 
301
- if (rota === '/api/ide/arvore') {
264
+ if (rota === '/api/arquivos/arvore') {
302
265
  const a = arvore(raiz);
303
266
  json(res, 200, { ok: true, pasta: raiz, nome: path.basename(raiz), itens: a.itens,
304
267
  cortada: a.cortada, entrada: entradaDaPrevia(raiz) });
305
268
  return true;
306
269
  }
307
- if (rota === '/api/ide/arquivo') {
270
+ if (rota === '/api/arquivos/ler') {
308
271
  const r = ler(raiz, url.searchParams.get('p') || '');
309
272
  json(res, r.status, r.corpo);
310
273
  return true;
311
274
  }
312
- if (rota === '/api/ide/salvar' && req.method === 'POST') {
313
- const dados = await lerCorpo(req);
314
- if (!dados) { json(res, 400, { ok: false, error: 'pedido ilegível ou maior que 1 MB' }); return true; }
315
- const r = salvar(raiz, dados);
316
- if (r.corpo.ok) salvosAgora.set(r.corpo.caminho, Date.now());
317
- json(res, r.status, r.corpo);
318
- return true;
319
- }
320
- /* /api/ide/previa/<chave>/<caminho>: a chave vai no CAMINHO, não na
275
+ /* /api/previa/<chave>/<caminho>: a chave vai no CAMINHO, não na
321
276
  query, porque a página da prévia pede style.css e script.js por
322
277
  endereço relativo — e o relativo herda o caminho, não a query. */
323
- const m = rota.match(/^\/api\/ide\/previa\/[0-9a-f]+\/?(.*)$/);
324
278
  if (m) { servirPrevia(raiz, m[1], res); return true; }
325
279
 
326
280
  json(res, 404, { ok: false, error: 'rota desconhecida' });
@@ -334,4 +288,4 @@ function montar(op) {
334
288
  };
335
289
  }
336
290
 
337
- module.exports = { montar, dentro, arvore, ler, salvar, vigiar, ehBinario, entradaDaPrevia, MAX_EDITOR };
291
+ module.exports = { montar, dentro, arvore, ler, vigiar, ehBinario, entradaDaPrevia, MAX_LEITURA };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * pre-leitura.js — os arquivos que o agente ia ler, já lidos.
3
+ *
4
+ * "Toda vez a IA lê as pastas novamente, só pra ler vão 30 chamadas de API."
5
+ * (dono, 24/09/2026)
6
+ *
7
+ * O caro não é o disco — ler index.html leva 1ms. O caro é a IDA AO MODELO: o
8
+ * servidor grátis conta pedidos por dia, e cada "deixa eu ler o style.css"
9
+ * numa resposta própria é um pedido. Medido com um modelo de mentira que
10
+ * segue uma política fixa (lê o que ainda não viu, um arquivo por resposta,
11
+ * e confere o index.html antes de editar), num "muda a cor do botão" em
12
+ * projeto de 10 arquivos: 9 pedidos ao modelo antes, 2 depois. Com todos os
13
+ * arquivos grandes demais para entrar aqui, a ordem de juntar leituras e a
14
+ * releitura sem mudança (lib/act.js, RELEITURA) ainda levam de 9 para 4.
15
+ *
16
+ * O mapa do projeto já dava os NOMES. Isto põe o CONTEÚDO dos poucos arquivos
17
+ * que quase todo pedido de página lê — o citado no pedido primeiro, depois
18
+ * index.html, o css e o js principais, package.json, README — dentro de um
19
+ * orçamento. Pequeno pelo servidor grátis (o teto é de ~6 mil tokens por
20
+ * MINUTO, e isto vai em toda rodada); folgado com um provedor próprio.
21
+ *
22
+ * Fica de fora o que não se lê como texto ou não vale o espaço: binário,
23
+ * minificado, lockfile, arquivo grande, e o que já entra por outro caminho
24
+ * (memory.md, PRIMO.md/AGENTS.md/CLAUDE.md). Arquivo que não cabe inteiro no
25
+ * que sobrou do orçamento NÃO entra cortado: meio arquivo marcado como
26
+ * "conteúdo atual" faria o modelo editar às cegas a metade que não viu.
27
+ *
28
+ * Puro, menos a leitura do disco — o teste mede a escolha sem rede.
29
+ */
30
+ 'use strict';
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const ORCAMENTO_SERVIDOR = 6 * 1024;
36
+ const ORCAMENTO_LOCAL = 40 * 1024;
37
+ const MAIOR_ARQUIVO = 20 * 1024;
38
+
39
+ const TEXTO = /\.(html?|css|scss|m?jsx?|tsx?|vue|svelte|json|md|txt|py|toml|ya?ml)$/i;
40
+ const LOCKFILES = new Set(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb',
41
+ 'composer.lock', 'Cargo.lock', 'poetry.lock', 'Gemfile.lock']);
42
+ // Já entram no contexto por outro caminho (buildMemoryContext / mencoes.js).
43
+ const JA_ENTRAM = new Set(['memory.md', 'PRIMO.md', 'AGENTS.md', 'CLAUDE.md']);
44
+
45
+ /** O orçamento de agora: folgado só com provedor próprio (/api). */
46
+ function orcamento() {
47
+ try { return require('./local/provedor.js').atual() ? ORCAMENTO_LOCAL : ORCAMENTO_SERVIDOR; }
48
+ catch { return ORCAMENTO_SERVIDOR; }
49
+ }
50
+
51
+ /** Quanto o arquivo importa: menor é mais importante; null = não entra. */
52
+ function prioridade(rel, pedido) {
53
+ const nome = path.basename(rel);
54
+ if (JA_ENTRAM.has(nome) || LOCKFILES.has(nome) || /\.min\./i.test(nome) || !TEXTO.test(nome)) return null;
55
+ const fundo = rel.split(/[\\/]/).length - 1;
56
+ const texto = String(pedido || '').toLowerCase();
57
+ // Citado no pedido ("muda o style.css") é o que ele vai ler com certeza.
58
+ if (texto && (texto.includes(rel.toLowerCase()) || texto.includes(nome.toLowerCase()))) return 0;
59
+ if (fundo > 1) return null;
60
+ if (/^index\.html?$/i.test(nome)) return 1 + fundo;
61
+ if (/\.(css|scss)$/i.test(nome)) return 3 + fundo;
62
+ if (/\.(m?jsx?|tsx?|vue|svelte)$/i.test(nome) && !/\.(test|spec|config)\./i.test(nome)) return 5 + fundo;
63
+ if (nome === 'package.json' && fundo === 0) return 7;
64
+ if (/^readme\.md$/i.test(nome) && fundo === 0) return 8;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Escolhe e lê. Devolve [{ rel, conteudo }] na ordem de importância.
70
+ * @param {string} dir a pasta do projeto
71
+ * @param {string[]} arquivos caminhos relativos (o mesmo que o mapa listou)
72
+ * @param {string} pedido o texto do pedido — o citado nele vem primeiro
73
+ * @param {number} limite bytes no total
74
+ */
75
+ function escolher(dir, arquivos, pedido, limite = orcamento()) {
76
+ const candidatos = (arquivos || [])
77
+ .map((rel) => ({ rel, p: prioridade(rel, pedido) }))
78
+ .filter((x) => x.p !== null)
79
+ .sort((a, b) => a.p - b.p || a.rel.localeCompare(b.rel));
80
+ const lidos = [];
81
+ let soma = 0;
82
+ for (const { rel } of candidatos) {
83
+ const abs = path.join(dir, rel);
84
+ let st;
85
+ try { st = fs.statSync(abs); } catch { continue; }
86
+ if (!st.isFile() || st.size === 0 || st.size > MAIOR_ARQUIVO || soma + st.size > limite) continue;
87
+ let buf;
88
+ try { buf = fs.readFileSync(abs); } catch { continue; }
89
+ if (buf.includes(0)) continue; // binário com nome de texto
90
+ const conteudo = buf.toString('utf-8');
91
+ // Uma linha de milhares de caracteres é minificado sem ".min" no nome.
92
+ if (conteudo.split('\n').some((l) => l.length > 2000)) continue;
93
+ lidos.push({ rel, conteudo });
94
+ soma += st.size;
95
+ }
96
+ return lidos;
97
+ }
98
+
99
+ /** O bloco que vai no contexto, ou '' quando não há nada que valha. */
100
+ function bloco(lidos) {
101
+ if (!lidos || !lidos.length) return '';
102
+ const partes = lidos.map(({ rel, conteudo }) =>
103
+ `[conteúdo atual de ${rel} — não precisa ler de novo]\n${conteudo.replace(/\s+$/, '')}\n[fim de ${rel}]`);
104
+ return 'Estes arquivos estão aqui como estavam no COMEÇO deste pedido — use o que está abaixo em vez de '
105
+ + 'chamar read_file neles. Depois que você editar um deles, o texto abaixo fica velho: aí, sim, leia.\n\n'
106
+ + partes.join('\n\n');
107
+ }
108
+
109
+ /* A ordem de juntar. Vai junto do mapa porque é ali que o modelo decide o que
110
+ ler. Cada resposta dele é uma ida ao servidor; cinco leituras em cinco
111
+ respostas custam cinco pedidos, e as mesmas cinco numa resposta só custam
112
+ um. */
113
+ const JUNTAR_LEITURAS = 'Precisa ler, listar ou buscar mais de uma coisa? Peça TUDO na MESMA resposta, '
114
+ + 'com várias chamadas de ferramenta juntas — nunca uma leitura por resposta. Cada resposta sua é '
115
+ + 'uma ida ao servidor, e o plano conta idas.';
116
+
117
+ module.exports = { escolher, bloco, prioridade, orcamento, JUNTAR_LEITURAS,
118
+ ORCAMENTO_SERVIDOR, ORCAMENTO_LOCAL, MAIOR_ARQUIVO };
package/lib/repeticao.js CHANGED
@@ -173,9 +173,11 @@ function ver(registro, nome, args) {
173
173
  }
174
174
 
175
175
  registro.vistos.set(chave, vezes);
176
- if (vezes === 1) return { repetida: false, vezes, aviso: null };
176
+ // `chave` volta para quem chamou guardar o resultado da leitura pela
177
+ // mesma época em que ela foi vista (lib/act.js, RELEITURA).
178
+ if (vezes === 1) return { repetida: false, vezes, aviso: null, chave };
177
179
 
178
- return { repetida: true, vezes, aviso: aviso(nome, vezes, parecidaCom) };
180
+ return { repetida: true, vezes, aviso: aviso(nome, vezes, parecidaCom), chave };
179
181
  }
180
182
 
181
183
  /** O recado, apertando a cada volta. */
package/lib/tempos.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * tempos.js — para onde foi o tempo de um pedido.
3
+ *
4
+ * PRIMOCODE_TEMPOS=1 primo
5
+ *
6
+ * No fim de cada pedido sai UMA linha com o que custou cada fase: o plano, a
7
+ * espera do resumo, o nome da pasta, cada rodada do modelo, as ferramentas e
8
+ * a conferência da página. Existe porque "está lento" sem número vira chute:
9
+ * medido em 24/09 com um servidor de mentira que responde na hora, o que o
10
+ * PrimoCode gastava sozinho num pedido de página era quase todo a conferência
11
+ * no navegador escondido (2,1 a 4,6s num Chrome recém-aberto), e não o que
12
+ * parecia: as ferramentas de três rodadas somaram 7 a 23ms, e o planejador
13
+ * nem é chamado em pedido de uma fase só.
14
+ *
15
+ * Desligado, não custa nada: `medir` chama a função e devolve, sem relógio.
16
+ *
17
+ * Um pedido por vez (é assim que o CLI trabalha), então o registro é do
18
+ * módulo. Subagente soma no mesmo pedido, com o nome da fase.
19
+ */
20
+ 'use strict';
21
+
22
+ let atual = null;
23
+
24
+ function ligado() { return process.env.PRIMOCODE_TEMPOS === '1'; }
25
+
26
+ /** Começa o registro de um pedido. Desligado, não faz nada. */
27
+ function comecar() {
28
+ atual = ligado() ? { inicio: Date.now(), fases: new Map() } : null;
29
+ return atual;
30
+ }
31
+
32
+ /** Soma `ms` à fase `nome` (várias rodadas somam na mesma linha, com contagem). */
33
+ function somar(nome, ms) {
34
+ if (!atual) return;
35
+ const f = atual.fases.get(nome) || { ms: 0, vezes: 0 };
36
+ f.ms += ms;
37
+ f.vezes++;
38
+ atual.fases.set(nome, f);
39
+ }
40
+
41
+ /** Mede uma função (síncrona ou async) e soma o tempo à fase. */
42
+ async function medir(nome, fn) {
43
+ if (!atual) return fn();
44
+ const t = Date.now();
45
+ try { return await fn(); } finally { somar(nome, Date.now() - t); }
46
+ }
47
+
48
+ function fmt(ms) { return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`; }
49
+
50
+ /** A linha do fim. Encerra o registro. null se estava desligado. */
51
+ function relatorio() {
52
+ if (!atual) return null;
53
+ const total = Date.now() - atual.inicio;
54
+ const partes = [...atual.fases].map(([nome, f]) =>
55
+ `${nome} ${fmt(f.ms)}${f.vezes > 1 ? ` (${f.vezes}x)` : ''}`);
56
+ atual = null;
57
+ return `tempos · ${partes.join(' · ')}${partes.length ? ' · ' : ''}total ${fmt(total)}`;
58
+ }
59
+
60
+ function ativo() { return !!atual; }
61
+
62
+ module.exports = { ligado, comecar, somar, medir, relatorio, ativo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.6",
3
+ "version": "9.8.0-beta.8",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/kits.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/ide.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/kits.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"