primocode 10.5.0 → 10.7.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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +117 -24
  3. package/lib/act.js +141 -9
  4. package/lib/agents-md.js +140 -0
  5. package/lib/auditoria.js +122 -0
  6. package/lib/browser.js +48 -1
  7. package/lib/catalogo.js +29 -6
  8. package/lib/comandos-extra.js +2 -0
  9. package/lib/config.js +32 -4
  10. package/lib/entrada.js +33 -11
  11. package/lib/estudio/cenarios.js +91 -0
  12. package/lib/estudio/colecao.js +35 -1
  13. package/lib/estudio/diretor.js +1 -0
  14. package/lib/estudio/falantes.js +30 -6
  15. package/lib/estudio/hibrido.js +144 -8
  16. package/lib/estudio/index.js +196 -63
  17. package/lib/estudio/kit/mundo.jsx +1060 -0
  18. package/lib/estudio/kit/voxel.jsx +315 -70
  19. package/lib/estudio/kit.js +1 -1
  20. package/lib/estudio/kokoro.js +18 -4
  21. package/lib/estudio/margem.js +1 -1
  22. package/lib/estudio/movimentos.js +212 -0
  23. package/lib/estudio/mundo.js +328 -0
  24. package/lib/estudio/personagem.js +164 -11
  25. package/lib/estudio/plano.js +6 -0
  26. package/lib/estudio/producao.js +107 -17
  27. package/lib/estudio/projeto.js +77 -22
  28. package/lib/leitura.js +176 -0
  29. package/lib/local/cerebro.json +180 -3
  30. package/lib/local/entrada.js +61 -4
  31. package/lib/mcp.js +296 -12
  32. package/lib/memoria-conversa.js +32 -3
  33. package/lib/mencoes.js +5 -1
  34. package/lib/nome-curto.js +126 -0
  35. package/lib/nome-projeto.js +5 -2
  36. package/lib/outra-pasta.js +161 -0
  37. package/lib/perigo.js +34 -2
  38. package/lib/referencia.js +40 -11
  39. package/lib/repeticao.js +20 -1
  40. package/lib/tela.js +1 -1
  41. package/lib/tools.js +76 -6
  42. package/lib/travado.js +141 -0
  43. package/lib/ui.js +1 -0
  44. package/package.json +2 -2
package/lib/mcp.js CHANGED
@@ -15,6 +15,17 @@
15
15
  *
16
16
  * O cliente é o mínimo do protocolo por stdio: JSON-RPC 2.0, uma mensagem por
17
17
  * linha — initialize, notifications/initialized, tools/list, tools/call.
18
+ *
19
+ * ── MCP PRIMEIRO, MOUSE POR ÚLTIMO ───────────────────────────────────────
20
+ * "Para a IA não ter que fazer as coisas manualmente no computador do
21
+ * usuário. Sempre procurar um MCP primeiro; se não tiver, pesquisar se a
22
+ * ferramenta tem MCP e, se tiver, conectar." (11/10)
23
+ *
24
+ * Clicar no Notion pela tela são vinte ações que quebram com um pop-up; a
25
+ * API do Notion é uma. Por isso `procurar` (a lista curada daqui + o registro
26
+ * oficial em registry.modelcontextprotocol.io) e `conectarNovo` (grava, sobe,
27
+ * lista as tools — e desfaz se não subir). Hoje viram tools nativas
28
+ * (mcp_procurar · mcp_conectar · mcp_usar), sem passar pelo run_shell.
18
29
  */
19
30
 
20
31
  'use strict';
@@ -46,9 +57,17 @@ function servidores(pasta = process.cwd()) {
46
57
  }
47
58
 
48
59
  /** Abre um servidor, faz o aperto de mão e devolve {pedir, fechar}. */
60
+ /* `${VAR}` no argumento vira o valor do env. Sem shell no meio (spawn puro),
61
+ ninguém expandiria — e é assim que o token entra no header do mcp-remote
62
+ sem ficar escrito duas vezes no mcp.json. */
63
+ function expandir(args, env) {
64
+ return (args || []).map((a) => String(a).replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => (env[k] != null ? env[k] : '')));
65
+ }
66
+
49
67
  function conectar(def, { tetoMs = 60000 } = {}) {
50
68
  return new Promise((resolve, reject) => {
51
- const p = spawn(def.command, def.args || [], { env: { ...process.env, ...(def.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
69
+ const env = { ...process.env, ...(def.env || {}) };
70
+ const p = spawn(def.command, expandir(def.args, env), { env, stdio: ['pipe', 'pipe', 'pipe'] });
52
71
  let buf = '', id = 0;
53
72
  const espera = new Map();
54
73
  const relogio = setTimeout(() => { try { p.kill(); } catch {} reject(new Error('o servidor MCP não respondeu')); }, tetoMs);
@@ -75,6 +94,21 @@ function conectar(def, { tetoMs = 60000 } = {}) {
75
94
  });
76
95
  }
77
96
 
97
+ function resumirTools(tools) {
98
+ return (tools || []).map((t) => ({ nome: t.name, descricao: String(t.description || '').split(/\n|\. /)[0].slice(0, 120), args: Object.keys((t.inputSchema && t.inputSchema.properties) || {}) }));
99
+ }
100
+
101
+ function gravarCache(mudar) {
102
+ try {
103
+ const c = lerJson(CACHE) || { servidores: {} };
104
+ c.servidores = c.servidores || {};
105
+ mudar(c.servidores);
106
+ c.quando = Date.now();
107
+ fs.mkdirSync(CASA, { recursive: true });
108
+ fs.writeFileSync(CACHE, JSON.stringify(c, null, 2));
109
+ } catch { /* sem cache */ }
110
+ }
111
+
78
112
  async function listar(pasta) {
79
113
  const lista = servidores(pasta);
80
114
  const out = {};
@@ -83,10 +117,10 @@ async function listar(pasta) {
83
117
  const c = await conectar(def);
84
118
  const r = await c.pedir('tools/list');
85
119
  c.fechar();
86
- out[nome] = (r.tools || []).map((t) => ({ nome: t.name, descricao: String(t.description || '').split(/\n|\. /)[0].slice(0, 120), args: Object.keys((t.inputSchema && t.inputSchema.properties) || {}) }));
120
+ out[nome] = resumirTools(r.tools);
87
121
  } catch (e) { out[nome] = { erro: e.message }; }
88
122
  }));
89
- try { fs.mkdirSync(CASA, { recursive: true }); fs.writeFileSync(CACHE, JSON.stringify({ quando: Date.now(), servidores: out }, null, 2)); } catch { /* sem cache */ }
123
+ gravarCache((sv) => { for (const k of Object.keys(sv)) delete sv[k]; Object.assign(sv, out); });
90
124
  return out;
91
125
  }
92
126
 
@@ -100,6 +134,232 @@ async function chamar(servidor, tool, args = {}, pasta) {
100
134
  } finally { c.fechar(); }
101
135
  }
102
136
 
137
+ /* ── A LISTA CURADA ─────────────────────────────────────────────────────
138
+ * Vem antes do registro, e funciona sem internet. O registro tem milhares de
139
+ * entradas, e na busca "github" as primeiras são de terceiros que pedem a
140
+ * chave da Smithery — o servidor OFICIAL do GitHub nem aparece. Aqui ficam os
141
+ * conhecidos, conferidos um a um (npm view / registro em 11/10): pacote que
142
+ * existe, não deprecado, e a chave que ele pede com onde pegar.
143
+ * `<...>` nos args é o que o usuário precisa dizer (a pasta, o banco). */
144
+ const REMOTO = (url, header) => ({ command: 'npx', args: ['-y', 'mcp-remote', url, ...(header ? ['--header', header] : [])] });
145
+ const CURADOS = [
146
+ { nome: 'github', termos: /git ?hub|reposit|pull request|\bpr\b|issue/i, descricao: 'GitHub oficial: repos, issues, PRs, Actions',
147
+ ...REMOTO('https://api.githubcopilot.com/mcp/', 'Authorization:Bearer ${GITHUB_PERSONAL_ACCESS_TOKEN}'),
148
+ precisa: [{ nome: 'GITHUB_PERSONAL_ACCESS_TOKEN', onde: 'github.com/settings/tokens' }] },
149
+ { nome: 'notion', termos: /notion/i, descricao: 'Notion oficial: páginas, bancos, busca',
150
+ command: 'npx', args: ['-y', '@notionhq/notion-mcp-server'],
151
+ precisa: [{ nome: 'NOTION_TOKEN', onde: 'notion.so/profile/integrations (integração interna, ntn_…)' }] },
152
+ { nome: 'slack', termos: /slack/i, descricao: 'Slack: canais, mensagens, busca',
153
+ command: 'npx', args: ['-y', 'slack-mcp-server@latest', '--transport', 'stdio'],
154
+ precisa: [{ nome: 'SLACK_MCP_XOXP_TOKEN', onde: 'api.slack.com/apps → OAuth & Permissions (User OAuth Token xoxp-…)' }] },
155
+ { nome: 'google-drive', termos: /google ?drive|\bdrive\b|planilha|sheets|google docs/i, descricao: 'Google Drive, Docs e Sheets',
156
+ command: 'npx', args: ['-y', '@piotr-agier/google-drive-mcp'],
157
+ precisa: [{ nome: 'GOOGLE_DRIVE_OAUTH_CREDENTIALS', onde: 'caminho do JSON de credencial OAuth (console.cloud.google.com → Credenciais)' }] },
158
+ { nome: 'figma', termos: /figma/i, descricao: 'Figma: lê arquivos, frames e estilos',
159
+ command: 'npx', args: ['-y', 'figma-developer-mcp', '--stdio'],
160
+ precisa: [{ nome: 'FIGMA_API_KEY', onde: 'figma.com → Settings → Personal access tokens' }] },
161
+ { nome: 'postgres', termos: /postgres|banco de dados|\bsql\b|\bbanco\b/i, descricao: 'Postgres: consulta, esquema, saúde do banco',
162
+ command: 'uvx', args: ['postgres-mcp', '--access-mode=restricted'],
163
+ precisa: [{ nome: 'DATABASE_URI', onde: 'a URL do banco (postgresql://usuario:senha@host:5432/banco)' }] },
164
+ { nome: 'sqlite', termos: /sqlite|\.db\b/i, descricao: 'SQLite: consulta um arquivo .db',
165
+ command: 'npx', args: ['-y', 'mcp-server-sqlite-npx', '<caminho do arquivo .db>'], precisa: [] },
166
+ { nome: 'filesystem', termos: /arquivos? (de|em) outra pasta|filesystem|sistema de arquivos/i, descricao: 'Arquivos de uma pasta fora do projeto',
167
+ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '<pasta>'], precisa: [] },
168
+ { nome: 'playwright', termos: /playwright|navegador|browser|site|p[áa]gina/i, descricao: 'Playwright (Microsoft): navegador por acessibilidade, sem pixel',
169
+ command: 'npx', args: ['-y', '@playwright/mcp@latest'], precisa: [] },
170
+ { nome: 'supabase', termos: /supabase/i, descricao: 'Supabase oficial: projetos, tabelas, SQL, edge functions',
171
+ command: 'npx', args: ['-y', '@supabase/mcp-server-supabase@latest'],
172
+ precisa: [{ nome: 'SUPABASE_ACCESS_TOKEN', onde: 'supabase.com/dashboard/account/tokens' }] },
173
+ { nome: 'stripe', termos: /stripe|pagamento|cobran[çc]a/i, descricao: 'Stripe oficial: clientes, cobranças, links de pagamento',
174
+ command: 'npx', args: ['-y', '@stripe/mcp', '--tools=all'],
175
+ precisa: [{ nome: 'STRIPE_SECRET_KEY', onde: 'dashboard.stripe.com/apikeys (de preferência uma restricted key)' }] },
176
+ { nome: 'linear', termos: /linear/i, descricao: 'Linear oficial: issues, projetos (login pelo navegador)',
177
+ ...REMOTO('https://mcp.linear.app/mcp'), precisa: [] },
178
+ { nome: 'sentry', termos: /sentry/i, descricao: 'Sentry oficial: erros, issues, releases',
179
+ command: 'npx', args: ['-y', '@sentry/mcp-server@latest'],
180
+ precisa: [{ nome: 'SENTRY_ACCESS_TOKEN', onde: 'sentry.io → Settings → Auth Tokens' }] },
181
+ { nome: 'vercel', termos: /vercel/i, descricao: 'Vercel oficial: projetos, deploys, logs (login pelo navegador)',
182
+ ...REMOTO('https://mcp.vercel.com'), precisa: [] },
183
+ { nome: 'cloudflare', termos: /cloudflare|workers?\b/i, descricao: 'Cloudflare oficial: Workers, KV, R2, D1 (login pelo navegador)',
184
+ ...REMOTO('https://bindings.mcp.cloudflare.com/mcp'), precisa: [] },
185
+ { nome: 'firebase', termos: /firebase|firestore/i, descricao: 'Firebase oficial (firebase-tools): projetos, Firestore, Auth',
186
+ command: 'npx', args: ['-y', 'firebase-tools@latest', 'mcp'], precisa: [] },
187
+ ];
188
+
189
+ const REGISTRO = 'https://registry.modelcontextprotocol.io/v0/servers';
190
+
191
+ /* O nome que fica no mcp.json: o pedaço final, curto. "io.github.github/
192
+ github-mcp-server" vira "github-mcp-server". */
193
+ function nomeCurto(n) {
194
+ return String(n || '').split('/').pop().replace(/[^a-zA-Z0-9_.-]+/g, '-').toLowerCase().slice(0, 40) || 'mcp';
195
+ }
196
+
197
+ /* Uma entrada do registro vira candidatos de instalação. Cada formato tem o
198
+ seu lançador: npm → npx, pypi → uvx, oci → docker; remoto (http/sse) entra
199
+ pelo mcp-remote, que fala stdio deste lado. */
200
+ function doRegistro(item) {
201
+ const s = (item && item.server) || item || {};
202
+ const instalar = [];
203
+ for (const pk of s.packages || []) {
204
+ const env = (pk.environmentVariables || []).map((e) => ({ nome: e.name, descricao: String(e.description || '').slice(0, 140), obrigatorio: !!e.isRequired, segredo: !!e.isSecret }));
205
+ const t = pk.transport && pk.transport.type;
206
+ if (t && t !== 'stdio') continue;
207
+ const extra = (pk.packageArguments || []).filter((a) => a.isRequired).flatMap((a) => {
208
+ const v = a.value || a.default || `<${a.valueHint || a.name || 'valor'}>`;
209
+ return a.type === 'named' ? [a.name, v] : [v];
210
+ });
211
+ if (pk.registryType === 'npm') instalar.push({ tipo: 'npm', command: 'npx', args: ['-y', pk.identifier, ...extra], env });
212
+ else if (pk.registryType === 'pypi') instalar.push({ tipo: 'pypi', command: 'uvx', args: [pk.identifier, ...extra], env });
213
+ else if (pk.registryType === 'oci') instalar.push({ tipo: 'oci', command: 'docker', args: ['run', '-i', '--rm', ...env.flatMap((e) => ['-e', e.nome]), pk.identifier, ...extra], env });
214
+ }
215
+ for (const r of s.remotes || []) {
216
+ if (!/^https:\/\//.test(r.url || '') || /\{/.test(r.url)) continue; // URL com molde pede dado que não temos
217
+ const env = [];
218
+ const args = ['-y', 'mcp-remote', r.url];
219
+ for (const h of r.headers || []) {
220
+ if (!h.isRequired && !h.isSecret) continue;
221
+ // "Bearer {smithery_api_key}" → "Bearer ${SMITHERY_API_KEY}"; sem molde, o header inteiro vem do env.
222
+ let valor = h.value ? String(h.value).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, k) => {
223
+ const n = k.toUpperCase();
224
+ env.push({ nome: n, descricao: String(h.description || '').slice(0, 140), obrigatorio: true, segredo: true });
225
+ return '${' + n + '}';
226
+ }) : null;
227
+ if (!valor) {
228
+ const n = `MCP_${String(h.name).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
229
+ env.push({ nome: n, descricao: `valor do header ${h.name} (ex: Bearer <token>)`, obrigatorio: true, segredo: true });
230
+ valor = '${' + n + '}';
231
+ }
232
+ args.push('--header', `${h.name}:${valor}`);
233
+ }
234
+ instalar.push({ tipo: r.type || 'remoto', command: 'npx', args, env });
235
+ }
236
+ return { nome: nomeCurto(s.name), registro: s.name, descricao: String(s.title ? `${s.title} — ${s.description || ''}` : s.description || '').slice(0, 160), versao: s.version, instalar };
237
+ }
238
+
239
+ function doCurado(c) {
240
+ return { nome: c.nome, fonte: 'curado', descricao: c.descricao,
241
+ instalar: [{ tipo: 'curado', command: c.command, args: c.args, env: (c.precisa || []).map((p) => ({ nome: p.nome, descricao: p.onde, obrigatorio: true, segredo: true })) }] };
242
+ }
243
+
244
+ /**
245
+ * Procura um servidor MCP para o serviço: a lista curada primeiro, depois o
246
+ * registro oficial. `buscar` é o fetch (o teste troca; sem rede, só a curada).
247
+ */
248
+ async function procurar(q, { buscar = globalThis.fetch, limite = 8, tetoMs = 8000 } = {}) {
249
+ const busca = String(q || '').trim();
250
+ if (!busca) return { ok: false, error: 'Diga o serviço ou app a procurar (ex: "notion", "github").' };
251
+ const ja = servidores();
252
+ const curados = CURADOS.filter((c) => c.termos.test(busca) || busca.toLowerCase().includes(c.nome)).map(doCurado);
253
+ let registro = [], aviso;
254
+ if (typeof buscar === 'function') {
255
+ const ctl = typeof AbortController === 'function' ? new AbortController() : null;
256
+ const relogio = ctl ? setTimeout(() => ctl.abort(), tetoMs) : null;
257
+ try {
258
+ const url = `${REGISTRO}?search=${encodeURIComponent(busca)}&limit=${Math.max(limite * 3, 20)}&version=latest`;
259
+ const r = await buscar(url, ctl ? { signal: ctl.signal } : {});
260
+ if (!r.ok) throw new Error(`registro respondeu ${r.status}`);
261
+ const j = await r.json();
262
+ const termo = busca.toLowerCase().split(/\s+/)[0];
263
+ registro = (j.servers || [])
264
+ // version=latest já filtra; a marca isLatest é a rede caso o parâmetro mude.
265
+ .filter((x) => { const m = x._meta && x._meta['io.modelcontextprotocol.registry/official']; return !m || m.isLatest !== false; })
266
+ .map(doRegistro)
267
+ .filter((x) => x.instalar.length)
268
+ // A busca do registro casa até a URL do repositório ("github" traz
269
+ // tudo que mora no GitHub). Fica o que fala do serviço no nome ou na descrição.
270
+ // O prefixo "io.github.<dono>/" não conta: é onde mora, não o que faz.
271
+ .filter((x) => `${x.nome} ${x.descricao}`.toLowerCase().includes(termo))
272
+ .map((x) => ({ ...x, fonte: 'registro',
273
+ // Relevância: o nome casa com a busca, e tem pacote que roda local (sem chave de intermediário).
274
+ pontos: (x.nome.includes(termo) ? 2 : 0) + (x.instalar.some((i) => i.tipo === 'npm') ? 1 : 0) - (/smithery/.test(x.registro) ? 2 : 0) }))
275
+ .sort((a, b) => b.pontos - a.pontos)
276
+ .map(({ pontos, ...x }) => x);
277
+ } catch (e) { aviso = `registro indisponível (${e.name === 'AbortError' ? 'demorou' : e.message}) — só a lista curada`; }
278
+ finally { if (relogio) clearTimeout(relogio); }
279
+ }
280
+ const vistos = new Set();
281
+ const resultados = [...curados, ...registro].filter((x) => !vistos.has(x.nome) && vistos.add(x.nome)).slice(0, limite)
282
+ .map((x) => ({ ...x, jaConectado: !!ja[x.nome] }));
283
+ return { ok: true, busca, resultados,
284
+ ...(aviso ? { aviso } : {}),
285
+ proximo: resultados.length
286
+ ? 'Escolha o primeiro que serve e chame mcp_conectar {nome, comando, args, env} com o "instalar" dele (curado: só {nome} basta). Env obrigatório sem valor: PEÇA a chave ao usuário antes.'
287
+ : 'Nenhum MCP para isso. Tente API/CLI pelo run_shell; o mouse (desktop_*) é o último recurso.' };
288
+ }
289
+
290
+ function lerConfig(arq) { const j = lerJson(arq) || {}; j.mcpServers = j.mcpServers || {}; return j; }
291
+ function gravarConfig(arq, j) {
292
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
293
+ // Pode levar token: só o dono lê.
294
+ fs.writeFileSync(arq, JSON.stringify(j, null, 2), { mode: 0o600 });
295
+ }
296
+
297
+ /**
298
+ * Grava, sobe e valida um servidor novo. Não salva o que não subiria: sem a
299
+ * chave obrigatória, devolve o que perguntar ao usuário; se não responder ao
300
+ * tools/list, desfaz a gravação.
301
+ */
302
+ async function conectarNovo({ nome, command, comando, args, env } = {}, { config = CONFIG, tetoMs = 120000 } = {}) {
303
+ const n = String(nome || '').trim().toLowerCase();
304
+ if (!/^[a-z0-9][a-z0-9_.-]{0,40}$/.test(n)) return { ok: false, error: 'nome inválido: letras, números, - _ . (ex: "notion")' };
305
+ const curado = CURADOS.find((c) => c.nome === n);
306
+ let cmd = command || comando;
307
+ let lista = Array.isArray(args) ? args.map(String) : (typeof args === 'string' && args.trim() ? args.trim().split(/\s+/) : null);
308
+ if (!cmd && curado) { cmd = curado.command; lista = lista || curado.args.slice(); }
309
+ if (!cmd) return { ok: false, error: `não conheço "${n}" de cor — chame mcp_procurar primeiro e passe comando e args do "instalar".` };
310
+ lista = lista || [];
311
+ const vars = {};
312
+ for (const [k, v] of Object.entries(env || {})) if (v != null && String(v).trim() !== '') vars[k] = String(v);
313
+
314
+ const molde = lista.filter((a) => /<[^>]+>/.test(a));
315
+ if (molde.length) return { ok: false, salvo: false, falta: molde, error: `Falta preencher ${molde.join(', ')} nos args. Pergunte ao usuário e chame de novo.` };
316
+
317
+ // A chave que falta: a da curadoria e a que os args citam como ${VAR}.
318
+ const pedidas = new Map((curado && cmd === curado.command ? curado.precisa : []).map((p) => [p.nome, p.onde]));
319
+ for (const a of lista) for (const m of a.matchAll(/\$\{([A-Z0-9_]+)\}/g)) if (!pedidas.has(m[1])) pedidas.set(m[1], '');
320
+ const faltam = [...pedidas].filter(([k]) => !vars[k] && !process.env[k]).map(([k, onde]) => ({ nome: k, onde }));
321
+ if (faltam.length) {
322
+ return { ok: false, salvo: false, precisaDe: faltam,
323
+ error: `O MCP "${n}" precisa de ${faltam.map((f) => f.nome).join(', ')}. NÃO salvei nada. Peça ao usuário`
324
+ + `${faltam.some((f) => f.onde) ? ` (onde pegar: ${faltam.filter((f) => f.onde).map((f) => `${f.nome} → ${f.onde}`).join('; ')})` : ''}`
325
+ + ' e chame mcp_conectar de novo com env.' };
326
+ }
327
+
328
+ const def = { command: cmd, args: lista, ...(Object.keys(vars).length ? { env: vars } : {}) };
329
+ const j = lerConfig(config);
330
+ const antes = j.mcpServers[n];
331
+ j.mcpServers[n] = def;
332
+ gravarConfig(config, j);
333
+ let c;
334
+ try {
335
+ c = await conectar(def, { tetoMs });
336
+ const r = await c.pedir('tools/list');
337
+ const ferramentas = resumirTools(r.tools);
338
+ gravarCache((sv) => { sv[n] = ferramentas; });
339
+ return { ok: true, servidor: n, ferramentas,
340
+ proximo: `Conectado. Use mcp_usar {servidor:"${n}", ferramenta, argumentos}.` };
341
+ } catch (e) {
342
+ // Não subiu: o mcp.json volta a ser o que era.
343
+ const k = lerConfig(config);
344
+ if (antes) k.mcpServers[n] = antes; else delete k.mcpServers[n];
345
+ gravarConfig(config, k);
346
+ return { ok: false, salvo: false, error: `o servidor "${n}" não subiu: ${e.message}. Nada ficou salvo.` };
347
+ } finally { if (c) c.fechar(); }
348
+ }
349
+
350
+ function textoDe(r) {
351
+ return (r.content || []).map((x) => (x.type === 'text' ? x.text : `[${x.type}]`)).join('\n');
352
+ }
353
+
354
+ /** A tool nativa mcp_usar: chama e devolve o texto, no formato das outras tools. */
355
+ async function usar(servidor, ferramenta, argumentos = {}, pasta) {
356
+ if (!servidor || !ferramenta) return { ok: false, error: 'Diga servidor e ferramenta (veja [mcp] no contexto, ou mcp_procurar).' };
357
+ let a = argumentos;
358
+ if (typeof a === 'string') { try { a = JSON.parse(a || '{}'); } catch { return { ok: false, error: 'argumentos não é JSON válido' }; } }
359
+ const r = await chamar(servidor, ferramenta, a || {}, pasta);
360
+ return { ok: !r.isError, servidor, ferramenta, resultado: textoDe(r).slice(0, 20000) };
361
+ }
362
+
103
363
  /** A linha do contexto: só se houver servidor configurado, a partir do cache. */
104
364
  function dica(pasta) {
105
365
  const lista = Object.keys(servidores(pasta));
@@ -109,8 +369,9 @@ function dica(pasta) {
109
369
  const tools = c && c.servidores && Array.isArray(c.servidores[n]) ? c.servidores[n].map((t) => t.nome).slice(0, 8).join(', ') : '';
110
370
  return tools ? `${n} (${tools})` : n;
111
371
  }).join(' · ');
112
- return `[mcp] Servidores MCP disponíveis: ${det}. Use pelo run_shell: primocode mcp call <servidor> <tool> --json '{...}'`
113
- + (c ? '' : ' (rode primocode mcp list para ver as tools).');
372
+ return `[mcp] Servidores MCP disponíveis: ${det}. Use com mcp_usar {servidor, ferramenta, argumentos}. `
373
+ + 'Serviço sem MCP aqui: mcp_procurar → mcp_conectar.'
374
+ + (c ? '' : ' (primocode mcp list mostra as tools.)');
114
375
  }
115
376
 
116
377
  /** `primocode mcp …` */
@@ -122,14 +383,15 @@ async function main(argv) {
122
383
  console.log('primocode mcp list — servidores e tools (de ~/.primocode/mcp.json e .mcp.json)');
123
384
  console.log("primocode mcp call <servidor> <tool> --json '{...}' — chama uma tool; sai o resultado em JSON");
124
385
  console.log('primocode mcp add <nome> -- <comando> [args…] — guarda um servidor em ~/.primocode/mcp.json');
386
+ console.log('primocode mcp procurar <serviço> — acha o MCP de um serviço (lista curada + registro oficial)');
387
+ console.log('primocode mcp conectar <nome> [--env K=V…] [-- <comando> [args…]] — grava, sobe e valida');
125
388
  return 0;
126
389
  }
127
390
  if (op === 'list') { console.log(JSON.stringify(await listar(process.cwd()))); return 0; }
128
391
  if (op === 'call') {
129
392
  if (json === null) { console.log(JSON.stringify({ ok: false, error: '--json inválido' })); return 1; }
130
393
  const r = await chamar(resto[0], resto[1], json, process.cwd());
131
- const texto = (r.content || []).map((x) => (x.type === 'text' ? x.text : `[${x.type}]`)).join('\n');
132
- console.log(JSON.stringify({ ok: !r.isError, resultado: texto.slice(0, 20000) }));
394
+ console.log(JSON.stringify({ ok: !r.isError, resultado: textoDe(r).slice(0, 20000) }));
133
395
  return r.isError ? 1 : 0;
134
396
  }
135
397
  if (op === 'add') {
@@ -137,14 +399,36 @@ async function main(argv) {
137
399
  const nome = resto[0];
138
400
  const cmd = i >= 0 ? resto.slice(i + 1) : resto.slice(1);
139
401
  if (!nome || !cmd.length) { console.log('uso: primocode mcp add <nome> -- <comando> [args…]'); return 1; }
140
- const j = lerJson(CONFIG) || { mcpServers: {} };
141
- j.mcpServers = j.mcpServers || {};
402
+ const j = lerConfig(CONFIG);
142
403
  j.mcpServers[nome] = { command: cmd[0], args: cmd.slice(1) };
143
- fs.mkdirSync(CASA, { recursive: true });
144
- fs.writeFileSync(CONFIG, JSON.stringify(j, null, 2));
404
+ gravarConfig(CONFIG, j);
145
405
  console.log(JSON.stringify({ ok: true, servidor: nome }));
146
406
  return 0;
147
407
  }
408
+ if (op === 'procurar' || op === 'search') {
409
+ const r = await procurar(resto.join(' '));
410
+ if (!r.ok) { console.log(r.error); return 1; }
411
+ for (const x of r.resultados) {
412
+ const i = x.instalar[0];
413
+ const chaves = i.env.filter((e) => e.obrigatorio).map((e) => e.nome);
414
+ console.log(`${x.jaConectado ? '✔' : '·'} ${x.nome} [${x.fonte}] ${x.descricao}`);
415
+ console.log(` ${i.command} ${i.args.join(' ')}${chaves.length ? ` (precisa: ${chaves.join(', ')})` : ''}`);
416
+ }
417
+ if (!r.resultados.length) console.log('nada encontrado.');
418
+ if (r.aviso) console.log(r.aviso);
419
+ return 0;
420
+ }
421
+ if (op === 'conectar' || op === 'connect') {
422
+ const i = resto.indexOf('--');
423
+ const antes = i >= 0 ? resto.slice(0, i) : resto;
424
+ const cmd = i >= 0 ? resto.slice(i + 1) : [];
425
+ const env = {};
426
+ antes.forEach((x, k) => { if (x === '--env' && antes[k + 1]) { const [a, ...b] = antes[k + 1].split('='); env[a] = b.join('='); } });
427
+ const r = await conectarNovo({ nome: antes[0], command: cmd[0], args: cmd.length ? cmd.slice(1) : undefined, env });
428
+ if (r.ok) console.log(`conectado: ${r.servidor} — ${r.ferramentas.length} tools (${r.ferramentas.map((t) => t.nome).slice(0, 10).join(', ')})`);
429
+ else console.log(r.error);
430
+ return r.ok ? 0 : 1;
431
+ }
148
432
  console.log(JSON.stringify({ ok: false, error: `operação desconhecida: ${op}` }));
149
433
  return 1;
150
434
  } catch (e) {
@@ -153,4 +437,4 @@ async function main(argv) {
153
437
  }
154
438
  }
155
439
 
156
- module.exports = { servidores, conectar, listar, chamar, dica, main };
440
+ module.exports = { servidores, conectar, listar, chamar, usar, dica, main, procurar, doRegistro, conectarNovo, CURADOS, expandir };
@@ -79,16 +79,45 @@ async function atualizar(memoria, history, resumir) {
79
79
  const novas = pendentes(memoria, history);
80
80
  if (!novas.length) return memoria;
81
81
 
82
+ // A memória da pasta (AGENTS.md) entra como parte do "resumo de antes":
83
+ // é assim que a sessão nova CONTINUA a de ontem em vez de recomeçar — e o
84
+ // AGENTS.md seguinte não apaga o que a anterior tinha anotado.
85
+ const semente = memoria.semente && memoria.semente.texto
86
+ ? `Memória da pasta ${memoria.semente.pasta} (sessões anteriores):\n${memoria.semente.texto}` : '';
87
+ const antes = [memoria.resumo, semente].filter(Boolean).join('\n\n');
88
+
82
89
  let texto;
83
90
  try {
84
- texto = await resumir({ resumo: memoria.resumo, mensagens: novas });
91
+ texto = await resumir({ resumo: antes, mensagens: novas });
85
92
  } catch {
86
93
  return memoria;
87
94
  }
88
95
  const limpo = String(texto || '').trim();
89
96
  if (!limpo) return memoria;
90
97
 
91
- return { resumo: limpo, resumidasAte: (history || []).length - CRUAS };
98
+ return {
99
+ resumo: limpo,
100
+ resumidasAte: (history || []).length - CRUAS,
101
+ dobrada: memoria.semente ? memoria.semente.pasta : (memoria.dobrada || null),
102
+ };
103
+ }
104
+
105
+ /* ── A MEMÓRIA DA PASTA ───────────────────────────────────────────────────
106
+ * `semear` guarda o que o AGENTS.md da pasta trazia, para o próximo resumo
107
+ * dobrar junto. `jaDobrou` diz se o resumo atual já carrega a memória
108
+ * daquela pasta — aí ela não precisa ir de novo no contexto (seria pagar duas
109
+ * vezes pelo mesmo texto). Ver lib/agents-md.js. */
110
+ function semear(memoria, pasta, texto) {
111
+ const base = memoria || nova();
112
+ if (!pasta || base.dobrada === pasta) return base;
113
+ const t = String(texto || '').trim();
114
+ // Pasta sem memória: não há o que dobrar, e ela conta como já dobrada.
115
+ if (!t) return { ...base, semente: null, dobrada: pasta };
116
+ return { ...base, semente: { pasta, texto: t } };
117
+ }
118
+
119
+ function jaDobrou(memoria, pasta) {
120
+ return !!(memoria && pasta && memoria.dobrada === pasta);
92
121
  }
93
122
 
94
123
  /**
@@ -153,4 +182,4 @@ function economia(memoria, history) {
153
182
  return { cheio, enxuto, poupado: Math.max(0, cheio - enxuto) };
154
183
  }
155
184
 
156
- module.exports = { nova, pendentes, usoDoContexto, TETO_CONVERSA, precisaResumir, atualizar, paraEnviar, economia, tokensDe, inicioSeguro, CRUAS, LIMITE_TOKENS };
185
+ module.exports = { nova, semear, jaDobrou, pendentes, usoDoContexto, TETO_CONVERSA, precisaResumir, atualizar, paraEnviar, economia, tokensDe, inicioSeguro, CRUAS, LIMITE_TOKENS };
package/lib/mencoes.js CHANGED
@@ -37,7 +37,11 @@ function instrucoesDoProjeto(projectDir, limite = LIMITE_INSTRUCOES) {
37
37
  if (global.trim()) partes.push(`[~/.primocode/PRIMO.md — suas instruções para todo projeto]\n${cortar(global, limite)}`);
38
38
  if (projectDir) {
39
39
  for (const nome of NOMES_INSTRUCOES) {
40
- const texto = lerSeExiste(path.join(projectDir, nome));
40
+ /* O bloco de memória que o PrimoCode escreve no AGENTS.md
41
+ (lib/agents-md.js) não é instrução de ninguém: sai daqui e entra
42
+ à parte, como memória. Um AGENTS.md que só tem o bloco conta
43
+ como vazio — e o CLAUDE.md do repo ainda tem a vez dele. */
44
+ const texto = require('./agents-md.js').semBloco(lerSeExiste(path.join(projectDir, nome)));
41
45
  if (!texto.trim()) continue;
42
46
  partes.push(`[${nome} — instruções deste projeto: siga]\n${cortar(texto, limite)}`);
43
47
  break;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * nome-curto.js — o nome da pasta é para ler, não para guardar a frase.
3
+ *
4
+ * "Essa estrutura de pastas está horrível. Os nomes de pasta que a IA
5
+ * decide estão horríveis." (11/10/2026)
6
+ *
7
+ * O que saía: `2026-10-11-claude-e-humano-conversam-sobre-o-segund` — a data
8
+ * na frente, a frase inteira com "e", "sobre", "o", e o corte no meio da
9
+ * palavra aos 40 caracteres. Ninguém acha nada numa lista assim.
10
+ *
11
+ * ── A REGRA ─────────────────────────────────────────────────────────────
12
+ * · de 1 a 4 palavras que dizem o assunto, em kebab-case, sem acento;
13
+ * · palavra de ligação (de, do, com, sobre, o, a, e…) e verbo de pedido
14
+ * (faça, crie, quero…) saem — não dizem O QUE é;
15
+ * · número solto (data, "2026") sai: a data mora no projeto.json;
16
+ * · NUNCA corta palavra no meio. Se não cabe em 28 letras, sai uma
17
+ * palavra inteira — e a escolha prefere guardar a primeira (o sujeito)
18
+ * e a última (quase sempre o assunto: "segundo CÉREBRO").
19
+ *
20
+ * Puro e sem dependência: o config.js e o Estúdio usam, e nenhum dos dois
21
+ * pode puxar o outro.
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const MAXIMO = 28;
27
+ const PALAVRAS = 4;
28
+
29
+ // Ligação e artigo: aparecem em toda frase e não dizem nada sozinhas.
30
+ const LIGACAO = new Set([
31
+ 'de', 'do', 'da', 'dos', 'das', 'd', 'com', 'sem', 'sobre', 'o', 'a', 'os', 'as', 'e', 'ou',
32
+ 'em', 'no', 'na', 'nos', 'nas', 'num', 'numa', 'um', 'uma', 'uns', 'umas', 'para', 'pra', 'pro',
33
+ 'por', 'pelo', 'pela', 'que', 'se', 'ao', 'aos', 'mais', 'muito', 'bem', 'entre', 'ate', 'como',
34
+ 'meu', 'minha', 'meus', 'minhas', 'seu', 'sua', 'nosso', 'nossa', 'esse', 'essa', 'este', 'esta',
35
+ 'isso', 'isto', 'aquele', 'aquela', 'the', 'of', 'and', 'to', 'for', 'with', 'in', 'on',
36
+ ]);
37
+
38
+ // Pedido e narração: o que a pessoa quer FAZER, não o que a coisa É.
39
+ const PEDIDO = new Set([
40
+ 'faca', 'faz', 'fazer', 'crie', 'cria', 'criar', 'gere', 'gera', 'gerar', 'monte', 'monta', 'montar',
41
+ 'quero', 'queria', 'preciso', 'bora', 'vamos', 'agora', 'novo', 'nova', 'outro', 'outra',
42
+ 'conversam', 'conversa', 'conversando', 'falam', 'fala', 'falando', 'explica', 'explicando',
43
+ 'mostra', 'mostrando', 'apresenta', 'apresentando', 'tipo', 'estilo', 'simples', 'rapido',
44
+ ]);
45
+
46
+ function semAcento(s) {
47
+ return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '');
48
+ }
49
+
50
+ /** As palavras que dizem o assunto, na ordem em que vieram. */
51
+ function palavrasDoAssunto(texto) {
52
+ return semAcento(texto).toLowerCase()
53
+ .split(/[^a-z0-9]+/)
54
+ .filter(Boolean)
55
+ .filter((p) => !LIGACAO.has(p) && !PEDIDO.has(p))
56
+ // Número solto é data, hora ou contagem ("24 09 2026", "21h36" fica).
57
+ .filter((p) => !/^\d+$/.test(p));
58
+ }
59
+
60
+ /**
61
+ * O melhor nome que cabe: o maior número de palavras inteiras, e entre os
62
+ * empates o que guarda a primeira e a última. Com no máximo 5 candidatas são
63
+ * 31 combinações — conta de cabeça, não de algoritmo.
64
+ */
65
+ function escolher(palavras, maximo, limite) {
66
+ const cand = palavras.slice(0, Math.max(limite + 1, 5));
67
+ if (!cand.length) return '';
68
+ let melhor = null;
69
+ const n = cand.length;
70
+ for (let mascara = 1; mascara < (1 << n); mascara++) {
71
+ const idx = [];
72
+ for (let i = 0; i < n; i++) if (mascara & (1 << i)) idx.push(i);
73
+ if (idx.length > limite) continue;
74
+ const nome = idx.map((i) => cand[i]).join('-');
75
+ if (nome.length > maximo) continue;
76
+ let juntas = 0;
77
+ for (let k = 1; k < idx.length; k++) if (idx[k] === idx[k - 1] + 1) juntas++;
78
+ const pontos = idx.length * 1000
79
+ + (idx[0] === 0 ? 100 : 0)
80
+ + (idx[idx.length - 1] === n - 1 ? 50 : 0)
81
+ // as duas últimas juntas costumam ser um nome só ("segundo cérebro")
82
+ + (n > 1 && idx.includes(n - 2) && idx.includes(n - 1) ? 20 : 0)
83
+ // a frase como veio, sem pular, lê melhor
84
+ + juntas * 10
85
+ // mais palavras do começo da frase (o pedido diz o principal antes)
86
+ - idx.reduce((a, b) => a + b, 0);
87
+ if (!melhor || pontos > melhor.pontos || (pontos === melhor.pontos && nome.length > melhor.nome.length)) {
88
+ melhor = { nome, pontos };
89
+ }
90
+ }
91
+ // Nem uma palavra coube: é uma palavra só, gigante. Aí corta — é o único
92
+ // caso, e o hífen do fim não fica.
93
+ if (!melhor) return cand[0].slice(0, maximo).replace(/-+$/, '');
94
+ return melhor.nome;
95
+ }
96
+
97
+ /**
98
+ * "Claude e humano conversam sobre o segundo cérebro" → "claude-segundo-cerebro".
99
+ * Devolve '' quando não sobra palavra nenhuma (quem chama decide a reserva).
100
+ */
101
+ function nomeCurto(texto, { maximo = MAXIMO, palavras = PALAVRAS } = {}) {
102
+ return escolher(palavrasDoAssunto(texto), maximo, palavras);
103
+ }
104
+
105
+ /**
106
+ * Nome que já existe: primeiro tenta uma palavra a mais da própria frase
107
+ * (`anuncio-imersao` → `anuncio-imersao-paris`), que diz QUAL é; só sem
108
+ * palavra que caiba vem o -2, -3.
109
+ */
110
+ function semColisao(nome, tomados, texto = '', { maximo = MAXIMO } = {}) {
111
+ const usados = new Set([...(tomados || [])].map((n) => String(n).toLowerCase()));
112
+ if (!usados.has(nome)) return nome;
113
+ const ja = new Set(nome.split('-'));
114
+ for (const extra of palavrasDoAssunto(texto)) {
115
+ if (ja.has(extra)) continue;
116
+ const outro = `${nome}-${extra}`;
117
+ if (outro.length <= maximo && !usados.has(outro)) return outro;
118
+ }
119
+ for (let i = 2; i < 1000; i++) {
120
+ const outro = `${nome}-${i}`;
121
+ if (!usados.has(outro)) return outro;
122
+ }
123
+ return `${nome}-${Date.now()}`;
124
+ }
125
+
126
+ module.exports = { nomeCurto, semColisao, palavrasDoAssunto, MAXIMO, LIGACAO, PEDIDO };
@@ -22,6 +22,7 @@
22
22
  'use strict';
23
23
 
24
24
  const { slugify } = require('./config.js');
25
+ const { nomeCurto } = require('./nome-curto.js');
25
26
 
26
27
  // Nome que não diz nada: o modelo devolveu isso, é como não ter respondido.
27
28
  const GENERICOS = new Set(['projeto', 'project', 'app', 'site', 'novo', 'teste', 'test', 'meu-projeto', 'my-project']);
@@ -48,7 +49,7 @@ function pergunta(pedido, nomes) {
48
49
  : 'Use kebab-case em português, de 1 a 3 palavras.';
49
50
  return 'Dê um nome curto para a pasta do projeto deste pedido. O nome diz O QUE é '
50
51
  + '(ex.: site-padaria, jogo-de-carro, loja-roupas), nunca o que a pessoa pediu para fazer. '
51
- + `${jeito} Responda SÓ com o nome, sem aspas e sem explicação.\n\nPEDIDO: ${String(pedido || '').slice(0, 600)}`;
52
+ + `${jeito} No máximo 28 letras, sem data e sem "de/da/com". Responda SÓ com o nome, sem aspas e sem explicação.\n\nPEDIDO: ${String(pedido || '').slice(0, 600)}`;
52
53
  }
53
54
 
54
55
  /** Limpa a resposta do modelo. Devolve null quando não dá para confiar nela. */
@@ -58,7 +59,9 @@ function limpar(resposta, nomes) {
58
59
  .replace(/^(nome|pasta)\s*:\s*/i, '');
59
60
  if (!primeira || primeira.length > 60 || primeira.split(/\s+/).length > 5) return null;
60
61
  const c = convencao(nomes);
61
- let nome = slugify(primeira).split('-').filter(Boolean).slice(0, 4).join('-');
62
+ // A mesma régua das pastas do Estúdio (lib/nome-curto.js): sem "de/da",
63
+ // sem corte no meio da palavra, até 28 letras.
64
+ let nome = nomeCurto(primeira) || slugify(primeira).split('-').filter(Boolean).slice(0, 4).join('-');
62
65
  if (!nome || GENERICOS.has(nome)) return null;
63
66
  if (c && c.separador === '_') nome = nome.replace(/-/g, '_');
64
67
  return semColisao(nome, nomes);