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,97 @@
1
+ /**
2
+ * Monkey Models API Client for Banana Code CLI
3
+ * Cloud provider wrapping OpenAICompatibleClient.
4
+ *
5
+ * Base URL: https://monkey-models-production.up.railway.app
6
+ * Tiers: silverback, mandrill, gibbon, tamarin
7
+ * Auth: Bearer token in Authorization header
8
+ * Vision: image_url content blocks (server proxies to Gemini Flash)
9
+ * The server handles personality injection, so no system prompts needed.
10
+ */
11
+
12
+ const { OpenAICompatibleClient } = require('./providerClients');
13
+
14
+ const MONKEY_MODELS_URL = 'https://monkey-models-production.up.railway.app';
15
+ // Default token for Banana Code's own Monkey Models server.
16
+ // This is a shared service token (not a user secret). Users can override via env var.
17
+ const MONKEY_MODELS_DEFAULT_TOKEN = '086399eca157e4ad2fc0fecfb254da1118d226ac53371757267388b23bd10fa6';
18
+ const MONKEY_MODELS_TOKEN = process.env.BANANA_MONKEY_TOKEN || MONKEY_MODELS_DEFAULT_TOKEN;
19
+
20
+ const TIERS = ['silverback', 'mandrill', 'gibbon', 'tamarin'];
21
+
22
+ class MonkeyModelsClient extends OpenAICompatibleClient {
23
+ constructor(options = {}) {
24
+ super({
25
+ label: 'Monkey Models',
26
+ baseUrl: options.baseUrl || MONKEY_MODELS_URL,
27
+ bearerToken: options.token || MONKEY_MODELS_TOKEN,
28
+ ...options
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Sanitizes error messages to prevent leaking upstream provider details (like OpenRouter).
34
+ * @param {string} errorText The raw error text from the provider.
35
+ * @returns {string} A sanitized error message.
36
+ */
37
+ _sanitizeError(errorText) {
38
+ if (!errorText) return '';
39
+ // If it looks like it contains OpenRouter or specific provider JSON/strings,
40
+ // we want to strip that out and just provide a clean error.
41
+ // The user's error shows: "OpenRouter 429: {\"error\":{\"message\":\"Provider returned error\"...}}"
42
+
43
+ // Check if it's an error that contains JSON or common provider markers
44
+ if (errorText.includes('OpenRouter') || errorText.includes('provider_name') || errorText.includes('is_byok')) {
45
+ return 'Service is temporarily unavailable or rate-limited. Please try again shortly.';
46
+ }
47
+
48
+ return errorText;
49
+ }
50
+
51
+ /**
52
+ * Overrides _request to sanitize error messages for Monkey Models.
53
+ */
54
+ async _request(path, body, signal) {
55
+ try {
56
+ return await super._request(path, body, signal);
57
+ } catch (err) {
58
+ if (err.message.startsWith('Monkey Models error')) {
59
+ // Extract the part after the prefix
60
+ const parts = err.message.split(': ');
61
+ if (parts.length > 1) {
62
+ const originalError = parts.slice(1).join(': ');
63
+ const sanitized = this._sanitizeError(originalError);
64
+ throw new Error(`Monkey Models error: ${sanitized}`);
65
+ }
66
+ }
67
+ throw err;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * GET /health - no auth required.
73
+ * Returns parsed JSON on success, null on failure.
74
+ */
75
+ async healthCheck() {
76
+ try {
77
+ const response = await fetch(`${this.baseUrl}/health`, {
78
+ signal: AbortSignal.timeout(5000)
79
+ });
80
+ if (!response.ok) return null;
81
+ return await response.json();
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Check connectivity via health endpoint first, then fall back to parent.
89
+ */
90
+ async isConnected(options = {}) {
91
+ const health = await this.healthCheck();
92
+ if (health) return true;
93
+ return super.isConnected(options);
94
+ }
95
+ }
96
+
97
+ module.exports = { MonkeyModelsClient, MONKEY_MODELS_URL, TIERS };
@@ -0,0 +1,167 @@
1
+ const OPENAI_AUTH_ISSUER = 'https://auth.openai.com';
2
+ const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
3
+
4
+ function trimSlash(url) {
5
+ return (url || '').replace(/\/+$/, '');
6
+ }
7
+
8
+ function sleep(ms) {
9
+ return new Promise(resolve => setTimeout(resolve, ms));
10
+ }
11
+
12
+ function parseInterval(value) {
13
+ const parsed = Number.parseInt(String(value || ''), 10);
14
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 5;
15
+ }
16
+
17
+ async function readError(response, prefix) {
18
+ const text = await response.text().catch(() => '');
19
+ throw new Error(`${prefix} (${response.status}): ${text || response.statusText}`);
20
+ }
21
+
22
+ async function requestDeviceCode(options = {}) {
23
+ const issuer = trimSlash(options.issuer || OPENAI_AUTH_ISSUER);
24
+ const clientId = options.clientId || OPENAI_CODEX_CLIENT_ID;
25
+ const response = await fetch(`${issuer}/api/accounts/deviceauth/usercode`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ client_id: clientId })
29
+ });
30
+
31
+ if (!response.ok) {
32
+ await readError(response, 'OpenAI device code request failed');
33
+ }
34
+
35
+ const data = await response.json();
36
+ return {
37
+ issuer,
38
+ clientId,
39
+ verificationUrl: `${issuer}/codex/device`,
40
+ userCode: data.user_code || data.usercode,
41
+ deviceAuthId: data.device_auth_id,
42
+ interval: parseInterval(data.interval)
43
+ };
44
+ }
45
+
46
+ async function pollForDeviceAuthorization(deviceCode, options = {}) {
47
+ const timeoutMs = options.timeoutMs || 15 * 60 * 1000;
48
+ const started = Date.now();
49
+ const issuer = trimSlash(deviceCode.issuer || OPENAI_AUTH_ISSUER);
50
+ const pollUrl = `${issuer}/api/accounts/deviceauth/token`;
51
+
52
+ while (Date.now() - started < timeoutMs) {
53
+ const response = await fetch(pollUrl, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ device_auth_id: deviceCode.deviceAuthId,
58
+ user_code: deviceCode.userCode
59
+ })
60
+ });
61
+
62
+ if (response.ok) {
63
+ return await response.json();
64
+ }
65
+
66
+ if (response.status === 403 || response.status === 404) {
67
+ await sleep((deviceCode.interval || 5) * 1000);
68
+ continue;
69
+ }
70
+
71
+ await readError(response, 'OpenAI device authorization failed');
72
+ }
73
+
74
+ throw new Error('OpenAI device authorization timed out after 15 minutes');
75
+ }
76
+
77
+ async function exchangeAuthorizationCode(options) {
78
+ const issuer = trimSlash(options.issuer || OPENAI_AUTH_ISSUER);
79
+ const clientId = options.clientId || OPENAI_CODEX_CLIENT_ID;
80
+ const redirectUri = options.redirectUri || `${issuer}/deviceauth/callback`;
81
+
82
+ const form = new URLSearchParams({
83
+ grant_type: 'authorization_code',
84
+ code: options.authorizationCode,
85
+ redirect_uri: redirectUri,
86
+ client_id: clientId,
87
+ code_verifier: options.codeVerifier
88
+ });
89
+
90
+ const response = await fetch(`${issuer}/oauth/token`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
93
+ body: form.toString()
94
+ });
95
+
96
+ if (!response.ok) {
97
+ await readError(response, 'OpenAI authorization-code exchange failed');
98
+ }
99
+
100
+ return await response.json();
101
+ }
102
+
103
+ async function refreshOpenAIToken(options) {
104
+ const issuer = trimSlash(options.issuer || OPENAI_AUTH_ISSUER);
105
+ const clientId = options.clientId || OPENAI_CODEX_CLIENT_ID;
106
+ const form = new URLSearchParams({
107
+ client_id: clientId,
108
+ grant_type: 'refresh_token',
109
+ refresh_token: options.refreshToken
110
+ });
111
+ const response = await fetch(`${issuer}/oauth/token`, {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
114
+ body: form.toString()
115
+ });
116
+
117
+ if (!response.ok) {
118
+ await readError(response, 'OpenAI token refresh failed');
119
+ }
120
+
121
+ return await response.json();
122
+ }
123
+
124
+ function buildTokenRecord(tokenPayload) {
125
+ const expiresIn = Number(tokenPayload.expires_in);
126
+ const expiresAt = Number.isFinite(expiresIn)
127
+ ? new Date(Date.now() + (expiresIn * 1000)).toISOString()
128
+ : null;
129
+
130
+ return {
131
+ accessToken: tokenPayload.access_token,
132
+ refreshToken: tokenPayload.refresh_token,
133
+ idToken: tokenPayload.id_token || null,
134
+ expiresAt
135
+ };
136
+ }
137
+
138
+ function isTokenExpired(expiresAt, skewSeconds = 60) {
139
+ if (!expiresAt) return false;
140
+ const expiry = new Date(expiresAt).getTime();
141
+ if (!Number.isFinite(expiry)) return false;
142
+ return Date.now() >= (expiry - skewSeconds * 1000);
143
+ }
144
+
145
+ async function completeDeviceCodeLogin(deviceCode, options = {}) {
146
+ const authorized = await pollForDeviceAuthorization(deviceCode, options);
147
+ const tokens = await exchangeAuthorizationCode({
148
+ issuer: deviceCode.issuer,
149
+ clientId: deviceCode.clientId,
150
+ authorizationCode: authorized.authorization_code,
151
+ codeVerifier: authorized.code_verifier,
152
+ redirectUri: `${trimSlash(deviceCode.issuer)}/deviceauth/callback`
153
+ });
154
+ return buildTokenRecord(tokens);
155
+ }
156
+
157
+ module.exports = {
158
+ OPENAI_AUTH_ISSUER,
159
+ OPENAI_CODEX_CLIENT_ID,
160
+ requestDeviceCode,
161
+ pollForDeviceAuthorization,
162
+ exchangeAuthorizationCode,
163
+ refreshOpenAIToken,
164
+ completeDeviceCodeLogin,
165
+ buildTokenRecord,
166
+ isTokenExpired
167
+ };
package/lib/parser.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Parser - Extract file operations from AI responses
3
+ */
4
+
5
+ /**
6
+ * Parse AI response for file operations
7
+ *
8
+ * Expected format:
9
+ * <file_operation>
10
+ * <action>create|edit|delete</action>
11
+ * <path>relative/path/to/file.ts</path>
12
+ * <content>
13
+ * ... file content ...
14
+ * </content>
15
+ * </file_operation>
16
+ *
17
+ * Also handles common local model variations:
18
+ * - <fileoperation> (no underscore)
19
+ * - <file-operation> (hyphen)
20
+ * - <parameter=action>create</parameter> (Nemotron-style)
21
+ * - <parameter=path>...</parameter> (Nemotron-style)
22
+ */
23
+ function parseFileOperations(response) {
24
+ const operations = [];
25
+
26
+ // First, strip markdown code fences that might wrap the XML blocks
27
+ let cleanedResponse = response
28
+ .replace(/```(?:xml|html|text)?\s*\n?(<(?:file[-_]?operation)>)/gi, '$1')
29
+ .replace(/(<\/(?:file[-_]?operation)>)\s*\n?```/gi, '$1');
30
+
31
+ // Match file_operation blocks - tolerate missing underscore, hyphen, etc.
32
+ const operationRegex = /<file[-_]?operation>([\s\S]*?)<\/file[-_]?operation>/gi;
33
+ let match;
34
+
35
+ while ((match = operationRegex.exec(cleanedResponse)) !== null) {
36
+ const block = match[1];
37
+
38
+ // Extract action - standard format OR Nemotron <parameter=action> format
39
+ const actionMatch = block.match(/<action>(create|edit|delete)<\/action>/i)
40
+ || block.match(/<parameter\s*=\s*"?action"?\s*>\s*(create|edit|delete)\s*<\/parameter>/i);
41
+ if (!actionMatch) continue;
42
+
43
+ // Extract path - standard format OR Nemotron <parameter=path> format
44
+ const pathMatch = block.match(/<path>([^<]+)<\/path>/)
45
+ || block.match(/<parameter\s*=\s*"?path"?\s*>\s*([^<]+?)\s*<\/parameter>/i);
46
+ if (!pathMatch) continue;
47
+
48
+ // Extract content - standard format OR Nemotron <parameter=content> format
49
+ const contentMatch = block.match(/<content>([\s\S]*?)<\/content>/)
50
+ || block.match(/<parameter\s*=\s*"?content"?\s*>([\s\S]*?)<\/parameter>/i);
51
+
52
+ operations.push({
53
+ action: actionMatch[1].toLowerCase(),
54
+ path: pathMatch[1].trim(),
55
+ content: contentMatch ? contentMatch[1].trim() : null
56
+ });
57
+ }
58
+
59
+ return operations;
60
+ }
61
+
62
+ /**
63
+ * Parse AI response for shell commands
64
+ *
65
+ * Expected format:
66
+ * <run_command>
67
+ * npm install axios
68
+ * </run_command>
69
+ */
70
+ function parseCommands(response) {
71
+ const commands = [];
72
+
73
+ // Strip markdown code fences that might wrap the XML blocks
74
+ let cleanedResponse = response
75
+ .replace(/```(?:bash|shell|sh|text)?\s*\n?(<run[-_]?command>)/gi, '$1')
76
+ .replace(/(<\/run[-_]?command>)\s*\n?```/gi, '$1');
77
+
78
+ const commandRegex = /<run[-_]?command>([\s\S]*?)<\/run[-_]?command>/gi;
79
+ let match;
80
+
81
+ while ((match = commandRegex.exec(cleanedResponse)) !== null) {
82
+ const command = match[1].trim();
83
+ if (command) {
84
+ commands.push(command);
85
+ }
86
+ }
87
+
88
+ return commands;
89
+ }
90
+
91
+ /**
92
+ * Extract the explanation text (everything not in special blocks)
93
+ */
94
+ function extractExplanation(response) {
95
+ let text = response;
96
+
97
+ // Remove file operation blocks (including model variants)
98
+ text = text.replace(/<file[-_]?operation>[\s\S]*?<\/file[-_]?operation>/gi, '');
99
+
100
+ // Remove command blocks (including model variants)
101
+ text = text.replace(/<run[-_]?command>[\s\S]*?<\/run[-_]?command>/gi, '');
102
+
103
+ // Clean up excess whitespace
104
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
105
+
106
+ return text;
107
+ }
108
+
109
+ /**
110
+ * Parse a complete AI response
111
+ */
112
+ function parseResponse(response) {
113
+ return {
114
+ explanation: extractExplanation(response),
115
+ fileOperations: parseFileOperations(response),
116
+ commands: parseCommands(response)
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Check if response contains any actionable items
122
+ */
123
+ function hasActions(response) {
124
+ const parsed = parseResponse(response);
125
+ return parsed.fileOperations.length > 0 || parsed.commands.length > 0;
126
+ }
127
+
128
+ module.exports = {
129
+ parseFileOperations,
130
+ parseCommands,
131
+ extractExplanation,
132
+ parseResponse,
133
+ hasActions
134
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Prompt Manager for Banana Code
3
+ * Loads system prompts from the prompts/ directory.
4
+ * Drop any .md file into prompts/ and it becomes available via /prompt <name>.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ class PromptManager {
11
+ constructor(promptsDir) {
12
+ this.promptsDir = promptsDir;
13
+ this.prompts = {};
14
+ this.load();
15
+ }
16
+
17
+ load() {
18
+ this.prompts = {};
19
+ try {
20
+ const files = fs.readdirSync(this.promptsDir).filter(f => f.endsWith('.md'));
21
+ for (const file of files) {
22
+ const name = path.basename(file, '.md');
23
+ this.prompts[name] = fs.readFileSync(path.join(this.promptsDir, file), 'utf-8');
24
+ }
25
+ } catch {
26
+ // No prompts directory or can't read - that's ok
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Get a prompt by name. Falls back to 'base' if not found.
32
+ *
33
+ * For code-agent-* prompts, auto-assembles: base.md + code-agent.md + code-agent-{model}.md
34
+ * This eliminates duplication - model files only need their unique quirks.
35
+ *
36
+ * Injects dynamic OS detection to replace the static ## OS block.
37
+ */
38
+ get(name) {
39
+ let prompt;
40
+
41
+ if (name.startsWith('code-agent-') && this.prompts[name]) {
42
+ // Assemble: base + code-agent (shared tool instructions) + model-specific quirks
43
+ const parts = [];
44
+ if (this.prompts['base']) parts.push(this.prompts['base']);
45
+ if (this.prompts['code-agent']) parts.push(this.prompts['code-agent']);
46
+ parts.push(this.prompts[name]);
47
+ prompt = parts.join('\n\n');
48
+ } else if (name === 'code-agent' && this.prompts['code-agent']) {
49
+ // code-agent alone: base + code-agent
50
+ const parts = [];
51
+ if (this.prompts['base']) parts.push(this.prompts['base']);
52
+ parts.push(this.prompts['code-agent']);
53
+ prompt = parts.join('\n\n');
54
+ } else {
55
+ prompt = this.prompts[name] || this.prompts['base'] || '';
56
+ }
57
+
58
+ // Dynamic OS detection
59
+ const osLine = process.platform === 'win32'
60
+ ? 'Windows. Use PowerShell/cmd syntax for shell commands. No bash, no `ls`, no `grep`. Use `dir`, `Get-ChildItem`, `findstr`, etc.'
61
+ : process.platform === 'darwin'
62
+ ? 'macOS. Use bash/zsh syntax for shell commands. No PowerShell, no `dir`, no `findstr`. Use `ls`, `find`, `grep`, etc.'
63
+ : 'Linux. Use bash syntax for shell commands. No PowerShell, no `dir`, no `findstr`. Use `ls`, `find`, `grep`, etc.';
64
+ prompt = prompt.replace(/^## OS\n\n.+$/gm, `## OS\n\n${osLine}`);
65
+ prompt = prompt.replace(/^OS: Windows\..+$/gm, `OS: ${osLine}`);
66
+ if (process.platform !== 'win32') {
67
+ prompt = prompt.replace(/PowerShell syntax only/g, 'bash/zsh syntax');
68
+ prompt = prompt.replace(/PowerShell syntax only, not bash/g, 'bash/zsh syntax');
69
+ prompt = prompt.replace(/\(PowerShell syntax\)/g, '(bash/zsh syntax)');
70
+ }
71
+ return prompt;
72
+ }
73
+
74
+ /**
75
+ * List available prompt names
76
+ */
77
+ list() {
78
+ return Object.keys(this.prompts);
79
+ }
80
+
81
+ /**
82
+ * Check if a prompt exists
83
+ */
84
+ has(name) {
85
+ return name in this.prompts;
86
+ }
87
+
88
+ /**
89
+ * Reload prompts from disk (useful if user drops a new .md file in)
90
+ */
91
+ reload() {
92
+ this.load();
93
+ }
94
+ }
95
+
96
+ module.exports = PromptManager;