@banaxi/banana-code 1.5.0 → 1.6.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/NOTICE ADDED
@@ -0,0 +1,43 @@
1
+ This project, Banana Code, incorporates code snippets, ideas, and authentication flows derived from the `opencode-openai-codex-auth` npm package.
2
+
3
+ Below is the required license and notice for that work:
4
+
5
+ =============================================================================
6
+ MIT License with Usage Disclaimer
7
+
8
+ Copyright (c) 2024-2025 numman-ali
9
+
10
+ USAGE NOTICE AND DISCLAIMER:
11
+ This software is provided for personal development use only. Users must comply
12
+ with OpenAI's Terms of Service (https://openai.com/policies/terms-of-use/) and
13
+ Usage Policies (https://openai.com/policies/usage-policies/) when using this
14
+ software to access OpenAI services.
15
+
16
+ The authors and contributors are not responsible for any violations of
17
+ third-party terms of service. For commercial use or production applications,
18
+ obtain proper API access from OpenAI directly through the OpenAI Platform
19
+ (https://platform.openai.com/).
20
+
21
+ This software uses OpenAI's official OAuth authentication system and is not
22
+ affiliated with, endorsed by, or sponsored by OpenAI.
23
+
24
+ ---
25
+
26
+ Permission is hereby granted, free of charge, to any person obtaining a copy
27
+ of this software and associated documentation files (the "Software"), to deal
28
+ in the Software without restriction, including without limitation the rights
29
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
30
+ copies of the Software, and to permit persons to whom the Software is
31
+ furnished to do so, subject to the following conditions:
32
+
33
+ The above copyright notice, usage notice, and this permission notice shall be
34
+ included in all copies or substantial portions of the Software.
35
+
36
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
37
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
38
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
39
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
40
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
41
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
42
+ SOFTWARE.
43
+ =============================================================================
package/README.md CHANGED
@@ -62,12 +62,14 @@ While tools like Cursor provide great GUI experiences, Banana Code is built for
62
62
 
63
63
  ## 🚀 Installation
64
64
 
65
- Install Banana Code globally via npm:
65
+ Install Banana Code globally via npm using the scoped package name:
66
66
 
67
67
  ```bash
68
68
  npm install -g @banaxi/banana-code
69
69
  ```
70
70
 
71
+ > **⚠️ Important Notice:** Please ensure you install `@banaxi/banana-code`. The unscoped `banana-code` package on npm is NOT affiliated with this project.
72
+
71
73
  ## 🛠️ Setup
72
74
 
73
75
  On your first run, Banana Code will walk you through a quick setup to configure your preferred AI providers:
@@ -97,6 +99,8 @@ While in a chat, use these special commands:
97
99
  - `/model`: Switch the active AI model on the fly.
98
100
  - `/chats`: Open an interactive menu to view and resume past auto-titled chat sessions.
99
101
  - `/clean`: Compress your current chat history into a dense summary to save tokens.
102
+ - `/memory`: Manage your global AI memories (facts the AI remembers across all projects).
103
+ - `/init`: Analyze the current project and generate a `BANANA.md` summary for instant context.
100
104
  - `/context`: View your current message count and estimated token usage.
101
105
  - `/settings`: Toggle UI features like syntax highlighting and auto-workspace feeding.
102
106
  - `/plan` & `/agent`: Toggle between Plan & Execute mode and standard Agent mode.
@@ -155,6 +159,69 @@ Banana Code supports the open standard [Model Context Protocol](https://modelcon
155
159
 
156
160
  Restart Banana Code, and the AI will instantly know how to use these new tools natively!
157
161
 
162
+ ### 🧠 Global AI Memory
163
+ Banana Code features a persistent "brain" that remembers your preferences across every project you work on.
164
+
165
+ 1. Enable **Enable Global AI Memory** in the `/settings` menu.
166
+ 2. Tell the AI facts about yourself or your coding style (e.g., "My name is Max" or "I prefer using Python for data scripts").
167
+ 3. Use the `/memory` command to view, manually add, or delete saved facts.
168
+ 4. The AI will now automatically adhere to these preferences in every future session!
169
+
170
+ ### 🍌 Project Initialization (`/init`)
171
+ Stop repeating yourself! When you start working in a new folder, type `/init`.
172
+
173
+ Banana Code will analyze your entire project structure and generate a **`BANANA.md`** file. This file acts as a high-level architectural summary. Every time you start `banana` in that folder, the AI silently reads this file, giving it instant context about your project's goals and technologies from the very first message.
174
+
175
+ ## 📡 Headless API Mode (`--api`)
176
+ Banana Code can be run as a background engine, exposing its powerful tool-calling and provider-switching logic via a local HTTP and WebSocket server. This allows you to build custom GUIs (Electron, Tauri, React) on top of the Banana Code engine without rewriting any AI logic.
177
+
178
+ Start the server:
179
+ ```bash
180
+ banana --api 4000
181
+ ```
182
+
183
+ ### HTTP Endpoints
184
+ - `GET /api/status`: Returns engine status, active provider, and model.
185
+ - `GET /api/sessions`: Returns a JSON array of all saved chat sessions.
186
+ - `GET /api/config`: Returns the current `config.json` preferences.
187
+
188
+ ### WebSocket Streaming & Chat
189
+ Connect a WebSocket client (like `wscat` or your GUI frontend) to `ws://localhost:4000`.
190
+
191
+ **Send a chat message:**
192
+ ```json
193
+ { "type": "chat", "text": "Run the sensors command" }
194
+ ```
195
+
196
+ **Streamed Response Format:**
197
+ Banana Code streams data back to the client in real-time chunks:
198
+ - `{"type": "chunk", "content": "The output of the command is..."}`
199
+ - `{"type": "tool_start", "tool": "execute_command"}`
200
+ - `{"type": "done", "finalResponse": "..."}`
201
+
202
+ ### Remote Tool Approval (Security)
203
+ If the AI decides to run a shell command or patch a file, Banana Code pauses execution and sends a permission ticket to your GUI:
204
+
205
+ ```json
206
+ {
207
+ "type": "permission_requested",
208
+ "ticketId": "5c9b2a...",
209
+ "action": "Execute Command",
210
+ "details": "sensors"
211
+ }
212
+ ```
213
+
214
+ Your GUI must present a dialog to the user and respond with the same `ticketId` to resume execution:
215
+ ```json
216
+ {
217
+ "type": "permission_response",
218
+ "ticketId": "5c9b2a...",
219
+ "allowed": true,
220
+ "session": true
221
+ }
222
+ ```
223
+ If an invalid `ticketId` is provided, Banana Code automatically blocks the tool execution to ensure safety.
224
+
158
225
  ## 🔐 Privacy & Security
159
226
 
160
227
  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.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "🍌 BananaCode",
5
5
  "keywords": [
6
6
  "banana",
@@ -32,12 +32,15 @@
32
32
  "@modelcontextprotocol/sdk": "^1.28.0",
33
33
  "@openai/codex": "^0.117.0",
34
34
  "chalk": "^5.4.1",
35
+ "cors": "^2.8.6",
35
36
  "diff": "^8.0.4",
37
+ "express": "^5.2.1",
36
38
  "glob": "13.0.6",
37
39
  "marked": "^15.0.12",
38
40
  "marked-terminal": "^7.3.0",
39
41
  "open": "^11.0.0",
40
42
  "openai": "^4.79.1",
41
- "ora": "^8.1.1"
43
+ "ora": "^8.1.1",
44
+ "ws": "^8.20.0"
42
45
  }
43
46
  }
package/src/index.js CHANGED
@@ -16,6 +16,7 @@ import { loadSession, saveSession, generateSessionId, getLatestSessionId, listSe
16
16
  import { printMarkdown } from './utils/markdown.js';
17
17
  import { estimateConversationTokens } from './utils/tokens.js';
18
18
  import { mcpManager } from './utils/mcp.js';
19
+ import { startApiServer } from './server.js';
19
20
 
20
21
  let config;
21
22
  let providerInstance;
@@ -311,17 +312,29 @@ async function handleSlashCommand(command) {
311
312
  value: 'useMarkedTerminal',
312
313
  checked: config.useMarkedTerminal || false
313
314
  },
315
+ {
316
+ name: 'Enable Surgical File Patching (patch_file tool)',
317
+ value: 'usePatchFile',
318
+ checked: config.usePatchFile !== false
319
+ },
314
320
  {
315
321
  name: 'Always show current token count in status bar',
316
322
  value: 'showTokenCount',
317
323
  checked: config.showTokenCount || false
324
+ },
325
+ {
326
+ name: 'Enable Global AI Memory (Allows AI to save facts persistently)',
327
+ value: 'useMemory',
328
+ checked: config.useMemory || false
318
329
  }
319
330
  ]
320
331
  });
321
332
 
322
333
  config.autoFeedWorkspace = enabledSettings.includes('autoFeedWorkspace');
323
334
  config.useMarkedTerminal = enabledSettings.includes('useMarkedTerminal');
335
+ config.usePatchFile = enabledSettings.includes('usePatchFile');
324
336
  config.showTokenCount = enabledSettings.includes('showTokenCount');
337
+ config.useMemory = enabledSettings.includes('useMemory');
325
338
  await saveConfig(config);
326
339
  if (providerInstance) {
327
340
  const savedMessages = providerInstance.messages;
@@ -424,6 +437,91 @@ async function handleSlashCommand(command) {
424
437
  }
425
438
  }
426
439
  break;
440
+ case '/memory':
441
+ if (!config.useMemory) {
442
+ console.log(chalk.yellow("Global AI Memory is disabled. Enable it in /settings first."));
443
+ break;
444
+ }
445
+ const { select: memSelect, input: memInput } = await import('@inquirer/prompts');
446
+ const { loadMemory, removeMemory, addMemory } = await import('./utils/memory.js');
447
+ let memAction = await memSelect({
448
+ message: 'Manage Global AI Memory:',
449
+ choices: [
450
+ { name: 'View all memories', value: 'view' },
451
+ { name: 'Add a new memory manually', value: 'add' },
452
+ { name: 'Delete a memory', value: 'delete' }
453
+ ],
454
+ loop: false
455
+ });
456
+
457
+ if (memAction === 'view') {
458
+ const mems = await loadMemory();
459
+ if (mems.length === 0) {
460
+ console.log(chalk.yellow("No global memories saved yet."));
461
+ } else {
462
+ console.log(chalk.cyan.bold("\nGlobal Memories:"));
463
+ mems.forEach(m => {
464
+ console.log(chalk.gray(`[${m.id}] `) + chalk.green(m.fact));
465
+ });
466
+ }
467
+ } else if (memAction === 'add') {
468
+ const newFact = await memInput({
469
+ message: 'Enter the fact you want the AI to remember globally:',
470
+ validate: (v) => v.trim().length > 0 || 'Memory cannot be empty'
471
+ });
472
+ const id = await addMemory(newFact);
473
+ console.log(chalk.green(`Memory saved with ID: ${id}`));
474
+ providerInstance = createProvider(); // Reload provider to inject new memory
475
+ } else if (memAction === 'delete') {
476
+ const mems = await loadMemory();
477
+ if (mems.length === 0) {
478
+ console.log(chalk.yellow("No memories to delete."));
479
+ } else {
480
+ const idToDelete = await memSelect({
481
+ message: 'Select a memory to delete:',
482
+ choices: mems.map(m => ({ name: m.fact, value: m.id })),
483
+ loop: false,
484
+ pageSize: 10
485
+ });
486
+ const success = await removeMemory(idToDelete);
487
+ if (success) {
488
+ console.log(chalk.green(`Memory deleted.`));
489
+ providerInstance = createProvider(); // Reload provider
490
+ }
491
+ }
492
+ }
493
+ break;
494
+ case '/init':
495
+ console.log(chalk.cyan("Generating project summary for BANANA.md..."));
496
+ const initSpinner = ora({ text: 'Analyzing project...', color: 'yellow', stream: process.stdout }).start();
497
+ try {
498
+ const { getWorkspaceTree } = await import('./utils/workspace.js');
499
+ const tree = await getWorkspaceTree();
500
+
501
+ const initProvider = createProvider();
502
+ // We use a completely blank slate for this so it doesn't get confused
503
+ initProvider.messages = [];
504
+
505
+ let initPrompt = "SYSTEM: You are a project summarizer. Review the following project file tree and briefly describe what this project is, what technologies it uses, and any obvious conventions. Keep it under 2 paragraphs. Output ONLY the summary text.";
506
+ initPrompt += `\n\n--- Project Tree ---\n${tree}`;
507
+
508
+ const summary = await initProvider.sendMessage(initPrompt);
509
+
510
+ const fs = await import('fs/promises');
511
+ const path = await import('path');
512
+ const bananaPath = path.join(process.cwd(), 'BANANA.md');
513
+ await fs.writeFile(bananaPath, summary, 'utf8');
514
+
515
+ initSpinner.stop();
516
+ console.log(chalk.green(`Successfully created BANANA.md!`));
517
+
518
+ // Re-init current provider so it picks up the new BANANA.md
519
+ providerInstance = createProvider();
520
+ } catch (err) {
521
+ initSpinner.stop();
522
+ console.log(chalk.red(`Failed to initialize project: ${err.message}`));
523
+ }
524
+ break;
427
525
  case '/help':
428
526
  console.log(chalk.yellow(`
429
527
  Available commands:
@@ -437,6 +535,8 @@ Available commands:
437
535
  /beta - Manage beta features and tools
438
536
  /settings - Manage app settings (workspace auto-feed, etc)
439
537
  /skills - List loaded agent skills
538
+ /memory - Manage global AI memories
539
+ /init - Generate a BANANA.md project summary file
440
540
  /plan - Enable Plan Mode (AI proposes a plan for big changes)
441
541
  /agent - Enable Agent Mode (default, AI edits directly)
442
542
  /debug - Toggle debug mode (show tool results)
@@ -829,6 +929,14 @@ async function main() {
829
929
  await mcpManager.init();
830
930
  }
831
931
 
932
+ const apiIdx = process.argv.indexOf('--api');
933
+ if (apiIdx !== -1) {
934
+ const portStr = process.argv[apiIdx + 1];
935
+ const port = portStr && !portStr.startsWith('-') ? parseInt(portStr) : 3000;
936
+ await startApiServer(port, createProvider);
937
+ return;
938
+ }
939
+
832
940
  const resumeIdx = process.argv.indexOf('--resume');
833
941
  if (resumeIdx !== -1) {
834
942
  let resumeId = process.argv[resumeIdx + 1];
@@ -1,5 +1,6 @@
1
1
  import { select } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
+ import crypto from 'crypto';
3
4
 
4
5
  const sessionPermissions = new Set();
5
6
 
@@ -18,6 +19,15 @@ export async function requestPermission(actionType, details) {
18
19
  return { allowed: true };
19
20
  }
20
21
 
22
+ if (typeof global.apiPermissionHandler === 'function') {
23
+ const ticketId = crypto.randomUUID();
24
+ const result = await global.apiPermissionHandler(ticketId, actionType, details);
25
+ if (result.remember) {
26
+ sessionPermissions.add(permKey);
27
+ }
28
+ return { allowed: result.allowed };
29
+ }
30
+
21
31
  const boxWidth = 41; // Internal width
22
32
 
23
33
  const actionLabel = ` Action: ${actionType}`;
package/src/prompt.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import os from 'os';
2
+ import fs from 'fs';
3
+ import path from 'path';
2
4
  import { getAvailableTools } from './tools/registry.js';
3
5
  import { getAvailableSkills } from './utils/skills.js';
4
6
 
@@ -29,6 +31,34 @@ SAFETY RULES:
29
31
 
30
32
  Always use tools when they would help. Be concise but thorough. `;
31
33
 
34
+ // Load Project Context (BANANA.md)
35
+ try {
36
+ const bananaPath = path.join(process.cwd(), 'BANANA.md');
37
+ if (fs.existsSync(bananaPath)) {
38
+ const projectContext = fs.readFileSync(bananaPath, 'utf8');
39
+ prompt += `\n\n# Project Context (BANANA.md)\nThe following is the summary of the current project. You already know this; DO NOT use tools to read BANANA.md manually:\n${projectContext}\n`;
40
+ }
41
+ } catch (e) {}
42
+
43
+ // Load Global Memory
44
+ if (config.useMemory) {
45
+ prompt += `\n\n# Global AI Memory\nYou have the ability to remember facts across ALL sessions and projects using the \`save_memory\` tool. If the user tells you their name, personal preferences, coding rules, or other information they might want to persist, feel free to use the \`save_memory\` tool so you can remember it in the future.\n`;
46
+
47
+ try {
48
+ const memPath = path.join(os.homedir(), '.config', 'banana-code', 'memory.json');
49
+ if (fs.existsSync(memPath)) {
50
+ const memData = fs.readFileSync(memPath, 'utf8');
51
+ const memories = JSON.parse(memData);
52
+ if (memories.length > 0) {
53
+ prompt += `You have persistently saved the following facts and preferences across ALL projects. Always adhere to these preferences:\n`;
54
+ for (const m of memories) {
55
+ prompt += `- ${m.fact}\n`;
56
+ }
57
+ }
58
+ }
59
+ } catch (e) {}
60
+ }
61
+
32
62
  if (skills && skills.length > 0) {
33
63
  prompt += `\n\n# Available Agent Skills\n\nYou have access to the following specialized skills. To activate a skill and receive its detailed instructions, call the \`activate_skill\` tool with the skill's name.\n\n<available_skills>\n`;
34
64
  for (const skill of skills) {
@@ -113,8 +113,14 @@ export class ClaudeProvider {
113
113
 
114
114
  const toolResultContent = [];
115
115
  for (const call of toolCalls) {
116
+ if (this.config.isApiMode && this.onToolStart) {
117
+ this.onToolStart(call.name);
118
+ }
116
119
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${call.name}]`));
117
120
  const res = await executeTool(call.name, call.input, this.config);
121
+ if (this.config.isApiMode && this.onToolEnd) {
122
+ this.onToolEnd(res);
123
+ }
118
124
  if (this.config.debug) {
119
125
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
120
126
  }
@@ -80,11 +80,16 @@ export class GeminiProvider {
80
80
  if (content && content.parts) {
81
81
  for (const part of content.parts) {
82
82
  if (part.text) {
83
- if (spinner && spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
83
+ if (spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
84
84
  if (!this.config.useMarkedTerminal) {
85
- process.stdout.write(chalk.cyan(part.text));
85
+ if (this.config.isApiMode && this.onChunk) {
86
+ this.onChunk(part.text);
87
+ } else {
88
+ process.stdout.write(chalk.cyan(part.text));
89
+ }
86
90
  }
87
91
  responseText += part.text;
92
+
88
93
  currentTurnText += part.text;
89
94
 
90
95
  // Aggregate sequential text parts
@@ -134,8 +139,14 @@ export class GeminiProvider {
134
139
  if (part.functionCall) {
135
140
  hasToolCalls = true;
136
141
  const call = part.functionCall;
142
+ if (this.config.isApiMode && this.onToolStart) {
143
+ this.onToolStart(call.name);
144
+ }
137
145
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${call.name}]`));
138
146
  const res = await executeTool(call.name, call.args, this.config);
147
+ if (this.config.isApiMode && this.onToolEnd) {
148
+ this.onToolEnd(res);
149
+ }
139
150
  if (this.config.debug) {
140
151
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
141
152
  }
@@ -109,6 +109,9 @@ export class MistralProvider {
109
109
  });
110
110
 
111
111
  for (const call of toolCalls) {
112
+ if (this.config.isApiMode && this.onToolStart) {
113
+ this.onToolStart(call.function.name);
114
+ }
112
115
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${call.function.name}]`));
113
116
  let args = {};
114
117
  try {
@@ -116,6 +119,9 @@ export class MistralProvider {
116
119
  } catch (e) { }
117
120
 
118
121
  const res = await executeTool(call.function.name, args, this.config);
122
+ if (this.config.isApiMode && this.onToolEnd) {
123
+ this.onToolEnd(res);
124
+ }
119
125
  if (this.config.debug) {
120
126
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
121
127
  }
@@ -75,9 +75,15 @@ export class OllamaProvider {
75
75
 
76
76
  for (const call of messageObj.tool_calls) {
77
77
  const fn = call.function;
78
+ if (this.config.isApiMode && this.onToolStart) {
79
+ this.onToolStart(fn.name);
80
+ }
78
81
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${fn.name}]`));
79
82
 
80
83
  let res = await executeTool(fn.name, fn.arguments, this.config);
84
+ if (this.config.isApiMode && this.onToolEnd) {
85
+ this.onToolEnd(res);
86
+ }
81
87
  if (this.config.debug) {
82
88
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
83
89
  }
@@ -84,7 +84,11 @@ export class OllamaCloudProvider {
84
84
  const content = data.message.content;
85
85
  if (spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
86
86
  if (!this.config.useMarkedTerminal) {
87
- process.stdout.write(chalk.cyan(content));
87
+ if (this.config.isApiMode && this.onChunk) {
88
+ this.onChunk(content);
89
+ } else {
90
+ process.stdout.write(chalk.cyan(content));
91
+ }
88
92
  }
89
93
  currentChunkResponse += content;
90
94
  finalResponse += content;
@@ -130,9 +134,15 @@ export class OllamaCloudProvider {
130
134
 
131
135
  for (const call of lastMessageObj.tool_calls) {
132
136
  const fn = call.function;
137
+ if (this.config.isApiMode && this.onToolStart) {
138
+ this.onToolStart(fn.name);
139
+ }
133
140
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${fn.name}]`));
134
141
 
135
142
  let res = await executeTool(fn.name, fn.arguments, this.config);
143
+ if (this.config.isApiMode && this.onToolEnd) {
144
+ this.onToolEnd(res);
145
+ }
136
146
  if (this.config.debug) {
137
147
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
138
148
  }
@@ -8,6 +8,11 @@ import fsSync from 'fs';
8
8
  import { getSystemPrompt } from '../prompt.js';
9
9
  import { printMarkdown } from '../utils/markdown.js';
10
10
 
11
+ /**
12
+ * Notice: Parts of the OAuth authentication flow and SSE streaming logic in this file
13
+ * are derived from or inspired by the 'opencode-openai-codex-auth' package
14
+ * (Copyright (c) 2024-2025 numman-ali). See NOTICE file for full license details.
15
+ */
11
16
  export class OpenAIProvider {
12
17
  constructor(config) {
13
18
  this.config = config;
@@ -69,7 +74,11 @@ export class OpenAIProvider {
69
74
  if (delta?.content) {
70
75
  if (spinner.isSpinning && !this.config.useMarkedTerminal) spinner.stop();
71
76
  if (!this.config.useMarkedTerminal) {
72
- process.stdout.write(chalk.cyan(delta.content));
77
+ if (this.config.isApiMode && this.onChunk) {
78
+ this.onChunk(delta.content);
79
+ } else {
80
+ process.stdout.write(chalk.cyan(delta.content));
81
+ }
73
82
  }
74
83
  chunkResponse += delta.content;
75
84
  finalResponse += delta.content;
@@ -114,7 +123,10 @@ export class OpenAIProvider {
114
123
  content: chunkResponse || null
115
124
  });
116
125
 
117
- for (const call of toolCalls) {
126
+ for (const call of activeToolCalls || toolCalls) {
127
+ if (this.config.isApiMode && this.onToolStart) {
128
+ this.onToolStart(call.function.name);
129
+ }
118
130
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${call.function.name}]`));
119
131
  let args = {};
120
132
  try {
@@ -122,6 +134,9 @@ export class OpenAIProvider {
122
134
  } catch (e) { }
123
135
 
124
136
  const res = await executeTool(call.function.name, args, this.config);
137
+ if (this.config.isApiMode && this.onToolEnd) {
138
+ this.onToolEnd(res);
139
+ }
125
140
  if (this.config.debug) {
126
141
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
127
142
  }
@@ -365,6 +380,9 @@ export class OpenAIProvider {
365
380
  });
366
381
 
367
382
  for (const call of activeToolCalls) {
383
+ if (this.config.isApiMode && this.onToolStart) {
384
+ this.onToolStart(call.function.name);
385
+ }
368
386
  console.log(chalk.yellow(`\n[Banana Calling Tool: ${call.function.name}]`));
369
387
  let args = {};
370
388
  try {
@@ -372,6 +390,9 @@ export class OpenAIProvider {
372
390
  } catch (e) { }
373
391
 
374
392
  const res = await executeTool(call.function.name, args, this.config);
393
+ if (this.config.isApiMode && this.onToolEnd) {
394
+ this.onToolEnd(res);
395
+ }
375
396
  if (this.config.debug) {
376
397
  console.log(chalk.gray(`[DEBUG] Tool Result: ${typeof res === 'string' ? res : JSON.stringify(res, null, 2)}`));
377
398
  }
package/src/server.js ADDED
@@ -0,0 +1,147 @@
1
+ import express from 'express';
2
+ import { WebSocketServer } from 'ws';
3
+ import cors from 'cors';
4
+ import http from 'http';
5
+ import chalk from 'chalk';
6
+ import { loadConfig } from './config.js';
7
+ import { listSessions, loadSession } from './sessions.js';
8
+
9
+ export async function startApiServer(port = 3000, createProvider) {
10
+ const app = express();
11
+ const server = http.createServer(app);
12
+ const wss = new WebSocketServer({ server });
13
+
14
+ app.use(cors());
15
+ app.use(express.json());
16
+
17
+ let config = await loadConfig();
18
+ let providerInstance = null;
19
+
20
+ // WebSocket connection handling
21
+ wss.on('connection', (ws) => {
22
+ console.log(chalk.cyan(`[API] GUI Client connected via WebSocket`));
23
+
24
+ const activeTickets = new Set();
25
+
26
+ // Setup global permission handler for API mode
27
+ global.apiPermissionHandler = (ticketId, actionType, details) => {
28
+ return new Promise((resolve) => {
29
+ const requestPayload = JSON.stringify({
30
+ type: 'permission_requested',
31
+ ticketId,
32
+ action: actionType,
33
+ details
34
+ });
35
+
36
+ if (ws.readyState === ws.OPEN) {
37
+ activeTickets.add(ticketId);
38
+ ws.send(requestPayload);
39
+ console.log(chalk.gray(`[API] Sent permission request: ${ticketId}`));
40
+ } else {
41
+ console.log(chalk.red(`[API] WebSocket closed, denying permission automatically.`));
42
+ resolve({ allowed: false });
43
+ return;
44
+ }
45
+
46
+ // Temporary listener to catch the GUI's response for this specific ticket
47
+ const responseHandler = (message) => {
48
+ try {
49
+ const data = JSON.parse(message);
50
+ if (data.type === 'permission_response' && data.ticketId === ticketId) {
51
+ console.log(chalk.gray(`[API] Received permission response for ${ticketId}: ${data.allowed}`));
52
+ activeTickets.delete(ticketId);
53
+ ws.removeListener('message', responseHandler); // clean up
54
+ resolve({ allowed: data.allowed, remember: data.session });
55
+ }
56
+ } catch (e) {}
57
+ };
58
+
59
+ ws.on('message', responseHandler);
60
+ });
61
+ };
62
+
63
+ ws.on('message', async (message) => {
64
+ console.log(chalk.gray(`[API] Received message: ${message}`));
65
+ try {
66
+ const data = JSON.parse(message);
67
+
68
+ // Ignore valid permission responses here, they are handled by the specific ticket listeners
69
+ if (data.type === 'permission_response') {
70
+ if (!activeTickets.has(data.ticketId)) {
71
+ console.log(chalk.red(`[API] Invalid ticket ID received: ${data.ticketId}`));
72
+ if (ws.readyState === ws.OPEN) {
73
+ ws.send(JSON.stringify({
74
+ type: 'error',
75
+ message: `Permission Denied: Ticket ID '${data.ticketId}' does not match any active requests.`
76
+ }));
77
+ }
78
+ }
79
+ return;
80
+ }
81
+
82
+ if (data.type === 'chat') {
83
+ if (!providerInstance) {
84
+ console.log(chalk.gray(`[API] Creating provider instance...`));
85
+ providerInstance = createProvider(config);
86
+ }
87
+
88
+ // Attach a temporary listener for this specific request
89
+ // We modify the provider config locally for this mode
90
+ providerInstance.config.isApiMode = true;
91
+ providerInstance.onChunk = (chunk) => {
92
+ if (ws.readyState === ws.OPEN) {
93
+ ws.send(JSON.stringify({ type: 'chunk', content: chunk }));
94
+ }
95
+ };
96
+ providerInstance.onToolStart = (tool) => {
97
+ if (ws.readyState === ws.OPEN) {
98
+ ws.send(JSON.stringify({ type: 'tool_start', tool }));
99
+ }
100
+ };
101
+ providerInstance.onToolEnd = (result) => {
102
+ if (ws.readyState === ws.OPEN) {
103
+ ws.send(JSON.stringify({ type: 'tool_end', result }));
104
+ }
105
+ };
106
+
107
+ console.log(chalk.gray(`[API] Sending message to AI...`));
108
+ const response = await providerInstance.sendMessage(data.text);
109
+ console.log(chalk.gray(`[API] AI response complete.`));
110
+
111
+ if (ws.readyState === ws.OPEN) {
112
+ ws.send(JSON.stringify({ type: 'done', finalResponse: response }));
113
+ }
114
+ }
115
+ } catch (err) {
116
+ console.error(chalk.red(`[API] Error: ${err.message}`));
117
+ if (ws.readyState === ws.OPEN) {
118
+ ws.send(JSON.stringify({ type: 'error', message: err.message }));
119
+ }
120
+ }
121
+ });
122
+
123
+ ws.on('close', () => {
124
+ console.log(chalk.gray(`[API] GUI Client disconnected`));
125
+ });
126
+
127
+ });
128
+
129
+ // HTTP Endpoints
130
+ app.get('/api/sessions', async (req, res) => {
131
+ const sessions = await listSessions();
132
+ res.json(sessions);
133
+ });
134
+
135
+ app.get('/api/config', (req, res) => {
136
+ res.json(config);
137
+ });
138
+
139
+ app.get('/api/status', (req, res) => {
140
+ res.json({ status: 'running', provider: config.provider, model: config.model });
141
+ });
142
+
143
+ server.listen(port, () => {
144
+ console.log(chalk.green.bold(`\n🍌 Banana Code API Server running at http://localhost:${port}`));
145
+ console.log(chalk.gray(`WebSocket streaming enabled on the same port.\n`));
146
+ });
147
+ }
@@ -0,0 +1,46 @@
1
+ import { addMemory, removeMemory, loadMemory } from '../utils/memory.js';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+
5
+ export async function saveMemoryTool({ fact }) {
6
+ const spinner = ora({ text: `Saving memory...`, color: 'magenta', stream: process.stdout }).start();
7
+ try {
8
+ const id = await addMemory(fact);
9
+ spinner.stop();
10
+ return `Successfully saved fact. ID: ${id}`;
11
+ } catch (err) {
12
+ spinner.stop();
13
+ return `Error saving memory: ${err.message}`;
14
+ }
15
+ }
16
+
17
+ export async function listMemoryTool() {
18
+ const spinner = ora({ text: `Reading memories...`, color: 'magenta', stream: process.stdout }).start();
19
+ try {
20
+ const memories = await loadMemory();
21
+ spinner.stop();
22
+ if (memories.length === 0) {
23
+ return `No memories currently saved.`;
24
+ }
25
+ return JSON.stringify(memories, null, 2);
26
+ } catch (err) {
27
+ spinner.stop();
28
+ return `Error listing memories: ${err.message}`;
29
+ }
30
+ }
31
+
32
+ export async function deleteMemoryTool({ id }) {
33
+ const spinner = ora({ text: `Deleting memory ${id}...`, color: 'magenta', stream: process.stdout }).start();
34
+ try {
35
+ const success = await removeMemory(id);
36
+ spinner.stop();
37
+ if (success) {
38
+ return `Successfully deleted memory with ID: ${id}`;
39
+ } else {
40
+ return `Error: Memory with ID '${id}' not found.`;
41
+ }
42
+ } catch (err) {
43
+ spinner.stop();
44
+ return `Error deleting memory: ${err.message}`;
45
+ }
46
+ }
@@ -10,6 +10,7 @@ import { patchFile } from './patchFile.js';
10
10
  import { activateSkill } from './activateSkill.js';
11
11
  import { delegateTask } from './delegateTask.js';
12
12
  import { mcpManager } from '../utils/mcp.js';
13
+ import { saveMemoryTool, listMemoryTool, deleteMemoryTool } from './memoryTools.js';
13
14
 
14
15
  export const TOOLS = [
15
16
  {
@@ -109,9 +110,9 @@ export const TOOLS = [
109
110
  },
110
111
  {
111
112
  name: 'patch_file',
112
- label: 'Surgical File Patch (Beta)',
113
+ label: 'Surgical File Patch',
113
114
  description: 'Edit a file by replacing specific sections of text. Much more efficient for large files.',
114
- beta: true,
115
+ settingsFeature: 'usePatchFile',
115
116
  parameters: {
116
117
  type: 'object',
117
118
  properties: {
@@ -170,6 +171,40 @@ export const TOOLS = [
170
171
  },
171
172
  required: ['task']
172
173
  }
174
+ },
175
+ {
176
+ name: 'save_memory',
177
+ description: 'Persists a fact across ALL future sessions globally. Use this ONLY to save facts or preferences you want to permanently remember across different projects. Do NOT use for session-specific or temporary data.',
178
+ memoryFeature: true,
179
+ parameters: {
180
+ type: 'object',
181
+ properties: {
182
+ fact: { type: 'string', description: 'A concise fact or preference to remember globally.' }
183
+ },
184
+ required: ['fact']
185
+ }
186
+ },
187
+ {
188
+ name: 'list_memory',
189
+ description: 'Retrieves all globally saved memories with their IDs. Use this to review facts you have saved or find an ID to delete an outdated fact.',
190
+ memoryFeature: true,
191
+ parameters: {
192
+ type: 'object',
193
+ properties: {},
194
+ required: []
195
+ }
196
+ },
197
+ {
198
+ name: 'delete_memory',
199
+ description: 'Deletes a specific global memory using its ID. Call list_memory first to find the ID.',
200
+ memoryFeature: true,
201
+ parameters: {
202
+ type: 'object',
203
+ properties: {
204
+ id: { type: 'string', description: 'The exact ID of the memory to delete.' }
205
+ },
206
+ required: ['id']
207
+ }
173
208
  }
174
209
  ];
175
210
 
@@ -178,6 +213,13 @@ export function getAvailableTools(config = {}) {
178
213
  if (tool.beta) {
179
214
  return config.betaTools && config.betaTools.includes(tool.name);
180
215
  }
216
+ if (tool.memoryFeature) {
217
+ return config.useMemory === true;
218
+ }
219
+ if (tool.settingsFeature) {
220
+ // Default to true if not explicitly set to false
221
+ return config[tool.settingsFeature] !== false;
222
+ }
181
223
  return true;
182
224
  });
183
225
 
@@ -241,6 +283,9 @@ export async function executeTool(name, args, config) {
241
283
  case 'patch_file': return await patchFile(args);
242
284
  case 'activate_skill': return await activateSkill(args);
243
285
  case 'delegate_task': return await delegateTask(args, config);
286
+ case 'save_memory': return await saveMemoryTool(args);
287
+ case 'list_memory': return await listMemoryTool(args);
288
+ case 'delete_memory': return await deleteMemoryTool(args);
244
289
  default: return `Unknown tool: ${name}`;
245
290
  }
246
291
  }
@@ -0,0 +1,52 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+
6
+ const MEMORY_DIR = path.join(os.homedir(), '.config', 'banana-code');
7
+ const MEMORY_FILE = path.join(MEMORY_DIR, 'memory.json');
8
+
9
+ export async function loadMemory() {
10
+ try {
11
+ const data = await fs.readFile(MEMORY_FILE, 'utf-8');
12
+ return JSON.parse(data);
13
+ } catch (error) {
14
+ if (error.code === 'ENOENT') {
15
+ return []; // Return empty array if file doesn't exist
16
+ }
17
+ throw error;
18
+ }
19
+ }
20
+
21
+ export async function saveMemory(memories) {
22
+ try {
23
+ await fs.mkdir(MEMORY_DIR, { recursive: true });
24
+ await fs.writeFile(MEMORY_FILE, JSON.stringify(memories, null, 2), 'utf-8');
25
+ } catch (error) {
26
+ console.error("Failed to save memory:", error);
27
+ }
28
+ }
29
+
30
+ export async function addMemory(fact) {
31
+ const memories = await loadMemory();
32
+ const newMemory = {
33
+ id: crypto.randomUUID().slice(0, 8),
34
+ fact,
35
+ createdAt: new Date().toISOString()
36
+ };
37
+ memories.push(newMemory);
38
+ await saveMemory(memories);
39
+ return newMemory.id;
40
+ }
41
+
42
+ export async function removeMemory(id) {
43
+ const memories = await loadMemory();
44
+ const initialLength = memories.length;
45
+ const filtered = memories.filter(m => m.id !== id);
46
+
47
+ if (filtered.length < initialLength) {
48
+ await saveMemory(filtered);
49
+ return true;
50
+ }
51
+ return false;
52
+ }