banana-code 1.2.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/banana.js +5464 -0
  4. package/lib/agenticRunner.js +1884 -0
  5. package/lib/borderRenderer.js +41 -0
  6. package/lib/commandRunner.js +205 -0
  7. package/lib/completer.js +286 -0
  8. package/lib/config.js +301 -0
  9. package/lib/contextBuilder.js +324 -0
  10. package/lib/diffViewer.js +295 -0
  11. package/lib/fileManager.js +224 -0
  12. package/lib/historyManager.js +124 -0
  13. package/lib/hookManager.js +1143 -0
  14. package/lib/imageHandler.js +268 -0
  15. package/lib/inlineComplete.js +192 -0
  16. package/lib/interactivePicker.js +254 -0
  17. package/lib/lmStudio.js +226 -0
  18. package/lib/markdownRenderer.js +423 -0
  19. package/lib/mcpClient.js +288 -0
  20. package/lib/modelRegistry.js +350 -0
  21. package/lib/monkeyModels.js +97 -0
  22. package/lib/oauthOpenAI.js +167 -0
  23. package/lib/parser.js +134 -0
  24. package/lib/promptManager.js +96 -0
  25. package/lib/providerClients.js +1014 -0
  26. package/lib/providerManager.js +130 -0
  27. package/lib/providerStore.js +413 -0
  28. package/lib/statusBar.js +283 -0
  29. package/lib/streamHandler.js +306 -0
  30. package/lib/subAgentManager.js +406 -0
  31. package/lib/tokenCounter.js +132 -0
  32. package/lib/visionAnalyzer.js +163 -0
  33. package/lib/watcher.js +138 -0
  34. package/models.json +57 -0
  35. package/package.json +42 -0
  36. package/prompts/base.md +23 -0
  37. package/prompts/code-agent-glm.md +16 -0
  38. package/prompts/code-agent-gptoss.md +25 -0
  39. package/prompts/code-agent-nemotron.md +17 -0
  40. package/prompts/code-agent-qwen.md +20 -0
  41. package/prompts/code-agent.md +70 -0
  42. package/prompts/plan.md +44 -0
@@ -0,0 +1,288 @@
1
+ /**
2
+ * MCP Client for Banana Code
3
+ * Calls an MCP server via HTTP.
4
+ * Used both for curated tools and the call_mcp escape hatch.
5
+ */
6
+
7
+ const DEFAULT_MCP_URL = '';
8
+
9
+ /**
10
+ * Parse SSE text into individual data payloads.
11
+ * Handles multi-event streams, finds the JSON-RPC result (has `id` + `result`),
12
+ * ignoring notifications (have `method` but no matching `id`).
13
+ */
14
+ function parseSSE(sseText, requestId) {
15
+ const events = [];
16
+ for (const line of sseText.split('\n')) {
17
+ if (line.startsWith('data: ')) {
18
+ const payload = line.slice(6).trim();
19
+ if (payload) events.push(payload);
20
+ }
21
+ }
22
+
23
+ // Find the JSON-RPC result event (has id + result or id + error)
24
+ for (const evt of events) {
25
+ try {
26
+ const parsed = JSON.parse(evt);
27
+ // Match by id if provided, otherwise look for any result/error response
28
+ if (requestId && parsed.id === requestId) return evt;
29
+ if (parsed.result !== undefined || parsed.error !== undefined) return evt;
30
+ } catch { /* skip malformed events */ }
31
+ }
32
+
33
+ // Fallback: return last event (original behavior)
34
+ return events.length > 0 ? events[events.length - 1] : sseText;
35
+ }
36
+
37
+ class McpClient {
38
+ constructor(options = {}) {
39
+ this.url = options.url || process.env.MCP_SERVER_URL || DEFAULT_MCP_URL;
40
+ this.sessionId = null;
41
+ this.initialized = false;
42
+ this.timeout = options.timeout || 30000;
43
+ this.toolTimeout = options.toolTimeout || 60000;
44
+ this.serverInfo = null; // Populated on initialize
45
+ }
46
+
47
+ /**
48
+ * Update the server URL (e.g., from config)
49
+ */
50
+ setUrl(url) {
51
+ if (url && url !== this.url) {
52
+ this.url = url;
53
+ this.sessionId = null;
54
+ this.initialized = false;
55
+ this.serverInfo = null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Initialize MCP session (get sessionId)
61
+ */
62
+ async initialize() {
63
+ if (this.initialized) return true;
64
+
65
+ try {
66
+ const res = await fetch(this.url, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/json',
70
+ 'Accept': 'application/json, text/event-stream'
71
+ },
72
+ body: JSON.stringify({
73
+ jsonrpc: '2.0',
74
+ id: 1,
75
+ method: 'initialize',
76
+ params: {
77
+ protocolVersion: '2024-11-05',
78
+ capabilities: {},
79
+ clientInfo: { name: 'banana', version: '1.2.0' }
80
+ }
81
+ }),
82
+ signal: AbortSignal.timeout(this.timeout)
83
+ });
84
+
85
+ if (!res.ok) return false;
86
+
87
+ const sessionId = res.headers.get('mcp-session-id') || res.headers.get('x-session-id');
88
+ if (sessionId) this.sessionId = sessionId;
89
+
90
+ // Parse server info from initialize response
91
+ const contentType = res.headers.get('content-type') || '';
92
+ let text = await res.text();
93
+ if (contentType.includes('text/event-stream')) {
94
+ text = parseSSE(text, 1);
95
+ }
96
+ try {
97
+ const data = JSON.parse(text);
98
+ if (data.result?.serverInfo) {
99
+ this.serverInfo = data.result.serverInfo;
100
+ }
101
+ } catch { /* ignore parse errors */ }
102
+
103
+ // MCP protocol requires sending notifications/initialized before any other requests.
104
+ // Without this, the server keeps the session in a pending state and rejects tool calls.
105
+ await this._sendInitializedNotification();
106
+
107
+ this.initialized = true;
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Send the required notifications/initialized notification to complete the MCP handshake.
116
+ */
117
+ async _sendInitializedNotification() {
118
+ const headers = {
119
+ 'Content-Type': 'application/json',
120
+ 'Accept': 'application/json, text/event-stream'
121
+ };
122
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
123
+
124
+ try {
125
+ await fetch(this.url, {
126
+ method: 'POST',
127
+ headers,
128
+ body: JSON.stringify({
129
+ jsonrpc: '2.0',
130
+ method: 'notifications/initialized'
131
+ }),
132
+ signal: AbortSignal.timeout(this.timeout)
133
+ });
134
+ } catch {
135
+ // Best-effort; some servers don't require it
136
+ }
137
+ }
138
+
139
+ /**
140
+ * List available tools from the MCP server
141
+ */
142
+ async listTools(canRetry = true) {
143
+ if (!this.initialized) {
144
+ const ok = await this.initialize();
145
+ if (!ok) throw new Error('MCP server unreachable');
146
+ }
147
+
148
+ const headers = {
149
+ 'Content-Type': 'application/json',
150
+ 'Accept': 'application/json, text/event-stream'
151
+ };
152
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
153
+
154
+ const requestId = Date.now();
155
+ const res = await fetch(this.url, {
156
+ method: 'POST',
157
+ headers,
158
+ body: JSON.stringify({
159
+ jsonrpc: '2.0',
160
+ id: requestId,
161
+ method: 'tools/list',
162
+ params: {}
163
+ }),
164
+ signal: AbortSignal.timeout(this.toolTimeout)
165
+ });
166
+
167
+ // 404 = stale/expired session. Re-initialize and retry once.
168
+ if (res.status === 404 && canRetry) {
169
+ this.initialized = false;
170
+ this.sessionId = null;
171
+ this.serverInfo = null;
172
+ return this.listTools(false);
173
+ }
174
+
175
+ if (!res.ok) {
176
+ throw new Error(`MCP error ${res.status}: ${await res.text()}`);
177
+ }
178
+
179
+ const contentType = res.headers.get('content-type') || '';
180
+ let text = await res.text();
181
+ if (contentType.includes('text/event-stream')) {
182
+ text = parseSSE(text, requestId);
183
+ }
184
+
185
+ const data = JSON.parse(text);
186
+ if (data.error) throw new Error(data.error.message || 'Failed to list tools');
187
+ return data.result?.tools || [];
188
+ }
189
+
190
+ /**
191
+ * Call any MCP tool by name with args
192
+ */
193
+ async callTool(toolName, args = {}) {
194
+ return this._callToolWithRetry(toolName, args, true);
195
+ }
196
+
197
+ async _callToolWithRetry(toolName, args, canRetry) {
198
+ if (!this.initialized) {
199
+ const ok = await this.initialize();
200
+ if (!ok) throw new Error('MCP server unreachable');
201
+ }
202
+
203
+ const headers = {
204
+ 'Content-Type': 'application/json',
205
+ 'Accept': 'application/json, text/event-stream'
206
+ };
207
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
208
+
209
+ const requestId = Date.now();
210
+ const body = JSON.stringify({
211
+ jsonrpc: '2.0',
212
+ id: requestId,
213
+ method: 'tools/call',
214
+ params: { name: toolName, arguments: args }
215
+ });
216
+
217
+ const res = await fetch(this.url, {
218
+ method: 'POST',
219
+ headers,
220
+ body,
221
+ signal: AbortSignal.timeout(this.toolTimeout)
222
+ });
223
+
224
+ // 404 = stale/expired session. Re-initialize and retry once.
225
+ if (res.status === 404 && canRetry) {
226
+ this.initialized = false;
227
+ this.sessionId = null;
228
+ this.serverInfo = null;
229
+ return this._callToolWithRetry(toolName, args, false);
230
+ }
231
+
232
+ if (!res.ok) {
233
+ throw new Error(`MCP error ${res.status}: ${await res.text()}`);
234
+ }
235
+
236
+ // Handle SSE or JSON response
237
+ const contentType = res.headers.get('content-type') || '';
238
+ let text = await res.text();
239
+
240
+ if (contentType.includes('text/event-stream')) {
241
+ text = parseSSE(text, requestId);
242
+ }
243
+
244
+ try {
245
+ const data = JSON.parse(text);
246
+ if (data.error) throw new Error(data.error.message || 'MCP tool error');
247
+ const result = data.result;
248
+ // Extract text content from MCP content array
249
+ if (result?.content) {
250
+ return result.content
251
+ .filter(c => c.type === 'text')
252
+ .map(c => c.text)
253
+ .join('\n');
254
+ }
255
+ return JSON.stringify(result);
256
+ } catch (e) {
257
+ if (e.message.includes('MCP')) throw e;
258
+ return text;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Health check - try initialize, which validates the full MCP handshake
264
+ */
265
+ async isConnected() {
266
+ try {
267
+ if (this.initialized) return true;
268
+ return await this.initialize();
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Get connection status info
276
+ */
277
+ getStatus() {
278
+ return {
279
+ url: this.url,
280
+ connected: this.initialized,
281
+ sessionId: this.sessionId,
282
+ serverName: this.serverInfo?.name || null,
283
+ serverVersion: this.serverInfo?.version || null
284
+ };
285
+ }
286
+ }
287
+
288
+ module.exports = McpClient;
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Model Registry for Banana Code v4
3
+ * Handles local models from models.json + connected remote provider models.
4
+ */
5
+
6
+ const fs = require('fs');
7
+
8
+ class ModelRegistry {
9
+ constructor(registryPath, lmStudio, providerStore = null) {
10
+ this.registryPath = registryPath;
11
+ this.lmStudio = lmStudio;
12
+ this.providerStore = providerStore;
13
+ this.localRegistry = { default: 'silverback', models: {} };
14
+ this.registry = { default: 'silverback', models: {} };
15
+ this.currentModel = null;
16
+ this.load();
17
+ }
18
+
19
+ load() {
20
+ try {
21
+ if (fs.existsSync(this.registryPath)) {
22
+ const parsed = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
23
+ this.localRegistry = {
24
+ default: parsed.default || 'silverback',
25
+ models: parsed.models || {}
26
+ };
27
+ }
28
+ } catch {
29
+ this.localRegistry = { default: 'silverback', models: {} };
30
+ }
31
+
32
+ this._normalizeLocalModels();
33
+ this.refreshRemoteModels();
34
+ }
35
+
36
+ save() {
37
+ fs.writeFileSync(this.registryPath, JSON.stringify(this.localRegistry, null, 2));
38
+ }
39
+
40
+ _normalizeLocalModels() {
41
+ for (const model of Object.values(this.localRegistry.models)) {
42
+ if (!model.provider) model.provider = 'local';
43
+ }
44
+ }
45
+
46
+ refreshRemoteModels() {
47
+ const mergedModels = {};
48
+
49
+ for (const [key, model] of Object.entries(this.localRegistry.models)) {
50
+ mergedModels[key] = { ...model, provider: model.provider || 'local' };
51
+ }
52
+
53
+ if (this.providerStore) {
54
+ for (const remoteModel of this.providerStore.getConnectedRemoteModels()) {
55
+ const key = remoteModel.key;
56
+ mergedModels[key] = {
57
+ name: remoteModel.name,
58
+ id: remoteModel.id,
59
+ providerModelId: remoteModel.id,
60
+ provider: remoteModel.provider,
61
+ reasoningEffort: remoteModel.reasoningEffort,
62
+ contextLimit: remoteModel.contextLimit || 128000,
63
+ supportsThinking: remoteModel.supportsThinking !== false,
64
+ prompt: remoteModel.prompt || 'code-agent',
65
+ inferenceSettings: {
66
+ temperature: 0.5,
67
+ topP: 0.9
68
+ },
69
+ tags: remoteModel.tags || ['remote', remoteModel.provider],
70
+ tier: 'remote',
71
+ description: `${remoteModel.name} via ${remoteModel.provider}`,
72
+ remote: true
73
+ };
74
+ }
75
+ }
76
+
77
+ this.registry = {
78
+ default: this.localRegistry.default,
79
+ models: mergedModels
80
+ };
81
+
82
+ if (this.currentModel && !this.registry.models[this.currentModel]) {
83
+ this.currentModel = this.registry.default;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get all models
89
+ */
90
+ list() {
91
+ return Object.entries(this.registry.models).map(([key, model]) => ({
92
+ key,
93
+ ...model,
94
+ active: key === this.currentModel
95
+ }));
96
+ }
97
+
98
+ /**
99
+ * Get a model by key
100
+ */
101
+ get(name) {
102
+ return this.registry.models[name] || null;
103
+ }
104
+
105
+ /**
106
+ * Get current active model key
107
+ */
108
+ getCurrent() {
109
+ if (this.currentModel && this.registry.models[this.currentModel]) {
110
+ return this.currentModel;
111
+ }
112
+ return this.registry.default || Object.keys(this.registry.models)[0] || null;
113
+ }
114
+
115
+ /**
116
+ * Get active provider key for current model
117
+ */
118
+ getCurrentProvider() {
119
+ const model = this.getCurrentModel();
120
+ return model?.provider || 'local';
121
+ }
122
+
123
+ /**
124
+ * Get current model identifier
125
+ */
126
+ getCurrentId() {
127
+ const model = this.getCurrentModel();
128
+ if (!model) return undefined;
129
+ return model.providerModelId || model.id;
130
+ }
131
+
132
+ /**
133
+ * Get current model metadata
134
+ */
135
+ getCurrentModel() {
136
+ const name = this.getCurrent();
137
+ if (!name) return null;
138
+ return { key: name, ...this.registry.models[name] };
139
+ }
140
+
141
+ /**
142
+ * Set active model
143
+ */
144
+ setCurrent(name) {
145
+ if (!this.registry.models[name]) {
146
+ throw new Error(`Unknown model: "${name}". Use /model to see available models.`);
147
+ }
148
+ this.currentModel = name;
149
+ }
150
+
151
+ /**
152
+ * Check if a model has a specific tag
153
+ */
154
+ hasTag(name, tag) {
155
+ const model = this.registry.models[name];
156
+ return model?.tags?.includes(tag) || false;
157
+ }
158
+
159
+ /**
160
+ * Check if current model supports vision
161
+ */
162
+ currentSupportsVision() {
163
+ const name = this.getCurrent();
164
+ return name ? this.hasTag(name, 'vision') : false;
165
+ }
166
+
167
+ /**
168
+ * Check if current model supports thinking mode
169
+ */
170
+ currentSupportsThinking() {
171
+ const name = this.getCurrent();
172
+ if (!name) return false;
173
+ return this.registry.models[name]?.supportsThinking === true;
174
+ }
175
+
176
+ /**
177
+ * Get context limit for current model
178
+ */
179
+ getContextLimit() {
180
+ const name = this.getCurrent();
181
+ if (!name) return 32768;
182
+ return this.registry.models[name]?.contextLimit || 32768;
183
+ }
184
+
185
+ /**
186
+ * Get preferred prompt name for current model
187
+ */
188
+ getPrompt() {
189
+ const name = this.getCurrent();
190
+ if (!name) return 'code-agent';
191
+ return this.registry.models[name]?.prompt || 'code-agent';
192
+ }
193
+
194
+ /**
195
+ * Get inference settings for current model
196
+ */
197
+ getInferenceSettings() {
198
+ const name = this.getCurrent();
199
+ if (!name) return { temperature: 0.6, topP: undefined, repeatPenalty: undefined };
200
+ const settings = this.registry.models[name]?.inferenceSettings;
201
+ return {
202
+ temperature: settings?.temperature ?? 0.6,
203
+ topP: settings?.topP ?? undefined,
204
+ repeatPenalty: settings?.repeatPenalty ?? undefined
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Check if current model requires reasoning preservation
210
+ */
211
+ requiresReasoningPreservation() {
212
+ const name = this.getCurrent();
213
+ if (!name) return false;
214
+ return this.registry.models[name]?.preserveReasoning === true;
215
+ }
216
+
217
+ /**
218
+ * Auto-discover local model IDs from LM Studio and update local registry
219
+ */
220
+ async discover() {
221
+ if (!this.lmStudio) return { matched: 0, total: 0 };
222
+
223
+ const lmModels = await this.lmStudio.listModels();
224
+ if (lmModels.length === 0) return { matched: 0, total: 0 };
225
+
226
+ let matched = 0;
227
+ const usedLmIds = new Set();
228
+ for (const [key, model] of Object.entries(this.localRegistry.models)) {
229
+ if ((model.provider || 'local') !== 'local') continue;
230
+
231
+ // Do not overwrite explicit model IDs from models.json.
232
+ const existingId = String(model.id || '').trim();
233
+ if (existingId) continue;
234
+
235
+ const keywords = this._getMatchKeywords(key, model.name);
236
+ for (const lmModel of lmModels) {
237
+ const lmId = (lmModel.id || '').toLowerCase();
238
+ if (!lmId || usedLmIds.has(lmId)) continue;
239
+ if (keywords.some(kw => lmId.includes(kw))) {
240
+ model.id = lmModel.id;
241
+ usedLmIds.add(lmId);
242
+ matched++;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+
248
+ if (matched > 0) {
249
+ this.save();
250
+ this.refreshRemoteModels();
251
+ }
252
+
253
+ return { matched, total: lmModels.length };
254
+ }
255
+
256
+ /**
257
+ * Generate match keywords from local model key and name
258
+ */
259
+ _getMatchKeywords(key, name) {
260
+ const keywords = [];
261
+ const keyMap = {
262
+ 'silverback': ['silverback', 'gpt_oss'],
263
+ 'qwen35': ['qwen3.5-35b', 'qwen3.5-35'],
264
+ 'nemotron': ['nemotron'],
265
+ 'coder': ['qwen3-coder-30b', 'qwen3-coder-30'],
266
+ 'glm': ['glm-4.7', 'glm-4-flash', 'glm4'],
267
+ 'max': ['qwen3-coder-next', 'coder-next'],
268
+ 'mistral': ['mistral-small']
269
+ };
270
+ if (keyMap[key]) keywords.push(...keyMap[key]);
271
+
272
+ if (typeof name === 'string' && name.trim()) {
273
+ const parts = name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
274
+ keywords.push(...parts.slice(0, 3));
275
+ }
276
+
277
+ return [...new Set(keywords)];
278
+ }
279
+
280
+ /**
281
+ * Get default model key
282
+ */
283
+ getDefault() {
284
+ if (this.registry.models[this.registry.default]) {
285
+ return this.registry.default;
286
+ }
287
+ return Object.keys(this.registry.models)[0] || null;
288
+ }
289
+
290
+ /**
291
+ * Resolve a "provider:alias" model key string to full model metadata.
292
+ *
293
+ * Supported formats:
294
+ * - "anthropic:claude-sonnet-4.6" -> looks up by key in merged registry
295
+ * - "local:current" -> resolves to the currently active local model
296
+ * - "nemotron" -> plain key lookup (no colon)
297
+ *
298
+ * @param {string} key - Model key to resolve
299
+ * @returns {Object|null} - Full model metadata with key, provider, providerModelId, etc.
300
+ */
301
+ resolveModelKey(key) {
302
+ if (!key || typeof key !== 'string') return null;
303
+ const trimmed = key.trim();
304
+
305
+ // Handle "local:current" special case
306
+ if (trimmed.toLowerCase() === 'local:current') {
307
+ return this.getCurrentModel();
308
+ }
309
+
310
+ // Check if it's a "provider:alias" format
311
+ const colonIdx = trimmed.indexOf(':');
312
+ if (colonIdx > 0) {
313
+ const provider = trimmed.slice(0, colonIdx).toLowerCase();
314
+ const alias = trimmed.slice(colonIdx + 1);
315
+
316
+ // Direct key lookup first (the merged key is "provider:alias")
317
+ if (this.registry.models[trimmed]) {
318
+ return { key: trimmed, ...this.registry.models[trimmed] };
319
+ }
320
+
321
+ // Try matching by provider + partial alias
322
+ for (const [k, model] of Object.entries(this.registry.models)) {
323
+ if ((model.provider || 'local') === provider) {
324
+ if (k === trimmed || k === alias || k.includes(alias) ||
325
+ (model.name && model.name.toLowerCase().includes(alias.toLowerCase()))) {
326
+ return { key: k, ...model };
327
+ }
328
+ }
329
+ }
330
+
331
+ return null;
332
+ }
333
+
334
+ // Plain key lookup (no colon)
335
+ if (this.registry.models[trimmed]) {
336
+ return { key: trimmed, ...this.registry.models[trimmed] };
337
+ }
338
+
339
+ // Fuzzy match by name or partial key
340
+ for (const [k, model] of Object.entries(this.registry.models)) {
341
+ if (k.includes(trimmed) || (model.name && model.name.toLowerCase().includes(trimmed.toLowerCase()))) {
342
+ return { key: k, ...model };
343
+ }
344
+ }
345
+
346
+ return null;
347
+ }
348
+ }
349
+
350
+ module.exports = ModelRegistry;