natureco-cli 5.20.3 → 5.21.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.
package/src/utils/api.js CHANGED
@@ -1,1188 +1,1211 @@
1
- // NatureCo CLI v2.10.1 - Universal LLM Provider Support + MCP Integration
2
- // Supports: OpenAI, Groq, Together, Fireworks, Perplexity, Mistral, DeepSeek, OpenRouter, Ollama, LM Studio, Anthropic
3
-
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
- const chalk = require('chalk');
8
- const { getConfig } = require('./config');
9
- const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
- const { MCPClient } = require('./mcp-client');
11
- const TB = require('./token-budget');
12
- const { accumulateToolCallDeltas } = require('./streaming-tools');
13
- const { ToolGuardrails } = require('./tool-guardrails');
14
- const guardrails = new ToolGuardrails();
15
-
16
- /**
17
- * v5.5.0: Provider-specific format detection
18
- * Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
19
- *
20
- * Canonical implementation lives in src/utils/provider-detect.js;
21
- * re-exported here so the historical `detectProvider` reference inside
22
- * api.js continues to work without touching every call site.
23
- */
24
- const { detectProvider, isMiniMax, isGemini } = require('./provider-detect');
25
-
26
- /**
27
- * v5.5.0: Tool definitions'ı provider'a göre normalize et
28
- * - OpenAI/Groq/Mistral/DeepSeek/OpenRouter: tool_choice, function calling OK
29
- * - Anthropic: tools, system ayrı, content array
30
- * - Ollama: tool support sınırlı, genelde yok
31
- * - Perplexity: tool support yok
32
- */
33
- function normalizeToolsForProvider(tools, provider) {
34
- if (!tools || tools.length === 0) return tools;
35
- if (provider === 'ollama' || provider === 'perplexity') {
36
- // Bu providerlar tool support etmiyor - bos dondur
37
- return [];
38
- }
39
- if (provider === 'anthropic') {
40
- // Anthropic tools format: { name, description, input_schema }
41
- return tools.map(t => ({
42
- name: t.function.name,
43
- description: t.function.description,
44
- input_schema: t.function.parameters || { type: 'object', properties: {} }
45
- }));
46
- }
47
- // OpenAI-compatible (Groq, Mistral, DeepSeek, OpenRouter, MiniMax, Together, Fireworks)
48
- return tools;
49
- }
50
-
51
- /**
52
- * v5.5.0: Tool call'ları provider'a göre parse et
53
- * OpenAI: tool_calls[].function.arguments (string)
54
- * Anthropic: content[].type=tool_use, input (object)
55
- * Ollama: genelde yok
56
- */
57
- function parseToolCallsFromResponse(message, provider) {
58
- if (provider === 'anthropic') {
59
- // Anthropic: content array içinde tool_use blokları var
60
- if (Array.isArray(message.content)) {
61
- const toolUses = message.content.filter(c => c.type === 'tool_use');
62
- return toolUses.map(tu => ({
63
- id: tu.id,
64
- type: 'function',
65
- function: {
66
- name: tu.name,
67
- arguments: JSON.stringify(tu.input || {})
68
- }
69
- }));
70
- }
71
- return [];
72
- }
73
- // OpenAI-compatible
74
- return message.tool_calls || [];
75
- }
76
-
77
- /**
78
- * Anthropic Messages API requires `system` to be either a non-empty string
79
- * or omitted entirely. Sending `undefined` (which JSON.stringify drops to
80
- * silent absence) leaves the model unanchored; sending `''` returns 400
81
- * "system: cannot be empty" on recent API revisions. Always pass a
82
- * meaningful default when no system message is present.
83
- */
84
- const DEFAULT_ANTHROPIC_SYSTEM =
85
- 'You are a helpful AI assistant running inside the natureco CLI.';
86
-
87
- function extractSystemForAnthropic(messages) {
88
- const systemMsg = messages.find(m => m.role === 'system');
89
- if (!systemMsg) return DEFAULT_ANTHROPIC_SYSTEM;
90
- // content may be a string or an array of content blocks; both round-trip.
91
- if (typeof systemMsg.content === 'string') {
92
- return systemMsg.content.trim() || DEFAULT_ANTHROPIC_SYSTEM;
93
- }
94
- if (Array.isArray(systemMsg.content) && systemMsg.content.length > 0) {
95
- return systemMsg.content;
96
- }
97
- return DEFAULT_ANTHROPIC_SYSTEM;
98
- }
99
-
100
- /**
101
- * v5.5.0: System mesajı provider'a göre ayarla
102
- * - OpenAI: messages[].role=system
103
- * - Anthropic: ayrı 'system' field
104
- */
105
- function buildRequestBody(messages, model, options, provider) {
106
- if (provider === 'anthropic') {
107
- const userMsgs = messages.filter(m => m.role !== 'system');
108
- return {
109
- model,
110
- messages: userMsgs.map(m => ({
111
- role: m.role,
112
- content: m.content
113
- })),
114
- system: extractSystemForAnthropic(messages),
115
- max_tokens: options.max_tokens || 4096,
116
- temperature: options.temperature || 0.7,
117
- ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
118
- };
119
- }
120
- // OpenAI-compatible
121
- return {
122
- model,
123
- messages,
124
- max_tokens: options.max_tokens || 4096,
125
- temperature: options.temperature || 0.7,
126
- ...(options.tools && options.tools.length > 0 ? { tools: options.tools, tool_choice: 'auto' } : {})
127
- };
128
- }
129
-
130
-
131
- // Persistent conversation directory
132
- const CONV_DIR = path.join(os.homedir(), '.natureco', 'conversations');
133
-
134
- // Conversation history for multi-turn chat (deprecated - now using disk storage)
135
- const conversationHistory = new Map();
136
-
137
- // MCP clients (server name -> { client, tools })
138
- const mcpClients = {};
139
-
140
- /**
141
- * Generate default conversation ID based on provider config
142
- */
143
- function generateDefaultConvId() {
144
- const config = getConfig();
145
-
146
- // Use provider URL + model as base for consistent ID
147
- const providerUrl = config.providerUrl || 'default';
148
- const model = config.providerModel || 'default';
149
-
150
- // Create simple hash-like ID from provider + model
151
- const base = `${providerUrl}_${model}`.replace(/[^a-z0-9]/gi, '_').toLowerCase();
152
-
153
- // Return consistent ID (e.g., "groq_llama_3_1_8b_instant")
154
- return base.slice(0, 50); // Limit length
155
- }
156
-
157
- /**
158
- * Load conversation from disk
159
- */
160
- function loadConversation(convId) {
161
- const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
162
- try {
163
- fs.mkdirSync(CONV_DIR, { recursive: true });
164
- if (fs.existsSync(file)) {
165
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
166
- return Array.isArray(parsed) ? parsed : [];
167
- }
168
- } catch (e) {
169
- // Silently fail
170
- }
171
- return [];
172
- }
173
-
174
- /**
175
- * Save conversation to disk
176
- */
177
- function saveConversation(convId, messages) {
178
- const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
179
- try {
180
- fs.mkdirSync(CONV_DIR, { recursive: true });
181
- // Keep only last 10 messages
182
- fs.writeFileSync(file, JSON.stringify(messages.slice(-(TB.load().conversationOnDisk)), null, 2));
183
- } catch (e) {
184
- // Silently fail
185
- }
186
- }
187
-
188
- /**
189
- * Start MCP servers from config
190
- */
191
- async function startMcpServers() {
192
- const config = getConfig();
193
-
194
- // Skip if MCP is disabled
195
- if (config.mcpEnabled === false) {
196
- debugLog('[MCP] MCP is disabled in config, skipping server startup');
197
- return;
198
- }
199
-
200
- const servers = config.mcpServers || {};
201
-
202
- for (const [name, server] of Object.entries(servers)) {
203
- // Skip disabled servers
204
- if (server.disabled) {
205
- debugLog(`[MCP] Skipping disabled server: ${name}`);
206
- continue;
207
- }
208
-
209
- // Skip if already started
210
- if (mcpClients[name]) {
211
- debugLog(`[MCP] Server already running: ${name}`);
212
- continue;
213
- }
214
-
215
- try {
216
- debugLog(`[MCP] Starting server: ${name}`);
217
-
218
- const client = new MCPClient(server.command, server.args, server.env || {});
219
- await client.start();
220
-
221
- const tools = await client.listTools();
222
- debugLog(`[MCP] Server ${name} loaded ${tools.length} tools`);
223
-
224
- mcpClients[name] = { client, tools };
225
-
226
- } catch (err) {
227
- debugLog(`[MCP] Failed to start server ${name}: ${err.message}`);
228
- }
229
- }
230
- }
231
-
232
- /**
233
- * Stop all MCP servers
234
- */
235
- function stopMcpServers() {
236
- for (const [name, { client }] of Object.entries(mcpClients)) {
237
- try {
238
- debugLog(`[MCP] Stopping server: ${name}`);
239
- client.stop();
240
- } catch (err) {
241
- debugLog(`[MCP] Failed to stop server ${name}: ${err.message}`);
242
- }
243
- }
244
-
245
- // Clear clients
246
- Object.keys(mcpClients).forEach(key => delete mcpClients[key]);
247
- }
248
-
249
- /**
250
- * Get all MCP tools (combined from all servers)
251
- */
252
- function getMcpTools() {
253
- const allTools = [];
254
-
255
- for (const [serverName, { tools }] of Object.entries(mcpClients)) {
256
- for (const tool of tools) {
257
- allTools.push({
258
- ...tool,
259
- _mcpServer: serverName, // Track which server this tool belongs to
260
- });
261
- }
262
- }
263
-
264
- return allTools;
265
- }
266
-
267
- // Groq-incompatible MCP tools (strict validation issues)
268
- const BLOCKED_MCP_TOOLS = ['search_issues', 'search_repositories'];
269
-
270
- /**
271
- * Get MCP tools filtered for AI consumption
272
- * Removes tools that are incompatible with Groq's strict validation
273
- */
274
- function getMcpToolsForAI() {
275
- const tools = getMcpTools();
276
- return tools.filter(t => !BLOCKED_MCP_TOOLS.includes(t.name));
277
- }
278
-
279
- /**
280
- * Normalize MCP tool schema for AI consumption
281
- * Adds hints to number/integer parameters to prevent string conversion
282
- */
283
- function normalizeMcpToolSchema(tool) {
284
- const schema = tool.inputSchema || tool.input_schema || {};
285
-
286
- // Ensure properties exist
287
- if (!schema.properties) return tool;
288
-
289
- // Clone schema to avoid mutating original
290
- const normalizedSchema = JSON.parse(JSON.stringify(schema));
291
-
292
- // Groq sometimes sends strings for number params
293
- // Add coercion hint to description
294
- for (const [key, prop] of Object.entries(normalizedSchema.properties)) {
295
- if (prop.type === 'number' || prop.type === 'integer') {
296
- prop.description = (prop.description || '') + ' (must be a number, not a string)';
297
- }
298
- }
299
-
300
- return { ...tool, inputSchema: normalizedSchema };
301
- }
302
-
303
- /**
304
- * Minimize MCP tool schema to reduce token usage
305
- * Truncates descriptions and removes unnecessary fields
306
- */
307
- function minimizeMcpTool(tool) {
308
- return {
309
- name: tool.name,
310
- description: TB.capMcpDesc(tool.description),
311
- inputSchema: {
312
- type: tool.inputSchema?.type || 'object',
313
- properties: Object.fromEntries(
314
- Object.entries(tool.inputSchema?.properties || {}).map(([k, v]) => [
315
- k,
316
- {
317
- type: v.type,
318
- ...(v.enum ? { enum: v.enum } : {}) // Include enum only if exists
319
- }
320
- ])
321
- ),
322
- required: tool.inputSchema?.required || []
323
- }
324
- };
325
- }
326
-
327
- /**
328
- * Coerce MCP tool parameters to match schema types
329
- */
330
- function coerceMcpParams(tool, params) {
331
- // GitHub MCP uses inputSchema, others may use input_schema
332
- // Try all possible schema locations
333
- const schema = tool.inputSchema?.properties ||
334
- tool.input_schema?.properties ||
335
- tool.parameters?.properties || // fallback
336
- {};
337
-
338
- const coerced = { ...params };
339
-
340
- for (const [key, def] of Object.entries(schema)) {
341
- if (coerced[key] === undefined || coerced[key] === null) continue;
342
-
343
- // Coerce number or integer
344
- if ((def.type === 'number' || def.type === 'integer') && typeof coerced[key] === 'string') {
345
- const num = Number(coerced[key]);
346
- if (!isNaN(num)) {
347
- coerced[key] = num;
348
- }
349
- }
350
-
351
- // Coerce boolean
352
- if (def.type === 'boolean' && typeof coerced[key] === 'string') {
353
- coerced[key] = coerced[key] === 'true' || coerced[key] === '1';
354
- }
355
- }
356
-
357
- return coerced;
358
- }
359
-
360
- /**
361
- * Execute MCP tool call
362
- */
363
- async function executeMcpTool(toolName, toolArgs) {
364
- // Find which server has this tool
365
- for (const [serverName, { client, tools }] of Object.entries(mcpClients)) {
366
- const tool = tools.find(t => t.name === toolName);
367
-
368
- if (tool) {
369
- debugLog(`[MCP] Calling tool ${toolName} on server ${serverName}`);
370
-
371
- try {
372
- // Coerce parameters to match schema types
373
- const coercedArgs = coerceMcpParams(tool, toolArgs);
374
-
375
- const result = await client.callTool(toolName, coercedArgs);
376
-
377
- // MCP returns { content: [{ type: 'text', text: '...' }] }
378
- if (result.content && result.content.length > 0) {
379
- // Extract all text content and join with newlines
380
- const textContents = result.content
381
- .filter(c => c.type === 'text')
382
- .map(c => c.text);
383
-
384
- if (textContents.length > 0) {
385
- let output = textContents.join('\n');
386
-
387
- // Truncate MCP result
388
- const maxChars = TB.load().toolMaxChars;
389
- if (output.length > maxChars) {
390
- output = output.slice(0, maxChars) + '... (truncated)';
391
- }
392
-
393
- return {
394
- success: true,
395
- output: output
396
- };
397
- }
398
- }
399
-
400
- // Fallback: return entire result as JSON
401
- let fallbackOutput = JSON.stringify(result, null, 2);
402
-
403
- // Truncate fallback output too
404
- const maxChars = TB.load().toolMaxChars;
405
- if (fallbackOutput.length > maxChars) {
406
- fallbackOutput = fallbackOutput.slice(0, maxChars) + '... (truncated)';
407
- }
408
-
409
- return {
410
- success: true,
411
- output: fallbackOutput
412
- };
413
-
414
- } catch (err) {
415
- return {
416
- success: false,
417
- error: `MCP tool error: ${err.message}`
418
- };
419
- }
420
- }
421
- }
422
-
423
- return {
424
- success: false,
425
- error: `MCP tool not found: ${toolName}`
426
- };
427
- }
428
-
429
- /**
430
- * Encode tool result for safe transmission
431
- * Works for both MCP and local tools
432
- */
433
- function encodeToolResult(toolResult) {
434
- let content;
435
-
436
- // Handle different result formats
437
- if (typeof toolResult === 'string') {
438
- content = toolResult;
439
- } else if (toolResult.output) {
440
- content = toolResult.output;
441
- } else if (toolResult.success !== undefined) {
442
- // Handle { success: true/false, output/error: ... } format
443
- content = toolResult.success ? (toolResult.output || JSON.stringify(toolResult)) : (toolResult.error || 'Unknown error');
444
- } else {
445
- content = JSON.stringify(toolResult);
446
- }
447
-
448
- // Base64 encode
449
- const encoded = Buffer.from(content).toString('base64');
450
- return `[BASE64_ENCODED_RESULT]: ${encoded}`;
451
- }
452
-
453
- /**
454
- * Check if debug mode is enabled
455
- */
456
- function isDebugEnabled() {
457
- const config = getConfig();
458
- return config.debug === true || config.debug === 'true';
459
- }
460
-
461
- /**
462
- * Debug log (only if debug mode enabled)
463
- */
464
- function debugLog(...args) {
465
- if (isDebugEnabled()) {
466
- console.log(...args);
467
- }
468
- }
469
-
470
- /**
471
- * Get provider configuration from config
472
- */
473
- function getProviderConfig() {
474
- const config = getConfig();
475
-
476
- // Universal provider config (v2.1.0+)
477
- if (config.providerUrl && config.providerApiKey) {
478
- return {
479
- url: config.providerUrl,
480
- apiKey: config.providerApiKey,
481
- model: config.providerModel || 'llama-3.3-70b-versatile',
482
- isAnthropic: config.providerUrl.includes('anthropic.com')
483
- };
484
- }
485
-
486
- // Legacy Groq config (v2.0.x)
487
- if (config.groqApiKey) {
488
- return {
489
- url: 'https://api.groq.com/openai/v1',
490
- apiKey: config.groqApiKey,
491
- model: config.groqModel || 'llama-3.3-70b-versatile',
492
- isAnthropic: false
493
- };
494
- }
495
-
496
- return null;
497
- }
498
-
499
- /**
500
- * Format tool definitions for OpenAI-compatible APIs
501
- */
502
- function formatToolsForOpenAI() {
503
- const config = getConfig();
504
- const localTools = getToolDefinitions();
505
-
506
- // Only add MCP tools if enabled
507
- let allTools = [...localTools];
508
- if (config.mcpEnabled !== false) {
509
- const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
510
- const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
511
- allTools = [...allTools, ...normalizedMcpTools];
512
- }
513
-
514
- return allTools.map(tool => ({
515
- type: 'function',
516
- function: {
517
- name: tool.name,
518
- description: tool.description,
519
- parameters: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
520
- }
521
- }));
522
- }
523
-
524
- /**
525
- * Format tool definitions for Anthropic API
526
- */
527
- function formatToolsForAnthropic() {
528
- const config = getConfig();
529
- const localTools = getToolDefinitions();
530
-
531
- // Only add MCP tools if enabled
532
- let allTools = [...localTools];
533
- if (config.mcpEnabled !== false) {
534
- const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
535
- const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
536
- allTools = [...allTools, ...normalizedMcpTools];
537
- }
538
-
539
- return allTools.map(tool => ({
540
- name: tool.name,
541
- description: tool.description,
542
- input_schema: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
543
- }));
544
- }
545
-
546
- /**
547
- * Send message to OpenAI-compatible provider (Groq, OpenAI, Together, etc.)
548
- */
549
- async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
550
- const baseUrl = providerConfig.url.replace(/\/+$/, '');
551
- // v5.9.5: buildChatEndpoint handles MiniMax, Gemini, and OpenAI-compatible.
552
- const endpoint = isMiniMax(baseUrl)
553
- ? `${baseUrl}/v1/text/chatcompletion_v2`
554
- : isGemini(baseUrl)
555
- ? `${baseUrl}/openai/chat/completions`
556
- : `${baseUrl}/chat/completions`;
557
- const requestBody = {
558
- model: providerConfig.model,
559
- messages: messages,
560
- temperature: 0.7,
561
- max_tokens: 2048,
562
- };
563
-
564
- // NatureCo için tool calling desteklenmiyor
565
- if (!providerConfig.url.includes('api.natureco.me')) {
566
- if (tools && tools.length > 0) {
567
- requestBody.tools = tools;
568
- requestBody.tool_choice = 'auto';
569
- }
570
- }
571
-
572
- const response = await fetch(endpoint, {
573
- method: 'POST',
574
- headers: {
575
- 'Authorization': `Bearer ${providerConfig.apiKey}`,
576
- 'Content-Type': 'application/json',
577
- },
578
- body: JSON.stringify(requestBody),
579
- });
580
-
581
- if (!response.ok) {
582
- const errorText = await response.text();
583
- throw new Error(`Provider API error: ${response.status} - ${errorText}`);
584
- }
585
-
586
- const data = await response.json();
587
- const content = data.choices?.[0]?.message?.content
588
- || data.choices?.[0]?.text
589
- || data.response
590
- || data.content
591
- || '';
592
- return {
593
- role: 'assistant',
594
- content,
595
- tool_calls: data.choices?.[0]?.message?.tool_calls || undefined,
596
- usage: data.usage || undefined,
597
- };
598
- }
599
-
600
- /**
601
- * Send message to Anthropic API
602
- */
603
- async function sendMessageAnthropic(providerConfig, messages, tools) {
604
- const endpoint = `${providerConfig.url}/v1/messages`;
605
-
606
- // Anthropic requires system message separate; never send empty string.
607
- const userMessages = messages.filter(m => m.role !== 'system');
608
-
609
- const response = await fetch(endpoint, {
610
- method: 'POST',
611
- headers: {
612
- 'x-api-key': providerConfig.apiKey,
613
- 'anthropic-version': '2023-06-01',
614
- 'Content-Type': 'application/json',
615
- },
616
- body: JSON.stringify({
617
- model: providerConfig.model,
618
- max_tokens: 2000,
619
- system: extractSystemForAnthropic(messages),
620
- messages: userMessages,
621
- tools: tools,
622
- }),
623
- });
624
-
625
- if (!response.ok) {
626
- const errorText = await response.text();
627
- throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
628
- }
629
-
630
- const data = await response.json();
631
-
632
- // Convert Anthropic response to OpenAI format
633
- const content = data.content.find(c => c.type === 'text')?.text || '';
634
- const toolCalls = data.content
635
- .filter(c => c.type === 'tool_use')
636
- .map(c => ({
637
- id: c.id,
638
- type: 'function',
639
- function: {
640
- name: c.name,
641
- arguments: JSON.stringify(c.input)
642
- }
643
- }));
644
-
645
- return {
646
- role: 'assistant',
647
- content: content,
648
- tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
649
- usage: data.usage || undefined,
650
- };
651
- }
652
-
653
- /**
654
- * Send message with tool support (universal)
655
- */
656
- async function sendMessageToProvider(apiKey, message, conversationId = null, systemPrompt = null, options = {}) {
657
- const providerConfig = getProviderConfig();
658
-
659
- if (!providerConfig) {
660
- throw new Error(
661
- 'Provider not configured. Set with:\n' +
662
- ' natureco config set providerUrl https://api.groq.com/openai/v1\n' +
663
- ' natureco config set providerApiKey gsk_xxx\n' +
664
- ' natureco config set providerModel llama-3.3-70b-versatile'
665
- );
666
- }
667
-
668
- // Start MCP servers if not already started
669
- if (Object.keys(mcpClients).length === 0) {
670
- await startMcpServers();
671
- }
672
-
673
- // Get or create conversation history (load from disk)
674
- // Use consistent ID based on provider config instead of timestamp
675
- const convId = conversationId || generateDefaultConvId();
676
- const history = loadConversation(convId);
677
-
678
- // Augment system prompt with project AGENTS.md instructions
679
- const agentsMd = require('./agents-md');
680
- const augmentedPrompt = agentsMd.injectIntoPrompt(systemPrompt || '', options?.cwd || process.cwd());
681
-
682
- // Build messages
683
- let messages = [];
684
- if (augmentedPrompt) {
685
- messages.push({ role: 'system', content: augmentedPrompt });
686
- }
687
- messages.push(...history);
688
- messages.push({ role: 'user', content: message });
689
-
690
- // Get tool definitions (local + MCP) — skip if noTools flag set (chat mode)
691
- const tools = options.noTools
692
- ? []
693
- : (providerConfig.isAnthropic ? formatToolsForAnthropic() : formatToolsForOpenAI());
694
-
695
- debugLog('\n[Provider] Sending request...');
696
- debugLog('[Provider] URL:', providerConfig.url);
697
- debugLog('[Provider] Model:', providerConfig.model);
698
- debugLog('[Provider] Type:', providerConfig.isAnthropic ? 'Anthropic' : 'OpenAI-compatible');
699
- debugLog('[Provider] Messages:', messages.length);
700
- debugLog('[Provider] Tools:', tools.length, `(${Object.keys(mcpClients).length} MCP servers)`);
701
-
702
- // Tool execution loop (max 10 iterations)
703
- let iteration = 0;
704
- const maxIterations = 10;
705
- let finalResponse = null;
706
- const stream = (options.stream ?? options.noStream === undefined) !== false &&
707
- !providerConfig.url.includes('api.natureco.me');
708
-
709
- while (iteration < maxIterations) {
710
- iteration++;
711
- debugLog(`\n[Provider] Iteration ${iteration}/${maxIterations}`);
712
-
713
- let assistantMessage;
714
-
715
- if (stream) {
716
- const result = await streamProviderCompletion(providerConfig, messages, tools);
717
- if (result.type === 'text') {
718
- messages.push({ role: 'assistant', content: result.content });
719
- finalResponse = result.content;
720
- break;
721
- }
722
- assistantMessage = result.message;
723
- } else {
724
- assistantMessage = providerConfig.isAnthropic
725
- ? await sendMessageAnthropic(providerConfig, messages, tools)
726
- : await sendMessageOpenAICompatible(providerConfig, messages, tools);
727
- }
728
-
729
- if (!assistantMessage) {
730
- return {
731
- reply: 'No response from provider',
732
- conversation_id: convId,
733
- message_id: `msg_${Date.now()}`,
734
- success: false
735
- };
736
- }
737
-
738
- // Track token usage if available
739
- if (assistantMessage.usage) {
740
- TB.trackUsage(convId, {
741
- input: assistantMessage.usage.prompt_tokens || assistantMessage.usage.input_tokens || 0,
742
- output: assistantMessage.usage.completion_tokens || assistantMessage.usage.output_tokens || 0
743
- });
744
- }
745
-
746
- debugLog('[Provider] Response type:', assistantMessage?.tool_calls ? 'tool_calls' : 'text');
747
-
748
- // Add assistant message to history
749
- messages.push(assistantMessage);
750
-
751
- // Check for tool calls
752
- const hasToolCalls = assistantMessage?.tool_calls?.length > 0;
753
- if (hasToolCalls) {
754
- debugLog(`[Provider] Tool calls: ${assistantMessage.tool_calls.length}`);
755
-
756
- // Separate local and MCP tool calls
757
- const toolCalls = assistantMessage.tool_calls.map(tc => ({
758
- id: tc.id,
759
- name: tc.function.name,
760
- input: JSON.parse(tc.function.arguments)
761
- }));
762
-
763
- // Group MCP and local tools
764
- const mcpTools = getMcpTools();
765
- const mcpCalls = toolCalls.filter(tc => mcpTools.find(t => t.name === tc.name));
766
- const localCalls = toolCalls.filter(tc => !mcpTools.find(t => t.name === tc.name));
767
-
768
- // Guardrails: filter blocked tools
769
- guardrails.reset();
770
- guardrails.startIteration();
771
- const blockedMcp = mcpCalls.filter(tc => {
772
- const check = guardrails.check(tc.name, tc.input);
773
- return check.blocked;
774
- });
775
- const blockedLocal = localCalls.filter(tc => {
776
- const check = guardrails.check(tc.name, tc.input);
777
- return check.blocked;
778
- });
779
-
780
- // Execute local tools in parallel (tool-runner already parallelizes safe tools)
781
- let localResults = [];
782
- if (localCalls.filter(tc => !blockedLocal.includes(tc)).length > 0) {
783
- debugLog(`[Local] Executing ${localCalls.length} tool(s) concurrently`);
784
- localResults = await executeToolCalls(
785
- localCalls.filter(tc => !blockedLocal.includes(tc)),
786
- { toolDefinitions: getToolDefinitions() }
787
- );
788
- for (const r of localResults) {
789
- guardrails.record(r.name, {}, r.result?.success !== false);
790
- }
791
- }
792
-
793
- // Execute MCP tools in parallel (they're independent by nature)
794
- let mcpResults = [];
795
- if (mcpCalls.filter(tc => !blockedMcp.includes(tc)).length > 0) {
796
- debugLog(`[MCP] Executing ${mcpCalls.length} tool(s) concurrently`);
797
- mcpResults = await Promise.all(
798
- mcpCalls.filter(tc => !blockedMcp.includes(tc)).map(async (tc) => {
799
- const result = await executeMcpTool(tc.name, tc.input);
800
- guardrails.record(tc.name, tc.input, result?.success !== false);
801
- return { id: tc.id, name: tc.name, result };
802
- })
803
- );
804
- }
805
-
806
- // Add blocked tool results as errors
807
- const allResults = [
808
- ...blockedMcp.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
809
- ...blockedLocal.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
810
- ...localResults,
811
- ...mcpResults,
812
- ];
813
-
814
- for (const result of allResults) {
815
- const encodedContent = encodeToolResult(result.result);
816
- messages.push({
817
- role: 'tool',
818
- tool_call_id: result.id,
819
- name: result.name,
820
- content: encodedContent
821
- });
822
- }
823
-
824
- // Continue loop to get final response
825
- continue;
826
- }
827
-
828
- // No tool calls, we have final response
829
- finalResponse = assistantMessage?.content;
830
- break;
831
- }
832
-
833
- if (iteration >= maxIterations) {
834
- debugLog('\n[Provider] Max iterations reached');
835
- finalResponse = finalResponse || 'Max tool execution iterations reached.';
836
- }
837
-
838
- // Apply token budget trimming
839
- messages = TB.trimMessages(messages);
840
-
841
- // Save to conversation history (only user and final assistant message)
842
- history.push({ role: 'user', content: message });
843
- history.push({ role: 'assistant', content: finalResponse });
844
-
845
- // Save to disk (automatically keeps last 20 messages)
846
- saveConversation(convId, history);
847
-
848
- return {
849
- reply: finalResponse,
850
- conversation_id: convId,
851
- message_id: `msg_${Date.now()}`,
852
- success: true
853
- };
854
- }
855
-
856
- /**
857
- * Clear conversation history
858
- */
859
- function clearConversation(conversationId) {
860
- if (conversationId) {
861
- // Delete from disk
862
- const file = path.join(CONV_DIR, `${conversationId.replace(/[^a-z0-9]/gi, '_')}.json`);
863
- try {
864
- if (fs.existsSync(file)) {
865
- fs.unlinkSync(file);
866
- }
867
- } catch (e) {
868
- // Silently fail
869
- }
870
- // Also clear from memory (legacy)
871
- conversationHistory.delete(conversationId);
872
- } else {
873
- // Clear all conversations from disk
874
- try {
875
- if (fs.existsSync(CONV_DIR)) {
876
- const files = fs.readdirSync(CONV_DIR);
877
- files.forEach(file => {
878
- fs.unlinkSync(path.join(CONV_DIR, file));
879
- });
880
- }
881
- } catch (e) {
882
- // Silently fail
883
- }
884
- // Also clear from memory (legacy)
885
- conversationHistory.clear();
886
- }
887
- }
888
-
889
- /**
890
- * Legacy function for compatibility
891
- * Now supports custom system prompts for different platforms (terminal, WhatsApp, etc.)
892
- * @param {string} chatSystemPrompt - System prompt from chat.js (skills + memory + agents)
893
- */
894
- async function sendMessage(apiKey, botId, message, conversationId = null, chatSystemPrompt = '', options = {}) {
895
- // Handle legacy 6th param (toolDefinitions array was passed)
896
- if (Array.isArray(options)) options = {};
897
- const providerConfig = getProviderConfig();
898
-
899
- // Get user's home directory
900
- const homeDir = os.homedir();
901
-
902
- // Load memory to get botName
903
- const { loadMemory } = require('./memory');
904
- const mem = loadMemory(botId);
905
-
906
- // Get config to check MCP status
907
- const config = getConfig();
908
-
909
- // NatureCo minimal system prompt, skip tool descriptions/MCP
910
- if (providerConfig && providerConfig.url.includes('api.natureco.me')) {
911
- const prompt = chatSystemPrompt || 'Sen yardımcı bir AI asistansın.';
912
- return sendMessageToProvider(apiKey, message, conversationId, prompt, options);
913
- }
914
-
915
- // Minimal base prompt (~200 token)
916
- const toolDefs = getToolDefinitions();
917
- const toolsDesc = toolDefs.map(t => t.name).join(', ');
918
- let systemPrompt = `Assistant. Tools: ${toolsDesc}. Home: ${homeDir}.`;
919
-
920
- // Skill prompts only, max 500 chars
921
- if (chatSystemPrompt) {
922
- systemPrompt += '\n' + chatSystemPrompt.slice(0, TB.load().systemPromptMaxChars);
923
- }
924
-
925
- return sendMessageToProvider(apiKey, message, conversationId, systemPrompt, options);
926
- }
927
-
928
- /**
929
- * Validate API key against NatureCo backend
930
- * Returns { valid, error, user }
931
- */
932
- async function validateApiKey(apiKey) {
933
- const result = { valid: false, error: null, user: null };
934
- if (!apiKey) {
935
- result.error = 'API key boş olamaz';
936
- return result;
937
- }
938
- try {
939
- const res = await fetch('https://api.natureco.me/api/v1/user/me', {
940
- headers: {
941
- 'Authorization': `Bearer ${apiKey}`,
942
- 'Content-Type': 'application/json',
943
- },
944
- });
945
- if (res.ok) {
946
- const body = await res.json();
947
- result.valid = true;
948
- result.user = body.user || body.data || body;
949
- return result;
950
- }
951
- let errorBody = '';
952
- try { errorBody = await res.text(); } catch {}
953
- result.error = `API doğrulama hatası (${res.status}): ${errorBody || 'Geçersiz API key'}`;
954
- return result;
955
- } catch (e) {
956
- result.error = `Bağlantı hatası: ${e.message}`;
957
- return result;
958
- }
959
- }
960
-
961
- /**
962
- * Get bots (not used in v2.x, kept for compatibility)
963
- */
964
- async function getBots(apiKey) {
965
- const config = getConfig();
966
- const providerConfig = getProviderConfig();
967
-
968
- // NatureCo provider — gerçek bot listesini API'den çek
969
- if (config.providerUrl && config.providerUrl.includes('natureco.me')) {
970
- try {
971
- const res = await fetch('https://api.natureco.me/api/v1/bots', {
972
- headers: {
973
- 'Authorization': `Bearer ${config.providerApiKey || apiKey}`,
974
- 'Content-Type': 'application/json',
975
- },
976
- });
977
- if (res.ok) {
978
- const data = await res.json();
979
- const bots = Array.isArray(data) ? data : (data.bots || data.data || []);
980
- if (bots.length > 0) {
981
- return {
982
- bots: bots.map(b => ({
983
- id: b.id,
984
- name: b.name,
985
- ai_provider: b.ai_provider || 'natureco',
986
- model: b.model || 'natureco-default',
987
- system_prompt: b.system_prompt || '',
988
- }))
989
- };
990
- }
991
- }
992
- } catch (e) {
993
- debugLog('[getBots] NatureCo API error:', e.message);
994
- return { bots: [], error: e.message };
995
- }
996
- }
997
-
998
- // Diğer provider'lar universal provider döndür
999
- const providerName = providerConfig?.isAnthropic ? 'Anthropic' : 'OpenAI-compatible';
1000
- const botName = config.botName || `Universal Provider (${providerName})`;
1001
- return {
1002
- bots: [
1003
- {
1004
- id: 'universal-provider',
1005
- name: botName,
1006
- ai_provider: providerName,
1007
- model: providerConfig?.model || 'unknown'
1008
- }
1009
- ]
1010
- };
1011
- }
1012
-
1013
- // ── Streaming Support ────────────────────────────────────────────────────────────
1014
-
1015
- async function streamProviderCompletion(providerConfig, messages, tools) {
1016
- if (providerConfig.isAnthropic) {
1017
- return streamAnthropicCompletion(providerConfig, messages);
1018
- }
1019
- return streamOpenAICompletion(providerConfig, messages, tools);
1020
- }
1021
-
1022
- async function streamOpenAICompletion(providerConfig, messages, tools) {
1023
- const baseUrl = providerConfig.url.replace(/\/+$/, '');
1024
- // v5.9.5: buildChatEndpoint handles MiniMax, Gemini, and OpenAI-compatible.
1025
- const endpoint = isMiniMax(baseUrl)
1026
- ? `${baseUrl}/v1/text/chatcompletion_v2`
1027
- : isGemini(baseUrl)
1028
- ? `${baseUrl}/openai/chat/completions`
1029
- : `${baseUrl}/chat/completions`;
1030
-
1031
- const requestBody = {
1032
- model: providerConfig.model,
1033
- messages,
1034
- temperature: 0.7,
1035
- max_tokens: 2000,
1036
- stream: true,
1037
- };
1038
- if (tools && tools.length > 0) {
1039
- requestBody.tools = tools;
1040
- requestBody.tool_choice = 'auto';
1041
- }
1042
-
1043
- const response = await fetch(endpoint, {
1044
- method: 'POST',
1045
- headers: {
1046
- 'Authorization': `Bearer ${providerConfig.apiKey}`,
1047
- 'Content-Type': 'application/json',
1048
- },
1049
- body: JSON.stringify(requestBody),
1050
- });
1051
-
1052
- if (!response.ok) {
1053
- throw new Error(`Provider API error: ${response.status} - ${await response.text()}`);
1054
- }
1055
-
1056
- const reader = response.body.getReader();
1057
- const decoder = new TextDecoder();
1058
- let fullText = '';
1059
- const toolCalls = [];
1060
- let hasToolCalls = false;
1061
-
1062
- while (true) {
1063
- const { done, value } = await reader.read();
1064
- if (done) break;
1065
-
1066
- const chunk = decoder.decode(value, { stream: true });
1067
- const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1068
-
1069
- for (const line of lines) {
1070
- const data = line.slice(6).trim();
1071
- if (data === '[DONE]') continue;
1072
- try {
1073
- const parsed = JSON.parse(data);
1074
- const delta = parsed.choices?.[0]?.delta;
1075
- if (!delta) continue;
1076
-
1077
- if (delta.tool_calls) {
1078
- hasToolCalls = true;
1079
- accumulateToolCallDeltas(toolCalls, delta.tool_calls);
1080
- }
1081
-
1082
- const token = delta.content || '';
1083
- if (token) {
1084
- if (!hasToolCalls) process.stdout.write(token);
1085
- fullText += token;
1086
- }
1087
- } catch {}
1088
- }
1089
- }
1090
-
1091
- if (hasToolCalls) {
1092
- process.stdout.write('\n');
1093
- return {
1094
- type: 'tool_calls',
1095
- message: {
1096
- role: 'assistant',
1097
- content: fullText || null,
1098
- tool_calls: toolCalls
1099
- .filter(tc => tc && tc.function && tc.function.name)
1100
- .map(tc => ({
1101
- id: tc.id || `call_${Date.now()}_${tc.index}`,
1102
- type: tc.type || 'function',
1103
- function: { name: tc.function.name, arguments: tc.function.arguments || '' }
1104
- }))
1105
- }
1106
- };
1107
- }
1108
-
1109
- process.stdout.write('\n');
1110
- return { type: 'text', content: fullText };
1111
- }
1112
-
1113
- async function streamAnthropicCompletion(providerConfig, messages) {
1114
- const endpoint = `${providerConfig.url}/v1/messages`;
1115
-
1116
- const userMessages = messages.filter(m => m.role !== 'system');
1117
-
1118
- const response = await fetch(endpoint, {
1119
- method: 'POST',
1120
- headers: {
1121
- 'x-api-key': providerConfig.apiKey,
1122
- 'anthropic-version': '2023-06-01',
1123
- 'Content-Type': 'application/json',
1124
- },
1125
- body: JSON.stringify({
1126
- model: providerConfig.model,
1127
- max_tokens: 2000,
1128
- system: extractSystemForAnthropic(messages),
1129
- messages: userMessages,
1130
- stream: true,
1131
- }),
1132
- });
1133
-
1134
- if (!response.ok) {
1135
- throw new Error(`Anthropic API error: ${response.status} - ${await response.text()}`);
1136
- }
1137
-
1138
- const reader = response.body.getReader();
1139
- const decoder = new TextDecoder();
1140
- let fullText = '';
1141
-
1142
- while (true) {
1143
- const { done, value } = await reader.read();
1144
- if (done) break;
1145
-
1146
- const chunk = decoder.decode(value, { stream: true });
1147
- const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1148
-
1149
- for (const line of lines) {
1150
- const data = line.slice(6).trim();
1151
- if (data === '[DONE]') continue;
1152
- try {
1153
- const parsed = JSON.parse(data);
1154
- const token = parsed.delta?.text || '';
1155
- if (token) {
1156
- process.stdout.write(token);
1157
- fullText += token;
1158
- }
1159
- } catch {}
1160
- }
1161
- }
1162
-
1163
- process.stdout.write('\n');
1164
- return { type: 'text', content: fullText };
1165
- }
1166
-
1167
- module.exports = {
1168
- sendMessage,
1169
- sendMessageToProvider,
1170
- validateApiKey,
1171
- getBots,
1172
- clearConversation,
1173
- getProviderConfig,
1174
- startMcpServers,
1175
- stopMcpServers,
1176
- getMcpTools,
1177
- streamProviderCompletion,
1178
- streamOpenAICompletion,
1179
- streamAnthropicCompletion,
1180
- // Exposed for tests + advanced consumers (does not appear in the
1181
- // public API surface of natureco's user-facing docs).
1182
- _internals: {
1183
- extractSystemForAnthropic,
1184
- DEFAULT_ANTHROPIC_SYSTEM,
1185
- buildRequestBody,
1186
- },
1187
- _sendMessage: sendMessage,
1188
- };
1
+ // NatureCo CLI v2.10.1 - Universal LLM Provider Support + MCP Integration
2
+ // Supports: OpenAI, Groq, Together, Fireworks, Perplexity, Mistral, DeepSeek, OpenRouter, Ollama, LM Studio, Anthropic
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const chalk = require('chalk');
8
+ const { getConfig } = require('./config');
9
+ const { getToolDefinitions, executeToolCalls } = require('./tool-runner');
10
+ const { MCPClient } = require('./mcp-client');
11
+ const TB = require('./token-budget');
12
+ const { accumulateToolCallDeltas } = require('./streaming-tools');
13
+ const { ToolGuardrails } = require('./tool-guardrails');
14
+ const guardrails = new ToolGuardrails();
15
+
16
+ /**
17
+ * v5.5.0: Provider-specific format detection
18
+ * Groq, OpenAI, Anthropic, Mistral, DeepSeek, OpenRouter, Ollama, MiniMax
19
+ *
20
+ * Canonical implementation lives in src/utils/provider-detect.js;
21
+ * re-exported here so the historical `detectProvider` reference inside
22
+ * api.js continues to work without touching every call site.
23
+ */
24
+ const { detectProvider, isMiniMax, isGemini, buildChatEndpoint } = require('./provider-detect');
25
+
26
+ /**
27
+ * v5.5.0: Tool definitions'ı provider'a göre normalize et
28
+ * - OpenAI/Groq/Mistral/DeepSeek/OpenRouter: tool_choice, function calling OK
29
+ * - Anthropic: tools, system ayrı, content array
30
+ * - Ollama: tool support sınırlı, genelde yok
31
+ * - Perplexity: tool support yok
32
+ */
33
+ function normalizeToolsForProvider(tools, provider) {
34
+ if (!tools || tools.length === 0) return tools;
35
+ if (provider === 'ollama' || provider === 'perplexity') {
36
+ // Bu providerlar tool support etmiyor - bos dondur
37
+ return [];
38
+ }
39
+ if (provider === 'anthropic') {
40
+ // Anthropic tools format: { name, description, input_schema }
41
+ return tools.map(t => ({
42
+ name: t.function.name,
43
+ description: t.function.description,
44
+ input_schema: t.function.parameters || { type: 'object', properties: {} }
45
+ }));
46
+ }
47
+ // OpenAI-compatible (Groq, Mistral, DeepSeek, OpenRouter, MiniMax, Together, Fireworks)
48
+ return tools;
49
+ }
50
+
51
+ /**
52
+ * v5.5.0: Tool call'ları provider'a göre parse et
53
+ * OpenAI: tool_calls[].function.arguments (string)
54
+ * Anthropic: content[].type=tool_use, input (object)
55
+ * Ollama: genelde yok
56
+ */
57
+ function parseToolCallsFromResponse(message, provider) {
58
+ if (provider === 'anthropic') {
59
+ // Anthropic: content array içinde tool_use blokları var
60
+ if (Array.isArray(message.content)) {
61
+ const toolUses = message.content.filter(c => c.type === 'tool_use');
62
+ return toolUses.map(tu => ({
63
+ id: tu.id,
64
+ type: 'function',
65
+ function: {
66
+ name: tu.name,
67
+ arguments: JSON.stringify(tu.input || {})
68
+ }
69
+ }));
70
+ }
71
+ return [];
72
+ }
73
+ // OpenAI-compatible
74
+ return message.tool_calls || [];
75
+ }
76
+
77
+ /**
78
+ * Anthropic Messages API requires `system` to be either a non-empty string
79
+ * or omitted entirely. Sending `undefined` (which JSON.stringify drops to
80
+ * silent absence) leaves the model unanchored; sending `''` returns 400
81
+ * "system: cannot be empty" on recent API revisions. Always pass a
82
+ * meaningful default when no system message is present.
83
+ */
84
+ const DEFAULT_ANTHROPIC_SYSTEM =
85
+ 'You are a helpful AI assistant running inside the natureco CLI.';
86
+
87
+ function extractSystemForAnthropic(messages) {
88
+ const systemMsg = messages.find(m => m.role === 'system');
89
+ if (!systemMsg) return DEFAULT_ANTHROPIC_SYSTEM;
90
+ // content may be a string or an array of content blocks; both round-trip.
91
+ if (typeof systemMsg.content === 'string') {
92
+ return systemMsg.content.trim() || DEFAULT_ANTHROPIC_SYSTEM;
93
+ }
94
+ if (Array.isArray(systemMsg.content) && systemMsg.content.length > 0) {
95
+ return systemMsg.content;
96
+ }
97
+ return DEFAULT_ANTHROPIC_SYSTEM;
98
+ }
99
+
100
+ /**
101
+ * v5.5.0: System mesajı provider'a göre ayarla
102
+ * - OpenAI: messages[].role=system
103
+ * - Anthropic: ayrı 'system' field
104
+ */
105
+ function buildRequestBody(messages, model, options, provider) {
106
+ if (provider === 'anthropic') {
107
+ const userMsgs = messages.filter(m => m.role !== 'system');
108
+ return {
109
+ model,
110
+ messages: userMsgs.map(m => ({
111
+ role: m.role,
112
+ content: m.content
113
+ })),
114
+ system: extractSystemForAnthropic(messages),
115
+ max_tokens: options.max_tokens || 4096,
116
+ temperature: options.temperature || 0.7,
117
+ ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {})
118
+ };
119
+ }
120
+ // OpenAI-compatible
121
+ return {
122
+ model,
123
+ messages,
124
+ max_tokens: options.max_tokens || 4096,
125
+ temperature: options.temperature || 0.7,
126
+ ...(options.tools && options.tools.length > 0 ? { tools: options.tools, tool_choice: 'auto' } : {})
127
+ };
128
+ }
129
+
130
+
131
+ // Persistent conversation directory
132
+ const CONV_DIR = path.join(os.homedir(), '.natureco', 'conversations');
133
+
134
+ // Conversation history for multi-turn chat (deprecated - now using disk storage)
135
+ const conversationHistory = new Map();
136
+
137
+ // MCP clients (server name -> { client, tools })
138
+ const mcpClients = {};
139
+
140
+ /**
141
+ * Generate default conversation ID based on provider config
142
+ */
143
+ function generateDefaultConvId() {
144
+ const config = getConfig();
145
+
146
+ // Use provider URL + model as base for consistent ID
147
+ const providerUrl = config.providerUrl || 'default';
148
+ const model = config.providerModel || 'default';
149
+
150
+ // Create simple hash-like ID from provider + model
151
+ const base = `${providerUrl}_${model}`.replace(/[^a-z0-9]/gi, '_').toLowerCase();
152
+
153
+ // Return consistent ID (e.g., "groq_llama_3_1_8b_instant")
154
+ return base.slice(0, 50); // Limit length
155
+ }
156
+
157
+ /**
158
+ * Load conversation from disk
159
+ */
160
+ function loadConversation(convId) {
161
+ const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
162
+ try {
163
+ fs.mkdirSync(CONV_DIR, { recursive: true });
164
+ if (fs.existsSync(file)) {
165
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
166
+ return Array.isArray(parsed) ? parsed : [];
167
+ }
168
+ } catch (e) {
169
+ // Silently fail
170
+ }
171
+ return [];
172
+ }
173
+
174
+ /**
175
+ * Save conversation to disk
176
+ */
177
+ function saveConversation(convId, messages) {
178
+ const file = path.join(CONV_DIR, `${convId.replace(/[^a-z0-9]/gi, '_')}.json`);
179
+ try {
180
+ fs.mkdirSync(CONV_DIR, { recursive: true });
181
+ // Keep only last 10 messages
182
+ fs.writeFileSync(file, JSON.stringify(messages.slice(-(TB.load().conversationOnDisk)), null, 2));
183
+ } catch (e) {
184
+ // Silently fail
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Start MCP servers from config
190
+ */
191
+ async function startMcpServers() {
192
+ const config = getConfig();
193
+
194
+ // Skip if MCP is disabled
195
+ if (config.mcpEnabled === false) {
196
+ debugLog('[MCP] MCP is disabled in config, skipping server startup');
197
+ return;
198
+ }
199
+
200
+ const servers = config.mcpServers || {};
201
+
202
+ for (const [name, server] of Object.entries(servers)) {
203
+ // Skip disabled servers
204
+ if (server.disabled) {
205
+ debugLog(`[MCP] Skipping disabled server: ${name}`);
206
+ continue;
207
+ }
208
+
209
+ // Skip if already started
210
+ if (mcpClients[name]) {
211
+ debugLog(`[MCP] Server already running: ${name}`);
212
+ continue;
213
+ }
214
+
215
+ try {
216
+ debugLog(`[MCP] Starting server: ${name}`);
217
+
218
+ const client = new MCPClient(server.command, server.args, server.env || {});
219
+ await client.start();
220
+
221
+ const tools = await client.listTools();
222
+ debugLog(`[MCP] Server ${name} loaded ${tools.length} tools`);
223
+
224
+ mcpClients[name] = { client, tools };
225
+
226
+ } catch (err) {
227
+ debugLog(`[MCP] Failed to start server ${name}: ${err.message}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Stop all MCP servers
234
+ */
235
+ function stopMcpServers() {
236
+ for (const [name, { client }] of Object.entries(mcpClients)) {
237
+ try {
238
+ debugLog(`[MCP] Stopping server: ${name}`);
239
+ client.stop();
240
+ } catch (err) {
241
+ debugLog(`[MCP] Failed to stop server ${name}: ${err.message}`);
242
+ }
243
+ }
244
+
245
+ // Clear clients
246
+ Object.keys(mcpClients).forEach(key => delete mcpClients[key]);
247
+ }
248
+
249
+ /**
250
+ * Get all MCP tools (combined from all servers)
251
+ */
252
+ function getMcpTools() {
253
+ const allTools = [];
254
+
255
+ for (const [serverName, { tools }] of Object.entries(mcpClients)) {
256
+ for (const tool of tools) {
257
+ allTools.push({
258
+ ...tool,
259
+ _mcpServer: serverName, // Track which server this tool belongs to
260
+ });
261
+ }
262
+ }
263
+
264
+ return allTools;
265
+ }
266
+
267
+ // Groq-incompatible MCP tools (strict validation issues)
268
+ const BLOCKED_MCP_TOOLS = ['search_issues', 'search_repositories'];
269
+
270
+ /**
271
+ * Get MCP tools filtered for AI consumption
272
+ * Removes tools that are incompatible with Groq's strict validation
273
+ */
274
+ function getMcpToolsForAI() {
275
+ const tools = getMcpTools();
276
+ return tools.filter(t => !BLOCKED_MCP_TOOLS.includes(t.name));
277
+ }
278
+
279
+ /**
280
+ * Normalize MCP tool schema for AI consumption
281
+ * Adds hints to number/integer parameters to prevent string conversion
282
+ */
283
+ function normalizeMcpToolSchema(tool) {
284
+ const schema = tool.inputSchema || tool.input_schema || {};
285
+
286
+ // Ensure properties exist
287
+ if (!schema.properties) return tool;
288
+
289
+ // Clone schema to avoid mutating original
290
+ const normalizedSchema = JSON.parse(JSON.stringify(schema));
291
+
292
+ // Groq sometimes sends strings for number params
293
+ // Add coercion hint to description
294
+ for (const [key, prop] of Object.entries(normalizedSchema.properties)) {
295
+ if (prop.type === 'number' || prop.type === 'integer') {
296
+ prop.description = (prop.description || '') + ' (must be a number, not a string)';
297
+ }
298
+ }
299
+
300
+ return { ...tool, inputSchema: normalizedSchema };
301
+ }
302
+
303
+ /**
304
+ * Minimize MCP tool schema to reduce token usage
305
+ * Truncates descriptions and removes unnecessary fields
306
+ */
307
+ function minimizeMcpTool(tool) {
308
+ return {
309
+ name: tool.name,
310
+ description: TB.capMcpDesc(tool.description),
311
+ inputSchema: {
312
+ type: tool.inputSchema?.type || 'object',
313
+ properties: Object.fromEntries(
314
+ Object.entries(tool.inputSchema?.properties || {}).map(([k, v]) => [
315
+ k,
316
+ {
317
+ type: v.type,
318
+ ...(v.enum ? { enum: v.enum } : {}) // Include enum only if exists
319
+ }
320
+ ])
321
+ ),
322
+ required: tool.inputSchema?.required || []
323
+ }
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Coerce MCP tool parameters to match schema types
329
+ */
330
+ function coerceMcpParams(tool, params) {
331
+ // GitHub MCP uses inputSchema, others may use input_schema
332
+ // Try all possible schema locations
333
+ const schema = tool.inputSchema?.properties ||
334
+ tool.input_schema?.properties ||
335
+ tool.parameters?.properties || // fallback
336
+ {};
337
+
338
+ const coerced = { ...params };
339
+
340
+ for (const [key, def] of Object.entries(schema)) {
341
+ if (coerced[key] === undefined || coerced[key] === null) continue;
342
+
343
+ // Coerce number or integer
344
+ if ((def.type === 'number' || def.type === 'integer') && typeof coerced[key] === 'string') {
345
+ const num = Number(coerced[key]);
346
+ if (!isNaN(num)) {
347
+ coerced[key] = num;
348
+ }
349
+ }
350
+
351
+ // Coerce boolean
352
+ if (def.type === 'boolean' && typeof coerced[key] === 'string') {
353
+ coerced[key] = coerced[key] === 'true' || coerced[key] === '1';
354
+ }
355
+ }
356
+
357
+ return coerced;
358
+ }
359
+
360
+ /**
361
+ * Execute MCP tool call
362
+ */
363
+ async function executeMcpTool(toolName, toolArgs) {
364
+ // Find which server has this tool
365
+ for (const [serverName, { client, tools }] of Object.entries(mcpClients)) {
366
+ const tool = tools.find(t => t.name === toolName);
367
+
368
+ if (tool) {
369
+ debugLog(`[MCP] Calling tool ${toolName} on server ${serverName}`);
370
+
371
+ try {
372
+ // Coerce parameters to match schema types
373
+ const coercedArgs = coerceMcpParams(tool, toolArgs);
374
+
375
+ const result = await client.callTool(toolName, coercedArgs);
376
+
377
+ // MCP returns { content: [{ type: 'text', text: '...' }] }
378
+ if (result.content && result.content.length > 0) {
379
+ // Extract all text content and join with newlines
380
+ const textContents = result.content
381
+ .filter(c => c.type === 'text')
382
+ .map(c => c.text);
383
+
384
+ if (textContents.length > 0) {
385
+ let output = textContents.join('\n');
386
+
387
+ // Truncate MCP result
388
+ const maxChars = TB.load().toolMaxChars;
389
+ if (output.length > maxChars) {
390
+ output = output.slice(0, maxChars) + '... (truncated)';
391
+ }
392
+
393
+ return {
394
+ success: true,
395
+ output: output
396
+ };
397
+ }
398
+ }
399
+
400
+ // Fallback: return entire result as JSON
401
+ let fallbackOutput = JSON.stringify(result, null, 2);
402
+
403
+ // Truncate fallback output too
404
+ const maxChars = TB.load().toolMaxChars;
405
+ if (fallbackOutput.length > maxChars) {
406
+ fallbackOutput = fallbackOutput.slice(0, maxChars) + '... (truncated)';
407
+ }
408
+
409
+ return {
410
+ success: true,
411
+ output: fallbackOutput
412
+ };
413
+
414
+ } catch (err) {
415
+ return {
416
+ success: false,
417
+ error: `MCP tool error: ${err.message}`
418
+ };
419
+ }
420
+ }
421
+ }
422
+
423
+ return {
424
+ success: false,
425
+ error: `MCP tool not found: ${toolName}`
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Encode tool result for safe transmission
431
+ * Works for both MCP and local tools
432
+ */
433
+ function encodeToolResult(toolResult) {
434
+ let content;
435
+
436
+ // Handle different result formats
437
+ if (typeof toolResult === 'string') {
438
+ content = toolResult;
439
+ } else if (toolResult.output) {
440
+ content = toolResult.output;
441
+ } else if (toolResult.success !== undefined) {
442
+ // Handle { success: true/false, output/error: ... } format
443
+ content = toolResult.success ? (toolResult.output || JSON.stringify(toolResult)) : (toolResult.error || 'Unknown error');
444
+ } else {
445
+ content = JSON.stringify(toolResult);
446
+ }
447
+
448
+ // Base64 encode
449
+ const encoded = Buffer.from(content).toString('base64');
450
+ return `[BASE64_ENCODED_RESULT]: ${encoded}`;
451
+ }
452
+
453
+ /**
454
+ * Check if debug mode is enabled
455
+ */
456
+ function isDebugEnabled() {
457
+ const config = getConfig();
458
+ return config.debug === true || config.debug === 'true';
459
+ }
460
+
461
+ /**
462
+ * Debug log (only if debug mode enabled)
463
+ */
464
+ function debugLog(...args) {
465
+ if (isDebugEnabled()) {
466
+ console.log(...args);
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Get provider configuration from config
472
+ */
473
+ function getProviderConfig() {
474
+ const config = getConfig();
475
+
476
+ // Universal provider config (v2.1.0+)
477
+ if (config.providerUrl && config.providerApiKey) {
478
+ return {
479
+ url: config.providerUrl,
480
+ apiKey: config.providerApiKey,
481
+ model: config.providerModel || 'llama-3.3-70b-versatile',
482
+ isAnthropic: config.providerUrl.includes('anthropic.com')
483
+ };
484
+ }
485
+
486
+ // Legacy Groq config (v2.0.x)
487
+ if (config.groqApiKey) {
488
+ return {
489
+ url: 'https://api.groq.com/openai/v1',
490
+ apiKey: config.groqApiKey,
491
+ model: config.groqModel || 'llama-3.3-70b-versatile',
492
+ isAnthropic: false
493
+ };
494
+ }
495
+
496
+ return null;
497
+ }
498
+
499
+ /**
500
+ * Format tool definitions for OpenAI-compatible APIs
501
+ */
502
+ function formatToolsForOpenAI() {
503
+ const config = getConfig();
504
+ const localTools = getToolDefinitions();
505
+
506
+ // Only add MCP tools if enabled
507
+ let allTools = [...localTools];
508
+ if (config.mcpEnabled !== false) {
509
+ const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
510
+ const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
511
+ allTools = [...allTools, ...normalizedMcpTools];
512
+ }
513
+
514
+ return allTools.map(tool => ({
515
+ type: 'function',
516
+ function: {
517
+ name: tool.name,
518
+ description: tool.description,
519
+ parameters: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
520
+ }
521
+ }));
522
+ }
523
+
524
+ /**
525
+ * Format tool definitions for Anthropic API
526
+ */
527
+ function formatToolsForAnthropic() {
528
+ const config = getConfig();
529
+ const localTools = getToolDefinitions();
530
+
531
+ // Only add MCP tools if enabled
532
+ let allTools = [...localTools];
533
+ if (config.mcpEnabled !== false) {
534
+ const mcpTools = getMcpToolsForAI().map(minimizeMcpTool);
535
+ const normalizedMcpTools = mcpTools.map(tool => normalizeMcpToolSchema(tool));
536
+ allTools = [...allTools, ...normalizedMcpTools];
537
+ }
538
+
539
+ return allTools.map(tool => ({
540
+ name: tool.name,
541
+ description: tool.description,
542
+ input_schema: tool.inputSchema || tool.input_schema || { type: 'object', properties: {} }
543
+ }));
544
+ }
545
+
546
+ /**
547
+ * Send message to OpenAI-compatible provider (Groq, OpenAI, Together, etc.)
548
+ */
549
+ async function sendMessageOpenAICompatible(providerConfig, messages, tools) {
550
+ const baseUrl = providerConfig.url.replace(/\/+$/, '');
551
+ // Tek doğruluk kaynağı: provider-detect.buildChatEndpoint (MiniMax /v1 toleransı dahil)
552
+ const endpoint = buildChatEndpoint(baseUrl);
553
+ const requestBody = {
554
+ model: providerConfig.model,
555
+ messages: messages,
556
+ temperature: 0.7,
557
+ max_tokens: 2048,
558
+ };
559
+
560
+ // NatureCo için tool calling desteklenmiyor
561
+ if (!providerConfig.url.includes('api.natureco.me')) {
562
+ if (tools && tools.length > 0) {
563
+ requestBody.tools = tools;
564
+ requestBody.tool_choice = 'auto';
565
+ }
566
+ }
567
+
568
+ const response = await fetch(endpoint, {
569
+ method: 'POST',
570
+ headers: {
571
+ 'Authorization': `Bearer ${providerConfig.apiKey}`,
572
+ 'Content-Type': 'application/json',
573
+ },
574
+ body: JSON.stringify(requestBody),
575
+ });
576
+
577
+ if (!response.ok) {
578
+ const errorText = await response.text();
579
+ throw new Error(`Provider API error: ${response.status} - ${errorText}`);
580
+ }
581
+
582
+ const data = await response.json();
583
+ const content = data.choices?.[0]?.message?.content
584
+ || data.choices?.[0]?.text
585
+ || data.response
586
+ || data.content
587
+ || '';
588
+ // Maliyet takibi — usage döndüren her çağrı kaydedilir (natureco cost)
589
+ recordUsageSafe(providerConfig, data.usage);
590
+ return {
591
+ role: 'assistant',
592
+ content,
593
+ tool_calls: data.choices?.[0]?.message?.tool_calls || undefined,
594
+ usage: data.usage || undefined,
595
+ };
596
+ }
597
+
598
+ /**
599
+ * Kullanımı cost-tracker'a kaydet; takip hatası asıl akışı asla bozmasın.
600
+ */
601
+ function recordUsageSafe(providerConfig, usage) {
602
+ if (!usage) return;
603
+ try {
604
+ const { recordUsage } = require('./cost-tracker');
605
+ recordUsage({
606
+ provider: detectProvider(providerConfig.url, providerConfig.model),
607
+ model: providerConfig.model,
608
+ input: usage.prompt_tokens ?? usage.input_tokens ?? 0,
609
+ output: usage.completion_tokens ?? usage.output_tokens ?? 0,
610
+ command: process.argv.slice(2).find(a => !a.startsWith('-')) || null,
611
+ });
612
+ } catch { /* takip hatası sessiz geçilir */ }
613
+ }
614
+
615
+ /**
616
+ * Send message to Anthropic API
617
+ */
618
+ async function sendMessageAnthropic(providerConfig, messages, tools) {
619
+ const endpoint = `${providerConfig.url}/v1/messages`;
620
+
621
+ // Anthropic requires system message separate; never send empty string.
622
+ const userMessages = messages.filter(m => m.role !== 'system');
623
+
624
+ const response = await fetch(endpoint, {
625
+ method: 'POST',
626
+ headers: {
627
+ 'x-api-key': providerConfig.apiKey,
628
+ 'anthropic-version': '2023-06-01',
629
+ 'Content-Type': 'application/json',
630
+ },
631
+ body: JSON.stringify({
632
+ model: providerConfig.model,
633
+ max_tokens: 2000,
634
+ system: extractSystemForAnthropic(messages),
635
+ messages: userMessages,
636
+ tools: tools,
637
+ }),
638
+ });
639
+
640
+ if (!response.ok) {
641
+ const errorText = await response.text();
642
+ throw new Error(`Anthropic API error: ${response.status} - ${errorText}`);
643
+ }
644
+
645
+ const data = await response.json();
646
+
647
+ // Convert Anthropic response to OpenAI format
648
+ const content = data.content.find(c => c.type === 'text')?.text || '';
649
+ const toolCalls = data.content
650
+ .filter(c => c.type === 'tool_use')
651
+ .map(c => ({
652
+ id: c.id,
653
+ type: 'function',
654
+ function: {
655
+ name: c.name,
656
+ arguments: JSON.stringify(c.input)
657
+ }
658
+ }));
659
+
660
+ // Maliyet takibi (Anthropic: input_tokens/output_tokens)
661
+ recordUsageSafe(providerConfig, data.usage);
662
+
663
+ return {
664
+ role: 'assistant',
665
+ content: content,
666
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
667
+ usage: data.usage || undefined,
668
+ };
669
+ }
670
+
671
+ /**
672
+ * Send message with tool support (universal)
673
+ */
674
+ async function sendMessageToProvider(apiKey, message, conversationId = null, systemPrompt = null, options = {}) {
675
+ const providerConfig = getProviderConfig();
676
+
677
+ if (!providerConfig) {
678
+ throw new Error(
679
+ 'Provider not configured. Set with:\n' +
680
+ ' natureco config set providerUrl https://api.groq.com/openai/v1\n' +
681
+ ' natureco config set providerApiKey gsk_xxx\n' +
682
+ ' natureco config set providerModel llama-3.3-70b-versatile'
683
+ );
684
+ }
685
+
686
+ // Start MCP servers if not already started
687
+ if (Object.keys(mcpClients).length === 0) {
688
+ await startMcpServers();
689
+ }
690
+
691
+ // Get or create conversation history (load from disk)
692
+ // Use consistent ID based on provider config instead of timestamp
693
+ const convId = conversationId || generateDefaultConvId();
694
+ const history = loadConversation(convId);
695
+
696
+ // Augment system prompt with project AGENTS.md instructions
697
+ const agentsMd = require('./agents-md');
698
+ const augmentedPrompt = agentsMd.injectIntoPrompt(systemPrompt || '', options?.cwd || process.cwd());
699
+
700
+ // Build messages
701
+ let messages = [];
702
+ if (augmentedPrompt) {
703
+ messages.push({ role: 'system', content: augmentedPrompt });
704
+ }
705
+ messages.push(...history);
706
+ messages.push({ role: 'user', content: message });
707
+
708
+ // Get tool definitions (local + MCP) — skip if noTools flag set (chat mode)
709
+ const tools = options.noTools
710
+ ? []
711
+ : (providerConfig.isAnthropic ? formatToolsForAnthropic() : formatToolsForOpenAI());
712
+
713
+ debugLog('\n[Provider] Sending request...');
714
+ debugLog('[Provider] URL:', providerConfig.url);
715
+ debugLog('[Provider] Model:', providerConfig.model);
716
+ debugLog('[Provider] Type:', providerConfig.isAnthropic ? 'Anthropic' : 'OpenAI-compatible');
717
+ debugLog('[Provider] Messages:', messages.length);
718
+ debugLog('[Provider] Tools:', tools.length, `(${Object.keys(mcpClients).length} MCP servers)`);
719
+
720
+ // Tool execution loop (max 10 iterations)
721
+ let iteration = 0;
722
+ const maxIterations = 10;
723
+ let finalResponse = null;
724
+ const stream = (options.stream ?? options.noStream === undefined) !== false &&
725
+ !providerConfig.url.includes('api.natureco.me');
726
+
727
+ while (iteration < maxIterations) {
728
+ iteration++;
729
+ debugLog(`\n[Provider] Iteration ${iteration}/${maxIterations}`);
730
+
731
+ let assistantMessage;
732
+
733
+ if (stream) {
734
+ const result = await streamProviderCompletion(providerConfig, messages, tools);
735
+ if (result.type === 'text') {
736
+ messages.push({ role: 'assistant', content: result.content });
737
+ finalResponse = result.content;
738
+ break;
739
+ }
740
+ assistantMessage = result.message;
741
+ } else {
742
+ assistantMessage = providerConfig.isAnthropic
743
+ ? await sendMessageAnthropic(providerConfig, messages, tools)
744
+ : await sendMessageOpenAICompatible(providerConfig, messages, tools);
745
+ }
746
+
747
+ if (!assistantMessage) {
748
+ return {
749
+ reply: 'No response from provider',
750
+ conversation_id: convId,
751
+ message_id: `msg_${Date.now()}`,
752
+ success: false
753
+ };
754
+ }
755
+
756
+ // Track token usage if available
757
+ if (assistantMessage.usage) {
758
+ TB.trackUsage(convId, {
759
+ input: assistantMessage.usage.prompt_tokens || assistantMessage.usage.input_tokens || 0,
760
+ output: assistantMessage.usage.completion_tokens || assistantMessage.usage.output_tokens || 0
761
+ });
762
+ }
763
+
764
+ debugLog('[Provider] Response type:', assistantMessage?.tool_calls ? 'tool_calls' : 'text');
765
+
766
+ // Add assistant message to history
767
+ messages.push(assistantMessage);
768
+
769
+ // Check for tool calls
770
+ const hasToolCalls = assistantMessage?.tool_calls?.length > 0;
771
+ if (hasToolCalls) {
772
+ debugLog(`[Provider] Tool calls: ${assistantMessage.tool_calls.length}`);
773
+
774
+ // Separate local and MCP tool calls
775
+ const toolCalls = assistantMessage.tool_calls.map(tc => ({
776
+ id: tc.id,
777
+ name: tc.function.name,
778
+ input: JSON.parse(tc.function.arguments)
779
+ }));
780
+
781
+ // Group MCP and local tools
782
+ const mcpTools = getMcpTools();
783
+ const mcpCalls = toolCalls.filter(tc => mcpTools.find(t => t.name === tc.name));
784
+ const localCalls = toolCalls.filter(tc => !mcpTools.find(t => t.name === tc.name));
785
+
786
+ // Guardrails: filter blocked tools
787
+ guardrails.reset();
788
+ guardrails.startIteration();
789
+ const blockedMcp = mcpCalls.filter(tc => {
790
+ const check = guardrails.check(tc.name, tc.input);
791
+ return check.blocked;
792
+ });
793
+ const blockedLocal = localCalls.filter(tc => {
794
+ const check = guardrails.check(tc.name, tc.input);
795
+ return check.blocked;
796
+ });
797
+
798
+ // Execute local tools in parallel (tool-runner already parallelizes safe tools)
799
+ let localResults = [];
800
+ if (localCalls.filter(tc => !blockedLocal.includes(tc)).length > 0) {
801
+ debugLog(`[Local] Executing ${localCalls.length} tool(s) concurrently`);
802
+ localResults = await executeToolCalls(
803
+ localCalls.filter(tc => !blockedLocal.includes(tc)),
804
+ { toolDefinitions: getToolDefinitions() }
805
+ );
806
+ for (const r of localResults) {
807
+ guardrails.record(r.name, {}, r.result?.success !== false);
808
+ }
809
+ }
810
+
811
+ // Execute MCP tools in parallel (they're independent by nature)
812
+ let mcpResults = [];
813
+ if (mcpCalls.filter(tc => !blockedMcp.includes(tc)).length > 0) {
814
+ debugLog(`[MCP] Executing ${mcpCalls.length} tool(s) concurrently`);
815
+ mcpResults = await Promise.all(
816
+ mcpCalls.filter(tc => !blockedMcp.includes(tc)).map(async (tc) => {
817
+ const result = await executeMcpTool(tc.name, tc.input);
818
+ guardrails.record(tc.name, tc.input, result?.success !== false);
819
+ return { id: tc.id, name: tc.name, result };
820
+ })
821
+ );
822
+ }
823
+
824
+ // Add blocked tool results as errors
825
+ const allResults = [
826
+ ...blockedMcp.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
827
+ ...blockedLocal.map(tc => ({ id: tc.id, name: tc.name, result: { error: `blocked_by_guardrails: ${tc.name}` } })),
828
+ ...localResults,
829
+ ...mcpResults,
830
+ ];
831
+
832
+ for (const result of allResults) {
833
+ const encodedContent = encodeToolResult(result.result);
834
+ messages.push({
835
+ role: 'tool',
836
+ tool_call_id: result.id,
837
+ name: result.name,
838
+ content: encodedContent
839
+ });
840
+ }
841
+
842
+ // Continue loop to get final response
843
+ continue;
844
+ }
845
+
846
+ // No tool calls, we have final response
847
+ finalResponse = assistantMessage?.content;
848
+ break;
849
+ }
850
+
851
+ if (iteration >= maxIterations) {
852
+ debugLog('\n[Provider] Max iterations reached');
853
+ finalResponse = finalResponse || 'Max tool execution iterations reached.';
854
+ }
855
+
856
+ // Apply token budget trimming
857
+ TB.trimMessages(messages);
858
+
859
+ // Save to conversation history (only user and final assistant message)
860
+ history.push({ role: 'user', content: message });
861
+ history.push({ role: 'assistant', content: finalResponse });
862
+
863
+ // Save to disk (automatically keeps last 20 messages)
864
+ saveConversation(convId, history);
865
+
866
+ return {
867
+ reply: finalResponse,
868
+ conversation_id: convId,
869
+ message_id: `msg_${Date.now()}`,
870
+ success: true
871
+ };
872
+ }
873
+
874
+ /**
875
+ * Clear conversation history
876
+ */
877
+ function clearConversation(conversationId) {
878
+ if (conversationId) {
879
+ // Delete from disk
880
+ const file = path.join(CONV_DIR, `${conversationId.replace(/[^a-z0-9]/gi, '_')}.json`);
881
+ try {
882
+ if (fs.existsSync(file)) {
883
+ fs.unlinkSync(file);
884
+ }
885
+ } catch (e) {
886
+ // Silently fail
887
+ }
888
+ // Also clear from memory (legacy)
889
+ conversationHistory.delete(conversationId);
890
+ } else {
891
+ // Clear all conversations from disk
892
+ try {
893
+ if (fs.existsSync(CONV_DIR)) {
894
+ const files = fs.readdirSync(CONV_DIR);
895
+ files.forEach(file => {
896
+ fs.unlinkSync(path.join(CONV_DIR, file));
897
+ });
898
+ }
899
+ } catch (e) {
900
+ // Silently fail
901
+ }
902
+ // Also clear from memory (legacy)
903
+ conversationHistory.clear();
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Legacy function for compatibility
909
+ * Now supports custom system prompts for different platforms (terminal, WhatsApp, etc.)
910
+ * @param {string} chatSystemPrompt - System prompt from chat.js (skills + memory + agents)
911
+ */
912
+ async function sendMessage(apiKey, botId, message, conversationId = null, chatSystemPrompt = '', options = {}) {
913
+ // Handle legacy 6th param (toolDefinitions array was passed)
914
+ if (Array.isArray(options)) options = {};
915
+ const providerConfig = getProviderConfig();
916
+
917
+ // Get user's home directory
918
+ const homeDir = os.homedir();
919
+
920
+ // Load memory to get botName
921
+ const { loadMemory } = require('./memory');
922
+ const mem = loadMemory(botId);
923
+
924
+ // Get config to check MCP status
925
+ const config = getConfig();
926
+
927
+ // NatureCo — minimal system prompt, skip tool descriptions/MCP
928
+ if (providerConfig && providerConfig.url.includes('api.natureco.me')) {
929
+ const prompt = chatSystemPrompt || 'Sen yardımcı bir AI asistansın.';
930
+ return sendMessageToProvider(apiKey, message, conversationId, prompt, options);
931
+ }
932
+
933
+ // Minimal base prompt (~200 token)
934
+ const toolDefs = getToolDefinitions();
935
+ const toolsDesc = toolDefs.map(t => t.name).join(', ');
936
+ let systemPrompt = `Assistant. Tools: ${toolsDesc}. Home: ${homeDir}.`;
937
+
938
+ // Skill prompts only, max 500 chars
939
+ if (chatSystemPrompt) {
940
+ systemPrompt += '\n' + chatSystemPrompt.slice(0, TB.load().systemPromptMaxChars);
941
+ }
942
+
943
+ return sendMessageToProvider(apiKey, message, conversationId, systemPrompt, options);
944
+ }
945
+
946
+ /**
947
+ * Validate API key against NatureCo backend
948
+ * Returns { valid, error, user }
949
+ */
950
+ async function validateApiKey(apiKey) {
951
+ const result = { valid: false, error: null, user: null };
952
+ if (!apiKey) {
953
+ result.error = 'API key boş olamaz';
954
+ return result;
955
+ }
956
+ try {
957
+ const res = await fetch('https://api.natureco.me/api/v1/user/me', {
958
+ headers: {
959
+ 'Authorization': `Bearer ${apiKey}`,
960
+ 'Content-Type': 'application/json',
961
+ },
962
+ });
963
+ if (res.ok) {
964
+ const body = await res.json();
965
+ result.valid = true;
966
+ result.user = body.user || body.data || body;
967
+ return result;
968
+ }
969
+ let errorBody = '';
970
+ try { errorBody = await res.text(); } catch {}
971
+ result.error = `API doğrulama hatası (${res.status}): ${errorBody || 'Geçersiz API key'}`;
972
+ return result;
973
+ } catch (e) {
974
+ result.error = `Bağlantı hatası: ${e.message}`;
975
+ return result;
976
+ }
977
+ }
978
+
979
+ /**
980
+ * Get bots (not used in v2.x, kept for compatibility)
981
+ */
982
+ async function getBots(apiKey) {
983
+ const config = getConfig();
984
+ const providerConfig = getProviderConfig();
985
+
986
+ // NatureCo provider — gerçek bot listesini API'den çek
987
+ if (config.providerUrl && config.providerUrl.includes('natureco.me')) {
988
+ try {
989
+ const res = await fetch('https://api.natureco.me/api/v1/bots', {
990
+ headers: {
991
+ 'Authorization': `Bearer ${config.providerApiKey || apiKey}`,
992
+ 'Content-Type': 'application/json',
993
+ },
994
+ });
995
+ if (res.ok) {
996
+ const data = await res.json();
997
+ const bots = Array.isArray(data) ? data : (data.bots || data.data || []);
998
+ if (bots.length > 0) {
999
+ return {
1000
+ bots: bots.map(b => ({
1001
+ id: b.id,
1002
+ name: b.name,
1003
+ ai_provider: b.ai_provider || 'natureco',
1004
+ model: b.model || 'natureco-default',
1005
+ system_prompt: b.system_prompt || '',
1006
+ }))
1007
+ };
1008
+ }
1009
+ }
1010
+ } catch (e) {
1011
+ debugLog('[getBots] NatureCo API error:', e.message);
1012
+ return { bots: [], error: e.message };
1013
+ }
1014
+ }
1015
+
1016
+ // Diğer provider'lar — universal provider döndür
1017
+ const providerName = providerConfig?.isAnthropic ? 'Anthropic' : 'OpenAI-compatible';
1018
+ const botName = config.botName || `Universal Provider (${providerName})`;
1019
+ return {
1020
+ bots: [
1021
+ {
1022
+ id: 'universal-provider',
1023
+ name: botName,
1024
+ ai_provider: providerName,
1025
+ model: providerConfig?.model || 'unknown'
1026
+ }
1027
+ ]
1028
+ };
1029
+ }
1030
+
1031
+ // ── Streaming Support ────────────────────────────────────────────────────────────
1032
+
1033
+ async function streamProviderCompletion(providerConfig, messages, tools) {
1034
+ if (providerConfig.isAnthropic) {
1035
+ return streamAnthropicCompletion(providerConfig, messages);
1036
+ }
1037
+ return streamOpenAICompletion(providerConfig, messages, tools);
1038
+ }
1039
+
1040
+ async function streamOpenAICompletion(providerConfig, messages, tools) {
1041
+ const baseUrl = providerConfig.url.replace(/\/+$/, '');
1042
+ // Tek doğruluk kaynağı: provider-detect.buildChatEndpoint (MiniMax /v1 toleransı dahil)
1043
+ const endpoint = buildChatEndpoint(baseUrl);
1044
+
1045
+ const requestBody = {
1046
+ model: providerConfig.model,
1047
+ messages,
1048
+ temperature: 0.7,
1049
+ max_tokens: 2000,
1050
+ stream: true,
1051
+ };
1052
+ if (tools && tools.length > 0) {
1053
+ requestBody.tools = tools;
1054
+ requestBody.tool_choice = 'auto';
1055
+ }
1056
+
1057
+ const response = await fetch(endpoint, {
1058
+ method: 'POST',
1059
+ headers: {
1060
+ 'Authorization': `Bearer ${providerConfig.apiKey}`,
1061
+ 'Content-Type': 'application/json',
1062
+ },
1063
+ body: JSON.stringify(requestBody),
1064
+ });
1065
+
1066
+ if (!response.ok) {
1067
+ throw new Error(`Provider API error: ${response.status} - ${await response.text()}`);
1068
+ }
1069
+
1070
+ const reader = response.body.getReader();
1071
+ const decoder = new TextDecoder();
1072
+ let fullText = '';
1073
+ const toolCalls = [];
1074
+ let hasToolCalls = false;
1075
+ let streamUsage = null;
1076
+ // SSE satırları TCP chunk sınırında bölünebilir — kuyruk buffer'da taşınmalı,
1077
+ // yoksa bölünen JSON parse edilemez ve token sessizce kaybolur
1078
+ let sseBuffer = '';
1079
+
1080
+ while (true) {
1081
+ const { done, value } = await reader.read();
1082
+ if (done) break;
1083
+
1084
+ sseBuffer += decoder.decode(value, { stream: true });
1085
+ const parts = sseBuffer.split('\n');
1086
+ sseBuffer = parts.pop() || ''; // son (muhtemelen yarım) satırı sakla
1087
+ const lines = parts.filter(l => l.startsWith('data: '));
1088
+
1089
+ for (const line of lines) {
1090
+ const data = line.slice(6).trim();
1091
+ if (data === '[DONE]') continue;
1092
+ try {
1093
+ const parsed = JSON.parse(data);
1094
+ if (parsed.usage) streamUsage = parsed.usage; // son chunk'ta gelir
1095
+ const delta = parsed.choices?.[0]?.delta;
1096
+ if (!delta) continue;
1097
+
1098
+ if (delta.tool_calls) {
1099
+ hasToolCalls = true;
1100
+ accumulateToolCallDeltas(toolCalls, delta.tool_calls);
1101
+ }
1102
+
1103
+ const token = delta.content || '';
1104
+ if (token) {
1105
+ if (!hasToolCalls) process.stdout.write(token);
1106
+ fullText += token;
1107
+ }
1108
+ } catch {}
1109
+ }
1110
+ }
1111
+ // Maliyet takibi — sağlayıcı stream sonunda usage gönderdiyse kaydet
1112
+ recordUsageSafe(providerConfig, streamUsage);
1113
+
1114
+ if (hasToolCalls) {
1115
+ process.stdout.write('\n');
1116
+ return {
1117
+ type: 'tool_calls',
1118
+ message: {
1119
+ role: 'assistant',
1120
+ content: fullText || null,
1121
+ tool_calls: toolCalls
1122
+ .filter(tc => tc && tc.function && tc.function.name)
1123
+ .map(tc => ({
1124
+ id: tc.id || `call_${Date.now()}_${tc.index}`,
1125
+ type: tc.type || 'function',
1126
+ function: { name: tc.function.name, arguments: tc.function.arguments || '' }
1127
+ }))
1128
+ }
1129
+ };
1130
+ }
1131
+
1132
+ process.stdout.write('\n');
1133
+ return { type: 'text', content: fullText };
1134
+ }
1135
+
1136
+ async function streamAnthropicCompletion(providerConfig, messages) {
1137
+ const endpoint = `${providerConfig.url}/v1/messages`;
1138
+
1139
+ const userMessages = messages.filter(m => m.role !== 'system');
1140
+
1141
+ const response = await fetch(endpoint, {
1142
+ method: 'POST',
1143
+ headers: {
1144
+ 'x-api-key': providerConfig.apiKey,
1145
+ 'anthropic-version': '2023-06-01',
1146
+ 'Content-Type': 'application/json',
1147
+ },
1148
+ body: JSON.stringify({
1149
+ model: providerConfig.model,
1150
+ max_tokens: 2000,
1151
+ system: extractSystemForAnthropic(messages),
1152
+ messages: userMessages,
1153
+ stream: true,
1154
+ }),
1155
+ });
1156
+
1157
+ if (!response.ok) {
1158
+ throw new Error(`Anthropic API error: ${response.status} - ${await response.text()}`);
1159
+ }
1160
+
1161
+ const reader = response.body.getReader();
1162
+ const decoder = new TextDecoder();
1163
+ let fullText = '';
1164
+
1165
+ while (true) {
1166
+ const { done, value } = await reader.read();
1167
+ if (done) break;
1168
+
1169
+ const chunk = decoder.decode(value, { stream: true });
1170
+ const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
1171
+
1172
+ for (const line of lines) {
1173
+ const data = line.slice(6).trim();
1174
+ if (data === '[DONE]') continue;
1175
+ try {
1176
+ const parsed = JSON.parse(data);
1177
+ const token = parsed.delta?.text || '';
1178
+ if (token) {
1179
+ process.stdout.write(token);
1180
+ fullText += token;
1181
+ }
1182
+ } catch {}
1183
+ }
1184
+ }
1185
+
1186
+ process.stdout.write('\n');
1187
+ return { type: 'text', content: fullText };
1188
+ }
1189
+
1190
+ module.exports = {
1191
+ sendMessage,
1192
+ sendMessageToProvider,
1193
+ validateApiKey,
1194
+ getBots,
1195
+ clearConversation,
1196
+ getProviderConfig,
1197
+ startMcpServers,
1198
+ stopMcpServers,
1199
+ getMcpTools,
1200
+ streamProviderCompletion,
1201
+ streamOpenAICompletion,
1202
+ streamAnthropicCompletion,
1203
+ // Exposed for tests + advanced consumers (does not appear in the
1204
+ // public API surface of natureco's user-facing docs).
1205
+ _internals: {
1206
+ extractSystemForAnthropic,
1207
+ DEFAULT_ANTHROPIC_SYSTEM,
1208
+ buildRequestBody,
1209
+ },
1210
+ _sendMessage: sendMessage,
1211
+ };