pokt-cli 1.0.10 → 1.0.12
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 +300 -45
- package/dist/chat/telemetry.d.ts +18 -0
- package/dist/chat/telemetry.js +62 -0
- package/dist/chat/tools.js +163 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +27 -0
- package/dist/mcp/client.js +3 -3
- package/package.json +1 -1
package/dist/chat/loop.js
CHANGED
|
@@ -11,6 +11,7 @@ import { connectMcpServer, getAllMcpToolsOpenAI, callMcpTool, isMcpTool, disconn
|
|
|
11
11
|
import { getMergedMcpServers } from '../mcp/project-mcp.js';
|
|
12
12
|
import { runMcpFromBashMarkdown, stripExecutedStyleMcpBashBlocks, tryAutoMcpForListDatabases, } from './mcp-from-text.js';
|
|
13
13
|
import { slimToolsForUpstreamPayload } from './slim-tools.js';
|
|
14
|
+
import { emptyUsageAccumulator, mergeCompletionUsage, sendCliUsageTelemetryFireAndForget, } from './telemetry.js';
|
|
14
15
|
/** Base do system prompt; a lista de ferramentas MCP é anexada em runtime quando houver servidores. */
|
|
15
16
|
const SYSTEM_PROMPT_BASE = `You are Pokt CLI, an elite AI Software Engineer.
|
|
16
17
|
Your goal is to help the user build, fix, and maintain software projects with high quality.
|
|
@@ -21,7 +22,8 @@ CORE CAPABILITIES:
|
|
|
21
22
|
3. **Problem Solving**: You analyze errors and propose/apply fixes.
|
|
22
23
|
|
|
23
24
|
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
|
+
- This chat uses OpenAI-style **tool_calls**. You MUST use the provided functions for actions: \`read_file\`, \`search_replace\`, \`write_file\`, \`run_command\`, \`list_files\`, \`delete_file\`, \`delete_directory\`, etc., and any tool whose name starts with \`mcp_\`.
|
|
26
|
+
- **Edits vs rewrites**: For modifying existing files, prefer \`search_replace\` (old_string, new_string, path) — targeted, minimal changes. Use \`write_file\` only for new files or full rewrites. Always call \`read_file\` first to get exact content before \`search_replace\`.
|
|
25
27
|
- **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
28
|
- 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
29
|
- **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;\`.
|
|
@@ -34,11 +36,13 @@ WHEN THE MODEL DOES NOT RETURN tool_calls (rare):
|
|
|
34
36
|
|
|
35
37
|
GUIDELINES:
|
|
36
38
|
- 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.
|
|
39
|
+
- **Session file focus**: User messages may include a \`[Pokt]\` line with the last path used via \`read_file\` / \`write_file\`. If the user asks to modify, migrate, or rewrite "the" script/app without naming a file, call \`read_file\` on that path and then \`write_file\` to the **same** path. Do **not** switch to \`main.py\` or another new name unless the user explicitly asks.
|
|
37
40
|
- When asked to fix something, first **read** the relevant files to understand the context.
|
|
38
41
|
- When creating a project, start by planning the structure, then use \`write_file\` (tool call) to create each file.
|
|
39
42
|
- 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
43
|
- **MCP tools**: Tools named \`mcp_<ServerName>_<toolName>\` connect to external services. Prefer them when they match the task.
|
|
41
44
|
- **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.
|
|
45
|
+
- **Next.js app/pages conflict**: When build fails with "Conflicting app and page file" (pages/index.js vs app/page.tsx), resolve by calling \`delete_directory("app")\` to remove the app folder and keep only pages. Prefer tool_calls over shell blocks. On Windows, \`delete_directory\` is cross-platform and does not require rmdir/cmd.
|
|
42
46
|
- **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.
|
|
43
47
|
- Be extremely concise in your explanations.
|
|
44
48
|
- The current working directory is: ${process.cwd()}
|
|
@@ -69,6 +73,45 @@ async function loadProjectStructure() {
|
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
}
|
|
76
|
+
/** Detecta se o usuário está pedindo modificação/migração em projeto existente (como gemini-cli faz). */
|
|
77
|
+
function suggestsProjectModification(userInput) {
|
|
78
|
+
const lower = userInput.toLowerCase().trim();
|
|
79
|
+
const modificationKeywords = [
|
|
80
|
+
'mudar', 'alterar', 'mudança', 'alteração', 'migrar', 'migração', 'trocar', 'converter',
|
|
81
|
+
'converta', 'modificar', 'refatorar', 'atualizar', 'substituir', 'troque', 'mude',
|
|
82
|
+
'change', 'migrate', 'convert', 'modify', 'refactor', 'update', 'replace', 'switch',
|
|
83
|
+
];
|
|
84
|
+
return modificationKeywords.some((kw) => lower.includes(kw));
|
|
85
|
+
}
|
|
86
|
+
/** Extrai arquivos .py da estrutura do projeto (list_files retorna paths separados por newline). */
|
|
87
|
+
function extractPyFilesFromStructure(structure) {
|
|
88
|
+
const lines = structure.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
89
|
+
return lines.filter((p) => /\.py$/i.test(p) && !p.includes('node_modules') && !p.includes('.git'));
|
|
90
|
+
}
|
|
91
|
+
/** Infere o arquivo alvo para edição quando usuário pede modificar "calculadora", "app", etc. */
|
|
92
|
+
function inferTargetFileFromProject(projectStructure, userInput) {
|
|
93
|
+
const pyFiles = extractPyFilesFromStructure(projectStructure);
|
|
94
|
+
if (pyFiles.length === 0)
|
|
95
|
+
return null;
|
|
96
|
+
const lower = userInput.toLowerCase();
|
|
97
|
+
// Palavras-chave que podem indicar o nome do arquivo
|
|
98
|
+
const keywords = ['calculadora', 'calculator', 'app', 'aplicativo', 'script', 'arquivo', 'projeto'];
|
|
99
|
+
for (const kw of keywords) {
|
|
100
|
+
if (lower.includes(kw)) {
|
|
101
|
+
const stem = kw.replace(/a$/, ''); // calculadora -> calculador
|
|
102
|
+
const match = pyFiles.find((f) => {
|
|
103
|
+
const base = f.replace(/\\/g, '/').split('/').pop()?.replace(/\.py$/, '') ?? '';
|
|
104
|
+
return base.toLowerCase().includes(kw) || base.toLowerCase().includes(stem);
|
|
105
|
+
});
|
|
106
|
+
if (match)
|
|
107
|
+
return match;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Se só tem um .py no projeto, é provavelmente o alvo
|
|
111
|
+
if (pyFiles.length === 1)
|
|
112
|
+
return pyFiles[0];
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
72
115
|
export async function startChatLoop(modelConfig) {
|
|
73
116
|
let activeModel = modelConfig;
|
|
74
117
|
let client = await getClient(activeModel);
|
|
@@ -190,6 +233,11 @@ Comandos do chat:
|
|
|
190
233
|
`));
|
|
191
234
|
}
|
|
192
235
|
let lastAssistantText = '';
|
|
236
|
+
/** Último caminho relativo usado em read_file/write_file pelas tool_calls do assistente (foco da sessão). */
|
|
237
|
+
const sessionFileCtx = {
|
|
238
|
+
lastFocusedRelativePath: null,
|
|
239
|
+
suggestedModificationTarget: null,
|
|
240
|
+
};
|
|
193
241
|
async function handleChatCommand(raw) {
|
|
194
242
|
const parts = raw.trim().split(/\s+/);
|
|
195
243
|
const cmd = parts[0].toLowerCase();
|
|
@@ -337,17 +385,36 @@ Atalhos:
|
|
|
337
385
|
`));
|
|
338
386
|
continue;
|
|
339
387
|
}
|
|
340
|
-
|
|
388
|
+
let userContent = userInput;
|
|
389
|
+
if (sessionFileCtx.lastFocusedRelativePath) {
|
|
390
|
+
userContent = `${userInput}\n\n[Pokt] Último arquivo usado com read_file/write_file/search_replace nesta sessão: \`${sessionFileCtx.lastFocusedRelativePath}\`. Para alterar esse trabalho sem o usuário citar o nome, leia esse caminho e edite com search_replace ou write_file no mesmo caminho — não crie outro arquivo (ex.: main.py) por padrão.]`;
|
|
391
|
+
}
|
|
392
|
+
messages.push({ role: 'user', content: userContent });
|
|
341
393
|
saveAuto(messages);
|
|
342
|
-
//
|
|
394
|
+
// Injeta estrutura do projeto para o modelo entender o contexto (como gemini-cli verifica antes de editar)
|
|
395
|
+
// Sempre na 1ª mensagem; também quando pedido sugere modificação/migração em projeto existente
|
|
343
396
|
const isFirstUserMessage = messages.filter(m => m.role === 'user').length === 1;
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
const needsProjectContext = isFirstUserMessage || suggestsProjectModification(userInput);
|
|
398
|
+
if (needsProjectContext) {
|
|
399
|
+
const loadSpinner = ora('Verificando estrutura do projeto...').start();
|
|
346
400
|
const projectStructure = await loadProjectStructure();
|
|
347
401
|
loadSpinner.stop();
|
|
348
|
-
|
|
402
|
+
const targetFile = suggestsProjectModification(userInput)
|
|
403
|
+
? inferTargetFileFromProject(projectStructure, userInput)
|
|
404
|
+
: null;
|
|
405
|
+
if (targetFile) {
|
|
406
|
+
sessionFileCtx.suggestedModificationTarget = targetFile;
|
|
407
|
+
}
|
|
408
|
+
let structureHint = '[Pokt] Use list_files/read_file para confirmar arquivos existentes. Para modificar: leia com read_file, edite com search_replace ou write_file no MESMO caminho — NÃO crie main.py ou novo projeto.';
|
|
409
|
+
if (targetFile) {
|
|
410
|
+
structureHint += ` OBRIGATÓRIO: o arquivo a editar é \`${targetFile}\`. Use tool_calls em \`${targetFile}\`, NUNCA em main.py.`;
|
|
411
|
+
}
|
|
412
|
+
messages.push({
|
|
413
|
+
role: 'system',
|
|
414
|
+
content: `Current Project Structure:\n${projectStructure}\n\n${structureHint}`,
|
|
415
|
+
});
|
|
349
416
|
}
|
|
350
|
-
await processLLMResponse(client, activeModel
|
|
417
|
+
await processLLMResponse(client, activeModel, messages, toolsForApi, sessionFileCtx);
|
|
351
418
|
// Atualiza auto-save após resposta
|
|
352
419
|
saveAuto(messages);
|
|
353
420
|
// Captura última resposta do assistente para /copy (melhor esforço)
|
|
@@ -393,6 +460,61 @@ const CODE_EXT = /\.(py|js|ts|tsx|jsx|html|css|json|md|txt|java|go|rs|c|cpp|rb|p
|
|
|
393
460
|
function isShellLikeBlock(lang) {
|
|
394
461
|
return /^(bash|sh|shell|zsh|powershell|ps1|cmd|console)$/i.test(lang);
|
|
395
462
|
}
|
|
463
|
+
/** Comandos que resolvem conflito Next.js app/pages - executar via run_command quando em blocos shell. */
|
|
464
|
+
function looksLikeNextJsConflictFix(code) {
|
|
465
|
+
const t = code.trim().toLowerCase();
|
|
466
|
+
if (/mcp_[a-z0-9_-]+/i.test(t))
|
|
467
|
+
return false; // MCP já tratado separadamente
|
|
468
|
+
return ((/\brmdir\b.*\bapp\b/.test(t) || /\bremove-item\b.*\bapp\b/i.test(t) || /\brm\s+-rf?\s+app\b/.test(t)) ||
|
|
469
|
+
(/\brmdir\b.*\.next\b/.test(t) || /\bremove-item\b.*\.next\b/i.test(t) || /\brm\s+-rf?\s+\.next\b/.test(t)));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Executa blocos shell (cmd, powershell) que parecem corrigir conflito Next.js.
|
|
473
|
+
* Fallback quando a IA coloca rmdir/Remove-Item em markdown em vez de usar delete_directory.
|
|
474
|
+
*/
|
|
475
|
+
async function executeShellBlocksFromContent(content) {
|
|
476
|
+
const codeBlockRe = /```(cmd|powershell|ps1|bash|sh)\s*\n([\s\S]*?)```/gi;
|
|
477
|
+
const executedBlocks = [];
|
|
478
|
+
let executed = false;
|
|
479
|
+
let m;
|
|
480
|
+
const isWin = process.platform === 'win32';
|
|
481
|
+
while ((m = codeBlockRe.exec(content)) !== null) {
|
|
482
|
+
const lang = (m[1] || '').toLowerCase();
|
|
483
|
+
const code = (m[2] || '').replace(/\r\n/g, '\n').trim();
|
|
484
|
+
if (!code || !looksLikeNextJsConflictFix(code))
|
|
485
|
+
continue;
|
|
486
|
+
const lines = code.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
487
|
+
if (lines.length === 0)
|
|
488
|
+
continue;
|
|
489
|
+
for (const line of lines) {
|
|
490
|
+
if (/mcp_[a-z0-9_-]+/i.test(line))
|
|
491
|
+
continue;
|
|
492
|
+
const cmd = isWin && /^(bash|sh)$/.test(lang)
|
|
493
|
+
? `cmd /c ${line.replace(/rm\s+-rf?\s+(\S+)/g, 'rmdir /s /q $1')}`
|
|
494
|
+
: line;
|
|
495
|
+
try {
|
|
496
|
+
if (toolsVerbose())
|
|
497
|
+
console.log(ui.warn(`\n[Fallback] Executando: ${cmd}`));
|
|
498
|
+
else
|
|
499
|
+
console.log(ui.dim(`\n[Fallback] Executando comando: ${cmd.slice(0, 60)}${cmd.length > 60 ? '…' : ''}`));
|
|
500
|
+
await executeTool('run_command', JSON.stringify({ command: cmd }));
|
|
501
|
+
executed = true;
|
|
502
|
+
executedBlocks.push({ start: m.index, end: m.index + m[0].length, command: cmd });
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
// ignora falha
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
let displayContent = content;
|
|
511
|
+
for (let i = executedBlocks.length - 1; i >= 0; i--) {
|
|
512
|
+
const { start, end, command } = executedBlocks[i];
|
|
513
|
+
const placeholder = `\n${ui.dim('[Comando executado: ' + command.slice(0, 50) + (command.length > 50 ? '…' : '') + ']')}\n`;
|
|
514
|
+
displayContent = displayContent.substring(0, start) + placeholder + displayContent.substring(end);
|
|
515
|
+
}
|
|
516
|
+
return { executed, displayContent };
|
|
517
|
+
}
|
|
396
518
|
/**
|
|
397
519
|
* Caminhos que o fallback nunca deve sobrescrever (config MCP, env, lockfile).
|
|
398
520
|
*/
|
|
@@ -451,9 +573,12 @@ function looksLikeFakeMcpInvocation(code) {
|
|
|
451
573
|
* Extrai blocos de código da resposta (```lang\n...\n```) e, se encontrar
|
|
452
574
|
* um nome de arquivo mencionado antes do bloco, aplica write_file.
|
|
453
575
|
*/
|
|
454
|
-
/** Remove markdown/formatting do nome de arquivo (ex: **hello.py** → hello.py). */
|
|
576
|
+
/** Remove markdown/formatting do nome de arquivo (ex: **hello.py** → hello.py, (main.py → main.py). */
|
|
455
577
|
function cleanFilename(candidate) {
|
|
456
|
-
return candidate
|
|
578
|
+
return candidate
|
|
579
|
+
.replace(/^[\s*`'"(\[]+/g, '')
|
|
580
|
+
.replace(/[\s*`'")\]\s]+$/g, '')
|
|
581
|
+
.trim();
|
|
457
582
|
}
|
|
458
583
|
/**
|
|
459
584
|
* Aplica blocos de código da resposta e retorna conteúdo para exibição (sem repetir o código).
|
|
@@ -471,7 +596,70 @@ function messageContentToString(content) {
|
|
|
471
596
|
}
|
|
472
597
|
return content != null ? String(content) : '';
|
|
473
598
|
}
|
|
474
|
-
|
|
599
|
+
function getFirstChoiceMessage(completion) {
|
|
600
|
+
const msg = completion.choices?.[0]?.message;
|
|
601
|
+
return msg ?? null;
|
|
602
|
+
}
|
|
603
|
+
function trackToolPathFocus(name, argsStr, ctx) {
|
|
604
|
+
if (name !== 'read_file' && name !== 'write_file' && name !== 'search_replace')
|
|
605
|
+
return;
|
|
606
|
+
try {
|
|
607
|
+
const args = JSON.parse(argsStr);
|
|
608
|
+
if (typeof args.path === 'string' && args.path.trim()) {
|
|
609
|
+
ctx.lastFocusedRelativePath = args.path.replace(/\\/g, '/').trim();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* ignore */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Se o fallback iria gravar em generated.* mas já há um arquivo “foco” na sessão com a mesma extensão,
|
|
618
|
+
* reutiliza esse caminho (evita criar generated.py ao lado de calculadora.py).
|
|
619
|
+
*/
|
|
620
|
+
function preferSessionPathOverGenerated(resolvedPath, ctx) {
|
|
621
|
+
const sessionLast = ctx?.lastFocusedRelativePath ?? null;
|
|
622
|
+
const suggested = ctx?.suggestedModificationTarget ?? null;
|
|
623
|
+
// Quando usuário pediu modificar e há arquivo inferido, NUNCA criar main.py/generated.py
|
|
624
|
+
if (suggested && /^(main|generated)\./i.test(resolvedPath)) {
|
|
625
|
+
return suggested.replace(/\\/g, '/');
|
|
626
|
+
}
|
|
627
|
+
if (!sessionLast || !/^generated\./i.test(resolvedPath))
|
|
628
|
+
return resolvedPath;
|
|
629
|
+
const suffix = resolvedPath.slice(resolvedPath.indexOf('.') + 1).toLowerCase();
|
|
630
|
+
const normLast = sessionLast.replace(/\\/g, '/').toLowerCase();
|
|
631
|
+
const extensionsForSuffix = {
|
|
632
|
+
py: ['.py'],
|
|
633
|
+
python: ['.py'],
|
|
634
|
+
js: ['.js'],
|
|
635
|
+
javascript: ['.js'],
|
|
636
|
+
mjs: ['.mjs'],
|
|
637
|
+
cjs: ['.cjs'],
|
|
638
|
+
ts: ['.ts', '.tsx'],
|
|
639
|
+
typescript: ['.ts', '.tsx'],
|
|
640
|
+
tsx: ['.tsx'],
|
|
641
|
+
jsx: ['.jsx'],
|
|
642
|
+
java: ['.java'],
|
|
643
|
+
go: ['.go'],
|
|
644
|
+
rs: ['.rs'],
|
|
645
|
+
html: ['.html', '.htm'],
|
|
646
|
+
css: ['.css'],
|
|
647
|
+
json: ['.json'],
|
|
648
|
+
md: ['.md', '.markdown'],
|
|
649
|
+
txt: ['.txt'],
|
|
650
|
+
cpp: ['.cpp', '.cc', '.cxx'],
|
|
651
|
+
c: ['.c'],
|
|
652
|
+
rb: ['.rb'],
|
|
653
|
+
php: ['.php'],
|
|
654
|
+
};
|
|
655
|
+
const exts = extensionsForSuffix[suffix];
|
|
656
|
+
if (exts?.some((e) => normLast.endsWith(e)))
|
|
657
|
+
return sessionLast.replace(/\\/g, '/');
|
|
658
|
+
if (normLast.endsWith('.' + suffix))
|
|
659
|
+
return sessionLast.replace(/\\/g, '/');
|
|
660
|
+
return resolvedPath;
|
|
661
|
+
}
|
|
662
|
+
async function applyCodeBlocksFromContent(content, sessionFileCtx) {
|
|
475
663
|
const codeBlockRe = /```(\w*)\n([\s\S]*?)```/g;
|
|
476
664
|
const appliedBlocks = [];
|
|
477
665
|
let applied = false;
|
|
@@ -495,7 +683,10 @@ async function applyCodeBlocksFromContent(content) {
|
|
|
495
683
|
const fileMatch = beforeBlock.match(/(\S+\.(?:py|js|ts|tsx|jsx|html|css|json|md|txt|java|go|rs|c|cpp|rb|php))(?=\s|$|[:.)\]*`"])/gi);
|
|
496
684
|
const rawCandidate = fileMatch ? fileMatch[fileMatch.length - 1].trim() : null;
|
|
497
685
|
const candidate = rawCandidate ? cleanFilename(rawCandidate) : null;
|
|
498
|
-
|
|
686
|
+
let path = candidate && CODE_EXT.test(candidate) ? candidate : lang === 'python' ? 'generated.py' : lang ? `generated.${lang}` : null;
|
|
687
|
+
if (path) {
|
|
688
|
+
path = preferSessionPathOverGenerated(path, sessionFileCtx);
|
|
689
|
+
}
|
|
499
690
|
if (path && isFallbackPathBlocked(path))
|
|
500
691
|
continue;
|
|
501
692
|
if (path &&
|
|
@@ -513,6 +704,8 @@ async function applyCodeBlocksFromContent(content) {
|
|
|
513
704
|
}
|
|
514
705
|
await executeTool('write_file', JSON.stringify({ path, content: code }));
|
|
515
706
|
applied = true;
|
|
707
|
+
if (sessionFileCtx)
|
|
708
|
+
sessionFileCtx.lastFocusedRelativePath = path.replace(/\\/g, '/');
|
|
516
709
|
appliedBlocks.push({ start: index, end: index + fullMatch.length, path });
|
|
517
710
|
}
|
|
518
711
|
catch {
|
|
@@ -529,16 +722,19 @@ async function applyCodeBlocksFromContent(content) {
|
|
|
529
722
|
}
|
|
530
723
|
return { applied, displayContent };
|
|
531
724
|
}
|
|
532
|
-
async function createCompletionWithRetry(client, modelId, messages, toolsList, toolChoice = 'auto') {
|
|
725
|
+
async function createCompletionWithRetry(client, modelId, messages, toolsList, toolChoice = 'auto', usageAcc) {
|
|
533
726
|
let lastError;
|
|
534
727
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
535
728
|
try {
|
|
536
|
-
|
|
729
|
+
const out = await client.chat.completions.create({
|
|
537
730
|
model: modelId,
|
|
538
731
|
messages,
|
|
539
732
|
tools: toolsList,
|
|
540
733
|
tool_choice: toolChoice,
|
|
541
734
|
});
|
|
735
|
+
if (usageAcc)
|
|
736
|
+
mergeCompletionUsage(usageAcc, out);
|
|
737
|
+
return out;
|
|
542
738
|
}
|
|
543
739
|
catch (err) {
|
|
544
740
|
lastError = err;
|
|
@@ -556,7 +752,7 @@ async function createCompletionWithRetry(client, modelId, messages, toolsList, t
|
|
|
556
752
|
/**
|
|
557
753
|
* Executa todas as rodadas de tool_calls até o modelo devolver mensagem sem ferramentas.
|
|
558
754
|
*/
|
|
559
|
-
async function drainToolCalls(client, modelId, messages, toolsList, startMessage, spinner) {
|
|
755
|
+
async function drainToolCalls(client, modelId, messages, toolsList, startMessage, spinner, sessionFileCtx, usageAcc) {
|
|
560
756
|
let message = startMessage;
|
|
561
757
|
let writeFileExecuted = false;
|
|
562
758
|
let anyToolExecuted = false;
|
|
@@ -567,11 +763,12 @@ async function drainToolCalls(client, modelId, messages, toolsList, startMessage
|
|
|
567
763
|
for (const toolCall of message.tool_calls) {
|
|
568
764
|
anyToolExecuted = true;
|
|
569
765
|
const name = toolCall.function.name;
|
|
570
|
-
if (name === 'write_file')
|
|
766
|
+
if (name === 'write_file' || name === 'search_replace')
|
|
571
767
|
writeFileExecuted = true;
|
|
572
768
|
if (isMcpTool(name))
|
|
573
769
|
mcpToolExecuted = true;
|
|
574
770
|
const args = toolCall.function.arguments ?? '{}';
|
|
771
|
+
trackToolPathFocus(name, args, sessionFileCtx);
|
|
575
772
|
const isMcp = isMcpTool(name);
|
|
576
773
|
if (isMcp && !verbose) {
|
|
577
774
|
console.log(ui.dim(`[MCP] ${name}…`));
|
|
@@ -600,19 +797,46 @@ async function drainToolCalls(client, modelId, messages, toolsList, startMessage
|
|
|
600
797
|
});
|
|
601
798
|
}
|
|
602
799
|
spinner.start('Thinking...');
|
|
603
|
-
const completion = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto');
|
|
800
|
+
const completion = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto', usageAcc);
|
|
604
801
|
spinner.stop();
|
|
605
|
-
|
|
802
|
+
const nextMsg = getFirstChoiceMessage(completion);
|
|
803
|
+
if (!nextMsg) {
|
|
804
|
+
throw new Error('Resposta da API sem choices após execução de ferramentas.');
|
|
805
|
+
}
|
|
806
|
+
message = nextMsg;
|
|
606
807
|
}
|
|
607
808
|
return { message, writeFileExecuted, anyToolExecuted, mcpToolExecuted };
|
|
608
809
|
}
|
|
609
|
-
|
|
810
|
+
function emitCliTelemetryForTurn(modelConfig, acc) {
|
|
811
|
+
const total = acc.prompt + acc.completion;
|
|
812
|
+
if (total <= 0)
|
|
813
|
+
return;
|
|
814
|
+
sendCliUsageTelemetryFireAndForget({
|
|
815
|
+
provider: modelConfig.provider,
|
|
816
|
+
model: modelConfig.id,
|
|
817
|
+
promptTokens: acc.prompt,
|
|
818
|
+
completionTokens: acc.completion,
|
|
819
|
+
totalTokens: total,
|
|
820
|
+
cost: acc.cost,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
async function processLLMResponse(client, modelConfig, messages, toolsList, sessionFileCtx) {
|
|
610
824
|
const spinner = ora('Thinking...').start();
|
|
825
|
+
const modelId = modelConfig.id;
|
|
826
|
+
const usageAcc = emptyUsageAccumulator();
|
|
611
827
|
try {
|
|
612
|
-
let completion = await createCompletionWithRetry(client, modelId, messages, toolsList);
|
|
613
|
-
|
|
828
|
+
let completion = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto', usageAcc);
|
|
829
|
+
const first = getFirstChoiceMessage(completion);
|
|
830
|
+
if (!first) {
|
|
831
|
+
spinner.stop();
|
|
832
|
+
console.log(ui.error('\nResposta da API sem choices/mensagem. Tente novamente ou outro modelo.'));
|
|
833
|
+
messages.pop();
|
|
834
|
+
emitCliTelemetryForTurn(modelConfig, usageAcc);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
let message = first;
|
|
614
838
|
spinner.stop();
|
|
615
|
-
const drained = await drainToolCalls(client, modelId, messages, toolsList, message, spinner);
|
|
839
|
+
const drained = await drainToolCalls(client, modelId, messages, toolsList, message, spinner, sessionFileCtx, usageAcc);
|
|
616
840
|
message = drained.message;
|
|
617
841
|
let writeFileExecutedThisTurn = drained.writeFileExecuted;
|
|
618
842
|
let anyToolExecutedThisTurn = drained.anyToolExecuted;
|
|
@@ -640,24 +864,37 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
|
|
|
640
864
|
contentStr = mcpFromText.augmentedAssistantText;
|
|
641
865
|
finalContent = mcpFromText.augmentedAssistantText;
|
|
642
866
|
}
|
|
643
|
-
// Resposta completamente vazia: recuperação (tool_choice required)
|
|
867
|
+
// Resposta completamente vazia: recuperação (tool_choice required)
|
|
644
868
|
if (!contentStr.trim() && toolsList.length > 0) {
|
|
645
|
-
messages.
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
869
|
+
const lastUserMsg = [...messages].reverse().find((m) => m.role === 'user');
|
|
870
|
+
const lastUserContent = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
|
871
|
+
const isModificationRequest = suggestsProjectModification(lastUserContent);
|
|
872
|
+
let recoveryHint = '[Pokt — recuperação] A última resposta veio vazia. Responda em português. Use tool_calls. Nunca devolva corpo vazio. ';
|
|
873
|
+
if (isModificationRequest) {
|
|
874
|
+
recoveryHint +=
|
|
875
|
+
'O usuário pediu modificação em projeto existente: chame list_files para ver arquivos, read_file no arquivo relevante, depois search_replace ou write_file no MESMO caminho. NÃO crie projeto novo. Para conflito Next.js app/pages: delete_directory("app"). ';
|
|
876
|
+
}
|
|
877
|
+
recoveryHint +=
|
|
878
|
+
'Para bancos PostgreSQL: mcp_*_run_sql com {"sql":"SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY 1;"}. ' +
|
|
879
|
+
'Alternativa: run_command com script (node, python, npx, psql).';
|
|
880
|
+
messages.push({ role: 'system', content: recoveryHint });
|
|
651
881
|
spinner.start('Recuperando resposta vazia…');
|
|
652
882
|
let recovery;
|
|
653
883
|
try {
|
|
654
|
-
recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'required');
|
|
884
|
+
recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'required', usageAcc);
|
|
655
885
|
}
|
|
656
886
|
catch {
|
|
657
|
-
recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto');
|
|
887
|
+
recovery = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto', usageAcc);
|
|
658
888
|
}
|
|
659
889
|
spinner.stop();
|
|
660
|
-
const
|
|
890
|
+
const recoveryFirst = getFirstChoiceMessage(recovery);
|
|
891
|
+
if (!recoveryFirst) {
|
|
892
|
+
console.log(ui.error('\nResposta da API sem choices na recuperação. Tente outro modelo.'));
|
|
893
|
+
messages.pop();
|
|
894
|
+
emitCliTelemetryForTurn(modelConfig, usageAcc);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const drained2 = await drainToolCalls(client, modelId, messages, toolsList, recoveryFirst, spinner, sessionFileCtx, usageAcc);
|
|
661
898
|
message = drained2.message;
|
|
662
899
|
writeFileExecutedThisTurn = writeFileExecutedThisTurn || drained2.writeFileExecuted;
|
|
663
900
|
anyToolExecutedThisTurn = anyToolExecutedThisTurn || drained2.anyToolExecuted;
|
|
@@ -691,29 +928,39 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
|
|
|
691
928
|
finalContent = autoDb;
|
|
692
929
|
}
|
|
693
930
|
}
|
|
931
|
+
// Executar blocos shell que resolvem conflito Next.js (rmdir app, etc.)
|
|
932
|
+
const shellResult = await executeShellBlocksFromContent(contentStr);
|
|
933
|
+
if (shellResult.executed)
|
|
934
|
+
contentStr = shellResult.displayContent;
|
|
694
935
|
// Quando a API não executa tools, tentar aplicar blocos de código da resposta
|
|
695
936
|
if (!writeFileExecutedThisTurn) {
|
|
696
|
-
let result = await applyCodeBlocksFromContent(contentStr);
|
|
937
|
+
let result = await applyCodeBlocksFromContent(contentStr, sessionFileCtx);
|
|
697
938
|
// Se a IA só disse "We will call read_file/write_file" e não há código, pedir o código em um follow-up
|
|
698
|
-
const looksLikeToolIntentOnly = /(We will call|We need to call|Let's call|I will call)\s+(read_file|write_file|run_command)/i.test(contentStr)
|
|
699
|
-
|| (/call\s+(read_file|write_file)/i.test(contentStr) && contentStr.length < 400);
|
|
939
|
+
const looksLikeToolIntentOnly = /(We will call|We need to call|Let's call|I will call)\s+(read_file|search_replace|write_file|run_command)/i.test(contentStr)
|
|
940
|
+
|| (/call\s+(read_file|search_replace|write_file)/i.test(contentStr) && contentStr.length < 400);
|
|
700
941
|
if (!result.applied && looksLikeToolIntentOnly) {
|
|
701
942
|
messages.push({ role: 'assistant', content: rawContent ?? contentStr });
|
|
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.`;
|
|
943
|
+
const followUpSystem = `You replied as if tools would run in text only. Use tool_calls for read_file/search_replace/write_file/run_command/delete_directory/delete_file/mcp_* when possible. Prefer search_replace for edits. For Next.js app/pages conflict: call delete_directory("app"). 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.`;
|
|
703
944
|
messages.push({ role: 'system', content: followUpSystem });
|
|
704
945
|
spinner.start('Getting code...');
|
|
705
|
-
const followUp = await createCompletionWithRetry(client, modelId, messages, toolsList);
|
|
946
|
+
const followUp = await createCompletionWithRetry(client, modelId, messages, toolsList, 'auto', usageAcc);
|
|
706
947
|
spinner.stop();
|
|
707
|
-
const followUpMsg = followUp
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
contentStr = followUpStr;
|
|
712
|
-
finalContent = followUpMsg.content ?? followUpStr;
|
|
713
|
-
messages.push({ role: 'assistant', content: finalContent });
|
|
948
|
+
const followUpMsg = getFirstChoiceMessage(followUp);
|
|
949
|
+
if (!followUpMsg) {
|
|
950
|
+
console.log(ui.error('\nFollow-up da API sem choices/mensagem.'));
|
|
951
|
+
messages.push({ role: 'assistant', content: '' });
|
|
714
952
|
}
|
|
715
953
|
else {
|
|
716
|
-
|
|
954
|
+
const followUpStr = messageContentToString(followUpMsg.content);
|
|
955
|
+
if (followUpStr.trim() !== '') {
|
|
956
|
+
result = await applyCodeBlocksFromContent(followUpStr, sessionFileCtx);
|
|
957
|
+
contentStr = followUpStr;
|
|
958
|
+
finalContent = followUpMsg.content ?? followUpStr;
|
|
959
|
+
messages.push({ role: 'assistant', content: finalContent });
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
messages.push({ role: 'assistant', content: '' });
|
|
963
|
+
}
|
|
717
964
|
}
|
|
718
965
|
}
|
|
719
966
|
if (contentStr.trim() !== '') {
|
|
@@ -726,7 +973,13 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
|
|
|
726
973
|
}
|
|
727
974
|
else {
|
|
728
975
|
console.log('\n' + ui.labelPokt());
|
|
729
|
-
|
|
976
|
+
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
|
|
977
|
+
const lastContent = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
|
978
|
+
const isMod = suggestsProjectModification(lastContent);
|
|
979
|
+
const hint = isMod
|
|
980
|
+
? '(Sem resposta da IA. Para modificar projeto: peça "liste os arquivos e leia o arquivo". Tente /model.)'
|
|
981
|
+
: '(Sem resposta da IA após recuperação. Tente: /model, ou peça mcp_Neon_run_sql, ou run_command.)';
|
|
982
|
+
console.log(ui.dim(hint));
|
|
730
983
|
messages.push({ role: 'assistant', content: '' });
|
|
731
984
|
}
|
|
732
985
|
}
|
|
@@ -742,9 +995,11 @@ async function processLLMResponse(client, modelId, messages, toolsList) {
|
|
|
742
995
|
messages.push({ role: 'assistant', content: '' });
|
|
743
996
|
}
|
|
744
997
|
}
|
|
998
|
+
emitCliTelemetryForTurn(modelConfig, usageAcc);
|
|
745
999
|
}
|
|
746
1000
|
catch (error) {
|
|
747
1001
|
spinner.stop();
|
|
1002
|
+
emitCliTelemetryForTurn(modelConfig, usageAcc);
|
|
748
1003
|
const status = getStatusCode(error);
|
|
749
1004
|
if (status === 429) {
|
|
750
1005
|
console.log(ui.error('\nLimite de taxa (429). O provedor está te limitando por volume ou quota.'));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Envia uso (tokens, modelo, provedor) ao Pokt_CLI_Back — não bloqueia o chat; falhas são ignoradas. */
|
|
2
|
+
export declare function sendCliUsageTelemetryFireAndForget(params: {
|
|
3
|
+
provider: string;
|
|
4
|
+
model: string;
|
|
5
|
+
promptTokens: number;
|
|
6
|
+
completionTokens: number;
|
|
7
|
+
totalTokens: number;
|
|
8
|
+
cost: number | null;
|
|
9
|
+
}): void;
|
|
10
|
+
export type ChatUsageAccumulator = {
|
|
11
|
+
prompt: number;
|
|
12
|
+
completion: number;
|
|
13
|
+
cost: number | null;
|
|
14
|
+
};
|
|
15
|
+
export declare function emptyUsageAccumulator(): ChatUsageAccumulator;
|
|
16
|
+
export declare function mergeCompletionUsage(acc: ChatUsageAccumulator, completion: {
|
|
17
|
+
usage?: unknown;
|
|
18
|
+
}): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { getOrCreateCliInstallId, getCliHostLabel, getPoktApiBaseUrl, getPoktToken, isCliTelemetryDisabled, } from '../config.js';
|
|
5
|
+
let cachedVersion = null;
|
|
6
|
+
function readCliVersion() {
|
|
7
|
+
if (cachedVersion)
|
|
8
|
+
return cachedVersion;
|
|
9
|
+
try {
|
|
10
|
+
const base = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkgPath = join(base, '..', '..', 'package.json');
|
|
12
|
+
const raw = readFileSync(pkgPath, 'utf8');
|
|
13
|
+
const j = JSON.parse(raw);
|
|
14
|
+
cachedVersion = typeof j.version === 'string' ? j.version.slice(0, 32) : 'unknown';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
cachedVersion = 'unknown';
|
|
18
|
+
}
|
|
19
|
+
return cachedVersion;
|
|
20
|
+
}
|
|
21
|
+
/** Envia uso (tokens, modelo, provedor) ao Pokt_CLI_Back — não bloqueia o chat; falhas são ignoradas. */
|
|
22
|
+
export function sendCliUsageTelemetryFireAndForget(params) {
|
|
23
|
+
if (isCliTelemetryDisabled())
|
|
24
|
+
return;
|
|
25
|
+
if (params.provider === 'controller')
|
|
26
|
+
return;
|
|
27
|
+
const pt = params.promptTokens + params.completionTokens;
|
|
28
|
+
if (pt <= 0 && params.totalTokens <= 0)
|
|
29
|
+
return;
|
|
30
|
+
const base = getPoktApiBaseUrl();
|
|
31
|
+
const url = `${base}/api/v1/telemetry/usage`;
|
|
32
|
+
const token = getPoktToken();
|
|
33
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
34
|
+
if (token)
|
|
35
|
+
headers.Authorization = `Bearer ${token}`;
|
|
36
|
+
const total = params.totalTokens > 0 ? params.totalTokens : params.promptTokens + params.completionTokens;
|
|
37
|
+
const body = JSON.stringify({
|
|
38
|
+
installId: getOrCreateCliInstallId(),
|
|
39
|
+
hostLabel: getCliHostLabel(),
|
|
40
|
+
provider: params.provider,
|
|
41
|
+
model: params.model,
|
|
42
|
+
prompt_tokens: params.promptTokens,
|
|
43
|
+
completion_tokens: params.completionTokens,
|
|
44
|
+
total_tokens: total,
|
|
45
|
+
cost: params.cost,
|
|
46
|
+
cli_version: readCliVersion(),
|
|
47
|
+
});
|
|
48
|
+
void fetch(url, { method: 'POST', headers, body }).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
export function emptyUsageAccumulator() {
|
|
51
|
+
return { prompt: 0, completion: 0, cost: null };
|
|
52
|
+
}
|
|
53
|
+
export function mergeCompletionUsage(acc, completion) {
|
|
54
|
+
const u = completion.usage;
|
|
55
|
+
if (!u)
|
|
56
|
+
return;
|
|
57
|
+
acc.prompt += Number(u.prompt_tokens) || 0;
|
|
58
|
+
acc.completion += Number(u.completion_tokens) || 0;
|
|
59
|
+
if (u.cost != null && !Number.isNaN(Number(u.cost))) {
|
|
60
|
+
acc.cost = (acc.cost ?? 0) + Number(u.cost);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/chat/tools.js
CHANGED
|
@@ -30,6 +30,56 @@ function looksLikeMcpJsonDump(s) {
|
|
|
30
30
|
}
|
|
31
31
|
return false;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* When exact old_string is not found, try to find a similar region and return helpful error.
|
|
35
|
+
* Inspired by gemini-cli fuzzy matcher fallback.
|
|
36
|
+
*/
|
|
37
|
+
function findClosestMatchAndBuildError(fileContent, oldString, filePath) {
|
|
38
|
+
const oldLines = oldString.split('\n').filter((l) => l.trim().length > 0);
|
|
39
|
+
if (oldLines.length === 0) {
|
|
40
|
+
return `search_replace failed: old_string is empty or only whitespace.`;
|
|
41
|
+
}
|
|
42
|
+
const firstLine = oldLines[0];
|
|
43
|
+
const idx = fileContent.indexOf(firstLine);
|
|
44
|
+
if (idx >= 0) {
|
|
45
|
+
const start = Math.max(0, idx - 200);
|
|
46
|
+
const end = Math.min(fileContent.length, idx + firstLine.length + 400);
|
|
47
|
+
const snippet = fileContent.slice(start, end);
|
|
48
|
+
const diffResult = diff.diffLines(oldString, snippet);
|
|
49
|
+
const diffPreview = diffResult
|
|
50
|
+
.slice(0, 8)
|
|
51
|
+
.map((p) => (p.added ? `+${p.value}` : p.removed ? `-${p.value}` : ` ${p.value}`))
|
|
52
|
+
.join('')
|
|
53
|
+
.slice(0, 600);
|
|
54
|
+
return `search_replace failed: old_string not found exactly. First line was found at position ${idx}. Suggestion: check whitespace, indentation, line endings. Diff (expected vs file snippet):\n${diffPreview}\n\nCall read_file to see full content and retry with exact match.`;
|
|
55
|
+
}
|
|
56
|
+
const norm = (s) => s.replace(/\r\n/g, '\n').replace(/\s+/g, ' ').trim();
|
|
57
|
+
const normOld = norm(oldString);
|
|
58
|
+
const fileLines = fileContent.split('\n');
|
|
59
|
+
let bestLineIdx = -1;
|
|
60
|
+
let bestLen = 0;
|
|
61
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
62
|
+
const lineNorm = norm(fileLines[i]);
|
|
63
|
+
if (lineNorm.length < 10)
|
|
64
|
+
continue;
|
|
65
|
+
let matchLen = 0;
|
|
66
|
+
for (let j = 0; j < Math.min(lineNorm.length, normOld.length); j++) {
|
|
67
|
+
if (lineNorm[j] === normOld[j])
|
|
68
|
+
matchLen++;
|
|
69
|
+
else
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (matchLen > bestLen && matchLen >= 15) {
|
|
73
|
+
bestLen = matchLen;
|
|
74
|
+
bestLineIdx = i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (bestLineIdx >= 0) {
|
|
78
|
+
const ctx = fileLines.slice(Math.max(0, bestLineIdx - 2), bestLineIdx + 4).join('\n');
|
|
79
|
+
return `search_replace failed: old_string not found. Closest region (line ${bestLineIdx + 1}):\n---\n${ctx}\n---\nUse read_file("${filePath}") and retry with exact content including indentation and newlines.`;
|
|
80
|
+
}
|
|
81
|
+
return `search_replace failed: old_string not found in ${filePath}. Use read_file to get current content and ensure old_string matches exactly (whitespace, tabs, newlines).`;
|
|
82
|
+
}
|
|
33
83
|
function showDiff(filePath, oldContent, newContent) {
|
|
34
84
|
const relativePath = path.relative(process.cwd(), filePath);
|
|
35
85
|
console.log(chalk.blue.bold(`\n📝 Edit ${relativePath}:`));
|
|
@@ -125,11 +175,28 @@ export const tools = [
|
|
|
125
175
|
}
|
|
126
176
|
}
|
|
127
177
|
},
|
|
178
|
+
{
|
|
179
|
+
type: 'function',
|
|
180
|
+
function: {
|
|
181
|
+
name: 'search_replace',
|
|
182
|
+
description: 'Replaces old_string with new_string in a file. PREFERRED for edits and modifications: targeted, minimal changes. Use read_file first to get current content. For new files or complete rewrites use write_file. expected_replacements (optional): if set, fails when count of matches ≠ value (prevents unintended broad changes).',
|
|
183
|
+
parameters: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
properties: {
|
|
186
|
+
path: { type: 'string', description: 'The path to the file' },
|
|
187
|
+
old_string: { type: 'string', description: 'Exact text to find and replace (must match file content including whitespace)' },
|
|
188
|
+
new_string: { type: 'string', description: 'Replacement text' },
|
|
189
|
+
expected_replacements: { type: 'number', description: 'Optional: fail if number of occurrences ≠ this (safety guard)' }
|
|
190
|
+
},
|
|
191
|
+
required: ['path', 'old_string', 'new_string']
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
128
195
|
{
|
|
129
196
|
type: 'function',
|
|
130
197
|
function: {
|
|
131
198
|
name: 'write_file',
|
|
132
|
-
description: 'Writes content to a file. Overwrites if exists, creates if not.',
|
|
199
|
+
description: 'Writes full content to a file. Overwrites if exists, creates if not. Use for new files or complete rewrites. For small edits, prefer search_replace. When modifying existing work from this chat, use the same path as before (see user message [Pokt] hint or prior tool calls); call read_file first if you need the current contents.',
|
|
133
200
|
parameters: {
|
|
134
201
|
type: 'object',
|
|
135
202
|
properties: {
|
|
@@ -180,6 +247,34 @@ export const tools = [
|
|
|
180
247
|
}
|
|
181
248
|
}
|
|
182
249
|
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
type: 'function',
|
|
253
|
+
function: {
|
|
254
|
+
name: 'delete_file',
|
|
255
|
+
description: 'Deletes a single file. Use for removing files that cause conflicts (e.g. app/page.tsx in Next.js).',
|
|
256
|
+
parameters: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
properties: {
|
|
259
|
+
path: { type: 'string', description: 'Relative path to the file to delete' }
|
|
260
|
+
},
|
|
261
|
+
required: ['path']
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
type: 'function',
|
|
267
|
+
function: {
|
|
268
|
+
name: 'delete_directory',
|
|
269
|
+
description: 'Deletes a directory and all its contents recursively. Use to resolve Next.js app/pages conflict: delete_directory("app") removes the app folder so only pages/ remains. Cross-platform, no shell needed.',
|
|
270
|
+
parameters: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
path: { type: 'string', description: 'Relative path to the directory to delete (e.g. "app" for Next.js app folder)' }
|
|
274
|
+
},
|
|
275
|
+
required: ['path']
|
|
276
|
+
}
|
|
277
|
+
}
|
|
183
278
|
}
|
|
184
279
|
];
|
|
185
280
|
export async function executeTool(name, argsStr) {
|
|
@@ -188,6 +283,34 @@ export async function executeTool(name, argsStr) {
|
|
|
188
283
|
if (name === 'read_file') {
|
|
189
284
|
return fs.readFileSync(path.resolve(process.cwd(), args.path), 'utf8');
|
|
190
285
|
}
|
|
286
|
+
if (name === 'search_replace') {
|
|
287
|
+
const filePath = path.resolve(process.cwd(), args.path);
|
|
288
|
+
if (!fs.existsSync(filePath)) {
|
|
289
|
+
return `Error: File not found: ${args.path}. Use write_file to create it.`;
|
|
290
|
+
}
|
|
291
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
292
|
+
const oldStr = args.old_string ?? '';
|
|
293
|
+
const newStr = args.new_string ?? '';
|
|
294
|
+
if (oldStr === newStr) {
|
|
295
|
+
return `search_replace: old_string and new_string are identical; no change made.`;
|
|
296
|
+
}
|
|
297
|
+
const expected = typeof args.expected_replacements === 'number' ? args.expected_replacements : undefined;
|
|
298
|
+
const occurrences = content.split(oldStr).length - 1;
|
|
299
|
+
if (occurrences === 0) {
|
|
300
|
+
const errMsg = findClosestMatchAndBuildError(content, oldStr, args.path);
|
|
301
|
+
return errMsg;
|
|
302
|
+
}
|
|
303
|
+
if (expected !== undefined && occurrences !== expected) {
|
|
304
|
+
return `search_replace failed: found ${occurrences} occurrence(s) of old_string, but expected_replacements=${expected}. Adjust old_string to be unique or set expected_replacements to ${occurrences}.`;
|
|
305
|
+
}
|
|
306
|
+
const newContent = content.split(oldStr).join(newStr);
|
|
307
|
+
fs.writeFileSync(filePath, newContent, 'utf8');
|
|
308
|
+
const rel = path.relative(process.cwd(), filePath);
|
|
309
|
+
if (!isGeneratedNoisePath(rel) || !looksLikeMcpJsonDump(newContent)) {
|
|
310
|
+
showDiff(filePath, content, newContent);
|
|
311
|
+
}
|
|
312
|
+
return `Successfully applied search_replace to ${args.path} (${occurrences} replacement(s)).`;
|
|
313
|
+
}
|
|
191
314
|
if (name === 'write_file') {
|
|
192
315
|
const filePath = path.resolve(process.cwd(), args.path);
|
|
193
316
|
const oldContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
@@ -236,6 +359,45 @@ export async function executeTool(name, argsStr) {
|
|
|
236
359
|
walk(root);
|
|
237
360
|
return files.join('\n') || 'No files found.';
|
|
238
361
|
}
|
|
362
|
+
/** Garante que o caminho resolvido está dentro do cwd (evita path traversal) */
|
|
363
|
+
function ensureWithinCwd(resolved) {
|
|
364
|
+
const cwd = process.cwd();
|
|
365
|
+
const normCwd = path.resolve(cwd);
|
|
366
|
+
const normResolved = path.resolve(resolved);
|
|
367
|
+
return normResolved.startsWith(normCwd + path.sep) || normResolved === normCwd;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'delete_file') {
|
|
370
|
+
const filePath = path.resolve(process.cwd(), args.path);
|
|
371
|
+
if (!ensureWithinCwd(filePath)) {
|
|
372
|
+
return `Error: Path "${args.path}" is outside the project directory. Refused for safety.`;
|
|
373
|
+
}
|
|
374
|
+
if (!fs.existsSync(filePath)) {
|
|
375
|
+
return `Error: File not found: ${args.path}`;
|
|
376
|
+
}
|
|
377
|
+
const stat = fs.statSync(filePath);
|
|
378
|
+
if (stat.isDirectory()) {
|
|
379
|
+
return `Error: ${args.path} is a directory. Use delete_directory to remove it.`;
|
|
380
|
+
}
|
|
381
|
+
fs.unlinkSync(filePath);
|
|
382
|
+
console.log(chalk.blue.bold(`\n🗑️ Removido: ${path.relative(process.cwd(), filePath)}\n`));
|
|
383
|
+
return `Successfully deleted ${args.path}`;
|
|
384
|
+
}
|
|
385
|
+
if (name === 'delete_directory') {
|
|
386
|
+
const dirPath = path.resolve(process.cwd(), args.path);
|
|
387
|
+
if (!ensureWithinCwd(dirPath)) {
|
|
388
|
+
return `Error: Path "${args.path}" is outside the project directory. Refused for safety.`;
|
|
389
|
+
}
|
|
390
|
+
if (!fs.existsSync(dirPath)) {
|
|
391
|
+
return `Error: Directory not found: ${args.path}`;
|
|
392
|
+
}
|
|
393
|
+
const stat = fs.statSync(dirPath);
|
|
394
|
+
if (!stat.isDirectory()) {
|
|
395
|
+
return `Error: ${args.path} is not a directory. Use delete_file to remove it.`;
|
|
396
|
+
}
|
|
397
|
+
fs.rmSync(dirPath, { recursive: true });
|
|
398
|
+
console.log(chalk.blue.bold(`\n🗑️ Pasta removida: ${path.relative(process.cwd(), dirPath)}\n`));
|
|
399
|
+
return `Successfully deleted directory ${args.path}`;
|
|
400
|
+
}
|
|
239
401
|
return `Unknown tool: ${name}`;
|
|
240
402
|
}
|
|
241
403
|
catch (error) {
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ interface AppConfig {
|
|
|
41
41
|
/** Só compra de token / checkout — Vercel */
|
|
42
42
|
tokenPurchaseBaseUrl: string;
|
|
43
43
|
poktToken: string;
|
|
44
|
+
/** ID estável por instalação (telemetria de uso no Back) */
|
|
45
|
+
cliInstallId: string;
|
|
44
46
|
registeredModels: ModelConfig[];
|
|
45
47
|
activeModel: ModelConfig | null;
|
|
46
48
|
mcpServers: McpServerConfig[];
|
|
@@ -54,6 +56,7 @@ export declare const env: {
|
|
|
54
56
|
readonly ollamaBaseUrl: readonly ["OLLAMA_BASE_URL"];
|
|
55
57
|
readonly ollamaCloudApiKey: readonly ["OLLAMA_CLOUD_API_KEY"];
|
|
56
58
|
readonly poktToken: readonly ["POKT_TOKEN"];
|
|
59
|
+
readonly disableTelemetry: readonly ["POKT_DISABLE_TELEMETRY"];
|
|
57
60
|
readonly poktApiBaseUrl: readonly ["POKT_API_BASE_URL"];
|
|
58
61
|
/** Painel e URLs gerais (Railway) */
|
|
59
62
|
readonly proPortalUrl: readonly ["POKT_PRO_PORTAL_URL", "POKT_CONTROLLER_PORTAL_URL"];
|
|
@@ -67,6 +70,11 @@ export declare function getGeminiApiKey(): string;
|
|
|
67
70
|
export declare function getOllamaBaseUrl(): string;
|
|
68
71
|
export declare function getOllamaCloudApiKey(): string;
|
|
69
72
|
export declare function getPoktToken(): string;
|
|
73
|
+
/** UUID persistente por máquina/instalação (identifica uso no painel quando não há token Pokt). */
|
|
74
|
+
export declare function getOrCreateCliInstallId(): string;
|
|
75
|
+
/** Nome do PC (sanitizado) para exibição no log de uso. */
|
|
76
|
+
export declare function getCliHostLabel(): string;
|
|
77
|
+
export declare function isCliTelemetryDisabled(): boolean;
|
|
70
78
|
/** Base da API só para provider `controller` (Bearer Pokt). OpenAI direto usa outro ramo no getClient. */
|
|
71
79
|
export declare function getPoktApiBaseUrl(): string;
|
|
72
80
|
/** Painel e links gerais (Railway), exceto compra de token — ver getTokenPurchaseUrl(). */
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import Conf from 'conf';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import os from 'os';
|
|
2
4
|
export const PROVIDER_LABELS = {
|
|
3
5
|
controller: 'Pokt API (Controller)',
|
|
4
6
|
openai: 'OpenAI',
|
|
@@ -41,6 +43,7 @@ export const config = new Conf({
|
|
|
41
43
|
poktApiBaseUrl: DEFAULT_POKT_SERVICE_BASE_URL,
|
|
42
44
|
tokenPurchaseBaseUrl: DEFAULT_TOKEN_PURCHASE_BASE_URL,
|
|
43
45
|
poktToken: '',
|
|
46
|
+
cliInstallId: '',
|
|
44
47
|
registeredModels: [
|
|
45
48
|
{ provider: 'controller', id: 'default' },
|
|
46
49
|
{ provider: 'openai', id: 'gpt-4o-mini' },
|
|
@@ -70,6 +73,7 @@ export const env = {
|
|
|
70
73
|
ollamaBaseUrl: ['OLLAMA_BASE_URL'],
|
|
71
74
|
ollamaCloudApiKey: ['OLLAMA_CLOUD_API_KEY'],
|
|
72
75
|
poktToken: ['POKT_TOKEN'],
|
|
76
|
+
disableTelemetry: ['POKT_DISABLE_TELEMETRY'],
|
|
73
77
|
poktApiBaseUrl: ['POKT_API_BASE_URL'],
|
|
74
78
|
/** Painel e URLs gerais (Railway) */
|
|
75
79
|
proPortalUrl: ['POKT_PRO_PORTAL_URL', 'POKT_CONTROLLER_PORTAL_URL'],
|
|
@@ -113,6 +117,29 @@ export function getOllamaCloudApiKey() {
|
|
|
113
117
|
export function getPoktToken() {
|
|
114
118
|
return readEnvFirst(env.poktToken) || config.get('poktToken') || '';
|
|
115
119
|
}
|
|
120
|
+
/** UUID persistente por máquina/instalação (identifica uso no painel quando não há token Pokt). */
|
|
121
|
+
export function getOrCreateCliInstallId() {
|
|
122
|
+
let id = config.get('cliInstallId');
|
|
123
|
+
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/i.test(id)) {
|
|
124
|
+
id = randomUUID();
|
|
125
|
+
config.set('cliInstallId', id);
|
|
126
|
+
}
|
|
127
|
+
return id;
|
|
128
|
+
}
|
|
129
|
+
/** Nome do PC (sanitizado) para exibição no log de uso. */
|
|
130
|
+
export function getCliHostLabel() {
|
|
131
|
+
try {
|
|
132
|
+
const h = os.hostname().replace(/[^\w.-]+/g, '_').slice(0, 120);
|
|
133
|
+
return h || 'PC';
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return 'PC';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export function isCliTelemetryDisabled() {
|
|
140
|
+
const v = readEnvFirst(env.disableTelemetry);
|
|
141
|
+
return v === '1' || v.toLowerCase() === 'true' || v.toLowerCase() === 'yes';
|
|
142
|
+
}
|
|
116
143
|
/** Base da API só para provider `controller` (Bearer Pokt). OpenAI direto usa outro ramo no getClient. */
|
|
117
144
|
export function getPoktApiBaseUrl() {
|
|
118
145
|
const fromEnv = readEnvFirst(env.poktApiBaseUrl);
|
package/dist/mcp/client.js
CHANGED
|
@@ -77,7 +77,7 @@ async function connectMcpStdio(serverConfig) {
|
|
|
77
77
|
args: serverConfig.args ?? [],
|
|
78
78
|
env: mergeProcessEnv(serverConfig.env),
|
|
79
79
|
});
|
|
80
|
-
const client = new Client({ name: 'pokt-cli', version: '1.0.
|
|
80
|
+
const client = new Client({ name: 'pokt-cli', version: '1.0.12' });
|
|
81
81
|
await client.connect(transport);
|
|
82
82
|
const tools = await buildToolsFromClient(serverConfig, client);
|
|
83
83
|
return pushSession(serverConfig, client, tools, transport);
|
|
@@ -112,7 +112,7 @@ async function connectMcpHttp(serverConfig) {
|
|
|
112
112
|
}
|
|
113
113
|
return makeStreamableTransport(url, { authProvider, headers });
|
|
114
114
|
};
|
|
115
|
-
const client = new Client({ name: 'pokt-cli', version: '1.0.
|
|
115
|
+
const client = new Client({ name: 'pokt-cli', version: '1.0.12' });
|
|
116
116
|
let transport = await makeTransport();
|
|
117
117
|
const cleanupCb = async () => {
|
|
118
118
|
try {
|
|
@@ -151,7 +151,7 @@ async function connectMcpHttp(serverConfig) {
|
|
|
151
151
|
const transport = useSse
|
|
152
152
|
? await makeSseTransport(url, { headers })
|
|
153
153
|
: await makeStreamableTransport(url, { headers });
|
|
154
|
-
const client = new Client({ name: 'pokt-cli', version: '1.0.
|
|
154
|
+
const client = new Client({ name: 'pokt-cli', version: '1.0.12' });
|
|
155
155
|
await client.connect(transport);
|
|
156
156
|
const tools = await buildToolsFromClient(serverConfig, client);
|
|
157
157
|
return pushSession(serverConfig, client, tools, transport);
|