pokt-cli 1.0.7 → 1.0.9

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/dist/chat/loop.js CHANGED
@@ -2,11 +2,17 @@ import prompts from 'prompts';
2
2
  import ora from 'ora';
3
3
  import { ui } from '../ui.js';
4
4
  import { runProFlow } from '../commands/pro.js';
5
+ import { PROVIDER_LABELS, ALL_PROVIDERS } from '../config.js';
5
6
  import { config } from '../config.js';
6
7
  import { getClient } from './client.js';
7
8
  import { tools, executeTool } from './tools.js';
9
+ import { saveAuto, loadAuto, listCheckpoints, saveCheckpoint, loadCheckpoint, deleteCheckpoint, exportConversation, getSessionsDir } from './sessions.js';
8
10
  import { connectMcpServer, getAllMcpToolsOpenAI, callMcpTool, isMcpTool, disconnectAllMcp, } from '../mcp/client.js';
9
- const SYSTEM_PROMPT = `You are Pokt CLI, an elite AI Software Engineer.
11
+ import { getMergedMcpServers } from '../mcp/project-mcp.js';
12
+ import { runMcpFromBashMarkdown, stripExecutedStyleMcpBashBlocks, tryAutoMcpForListDatabases, } from './mcp-from-text.js';
13
+ import { slimToolsForUpstreamPayload } from './slim-tools.js';
14
+ /** Base do system prompt; a lista de ferramentas MCP é anexada em runtime quando houver servidores. */
15
+ const SYSTEM_PROMPT_BASE = `You are Pokt CLI, an elite AI Software Engineer.
10
16
  Your goal is to help the user build, fix, and maintain software projects with high quality.
11
17
 
12
18
  CORE CAPABILITIES:
@@ -14,20 +20,38 @@ CORE CAPABILITIES:
14
20
  2. **Autonomous Coding**: You can create new files, rewrite existing ones, and run terminal commands.
15
21
  3. **Problem Solving**: You analyze errors and propose/apply fixes.
16
22
 
17
- CRITICAL - FILE CREATION/EDITS (this API does NOT support tool calls):
18
- - Do NOT reply with only "We will call read_file", "We will call write_file" or similar. Those tools will NOT run. The user will get no file.
19
- - You MUST output the complete file content in a markdown code block so the CLI can create/edit the file. Format: mention the filename (e.g. hello.py or **hello.py**) then a newline then \`\`\`python then newline then the full file content then \`\`\`.
20
- - For edits: first "read" the file by inferring its content from the user request and project context, then output the full updated file in a \`\`\`python (or correct language) block with the filename mentioned just above the block.
21
- - Never end your response with only an intention to call a tool. Always include the actual code in a block.
23
+ FUNCTION CALLING (native tools USE THEM):
24
+ - This chat uses OpenAI-style **tool_calls**. You MUST use the provided functions for actions: \`read_file\`, \`write_file\`, \`run_command\`, \`list_files\`, etc., and any tool whose name starts with \`mcp_\`.
25
+ - **Avoid** shell lines like \`mcp_Something_tool "..."\` in markdown the CLI may run them as **fallback** if they match a registered tool, but **native tool_calls are always better** (correct args, one round-trip).
26
+ - For databases/APIs exposed via MCP, call the real \`mcp_*\` tools with the correct JSON arguments (e.g. Neon: run SQL via the server's SQL tool, not a invented command name).
27
+ - **Neon MCP**: tools like \`get_database_tables\`, \`describe_project\`, \`list_branch_computes\` need \`projectId\`. Call \`list_projects\` first and pass the \`id\` of the target project, or rely on the CLI: if your account has exactly one project, Pokt may inject \`projectId\` automatically. To list logical Postgres databases, prefer \`run_sql\` with \`SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1;\`.
28
+ - **Neon \`run_sql\` (tool_calls)**: pass **JSON** fields expected by the schema (e.g. \`projectId\`, \`branchId\`, \`sql\`). Do **not** put \`neonctl\`-style flags inside \`sql\` (wrong: \`{"sql":"--project-id ... --sql \\"SELECT 1\\""}\`; right: \`{"projectId":"...","branchId":"...","sql":"SELECT 1"}\`). Use the \`id\` from \`list_projects\` for \`projectId\` — do not invent project ids.
29
+ - If a tool call fails, read the error, adjust arguments, and retry or explain what the user must fix in config.
30
+
31
+ WHEN THE MODEL DOES NOT RETURN tool_calls (rare):
32
+ - You may still output the complete file content in a markdown code block so the CLI fallback can apply it. Format: mention the filename (e.g. hello.py) then \`\`\`lang ... \`\`\`.
33
+ - Do NOT use this for shell-only or SQL-in-bash blocks meant to be executed — use \`run_command\` or MCP tools instead.
22
34
 
23
35
  GUIDELINES:
24
36
  - You will receive the user request first, then the current project structure. Use the project structure to understand the context before creating or editing anything.
25
37
  - When asked to fix something, first **read** the relevant files to understand the context.
26
- - When creating a project, start by planning the structure, then use \`write_file\` to create each file.
27
- - You have full access to the current terminal. You can run \`run_command\` for \`npm install\`, \`tsc\`, or any other command.
38
+ - When creating a project, start by planning the structure, then use \`write_file\` (tool call) to create each file.
39
+ - You have full access to the current terminal via \`run_command\` for \`npm install\`, \`tsc\`, etc. You may also emit **scripts executáveis** (Node, Python, npx, \`psql\`, etc.) via \`run_command\` when MCP não estiver disponível ou o usuário pedir código para rodar localmente.
40
+ - **MCP tools**: Tools named \`mcp_<ServerName>_<toolName>\` connect to external services. Prefer them when they match the task.
41
+ - **Never** return a completely empty assistant message: always include a short natural-language answer and/or use tool_calls. After tools run, summarize results for the user in Portuguese.
42
+ - **After MCP/SQL succeeds**: give a **short** confirmation plus a **markdown table** (or bullet list) for rows/columns — do **not** repeat bash blocks with mcp_* lines, raw tool JSON, or invented shell commands; the CLI already executed native tool_calls.
28
43
  - Be extremely concise in your explanations.
29
44
  - The current working directory is: ${process.cwd()}
30
45
  `;
46
+ function buildSystemPrompt(mcpToolNames) {
47
+ if (mcpToolNames.length === 0)
48
+ return SYSTEM_PROMPT_BASE;
49
+ const list = mcpToolNames.join(', ');
50
+ return `${SYSTEM_PROMPT_BASE}
51
+
52
+ REGISTERED MCP TOOL NAMES (use only these exact names in tool_calls, never bash):
53
+ ${list}`;
54
+ }
31
55
  async function loadProjectStructure() {
32
56
  try {
33
57
  const timeoutMs = 8000;
@@ -46,9 +70,13 @@ async function loadProjectStructure() {
46
70
  }
47
71
  }
48
72
  export async function startChatLoop(modelConfig) {
49
- const client = await getClient(modelConfig);
50
- // Conectar servidores MCP configurados e montar lista de tools (nativas + MCP)
51
- const mcpServers = config.get('mcpServers') ?? [];
73
+ let activeModel = modelConfig;
74
+ let client = await getClient(activeModel);
75
+ // MCP: config global + pokt_cli/mcp.json na raiz do projeto (projeto sobrescreve por nome)
76
+ const { merged: mcpServers, poktDir, mcpJsonPath } = getMergedMcpServers(process.cwd());
77
+ if (poktDir) {
78
+ console.log(ui.dim(`[MCP] Projeto: ${mcpJsonPath ?? poktDir}`));
79
+ }
52
80
  for (const server of mcpServers) {
53
81
  const session = await connectMcpServer(server);
54
82
  if (session) {
@@ -59,14 +87,179 @@ export async function startChatLoop(modelConfig) {
59
87
  ...tools,
60
88
  ...getAllMcpToolsOpenAI(),
61
89
  ];
62
- const messages = [
63
- { role: 'system', content: SYSTEM_PROMPT }
64
- ];
90
+ /** Controller (Express) usa body parser com limite finito; MCPs como Neon geram schemas enormes. */
91
+ const toolsForApi = activeModel.provider === 'controller' ? slimToolsForUpstreamPayload(allTools) : allTools;
92
+ const mcpToolNames = allTools
93
+ .filter((t) => t.type === 'function' && Boolean(t.function?.name?.startsWith('mcp_')))
94
+ .map((t) => t.function.name);
95
+ const messages = [{ role: 'system', content: buildSystemPrompt(mcpToolNames) }];
96
+ // Auto-resume do projeto (estilo gemini): se existir, adiciona mensagens anteriores (sem duplicar system prompt)
97
+ const prev = loadAuto();
98
+ if (prev && prev.length > 0) {
99
+ for (const m of prev) {
100
+ if (m.role === 'system')
101
+ continue;
102
+ messages.push({ role: m.role, content: m.content });
103
+ }
104
+ console.log(ui.dim(`Sessão anterior carregada (projeto). Use /chat list | /chat save <tag>.`));
105
+ }
106
+ function modelLabel(m) {
107
+ const provider = PROVIDER_LABELS[m.provider] ?? m.provider;
108
+ return `[${provider}] ${m.id}`;
109
+ }
110
+ async function switchModelFlow(mode = 'model') {
111
+ const models = config.get('registeredModels') ?? [];
112
+ if (!Array.isArray(models) || models.length === 0) {
113
+ console.log(ui.error('Nenhum modelo registrado. Rode: pokt models list'));
114
+ return;
115
+ }
116
+ const providerChoices = ALL_PROVIDERS.map((p) => {
117
+ const label = PROVIDER_LABELS[p] ?? p;
118
+ const hasAny = models.some((m) => m.provider === p);
119
+ const star = activeModel.provider === p ? '★ ' : '';
120
+ return {
121
+ title: `${star}${label}${hasAny ? '' : ' (sem modelos)'}`,
122
+ value: p,
123
+ disabled: !hasAny,
124
+ };
125
+ });
126
+ const providerPick = await prompts({
127
+ type: 'select',
128
+ name: 'provider',
129
+ message: mode === 'provider' ? 'Trocar provedor:' : 'Selecione o provedor:',
130
+ choices: [...providerChoices, { title: '🔙 Cancelar', value: 'cancel' }],
131
+ });
132
+ if (!providerPick.provider || providerPick.provider === 'cancel')
133
+ return;
134
+ const provider = providerPick.provider;
135
+ const providerModels = models.filter((m) => m.provider === provider);
136
+ if (providerModels.length === 0) {
137
+ console.log(ui.error(`Sem modelos para ${PROVIDER_LABELS[provider] ?? provider}.`));
138
+ return;
139
+ }
140
+ let selected = null;
141
+ if (mode === 'provider') {
142
+ selected = providerModels[0];
143
+ }
144
+ else {
145
+ const pick = await prompts({
146
+ type: 'select',
147
+ name: 'idx',
148
+ message: `Modelos em ${PROVIDER_LABELS[provider] ?? provider}:`,
149
+ choices: [
150
+ ...providerModels.map((m, i) => ({
151
+ title: `${activeModel.provider === m.provider && activeModel.id === m.id ? '★ ' : ''}${m.id}`,
152
+ value: i,
153
+ })),
154
+ { title: '🔙 Cancelar', value: 'cancel' },
155
+ ],
156
+ });
157
+ if (pick.idx === 'cancel' || typeof pick.idx !== 'number')
158
+ return;
159
+ selected = providerModels[pick.idx] ?? null;
160
+ }
161
+ if (!selected)
162
+ return;
163
+ // Validar chaves/credenciais necessárias (reaproveita mesma lógica do providerCommand / getClient)
164
+ try {
165
+ const newClient = await getClient(selected);
166
+ client = newClient;
167
+ }
168
+ catch (e) {
169
+ console.log(ui.error(e?.message ?? String(e)));
170
+ return;
171
+ }
172
+ activeModel = selected;
173
+ config.set('activeModel', selected);
174
+ console.log(ui.success(`Modelo ativo atualizado: ${modelLabel(selected)}`));
175
+ console.log(ui.dim('Dica: o histórico do chat foi mantido; apenas o modelo/provedor mudou.'));
176
+ }
177
+ function printHelp() {
178
+ console.log(ui.dim(`
179
+ Comandos do chat:
180
+ ${ui.accent('/help')} — mostra esta ajuda
181
+ ${ui.accent('/clear')} — limpa a tela
182
+ ${ui.accent('/status')} — mostra modelo/provider atual
183
+ ${ui.accent('/model')} — trocar modelo (menu interativo)
184
+ ${ui.accent('/provider')} — trocar provedor (usa o 1º modelo disponível)
185
+ ${ui.accent('/chat')} — checkpoints/sessões (list/save/resume/delete/share)
186
+ ${ui.accent('/resume')} — alias de /chat
187
+ ${ui.accent('/copy')} — copia a última resposta do Pokt (Windows: clip)
188
+ ${ui.accent('/pro')} — abrir Pokt Pro no navegador
189
+ ${ui.accent('/quit')} ou ${ui.accent('exit')} — sair do chat
190
+ `));
191
+ }
192
+ let lastAssistantText = '';
193
+ async function handleChatCommand(raw) {
194
+ const parts = raw.trim().split(/\s+/);
195
+ const cmd = parts[0].toLowerCase();
196
+ const sub = (parts[1] ?? 'list').toLowerCase();
197
+ const arg = parts.slice(2).join(' ').trim();
198
+ if (sub === 'dir') {
199
+ console.log(ui.dim(`Sessões: ${getSessionsDir()}`));
200
+ return;
201
+ }
202
+ if (sub === 'list') {
203
+ const items = listCheckpoints();
204
+ if (items.length === 0) {
205
+ console.log(ui.dim('Nenhum checkpoint salvo ainda. Use: /chat save <tag>'));
206
+ return;
207
+ }
208
+ console.log(ui.dim('Checkpoints:'));
209
+ for (const it of items) {
210
+ console.log(`- ${ui.accent(it.tag)} ${it.updatedAt ? ui.dim(`(${it.updatedAt})`) : ''}`);
211
+ }
212
+ return;
213
+ }
214
+ if (sub === 'save') {
215
+ if (!arg) {
216
+ console.log(ui.error('Uso: /chat save <tag>'));
217
+ return;
218
+ }
219
+ saveCheckpoint(arg, messages);
220
+ console.log(ui.success(`Checkpoint salvo: ${arg}`));
221
+ return;
222
+ }
223
+ if (sub === 'resume' || sub === 'load') {
224
+ if (!arg) {
225
+ console.log(ui.error('Uso: /chat resume <tag>'));
226
+ return;
227
+ }
228
+ const loaded = loadCheckpoint(arg);
229
+ // mantém system prompt; substitui resto
230
+ const sys = messages[0];
231
+ messages.length = 0;
232
+ messages.push(sys);
233
+ for (const m of loaded) {
234
+ if (m.role === 'system')
235
+ continue;
236
+ messages.push({ role: m.role, content: m.content });
237
+ }
238
+ console.log(ui.success(`Checkpoint carregado: ${arg}`));
239
+ return;
240
+ }
241
+ if (sub === 'delete' || sub === 'rm') {
242
+ if (!arg) {
243
+ console.log(ui.error('Uso: /chat delete <tag>'));
244
+ return;
245
+ }
246
+ deleteCheckpoint(arg);
247
+ console.log(ui.success(`Checkpoint removido: ${arg}`));
248
+ return;
249
+ }
250
+ if (sub === 'share' || sub === 'export') {
251
+ const filename = arg || `pokt-chat-${Date.now()}.md`;
252
+ const out = exportConversation(filename, messages);
253
+ console.log(ui.success(`Exportado: ${out}`));
254
+ return;
255
+ }
256
+ console.log(ui.warn(`Subcomando desconhecido: ${sub}. Use /chat list|save|resume|delete|share`));
257
+ }
65
258
  while (true) {
66
259
  console.log('');
67
260
  const cwd = process.cwd();
68
261
  console.log(ui.dim(`Diretório atual: ${cwd}`));
69
- console.log(ui.shortcutsLine('shift+tab to accept edits', '? · /pro (Torne-se Pro)'));
262
+ console.log(ui.shortcutsLine(undefined, '? · /help · /pro'));
70
263
  const response = await prompts({
71
264
  type: 'text',
72
265
  name: 'input',
@@ -82,6 +275,55 @@ export async function startChatLoop(modelConfig) {
82
275
  }
83
276
  const trimmed = userInput.trim();
84
277
  const low = trimmed.toLowerCase();
278
+ if (low === '/help' || low === '/?' || low === 'help') {
279
+ printHelp();
280
+ continue;
281
+ }
282
+ if (low === '/clear') {
283
+ console.clear();
284
+ continue;
285
+ }
286
+ if (low === '/status') {
287
+ console.log(ui.statusBar({ cwd: process.cwd(), model: `/model ${activeModel.provider} (${activeModel.id})` }));
288
+ continue;
289
+ }
290
+ if (low.startsWith('/chat') || low.startsWith('/resume')) {
291
+ await handleChatCommand(trimmed.replace(/^\/resume/i, '/chat'));
292
+ continue;
293
+ }
294
+ if (low === '/copy') {
295
+ try {
296
+ if (!lastAssistantText) {
297
+ console.log(ui.warn('Nada para copiar ainda.'));
298
+ continue;
299
+ }
300
+ // Sem interpolar conteúdo no comando (evita quebra por aspas/newlines).
301
+ // Escreve para um arquivo temporário e copia via Get-Content | clip (Windows).
302
+ const tmp = `.pokt_copy_${Date.now()}.txt`;
303
+ await executeTool('write_file', JSON.stringify({ path: tmp, content: lastAssistantText }));
304
+ await executeTool('run_command', JSON.stringify({ command: `powershell -NoProfile -Command "Get-Content -Raw '${tmp}' | clip"` }));
305
+ // best-effort cleanup
306
+ try {
307
+ await executeTool('delete_file', JSON.stringify({ path: tmp }));
308
+ }
309
+ catch {
310
+ // ignore
311
+ }
312
+ console.log(ui.success('Copiado para a área de transferência.'));
313
+ }
314
+ catch {
315
+ console.log(ui.warn('Falha ao copiar. Se estiver no Windows, verifique se o comando "clip" está disponível.'));
316
+ }
317
+ continue;
318
+ }
319
+ if (low === '/model') {
320
+ await switchModelFlow('model');
321
+ continue;
322
+ }
323
+ if (low === '/provider') {
324
+ await switchModelFlow('provider');
325
+ continue;
326
+ }
85
327
  if (low === '/pro' || low === '/torne-se-pro' || low === 'torne-se pro') {
86
328
  runProFlow();
87
329
  continue;
@@ -91,10 +333,12 @@ export async function startChatLoop(modelConfig) {
91
333
  Atalhos:
92
334
  ${ui.accent('/pro')} ou ${ui.accent('/torne-se-pro')} — abrir Pokt Pro no navegador (pagamento + chave)
93
335
  exit, ${ui.accent('/quit')} — sair do chat
336
+ ${ui.accent('/help')} — ver comandos do chat
94
337
  `));
95
338
  continue;
96
339
  }
97
340
  messages.push({ role: 'user', content: userInput });
341
+ saveAuto(messages);
98
342
  // Primeiro o modelo vê o pedido; depois carregamos a estrutura do projeto para ele entender e então criar/editar
99
343
  const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 1;
100
344
  if (isFirstUserMessage) {
@@ -103,25 +347,110 @@ Atalhos:
103
347
  loadSpinner.stop();
104
348
  messages.push({ role: 'system', content: `Current Project Structure:\n${projectStructure}` });
105
349
  }
106
- await processLLMResponse(client, modelConfig.id, messages, allTools);
350
+ await processLLMResponse(client, activeModel.id, messages, toolsForApi);
351
+ // Atualiza auto-save após resposta
352
+ saveAuto(messages);
353
+ // Captura última resposta do assistente para /copy (melhor esforço)
354
+ for (let i = messages.length - 1; i >= 0; i--) {
355
+ const m = messages[i];
356
+ if (m?.role === 'assistant') {
357
+ const c = m.content;
358
+ lastAssistantText = typeof c === 'string' ? c : Array.isArray(c) ? c.map((p) => (p && p.text ? p.text : String(p))).join('') : String(c ?? '');
359
+ break;
360
+ }
361
+ }
107
362
  }
108
363
  await disconnectAllMcp();
109
364
  }
110
- const MAX_429_RETRIES = 3;
111
- const BASE_429_DELAY_MS = 5000;
365
+ const MAX_RETRIES = 4;
366
+ const BASE_RETRY_DELAY_MS = 1500;
367
+ const MAX_RETRY_DELAY_MS = 15000;
368
+ function sleep(ms) {
369
+ return new Promise((r) => setTimeout(r, ms));
370
+ }
371
+ function getStatusCode(err) {
372
+ const s = err?.status ?? err?.response?.status;
373
+ return typeof s === 'number' ? s : null;
374
+ }
375
+ function isRetryable(err) {
376
+ const status = getStatusCode(err);
377
+ if (status === 429 || status === 408)
378
+ return true;
379
+ if (status && status >= 500 && status <= 599)
380
+ return true;
381
+ const msg = String(err?.message ?? '');
382
+ // erros comuns de rede/timeout
383
+ return /(ETIMEDOUT|ECONNRESET|EAI_AGAIN|ENOTFOUND|fetch failed|network|timeout)/i.test(msg);
384
+ }
385
+ function computeBackoff(attempt) {
386
+ const exp = Math.min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS * Math.pow(2, attempt));
387
+ const jitter = Math.floor(Math.random() * 250);
388
+ return exp + jitter;
389
+ }
112
390
  /** Extensões que consideramos como arquivos de código para aplicar fallback */
113
391
  const CODE_EXT = /\.(py|js|ts|tsx|jsx|html|css|json|md|txt|java|go|rs|c|cpp|rb|php)$/i;
392
+ /** Blocos shell/console: nunca viram arquivo no fallback (evita SQL/comandos fictícios em mcp.json). */
393
+ function isShellLikeBlock(lang) {
394
+ return /^(bash|sh|shell|zsh|powershell|ps1|cmd|console)$/i.test(lang);
395
+ }
396
+ /**
397
+ * Caminhos que o fallback nunca deve sobrescrever (config MCP, env, lockfile).
398
+ */
399
+ const FALLBACK_PATH_BLOCKLIST = /(^|\/|\\)(mcp\.json|\.env(\.[a-zA-Z0-9_-]+)?|package-lock\.json)$/i;
400
+ function isFallbackPathBlocked(relPath) {
401
+ const norm = relPath.replace(/\\/g, '/');
402
+ const base = norm.split('/').pop() ?? norm;
403
+ return FALLBACK_PATH_BLOCKLIST.test(norm) || FALLBACK_PATH_BLOCKLIST.test(base);
404
+ }
405
+ /** Evita gravar `generated.*` com JSON de resposta MCP/SQL (eco do modelo). */
406
+ function looksLikeMcpOrSqlResultSnippet(code) {
407
+ const t = code.trim();
408
+ if (!t)
409
+ return false;
410
+ if (/MCP error|"invalid_type"|Input validation error/i.test(t))
411
+ return true;
412
+ try {
413
+ const j = JSON.parse(t);
414
+ if (j !== null && typeof j === 'object' && !Array.isArray(j)) {
415
+ const o = j;
416
+ if ('success' in o && 'result' in o)
417
+ return true;
418
+ if (typeof o.error === 'string' && /mcp|tool|validation|invalid/i.test(o.error))
419
+ return true;
420
+ }
421
+ if (Array.isArray(j) && j.length > 0 && j.length <= 200) {
422
+ const first = j[0];
423
+ if (first && typeof first === 'object' && !Array.isArray(first)) {
424
+ const row = first;
425
+ const keys = Object.keys(row);
426
+ if (keys.length === 0)
427
+ return false;
428
+ if (!keys.every((k) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k)))
429
+ return false;
430
+ const vals = Object.values(row);
431
+ if (vals.every((v) => v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
432
+ return true;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ catch {
438
+ // não é JSON — não marcar
439
+ }
440
+ return false;
441
+ }
442
+ function toolsVerbose() {
443
+ return process.env.POKT_VERBOSE_TOOLS === '1' || process.env.POKT_VERBOSE === '1';
444
+ }
445
+ /** Linhas estilo "mcp_Server_tool ..." não são arquivos — são invocações inventadas. */
446
+ function looksLikeFakeMcpInvocation(code) {
447
+ return /^\s*mcp_[A-Za-z0-9_.-]+\s+/m.test(code.trim());
448
+ }
114
449
  /**
115
450
  * Quando a API não retorna tool_calls, alguns backends só devolvem texto.
116
451
  * Extrai blocos de código da resposta (```lang\n...\n```) e, se encontrar
117
452
  * um nome de arquivo mencionado antes do bloco, aplica write_file.
118
453
  */
119
- /** Blocos de comando "como rodar" (bash/sh de 1–2 linhas) não viram arquivo para não poluir. */
120
- function isRunCommandOnly(lang, code) {
121
- const shellLike = /^(bash|sh|shell|zsh)$/i.test(lang);
122
- const lines = code.split('\n').filter((l) => l.trim().length > 0);
123
- return shellLike && lines.length <= 2;
124
- }
125
454
  /** Remove markdown/formatting do nome de arquivo (ex: **hello.py** → hello.py). */
126
455
  function cleanFilename(candidate) {
127
456
  return candidate.replace(/^[\s*`'"]+/g, '').replace(/[\s*`'")\]\s]+$/g, '').trim();
@@ -157,7 +486,9 @@ async function applyCodeBlocksFromContent(content) {
157
486
  });
158
487
  }
159
488
  for (const { fullMatch, index, lang, code } of matches) {
160
- if (isRunCommandOnly(lang, code))
489
+ if (isShellLikeBlock(lang))
490
+ continue;
491
+ if (looksLikeFakeMcpInvocation(code))
161
492
  continue;
162
493
  const beforeBlock = content.substring(0, index);
163
494
  // Nome de arquivo: aceita "**hello.py**", "hello.py" antes de espaço/newline/backtick, etc.
@@ -165,9 +496,21 @@ async function applyCodeBlocksFromContent(content) {
165
496
  const rawCandidate = fileMatch ? fileMatch[fileMatch.length - 1].trim() : null;
166
497
  const candidate = rawCandidate ? cleanFilename(rawCandidate) : null;
167
498
  const path = candidate && CODE_EXT.test(candidate) ? candidate : (lang === 'python' ? 'generated.py' : lang ? `generated.${lang}` : null);
499
+ if (path && isFallbackPathBlocked(path))
500
+ continue;
501
+ if (path &&
502
+ /^generated\./i.test(path) &&
503
+ looksLikeMcpOrSqlResultSnippet(code)) {
504
+ continue;
505
+ }
168
506
  if (path && code) {
169
507
  try {
170
- console.log(ui.warn(`\n[Fallback] Aplicando código da resposta ao arquivo: ${path}`));
508
+ if (toolsVerbose()) {
509
+ console.log(ui.warn(`\n[Fallback] Aplicando código da resposta ao arquivo: ${path}`));
510
+ }
511
+ else {
512
+ console.log(ui.dim(`\n[Fallback] ${path}`));
513
+ }
171
514
  await executeTool('write_file', JSON.stringify({ path, content: code }));
172
515
  applied = true;
173
516
  appliedBlocks.push({ start: index, end: index + fullMatch.length, path });
@@ -186,23 +529,23 @@ async function applyCodeBlocksFromContent(content) {
186
529
  }
187
530
  return { applied, displayContent };
188
531
  }
189
- async function createCompletionWithRetry(client, modelId, messages, toolsList) {
532
+ async function createCompletionWithRetry(client, modelId, messages, toolsList, toolChoice = 'auto') {
190
533
  let lastError;
191
- for (let attempt = 0; attempt <= MAX_429_RETRIES; attempt++) {
534
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
192
535
  try {
193
536
  return await client.chat.completions.create({
194
537
  model: modelId,
195
538
  messages,
196
539
  tools: toolsList,
197
- tool_choice: 'auto'
540
+ tool_choice: toolChoice,
198
541
  });
199
542
  }
200
543
  catch (err) {
201
544
  lastError = err;
202
- const is429 = err?.status === 429 || (err?.message && String(err.message).includes('429'));
203
- if (is429 && attempt < MAX_429_RETRIES) {
204
- const delayMs = BASE_429_DELAY_MS * (attempt + 1);
205
- await new Promise(r => setTimeout(r, delayMs));
545
+ const retryable = isRetryable(err);
546
+ if (retryable && attempt < MAX_RETRIES) {
547
+ const delayMs = computeBackoff(attempt);
548
+ await sleep(delayMs);
206
549
  continue;
207
550
  }
208
551
  throw err;
@@ -210,42 +553,144 @@ async function createCompletionWithRetry(client, modelId, messages, toolsList) {
210
553
  }
211
554
  throw lastError;
212
555
  }
556
+ /**
557
+ * Executa todas as rodadas de tool_calls até o modelo devolver mensagem sem ferramentas.
558
+ */
559
+ async function drainToolCalls(client, modelId, messages, toolsList, startMessage, spinner) {
560
+ let message = startMessage;
561
+ let writeFileExecuted = false;
562
+ let anyToolExecuted = false;
563
+ let mcpToolExecuted = false;
564
+ const verbose = toolsVerbose();
565
+ while (message.tool_calls && message.tool_calls.length > 0) {
566
+ messages.push(message);
567
+ for (const toolCall of message.tool_calls) {
568
+ anyToolExecuted = true;
569
+ const name = toolCall.function.name;
570
+ if (name === 'write_file')
571
+ writeFileExecuted = true;
572
+ if (isMcpTool(name))
573
+ mcpToolExecuted = true;
574
+ const args = toolCall.function.arguments ?? '{}';
575
+ const isMcp = isMcpTool(name);
576
+ if (isMcp && !verbose) {
577
+ console.log(ui.dim(`[MCP] ${name}…`));
578
+ }
579
+ else {
580
+ console.log(ui.warn(`\n[Executing Tool: ${name}]`));
581
+ if (verbose || !isMcp)
582
+ console.log(ui.dim(`Arguments: ${args}`));
583
+ }
584
+ const toolSpinner = ora('Running tool...').start();
585
+ const result = isMcp ? await callMcpTool(name, args) : await executeTool(name, args);
586
+ toolSpinner.stop();
587
+ if (verbose) {
588
+ console.log(ui.dim(`Result: ${result.length} characters`));
589
+ }
590
+ else if (isMcp) {
591
+ console.log(ui.dim(`[MCP] ${name} ✓ (${result.length} chars)`));
592
+ }
593
+ else {
594
+ console.log(ui.dim(`Result: ${result.length} characters`));
595
+ }
596
+ messages.push({
597
+ role: 'tool',
598
+ tool_call_id: toolCall.id,
599
+ content: result,
600
+ });
601
+ }
602
+ spinner.start('Thinking...');
603
+ const completion = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto');
604
+ spinner.stop();
605
+ message = completion.choices[0].message;
606
+ }
607
+ return { message, writeFileExecuted, anyToolExecuted, mcpToolExecuted };
608
+ }
213
609
  async function processLLMResponse(client, modelId, messages, toolsList) {
214
610
  const spinner = ora('Thinking...').start();
215
611
  try {
216
612
  let completion = await createCompletionWithRetry(client, modelId, messages, toolsList);
217
613
  let message = completion.choices[0].message;
218
614
  spinner.stop();
219
- let writeFileExecutedThisTurn = false;
220
- while (message.tool_calls && message.tool_calls.length > 0) {
221
- messages.push(message);
222
- for (const toolCall of message.tool_calls) {
223
- const name = toolCall.function.name;
224
- if (name === 'write_file')
225
- writeFileExecutedThisTurn = true;
226
- const args = toolCall.function.arguments ?? '{}';
227
- console.log(ui.warn(`\n[Executing Tool: ${name}]`));
228
- console.log(ui.dim(`Arguments: ${args}`));
229
- const toolSpinner = ora('Running tool...').start();
230
- const result = isMcpTool(name)
231
- ? await callMcpTool(name, args)
232
- : await executeTool(name, args);
233
- toolSpinner.stop();
234
- console.log(ui.dim(`Result: ${result.length} characters`));
235
- messages.push({
236
- role: 'tool',
237
- tool_call_id: toolCall.id,
238
- content: result,
239
- });
615
+ const drained = await drainToolCalls(client, modelId, messages, toolsList, message, spinner);
616
+ message = drained.message;
617
+ let writeFileExecutedThisTurn = drained.writeFileExecuted;
618
+ let anyToolExecutedThisTurn = drained.anyToolExecuted;
619
+ let mcpToolExecutedThisTurn = drained.mcpToolExecuted;
620
+ let rawContent = message.content;
621
+ let contentStr = messageContentToString(rawContent);
622
+ let finalContent = rawContent ?? contentStr;
623
+ if (mcpToolExecutedThisTurn) {
624
+ contentStr = stripExecutedStyleMcpBashBlocks(contentStr);
625
+ finalContent = contentStr;
626
+ }
627
+ // Modelo devolveu só tool_calls e texto vazio — evita mensagem inútil
628
+ if (!contentStr.trim() && anyToolExecutedThisTurn) {
629
+ contentStr =
630
+ '*(Ferramentas foram executadas (veja os logs `[MCP]` acima). O modelo não gerou texto final — peça um resumo com tabelas/dados se precisar.)*';
631
+ finalContent = contentStr;
632
+ }
633
+ // Só executa fallback bash se não houve MCP nativo (evita SQL duplicado e ruído).
634
+ let mcpFromText = mcpToolExecutedThisTurn
635
+ ? { invocationCount: 0, executedCount: 0, augmentedAssistantText: contentStr }
636
+ : await runMcpFromBashMarkdown(contentStr, {
637
+ skipDuplicateAppendix: /\n##\s+Resultados\s+MCP\b/i.test(contentStr),
638
+ });
639
+ if (mcpFromText.invocationCount > 0) {
640
+ contentStr = mcpFromText.augmentedAssistantText;
641
+ finalContent = mcpFromText.augmentedAssistantText;
642
+ }
643
+ // Resposta completamente vazia: recuperação (tool_choice required) + dreno + MCP em texto + SQL automático
644
+ if (!contentStr.trim() && toolsList.length > 0) {
645
+ messages.push({
646
+ role: 'system',
647
+ content: '[Pokt — recuperação] A última resposta veio vazia (sem texto útil). Responda em português. ' +
648
+ 'Use tool_calls: para listar bancos PostgreSQL use mcp_*_run_sql com {"sql":"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1;"}. ' +
649
+ 'Alternativa: run_command com script executável (node, python, npx, psql). Nunca devolva corpo vazio.',
650
+ });
651
+ spinner.start('Recuperando resposta vazia…');
652
+ let recovery;
653
+ try {
654
+ recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'required');
655
+ }
656
+ catch {
657
+ recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto');
240
658
  }
241
- spinner.start('Thinking...');
242
- completion = await createCompletionWithRetry(client, modelId, messages, toolsList);
243
- message = completion.choices[0].message;
244
659
  spinner.stop();
660
+ const drained2 = await drainToolCalls(client, modelId, messages, toolsList, recovery.choices[0].message, spinner);
661
+ message = drained2.message;
662
+ writeFileExecutedThisTurn = writeFileExecutedThisTurn || drained2.writeFileExecuted;
663
+ anyToolExecutedThisTurn = anyToolExecutedThisTurn || drained2.anyToolExecuted;
664
+ mcpToolExecutedThisTurn = mcpToolExecutedThisTurn || drained2.mcpToolExecuted;
665
+ rawContent = message.content;
666
+ contentStr = messageContentToString(rawContent);
667
+ finalContent = rawContent ?? contentStr;
668
+ if (mcpToolExecutedThisTurn) {
669
+ contentStr = stripExecutedStyleMcpBashBlocks(contentStr);
670
+ finalContent = contentStr;
671
+ }
672
+ if (!contentStr.trim() && anyToolExecutedThisTurn) {
673
+ contentStr =
674
+ '*(Ferramentas executadas no terminal acima; o modelo ainda não resumiu em texto — diga “resuma” se quiser.)*';
675
+ finalContent = contentStr;
676
+ }
677
+ mcpFromText = mcpToolExecutedThisTurn
678
+ ? { invocationCount: 0, executedCount: 0, augmentedAssistantText: contentStr }
679
+ : await runMcpFromBashMarkdown(contentStr, {
680
+ skipDuplicateAppendix: /\n##\s+Resultados\s+MCP\b/i.test(contentStr),
681
+ });
682
+ if (mcpFromText.invocationCount > 0) {
683
+ contentStr = mcpFromText.augmentedAssistantText;
684
+ finalContent = mcpFromText.augmentedAssistantText;
685
+ }
686
+ }
687
+ if (!contentStr.trim()) {
688
+ const autoDb = await tryAutoMcpForListDatabases(messages);
689
+ if (autoDb) {
690
+ contentStr = autoDb;
691
+ finalContent = autoDb;
692
+ }
245
693
  }
246
- const rawContent = message.content;
247
- let contentStr = messageContentToString(rawContent);
248
- let finalContent = rawContent ?? contentStr;
249
694
  // Quando a API não executa tools, tentar aplicar blocos de código da resposta
250
695
  if (!writeFileExecutedThisTurn) {
251
696
  let result = await applyCodeBlocksFromContent(contentStr);
@@ -254,7 +699,7 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
254
699
  || (/call\s+(read_file|write_file)/i.test(contentStr) && contentStr.length < 400);
255
700
  if (!result.applied && looksLikeToolIntentOnly) {
256
701
  messages.push({ role: 'assistant', content: rawContent ?? contentStr });
257
- const followUpSystem = `This API does not support tool calls. You must NOT reply with "We will call X". Output the complete file content in a markdown code block so the user's CLI can create/edit the file. Format: mention the filename (e.g. hello.py) then newline then \`\`\`python then newline then the FULL file content then \`\`\`. Do that now for the user's last request.`;
702
+ const followUpSystem = `You replied as if tools would run in text only. Use tool_calls for read_file/write_file/run_command/mcp_* when possible. If you must output a file as markdown only: mention the filename then a full \`\`\`lang\`\`\` block never use fake shell lines like mcp_Foo_bar. Do that now for the user's last request.`;
258
703
  messages.push({ role: 'system', content: followUpSystem });
259
704
  spinner.start('Getting code...');
260
705
  const followUp = await createCompletionWithRetry(client, modelId, messages, toolsList);
@@ -281,7 +726,7 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
281
726
  }
282
727
  else {
283
728
  console.log('\n' + ui.labelPokt());
284
- console.log(ui.dim('(A IA não retornou código utilizável. Tente reformular o pedido.)'));
729
+ console.log(ui.dim('(Sem resposta da IA após recuperação. Tente: outro modelo em /model, ou peça explicitamente “chame mcp_Neon_run_sql com SELECT datname FROM pg_database…”, ou use run_command com psql/node.)'));
285
730
  messages.push({ role: 'assistant', content: '' });
286
731
  }
287
732
  }
@@ -300,10 +745,22 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
300
745
  }
301
746
  catch (error) {
302
747
  spinner.stop();
303
- const is429 = error?.status === 429 || (error?.message && String(error.message).includes('429'));
304
- if (is429) {
305
- console.log(ui.error('\nLimite de taxa atingido (429). O provedor está recebendo muitas requisições.'));
306
- console.log(ui.dim('Aguarde alguns segundos e tente novamente.'));
748
+ const status = getStatusCode(error);
749
+ if (status === 429) {
750
+ console.log(ui.error('\nLimite de taxa (429). O provedor está te limitando por volume ou quota.'));
751
+ console.log(ui.dim('Dica: aguarde um pouco e tente novamente; se persistir, troque o provider/model ou verifique sua quota.'));
752
+ }
753
+ else if (status === 401 || status === 403) {
754
+ console.log(ui.error(`\nNão autorizado (${status}). Sua chave/token pode estar inválida ou sem permissão.`));
755
+ console.log(ui.dim('Dica: rode "pokt doctor" e confira suas variáveis de ambiente / pokt config show.'));
756
+ }
757
+ else if (status && status >= 500 && status <= 599) {
758
+ console.log(ui.error(`\nFalha no servidor (${status}). O provedor está instável no momento.`));
759
+ console.log(ui.dim('Dica: tente novamente em alguns segundos ou troque de provider.'));
760
+ }
761
+ else if (status === 413 || /413|Payload Too Large/i.test(String(error?.message ?? ''))) {
762
+ console.log(ui.error('\nCorpo da requisição muito grande (413 Payload Too Large).'));
763
+ console.log(ui.dim('Dica: use /chat save e /clear ou comece sessão nova; histórico + ferramentas MCP (Neon) estouram o limite do servidor. O Pokt já reduz schemas ao usar o Controller — atualize o Controller (limite JSON) e o CLI.'));
307
764
  }
308
765
  else {
309
766
  console.log(ui.error(`\nError: ${error?.message ?? error}`));