@banaxi/banana-code 1.4.0 → 1.5.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/README.md CHANGED
@@ -49,13 +49,15 @@ While tools like Cursor provide great GUI experiences, Banana Code is built for
49
49
 
50
50
  ## ✨ Key Features
51
51
 
52
- - **Multi-Provider Support**: Switch between **Google Gemini**, **Anthropic Claude**, **OpenAI**, **Ollama Cloud**, and **Ollama (Local)** effortlessly.
52
+ - **Multi-Provider Support**: Switch between **Google Gemini**, **Anthropic Claude**, **OpenAI**, **Mistral AI**, **Ollama Cloud**, and **Ollama (Local)** effortlessly.
53
+ - **Model Context Protocol (MCP)**: Connect Banana Code to any community-built MCP server (like SQLite, GitHub, Google Maps) to give your AI infinite new superpowers via `/beta`.
53
54
  - **Plan & Agent Modes**: Use `/agent` for instant execution, or `/plan` to make the AI draft a step-by-step implementation plan for your approval before it touches any code.
55
+ - **Hierarchical Sub-Agents**: The main AI can spawn specialized "sub-agents" (Researchers, Coders, Reviewers) to handle complex tasks without polluting your main chat history.
54
56
  - **Self-Healing Loop**: If the AI runs a command (like running tests) and it fails, Banana Code automatically feeds the error trace back to the AI so it can fix its own code.
55
57
  - **Agent Skills**: Teach your AI specialized workflows. Drop a `SKILL.md` file in your config folder, and the AI will automatically activate it when relevant.
56
- - **Smart Context**: Use `@file/path.js` to instantly inject file contents into your prompt, or use `/settings` to auto-feed your entire workspace structure (respecting `.gitignore`).
58
+ - **Smart Context & Pruning**: Use `@file/path.js` to instantly inject file contents, auto-feed your workspace, and use `/clean` to instantly compress long chat histories to save tokens.
57
59
  - **Web Research**: Deep integration with DuckDuckGo APIs and Scrapers to give the AI real-time access to the internet.
58
- - **Persistent Sessions**: All chats are saved to `~/.config/banana-code/chats/`. Resume any session with a single command.
60
+ - **Persistent Sessions**: All chats are auto-titled and saved. Use `/chats` for a fully interactive menu to resume any past session.
59
61
  - **Syntax Highlighting**: Beautiful, readable markdown output with syntax coloring directly in your terminal.
60
62
 
61
63
  ## 🚀 Installation
@@ -91,7 +93,14 @@ banana --resume <uuid>
91
93
 
92
94
  ### In-App Commands
93
95
  While in a chat, use these special commands:
96
+ - `/provider`: Switch AI provider (gemini, claude, openai, mistral, ollama_cloud, ollama).
94
97
  - `/model`: Switch the active AI model on the fly.
98
+ - `/chats`: Open an interactive menu to view and resume past auto-titled chat sessions.
99
+ - `/clean`: Compress your current chat history into a dense summary to save tokens.
100
+ - `/context`: View your current message count and estimated token usage.
101
+ - `/settings`: Toggle UI features like syntax highlighting and auto-workspace feeding.
102
+ - `/plan` & `/agent`: Toggle between Plan & Execute mode and standard Agent mode.
103
+ - `/beta`: Enable experimental features like MCP Support and Sub-Agents.
95
104
  - `/clear`: Clear the current chat history.
96
105
  - `/exit` or `CTRL+D`: Save and exit the session.
97
106
 
@@ -126,6 +135,26 @@ description: Use this skill whenever you are asked to build or edit a React comp
126
135
  ```
127
136
  3. Type `/skills` in Banana Code to verify it loaded. The AI will now follow these rules automatically!
128
137
 
138
+ ### 🔌 Model Context Protocol (MCP) Support
139
+ Banana Code supports the open standard [Model Context Protocol](https://modelcontextprotocol.io/), allowing you to plug in community-built servers to give your AI access to your databases, GitHub, Slack, Google Maps, and more.
140
+
141
+ 1. Enable **MCP Support** in the `/beta` menu.
142
+ 2. Create a configuration file at `~/.config/banana-code/mcp.json`.
143
+ 3. Add your servers. For example, to add the "fetch" and "math" tools using the test server:
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "everything": {
149
+ "command": "npx",
150
+ "args": ["-y", "@modelcontextprotocol/server-everything"]
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ Restart Banana Code, and the AI will instantly know how to use these new tools natively!
157
+
129
158
  ## 🔐 Privacy & Security
130
159
 
131
160
  Banana Code is built with transparency in mind:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banaxi/banana-code",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "🍌 BananaCode",
5
5
  "keywords": [
6
6
  "banana",
@@ -29,6 +29,7 @@
29
29
  "@anthropic-ai/sdk": "^0.36.1",
30
30
  "@google/generative-ai": "^0.21.0",
31
31
  "@inquirer/prompts": "^7.2.3",
32
+ "@modelcontextprotocol/sdk": "^1.28.0",
32
33
  "@openai/codex": "^0.117.0",
33
34
  "chalk": "^5.4.1",
34
35
  "diff": "^8.0.4",
package/src/config.js CHANGED
@@ -85,7 +85,7 @@ export async function setupProvider(provider, config = {}) {
85
85
  message: 'Select a Mistral model:',
86
86
  choices,
87
87
  loop: false,
88
- pageSize: Math.max(choices.length, 15)
88
+ pageSize: 10
89
89
  });
90
90
 
91
91
  if (selectedModel === 'CUSTOM_ID') {
package/src/index.js CHANGED
@@ -15,10 +15,12 @@ import { MistralProvider } from './providers/mistral.js';
15
15
  import { loadSession, saveSession, generateSessionId, getLatestSessionId, listSessions } from './sessions.js';
16
16
  import { printMarkdown } from './utils/markdown.js';
17
17
  import { estimateConversationTokens } from './utils/tokens.js';
18
+ import { mcpManager } from './utils/mcp.js';
18
19
 
19
20
  let config;
20
21
  let providerInstance;
21
22
  let currentSessionId;
23
+ let currentSessionTitle = null;
22
24
  const commandHistory = [];
23
25
  let historyIndex = -1;
24
26
  let currentInputSaved = '';
@@ -107,7 +109,7 @@ async function handleSlashCommand(command) {
107
109
  message: 'Select a model:',
108
110
  choices: finalChoices,
109
111
  loop: false,
110
- pageSize: Math.max(finalChoices.length, 15)
112
+ pageSize: 10
111
113
  });
112
114
 
113
115
  if (newModel === 'CUSTOM_ID') {
@@ -244,6 +246,12 @@ async function handleSlashCommand(command) {
244
246
  checked: (config.betaTools || []).includes('clean_command')
245
247
  });
246
248
 
249
+ choices.push({
250
+ name: 'MCP Support (Model Context Protocol)',
251
+ value: 'mcp_support',
252
+ checked: (config.betaTools || []).includes('mcp_support')
253
+ });
254
+
247
255
  if (choices.length === 0) {
248
256
  console.log(chalk.yellow("No beta features available."));
249
257
  break;
@@ -271,6 +279,12 @@ async function handleSlashCommand(command) {
271
279
  }
272
280
  }
273
281
 
282
+ if (enabledBetaTools.includes('mcp_support') && !(config.betaTools || []).includes('mcp_support')) {
283
+ await mcpManager.init();
284
+ } else if (!enabledBetaTools.includes('mcp_support') && (config.betaTools || []).includes('mcp_support')) {
285
+ await mcpManager.cleanup();
286
+ }
287
+
274
288
  config.betaTools = enabledBetaTools;
275
289
  await saveConfig(config);
276
290
  if (providerInstance) {
@@ -375,12 +389,39 @@ async function handleSlashCommand(command) {
375
389
  if (sessions.length === 0) {
376
390
  console.log(chalk.yellow("No saved chat sessions found."));
377
391
  } else {
378
- console.log(chalk.cyan.bold("\nRecent Chat Sessions:"));
379
- sessions.forEach((s, i) => {
380
- const active = s.uuid === currentSessionId ? chalk.green(' (active)') : '';
381
- console.log(chalk.gray(`${i + 1}. [${s.updatedAt}] ${s.uuid.slice(0, 8)}... (${s.provider}/${s.model})${active}`));
392
+ const { select: chatSelect } = await import('@inquirer/prompts');
393
+ const choices = sessions.map(s => {
394
+ const active = s.uuid === currentSessionId ? ' (active)' : '';
395
+ const date = new Date(s.updatedAt).toLocaleString();
396
+ const titleText = s.title ? `"${s.title}"` : s.uuid.slice(0, 8) + '...';
397
+ return {
398
+ name: `${titleText} - ${date} (${s.provider}/${s.model})${active}`,
399
+ value: s.uuid
400
+ };
401
+ });
402
+
403
+ const selectedSessionId = await chatSelect({
404
+ message: 'Select a chat session to resume:',
405
+ choices: choices,
406
+ pageSize: 10,
407
+ loop: false
382
408
  });
383
- console.log(chalk.gray("\nTo resume a chat, restart with: banana --resume <uuid>\n"));
409
+
410
+ if (selectedSessionId && selectedSessionId !== currentSessionId) {
411
+ const session = await loadSession(selectedSessionId);
412
+ if (session) {
413
+ currentSessionId = session.uuid;
414
+ currentSessionTitle = session.title || null;
415
+ config.provider = session.provider;
416
+ config.model = session.model;
417
+ providerInstance = createProvider();
418
+ if (providerInstance.messages !== undefined) {
419
+ providerInstance.messages = session.messages;
420
+ }
421
+ playHistory(session);
422
+ console.log(chalk.green(`Resumed session: ${currentSessionTitle || currentSessionId} (${session.provider}/${session.model})\n`));
423
+ }
424
+ }
384
425
  }
385
426
  break;
386
427
  case '/help':
@@ -430,6 +471,75 @@ function padLine(text, width) {
430
471
  return text + ' '.repeat(pad);
431
472
  }
432
473
 
474
+ function playHistory(session) {
475
+ process.stdout.write('\x1bc'); // Clear screen
476
+ console.log(chalk.yellow.bold("🍌 Banana Code — Peeling the project..."));
477
+ console.log(chalk.gray("-------------------------------------------"));
478
+ for (const msg of session.messages) {
479
+ if (msg.role === 'system') continue;
480
+
481
+ if (session.provider === 'gemini') {
482
+ if (msg.role === 'user') {
483
+ if (msg.parts[0]?.text) console.log(`${chalk.yellow('🍌 >')} ${msg.parts[0].text}`);
484
+ else if (msg.parts[0]?.functionResponse) {
485
+ console.log(chalk.yellow(`[Tool Result Received]`));
486
+ }
487
+ } else if (msg.role === 'model') {
488
+ msg.parts.forEach(p => {
489
+ if (p.text) {
490
+ if (config.useMarkedTerminal) printMarkdown(p.text);
491
+ else process.stdout.write(chalk.cyan(p.text));
492
+ }
493
+ if (p.functionCall) console.log(chalk.yellow(`\n[Banana Calling Tool: ${p.functionCall.name}]`));
494
+ });
495
+ console.log();
496
+ }
497
+ } else if (session.provider === 'claude') {
498
+ if (msg.role === 'user') {
499
+ if (typeof msg.content === 'string') console.log(`${chalk.yellow('🍌 >')} ${msg.content}`);
500
+ else {
501
+ msg.content.forEach(c => {
502
+ if (c.type === 'tool_result') console.log(chalk.yellow(`[Tool Result Received]`));
503
+ });
504
+ }
505
+ } else if (msg.role === 'assistant') {
506
+ if (typeof msg.content === 'string') {
507
+ if (config.useMarkedTerminal) printMarkdown(msg.content);
508
+ else process.stdout.write(chalk.cyan(msg.content));
509
+ } else {
510
+ msg.content.forEach(c => {
511
+ if (c.type === 'text') {
512
+ if (config.useMarkedTerminal) printMarkdown(c.text);
513
+ else process.stdout.write(chalk.cyan(c.text));
514
+ }
515
+ if (c.type === 'tool_use') console.log(chalk.yellow(`\n[Banana Calling Tool: ${c.name}]`));
516
+ });
517
+ }
518
+ console.log();
519
+ }
520
+ } else {
521
+ // OpenAI, Ollama, Mistral
522
+ if (msg.role === 'user') {
523
+ console.log(`${chalk.yellow('🍌 >')} ${msg.content}`);
524
+ } else if (msg.role === 'assistant' || msg.role === 'output_text') {
525
+ if (msg.content) {
526
+ if (config.useMarkedTerminal) printMarkdown(msg.content);
527
+ else process.stdout.write(chalk.cyan(msg.content));
528
+ }
529
+ if (msg.tool_calls) {
530
+ msg.tool_calls.forEach(tc => {
531
+ const name = tc.function ? tc.function.name : tc.name;
532
+ console.log(chalk.yellow(`\n[Banana Calling Tool: ${name}]`));
533
+ });
534
+ }
535
+ console.log();
536
+ } else if (msg.role === 'tool') {
537
+ console.log(chalk.yellow(`[Tool Result Received]`));
538
+ }
539
+ }
540
+ }
541
+ }
542
+
433
543
  let lastPromptRows = 1;
434
544
 
435
545
  function drawPromptBox(inputText, cursorPos) {
@@ -598,6 +708,7 @@ function promptUser() {
598
708
  process.stdout.write(`\x1b[${moveDown}B\x1b[2K\x1b[1B\x1b[2K\x1b[1G\n`);
599
709
  console.log(chalk.yellow(`\nTo resume this session: node bin/banana.js --resume ${currentSessionId}`));
600
710
  console.log(chalk.yellow("🍌 Bye BananaCode. See ya!"));
711
+ mcpManager.cleanup();
601
712
  process.exit(0);
602
713
  }
603
714
  };
@@ -714,6 +825,10 @@ async function main() {
714
825
  config = await loadConfig();
715
826
  await runStartup();
716
827
 
828
+ if (config.betaTools && config.betaTools.includes('mcp_support')) {
829
+ await mcpManager.init();
830
+ }
831
+
717
832
  const resumeIdx = process.argv.indexOf('--resume');
718
833
  if (resumeIdx !== -1) {
719
834
  let resumeId = process.argv[resumeIdx + 1];
@@ -725,78 +840,15 @@ async function main() {
725
840
  const session = await loadSession(resumeId);
726
841
  if (session) {
727
842
  currentSessionId = session.uuid;
843
+ currentSessionTitle = session.title || null;
728
844
  config.provider = session.provider;
729
845
  config.model = session.model;
730
846
  providerInstance = createProvider();
731
847
  if (providerInstance.messages !== undefined) {
732
848
  providerInstance.messages = session.messages;
733
849
  }
734
- console.log(chalk.green(`Resumed session: ${currentSessionId} (${session.provider}/${session.model})\n`));
735
-
736
- // Playback history
737
- for (const msg of session.messages) {
738
- if (msg.role === 'system') continue;
739
-
740
- if (config.provider === 'gemini') {
741
- if (msg.role === 'user') {
742
- if (msg.parts[0]?.text) console.log(`${chalk.yellow('🍌 >')} ${msg.parts[0].text}`);
743
- else if (msg.parts[0]?.functionResponse) {
744
- console.log(chalk.yellow(`[Tool Result Received]`));
745
- }
746
- } else if (msg.role === 'model') {
747
- msg.parts.forEach(p => {
748
- if (p.text) {
749
- if (config.useMarkedTerminal) printMarkdown(p.text);
750
- else process.stdout.write(chalk.cyan(p.text));
751
- }
752
- if (p.functionCall) console.log(chalk.yellow(`\n[Banana Calling Tool: ${p.functionCall.name}]`));
753
- });
754
- console.log();
755
- }
756
- } else if (config.provider === 'claude') {
757
- if (msg.role === 'user') {
758
- if (typeof msg.content === 'string') console.log(`${chalk.yellow('🍌 >')} ${msg.content}`);
759
- else {
760
- msg.content.forEach(c => {
761
- if (c.type === 'tool_result') console.log(chalk.yellow(`[Tool Result Received]`));
762
- });
763
- }
764
- } else if (msg.role === 'assistant') {
765
- if (typeof msg.content === 'string') {
766
- if (config.useMarkedTerminal) printMarkdown(msg.content);
767
- else process.stdout.write(chalk.cyan(msg.content));
768
- } else {
769
- msg.content.forEach(c => {
770
- if (c.type === 'text') {
771
- if (config.useMarkedTerminal) printMarkdown(c.text);
772
- else process.stdout.write(chalk.cyan(c.text));
773
- }
774
- if (c.type === 'tool_use') console.log(chalk.yellow(`\n[Banana Calling Tool: ${c.name}]`));
775
- });
776
- }
777
- console.log();
778
- }
779
- } else {
780
- // OpenAI, Ollama
781
- if (msg.role === 'user') {
782
- console.log(`${chalk.yellow('🍌 >')} ${msg.content}`);
783
- } else if (msg.role === 'assistant' || msg.role === 'output_text') {
784
- if (msg.content) {
785
- if (config.useMarkedTerminal) printMarkdown(msg.content);
786
- else process.stdout.write(chalk.cyan(msg.content));
787
- }
788
- if (msg.tool_calls) {
789
- msg.tool_calls.forEach(tc => {
790
- const name = tc.function ? tc.function.name : tc.name;
791
- console.log(chalk.yellow(`\n[Banana Calling Tool: ${name}]`));
792
- });
793
- }
794
- console.log();
795
- } else if (msg.role === 'tool') {
796
- console.log(chalk.yellow(`[Tool Result Received]`));
797
- }
798
- }
799
- }
850
+ playHistory(session);
851
+ console.log(chalk.green(`Resumed session: ${currentSessionTitle || currentSessionId} (${session.provider}/${session.model})\n`));
800
852
  } else {
801
853
  console.log(chalk.red(`Could not find session ${resumeId}. Starting fresh.`));
802
854
  }
@@ -867,11 +919,53 @@ async function main() {
867
919
  process.stdout.write(chalk.cyan('✦ '));
868
920
  await providerInstance.sendMessage(finalInput);
869
921
  console.log(); // Extra newline after AI response
922
+
923
+ // Auto-generate title every 10 messages (or on the 3rd message)
924
+ const msgLen = providerInstance.messages ? providerInstance.messages.length : 0;
925
+ if (!currentSessionTitle && msgLen >= 3 || msgLen > 0 && msgLen % 10 === 0) {
926
+ const titleSpinner = ora({ text: 'Generating chat title...', color: 'gray', stream: process.stdout }).start();
927
+ try {
928
+ // Temporarily disable terminal formatting and debug for the title request
929
+ const originalUseMarked = config.useMarkedTerminal;
930
+ const originalDebug = config.debug;
931
+ config.useMarkedTerminal = false;
932
+ config.debug = false;
933
+
934
+ // Temporarily silence stdout so the model's streaming text doesn't bleed into the terminal
935
+ const originalStdoutWrite = process.stdout.write;
936
+ process.stdout.write = () => {};
937
+
938
+ const titlePrompt = "SYSTEM: You are a title generator. Based on this conversation, provide a VERY SHORT (2-5 words) title. Reply ONLY with the title string, no quotes or formatting.";
939
+
940
+ // Use a temporary provider instance so we never pollute the main history array
941
+ const titleProvider = createProvider();
942
+ // Deep copy messages so the AI knows the context, but modifications don't leak back
943
+ titleProvider.messages = JSON.parse(JSON.stringify(providerInstance.messages));
944
+
945
+ let title = '';
946
+ try {
947
+ title = await titleProvider.sendMessage(titlePrompt);
948
+ } finally {
949
+ // Always restore stdout even if it crashes
950
+ process.stdout.write = originalStdoutWrite;
951
+ }
952
+
953
+ currentSessionTitle = title.replace(/['"]/g, '').trim();
954
+
955
+ config.useMarkedTerminal = originalUseMarked;
956
+ config.debug = originalDebug;
957
+ } catch (e) {
958
+ // ignore title gen errors
959
+ }
960
+ titleSpinner.stop();
961
+ }
962
+
870
963
  // Save session after AI message
871
964
  await saveSession(currentSessionId, {
872
965
  provider: config.provider,
873
966
  model: config.model || providerInstance.modelName,
874
- messages: providerInstance.messages
967
+ messages: providerInstance.messages,
968
+ title: currentSessionTitle
875
969
  });
876
970
  }
877
971
  }
@@ -1,4 +1,4 @@
1
- import { getAvailableTools, executeTool } from '../tools/registry.js';
1
+ import { getAvailableTools, executeTool, sanitizeSchemaForStrictAPIs } from '../tools/registry.js';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { getSystemPrompt } from '../prompt.js';
@@ -13,7 +13,7 @@ export class GeminiProvider {
13
13
  this.tools = getAvailableTools(config).map(t => ({
14
14
  name: t.name,
15
15
  description: t.description,
16
- parameters: t.parameters
16
+ parameters: sanitizeSchemaForStrictAPIs(t.parameters)
17
17
  }));
18
18
  this.systemPrompt = getSystemPrompt(config);
19
19
  }
@@ -1,4 +1,4 @@
1
- import { getAvailableTools, executeTool } from '../tools/registry.js';
1
+ import { getAvailableTools, executeTool, sanitizeSchemaForStrictAPIs } from '../tools/registry.js';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { getSystemPrompt } from '../prompt.js';
@@ -15,7 +15,7 @@ export class OllamaProvider {
15
15
  function: {
16
16
  name: t.name,
17
17
  description: t.description,
18
- parameters: t.parameters
18
+ parameters: sanitizeSchemaForStrictAPIs(t.parameters)
19
19
  }
20
20
  }));
21
21
  this.URL = 'http://localhost:11434/api/chat';
@@ -42,13 +42,14 @@ export class OllamaProvider {
42
42
  body: JSON.stringify({
43
43
  model: this.modelName,
44
44
  messages: this.messages,
45
- tools: this.tools,
45
+ tools: this.tools.length > 0 ? this.tools : undefined,
46
46
  stream: false
47
47
  })
48
48
  });
49
49
 
50
50
  if (!response.ok) {
51
- throw new Error(`HTTP error! status: ${response.status}`);
51
+ const errorText = await response.text();
52
+ throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`);
52
53
  }
53
54
 
54
55
  const data = await response.json();
@@ -76,7 +77,7 @@ export class OllamaProvider {
76
77
  const fn = call.function;
77
78
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${fn.name}]`));
78
79
 
79
- let res = await executeTool(fn.name, fn.arguments);
80
+ let res = await executeTool(fn.name, fn.arguments, this.config);
80
81
  if (this.config.debug) {
81
82
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
82
83
  }
@@ -84,6 +85,7 @@ export class OllamaProvider {
84
85
 
85
86
  this.messages.push({
86
87
  role: 'tool',
88
+ tool_call_id: call.id || 'mcp_call',
87
89
  content: typeof res === 'string' ? res : JSON.stringify(res)
88
90
  });
89
91
  }
@@ -1,4 +1,4 @@
1
- import { getAvailableTools, executeTool } from '../tools/registry.js';
1
+ import { getAvailableTools, executeTool, sanitizeSchemaForStrictAPIs } from '../tools/registry.js';
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import { getSystemPrompt } from '../prompt.js';
@@ -16,7 +16,7 @@ export class OllamaCloudProvider {
16
16
  function: {
17
17
  name: t.name,
18
18
  description: t.description,
19
- parameters: t.parameters
19
+ parameters: sanitizeSchemaForStrictAPIs(t.parameters)
20
20
  }
21
21
  }));
22
22
  this.URL = 'https://ollama.com/api/chat';
@@ -60,20 +60,26 @@ export class OllamaCloudProvider {
60
60
  const reader = response.body.getReader();
61
61
  const decoder = new TextDecoder();
62
62
  let currentChunkResponse = '';
63
- let lastMessageObj = null;
63
+ let lastMessageObj = { role: 'assistant', content: '' };
64
+ let lineBuffer = '';
64
65
 
65
66
  while (true) {
66
67
  const { done, value } = await reader.read();
67
68
  if (done) break;
68
69
 
69
70
  const chunk = decoder.decode(value, { stream: true });
70
- const lines = chunk.split('\n').filter(l => l.trim() !== '');
71
+ lineBuffer += chunk;
72
+ const lines = lineBuffer.split('\n');
73
+ lineBuffer = lines.pop(); // Keep partial line
71
74
 
72
75
  for (const line of lines) {
76
+ if (!line.trim()) continue;
73
77
  try {
74
78
  const data = JSON.parse(line);
75
79
  if (data.message) {
76
- lastMessageObj = data.message;
80
+ if (data.message.tool_calls) {
81
+ lastMessageObj.tool_calls = data.message.tool_calls;
82
+ }
77
83
  if (data.message.content) {
78
84
  const content = data.message.content;
79
85
  if (spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
@@ -84,22 +90,37 @@ export class OllamaCloudProvider {
84
90
  finalResponse += content;
85
91
  }
86
92
  }
87
- } catch (e) {
88
- // Ignore partial JSON chunks
89
- }
93
+ } catch (e) { }
90
94
  }
91
95
  }
92
96
 
97
+ if (lineBuffer.trim()) {
98
+ try {
99
+ const data = JSON.parse(lineBuffer);
100
+ if (data.message) {
101
+ if (data.message.tool_calls) {
102
+ lastMessageObj.tool_calls = data.message.tool_calls;
103
+ }
104
+ if (data.message.content) {
105
+ const content = data.message.content;
106
+ if (spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
107
+ if (!this.config.useMarkedTerminal) {
108
+ process.stdout.write(chalk.cyan(content));
109
+ }
110
+ currentChunkResponse += content;
111
+ finalResponse += content;
112
+ }
113
+ }
114
+ } catch (e) { }
115
+ }
116
+
93
117
  if (spinner.isSpinning) spinner.stop();
94
118
 
95
119
  if (currentChunkResponse && this.config.useMarkedTerminal) {
96
120
  printMarkdown(currentChunkResponse);
97
121
  }
98
122
 
99
- if (!lastMessageObj) {
100
- throw new Error("Empty response from Ollama Cloud");
101
- }
102
-
123
+ lastMessageObj.content = currentChunkResponse || '';
103
124
  this.messages.push(lastMessageObj);
104
125
 
105
126
  if (!lastMessageObj.tool_calls || lastMessageObj.tool_calls.length === 0) {
@@ -119,6 +140,7 @@ export class OllamaCloudProvider {
119
140
 
120
141
  this.messages.push({
121
142
  role: 'tool',
143
+ tool_call_id: call.id || 'mcp_call', // Use ID from call if available
122
144
  content: typeof res === 'string' ? res : JSON.stringify(res)
123
145
  });
124
146
  }
package/src/sessions.js CHANGED
@@ -21,6 +21,7 @@ export async function saveSession(uuid, data) {
21
21
  const filePath = path.join(CHATS_DIR, `${uuid}.json`);
22
22
  const sessionData = {
23
23
  uuid,
24
+ title: data.title || null,
24
25
  updatedAt: new Date().toISOString(),
25
26
  provider: data.provider,
26
27
  model: data.model,
@@ -9,6 +9,7 @@ import { duckDuckGoScrape } from './duckDuckGoScrape.js';
9
9
  import { patchFile } from './patchFile.js';
10
10
  import { activateSkill } from './activateSkill.js';
11
11
  import { delegateTask } from './delegateTask.js';
12
+ import { mcpManager } from '../utils/mcp.js';
12
13
 
13
14
  export const TOOLS = [
14
15
  {
@@ -173,15 +174,61 @@ export const TOOLS = [
173
174
  ];
174
175
 
175
176
  export function getAvailableTools(config = {}) {
176
- return TOOLS.filter(tool => {
177
+ let available = TOOLS.filter(tool => {
177
178
  if (tool.beta) {
178
179
  return config.betaTools && config.betaTools.includes(tool.name);
179
180
  }
180
181
  return true;
181
182
  });
183
+
184
+ // Add MCP tools if enabled in beta
185
+ if (config.betaTools && config.betaTools.includes('mcp_support')) {
186
+ const mcpTools = mcpManager.getTools().map(t => ({
187
+ name: t.name,
188
+ description: t.description,
189
+ parameters: t.inputSchema,
190
+ isMcp: true
191
+ }));
192
+ available = available.concat(mcpTools);
193
+ }
194
+
195
+ return available;
196
+ }
197
+
198
+ /**
199
+ * Some providers (Gemini, Ollama) are extremely strict about JSON Schema.
200
+ * They will throw a 400 if they see "additionalProperties", "$schema", or other unknown fields.
201
+ */
202
+ export function sanitizeSchemaForStrictAPIs(schema) {
203
+ if (!schema || typeof schema !== 'object') return schema;
204
+
205
+ const sanitized = Array.isArray(schema) ? [] : {};
206
+
207
+ for (const [key, value] of Object.entries(schema)) {
208
+ // Skip keys that strict APIs don't support
209
+ if (key === 'additionalProperties' || key === '$schema' || key === 'const') {
210
+ continue;
211
+ }
212
+
213
+ if (typeof value === 'object' && value !== null) {
214
+ sanitized[key] = sanitizeSchemaForStrictAPIs(value);
215
+ } else {
216
+ sanitized[key] = value;
217
+ }
218
+ }
219
+
220
+ return sanitized;
182
221
  }
183
222
 
184
223
  export async function executeTool(name, args, config) {
224
+ // Check if it's an MCP tool first
225
+ if (config.betaTools && config.betaTools.includes('mcp_support')) {
226
+ const mcpTools = mcpManager.getTools();
227
+ if (mcpTools.some(t => t.name === name)) {
228
+ return await mcpManager.callTool(name, args);
229
+ }
230
+ }
231
+
185
232
  switch (name) {
186
233
  case 'execute_command': return await execCommand(args);
187
234
  case 'read_file': return await readFile(args);
@@ -0,0 +1,96 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import chalk from 'chalk';
7
+
8
+ const MCP_CONFIG_FILE = path.join(os.homedir(), '.config', 'banana-code', 'mcp.json');
9
+
10
+ class MCPManager {
11
+ constructor() {
12
+ this.clients = new Map();
13
+ this.tools = [];
14
+ }
15
+
16
+ async init() {
17
+ try {
18
+ const configData = await fs.readFile(MCP_CONFIG_FILE, 'utf-8');
19
+ const config = JSON.parse(configData);
20
+
21
+ if (!config.mcpServers) return;
22
+
23
+ console.log(chalk.cyan("\nInitializing MCP Servers..."));
24
+
25
+ for (const [serverName, serverConfig] of Object.entries(config.mcpServers)) {
26
+ try {
27
+ const transport = new StdioClientTransport({
28
+ command: serverConfig.command,
29
+ args: serverConfig.args || [],
30
+ env: { ...process.env, ...serverConfig.env }
31
+ });
32
+
33
+ const client = new Client(
34
+ { name: "banana-code-client", version: "1.0.0" },
35
+ { capabilities: { tools: {} } }
36
+ );
37
+
38
+ await client.connect(transport);
39
+ this.clients.set(serverName, client);
40
+
41
+ const response = await client.listTools();
42
+
43
+ // Map MCP tools to our internal format
44
+ const mappedTools = response.tools.map(tool => ({
45
+ ...tool,
46
+ serverName, // Track which server this belongs to
47
+ isMcp: true
48
+ }));
49
+
50
+ this.tools.push(...mappedTools);
51
+ console.log(chalk.green(` ✔ Connected to ${serverName} (${response.tools.length} tools discovered)`));
52
+ } catch (err) {
53
+ console.log(chalk.red(` ✘ Failed to connect to ${serverName}: ${err.message}`));
54
+ }
55
+ }
56
+ } catch (err) {
57
+ if (err.code !== 'ENOENT') {
58
+ console.log(chalk.red(`Error loading MCP config: ${err.message}`));
59
+ } else {
60
+ // Create default empty config if not found
61
+ await fs.writeFile(MCP_CONFIG_FILE, JSON.stringify({ mcpServers: {} }, null, 2));
62
+ }
63
+ }
64
+ }
65
+
66
+ getTools() {
67
+ return this.tools;
68
+ }
69
+
70
+ async callTool(name, args) {
71
+ // Find which server has this tool
72
+ const tool = this.tools.find(t => t.name === name);
73
+ if (!tool) throw new Error(`MCP Tool ${name} not found`);
74
+
75
+ const client = this.clients.get(tool.serverName);
76
+ if (!client) throw new Error(`MCP Client for ${tool.serverName} not found`);
77
+
78
+ const result = await client.callTool({
79
+ name: name,
80
+ arguments: args
81
+ });
82
+
83
+ // MCP returns content array, we usually want the text from the first item
84
+ return result.content.map(c => c.text).join('\n');
85
+ }
86
+
87
+ async cleanup() {
88
+ for (const [name, client] of this.clients) {
89
+ try {
90
+ await client.close();
91
+ } catch (e) {}
92
+ }
93
+ }
94
+ }
95
+
96
+ export const mcpManager = new MCPManager();