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
package/lib/config.js ADDED
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Configuration management for Banana Code
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const DEFAULT_CONFIG = {
9
+ lmStudioUrl: 'http://localhost:1234',
10
+ compactMode: false,
11
+ maxTokens: 32000,
12
+ tokenWarningThreshold: 0.8, // Warn at 80% of max
13
+ streamingEnabled: true,
14
+ autoSaveHistory: true,
15
+ historyLimit: 50,
16
+ steeringEnabled: true,
17
+ agenticMode: true, // Enable AI tool calling (read files, search code)
18
+ activeModel: null, // Persisted model selection (friendly name from models.json)
19
+ activePrompt: 'base', // Active system prompt
20
+ mcpUrl: null, // MCP server URL (overrides MCP_SERVER_URL env var)
21
+ geminiApiKey: null, // For vision analysis fallback (Alt+V screenshots)
22
+ ignorePatterns: [
23
+ 'node_modules/**',
24
+ '.git/**',
25
+ 'dist/**',
26
+ 'build/**',
27
+ '.next/**',
28
+ 'coverage/**',
29
+ '*.lock',
30
+ '*.log'
31
+ ]
32
+ };
33
+
34
+ class Config {
35
+ constructor(projectDir) {
36
+ this.projectDir = projectDir;
37
+ this.bananaDir = path.join(projectDir, '.banana');
38
+ this.configPath = path.join(this.bananaDir, 'config.json');
39
+ this.instructionsPath = path.join(this.bananaDir, 'instructions.md');
40
+ this.historyDir = path.join(this.bananaDir, 'history');
41
+ this.config = { ...DEFAULT_CONFIG };
42
+ this.load();
43
+ }
44
+
45
+ ensureDir() {
46
+ if (!fs.existsSync(this.bananaDir)) {
47
+ fs.mkdirSync(this.bananaDir, { recursive: true });
48
+ }
49
+ if (!fs.existsSync(this.historyDir)) {
50
+ fs.mkdirSync(this.historyDir, { recursive: true });
51
+ }
52
+ }
53
+
54
+ load() {
55
+ try {
56
+ if (fs.existsSync(this.configPath)) {
57
+ const saved = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
58
+ this.config = { ...DEFAULT_CONFIG, ...saved };
59
+ }
60
+ } catch {
61
+ // Use defaults
62
+ }
63
+
64
+ // Migrate old config: apiUrl -> lmStudioUrl
65
+ if (this.config.apiUrl && !this.config.lmStudioUrl) {
66
+ this.config.lmStudioUrl = DEFAULT_CONFIG.lmStudioUrl;
67
+ delete this.config.apiUrl;
68
+ this.save();
69
+ }
70
+
71
+ // Env overrides (support legacy RIPLEY_ prefix with deprecation)
72
+ const envUrl = process.env.BANANA_LM_STUDIO_URL || process.env.RIPLEY_LM_STUDIO_URL;
73
+ if (envUrl) {
74
+ this.config.lmStudioUrl = envUrl;
75
+ }
76
+ }
77
+
78
+ save() {
79
+ this.ensureDir();
80
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
81
+ }
82
+
83
+ get(key) {
84
+ return this.config[key];
85
+ }
86
+
87
+ set(key, value) {
88
+ this.config[key] = value;
89
+ this.save();
90
+ }
91
+
92
+ getAll() {
93
+ return { ...this.config };
94
+ }
95
+
96
+ // Project-specific instructions
97
+ // Checks BANANA.md at project root first, then .banana/instructions.md as fallback
98
+ getInstructions() {
99
+ // Primary: BANANA.md at project root (like CLAUDE.md pattern)
100
+ const bananaMdPath = path.join(this.projectDir, 'BANANA.md');
101
+ try {
102
+ if (fs.existsSync(bananaMdPath)) {
103
+ return { content: fs.readFileSync(bananaMdPath, 'utf-8'), source: 'BANANA.md' };
104
+ }
105
+ } catch {
106
+ // Fall through
107
+ }
108
+ // Fallback: .banana/instructions.md
109
+ try {
110
+ if (fs.existsSync(this.instructionsPath)) {
111
+ return { content: fs.readFileSync(this.instructionsPath, 'utf-8'), source: '.banana/instructions.md' };
112
+ }
113
+ } catch {
114
+ // No instructions
115
+ }
116
+ // Legacy fallback: RIPLEY.md at project root
117
+ const ripleyMdPath = path.join(this.projectDir, 'RIPLEY.md');
118
+ try {
119
+ if (fs.existsSync(ripleyMdPath)) {
120
+ return { content: fs.readFileSync(ripleyMdPath, 'utf-8'), source: 'RIPLEY.md' };
121
+ }
122
+ } catch {
123
+ // No legacy instructions
124
+ }
125
+ return null;
126
+ }
127
+
128
+ createDefaultInstructions() {
129
+ const bananaMdPath = path.join(this.projectDir, 'BANANA.md');
130
+ const template = `# BANANA.md
131
+
132
+ This file provides project-specific instructions to Banana Code.
133
+ It is automatically loaded into the AI context at the start of every conversation.
134
+
135
+ ## Project Overview
136
+ <!-- Describe your project briefly -->
137
+
138
+ ## Code Style
139
+ <!-- Describe your preferred code style -->
140
+ - We use TypeScript
141
+ - We prefer functional components with hooks
142
+ - We use Tailwind CSS for styling
143
+
144
+ ## Important Notes
145
+ <!-- Any special instructions for the AI -->
146
+ - Always use pnpm instead of npm
147
+ - Follow the existing patterns in the codebase
148
+
149
+ ## Off-Limits
150
+ <!-- Things the AI should NOT do -->
151
+ - Don't modify package-lock.json or pnpm-lock.yaml
152
+ - Don't change the build configuration without asking
153
+ `;
154
+ fs.writeFileSync(bananaMdPath, template);
155
+ return template;
156
+ }
157
+
158
+ // Conversation history persistence
159
+ saveConversation(name, history) {
160
+ this.ensureDir();
161
+ const filename = `${name.replace(/[^a-z0-9]/gi, '_')}_${Date.now()}.json`;
162
+ const filepath = path.join(this.historyDir, filename);
163
+ fs.writeFileSync(filepath, JSON.stringify({
164
+ name,
165
+ savedAt: new Date().toISOString(),
166
+ history
167
+ }, null, 2));
168
+ return filename;
169
+ }
170
+
171
+ listConversations() {
172
+ this.ensureDir();
173
+ try {
174
+ const files = fs.readdirSync(this.historyDir)
175
+ .filter(f => f.endsWith('.json'))
176
+ .map(f => {
177
+ try {
178
+ const content = JSON.parse(fs.readFileSync(path.join(this.historyDir, f), 'utf-8'));
179
+ return {
180
+ filename: f,
181
+ name: content.name,
182
+ savedAt: content.savedAt,
183
+ messageCount: content.history?.length || 0
184
+ };
185
+ } catch {
186
+ return null;
187
+ }
188
+ })
189
+ .filter(Boolean)
190
+ .sort((a, b) => new Date(b.savedAt) - new Date(a.savedAt));
191
+ return files;
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
197
+ loadConversation(filename) {
198
+ const filepath = path.join(this.historyDir, filename);
199
+ try {
200
+ const content = JSON.parse(fs.readFileSync(filepath, 'utf-8'));
201
+ return content.history || [];
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ deleteConversation(filename) {
208
+ const filepath = path.join(this.historyDir, filename);
209
+ try {
210
+ fs.unlinkSync(filepath);
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ // Hooks config
218
+ getHooksPath() {
219
+ return path.join(this.bananaDir, 'hooks.json');
220
+ }
221
+
222
+ getHooks() {
223
+ try {
224
+ const hooksPath = this.getHooksPath();
225
+ if (fs.existsSync(hooksPath)) {
226
+ return JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
227
+ }
228
+ } catch {
229
+ // Invalid hooks.json
230
+ }
231
+ return {};
232
+ }
233
+
234
+ saveHooks(hookConfig) {
235
+ this.ensureDir();
236
+ fs.writeFileSync(this.getHooksPath(), JSON.stringify(hookConfig, null, 2));
237
+ }
238
+ }
239
+
240
+ // ─── Global Config (~/.banana/) ──────────────────────────────────────────────
241
+
242
+ const GLOBAL_BANANA_DIR = path.join(require('os').homedir(), '.banana');
243
+
244
+ class GlobalConfig {
245
+ constructor() {
246
+ this.bananaDir = GLOBAL_BANANA_DIR;
247
+ this.configPath = path.join(this.bananaDir, 'config.json');
248
+ this.instructionsPath = path.join(this.bananaDir, 'BANANA.md');
249
+ this.commandsDir = path.join(this.bananaDir, 'commands');
250
+ this.config = {};
251
+ this.load();
252
+ }
253
+
254
+ ensureDir() {
255
+ for (const dir of [this.bananaDir, this.commandsDir, path.join(this.bananaDir, 'logs')]) {
256
+ if (!fs.existsSync(dir)) {
257
+ fs.mkdirSync(dir, { recursive: true });
258
+ }
259
+ }
260
+ }
261
+
262
+ load() {
263
+ try {
264
+ if (fs.existsSync(this.configPath)) {
265
+ this.config = JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
266
+ }
267
+ } catch {
268
+ this.config = {};
269
+ }
270
+ }
271
+
272
+ save() {
273
+ this.ensureDir();
274
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
275
+ }
276
+
277
+ get(key) {
278
+ return this.config[key];
279
+ }
280
+
281
+ set(key, value) {
282
+ this.config[key] = value;
283
+ this.save();
284
+ }
285
+
286
+ /**
287
+ * Get global instructions from ~/.banana/BANANA.md
288
+ */
289
+ getInstructions() {
290
+ try {
291
+ if (fs.existsSync(this.instructionsPath)) {
292
+ return { content: fs.readFileSync(this.instructionsPath, 'utf-8'), source: '~/.banana/BANANA.md' };
293
+ }
294
+ } catch {
295
+ // No global instructions
296
+ }
297
+ return null;
298
+ }
299
+ }
300
+
301
+ module.exports = { Config, GlobalConfig, GLOBAL_BANANA_DIR };
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Context Builder - Build project context for AI
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ // Directories to always ignore
9
+ const IGNORED_DIRS = [
10
+ 'node_modules', '.git', '.next', 'dist', 'build', '.cache',
11
+ 'coverage', '.nyc_output', '.vercel', '.netlify', '.svelte-kit',
12
+ '__pycache__', 'venv', '.venv', 'env', '.env',
13
+ '.banana', '.ripley', '.idea', '.vscode'
14
+ ];
15
+
16
+ // Files to always ignore
17
+ const IGNORED_FILES = [
18
+ '.DS_Store', 'Thumbs.db', '.gitkeep',
19
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'
20
+ ];
21
+
22
+ // Priority files to always include if they exist
23
+ const PRIORITY_FILES = [
24
+ 'package.json', 'tsconfig.json', 'README.md', 'README.txt',
25
+ '.env.example', 'next.config.js', 'next.config.mjs', 'next.config.ts',
26
+ 'vite.config.js', 'vite.config.ts', 'tailwind.config.js', 'tailwind.config.ts',
27
+ 'prisma/schema.prisma', 'drizzle.config.ts'
28
+ ];
29
+
30
+ // Extensions for source files
31
+ const SOURCE_EXTENSIONS = [
32
+ '.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte',
33
+ '.py', '.rb', '.go', '.rs', '.java', '.kt',
34
+ '.css', '.scss', '.sass', '.less',
35
+ '.html', '.htm', '.json', '.yaml', '.yml', '.toml',
36
+ '.md', '.mdx', '.sql', '.graphql', '.gql'
37
+ ];
38
+
39
+ class ContextBuilder {
40
+ constructor(fileManager, ignorePatterns = []) {
41
+ this.fileManager = fileManager;
42
+ this.projectDir = fileManager.projectDir;
43
+ this.customIgnores = ignorePatterns;
44
+ this.loadedFiles = new Map(); // path -> content
45
+ }
46
+
47
+ /**
48
+ * Load .gitignore patterns
49
+ */
50
+ loadGitignore() {
51
+ const gitignorePath = path.join(this.projectDir, '.gitignore');
52
+ if (fs.existsSync(gitignorePath)) {
53
+ try {
54
+ const content = fs.readFileSync(gitignorePath, 'utf-8');
55
+ return content
56
+ .split('\n')
57
+ .map(line => line.trim())
58
+ .filter(line => line && !line.startsWith('#'));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+ return [];
64
+ }
65
+
66
+ /**
67
+ * Check if path should be ignored
68
+ */
69
+ shouldIgnore(relativePath) {
70
+ const basename = path.basename(relativePath);
71
+ const parts = relativePath.split(path.sep);
72
+
73
+ // Check ignored directories
74
+ for (const dir of IGNORED_DIRS) {
75
+ if (parts.includes(dir)) return true;
76
+ }
77
+
78
+ // Check ignored files
79
+ if (IGNORED_FILES.includes(basename)) return true;
80
+
81
+ // Check custom patterns (simple matching)
82
+ for (const pattern of this.customIgnores) {
83
+ if (pattern.endsWith('/') || pattern.endsWith('\\')) {
84
+ // Directory pattern
85
+ const dirName = pattern.slice(0, -1);
86
+ if (parts.includes(dirName)) return true;
87
+ } else if (pattern.startsWith('*.')) {
88
+ // Extension pattern
89
+ const ext = pattern.slice(1);
90
+ if (relativePath.endsWith(ext)) return true;
91
+ } else if (relativePath.includes(pattern)) {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * Recursively scan directory structure
101
+ * @param {string} dir - Directory to scan
102
+ * @param {number} depth - Current depth
103
+ * @param {number} maxDepth - Maximum recursion depth
104
+ * @param {number} maxFiles - Maximum total files to collect (prevents runaway scans)
105
+ * @param {object} counter - Shared counter across recursive calls
106
+ */
107
+ scanDirectory(dir = this.projectDir, depth = 0, maxDepth = 6, maxFiles = 5000, counter = { count: 0 }) {
108
+ if (depth > maxDepth) return [];
109
+ if (counter.count >= maxFiles) return [];
110
+
111
+ const results = [];
112
+ const relativePath = path.relative(this.projectDir, dir) || '.';
113
+
114
+ if (this.shouldIgnore(relativePath)) return [];
115
+
116
+ try {
117
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
118
+
119
+ for (const entry of entries) {
120
+ if (counter.count >= maxFiles) break;
121
+
122
+ const fullPath = path.join(dir, entry.name);
123
+ const entryRelPath = path.relative(this.projectDir, fullPath);
124
+
125
+ if (this.shouldIgnore(entryRelPath)) continue;
126
+
127
+ if (entry.isDirectory()) {
128
+ results.push({
129
+ type: 'dir',
130
+ name: entry.name,
131
+ path: entryRelPath,
132
+ depth
133
+ });
134
+ results.push(...this.scanDirectory(fullPath, depth + 1, maxDepth, maxFiles, counter));
135
+ } else {
136
+ const ext = path.extname(entry.name).toLowerCase();
137
+ const isSource = SOURCE_EXTENSIONS.includes(ext);
138
+ results.push({
139
+ type: 'file',
140
+ name: entry.name,
141
+ path: entryRelPath,
142
+ isSource,
143
+ depth
144
+ });
145
+ counter.count++;
146
+ }
147
+ }
148
+ } catch {
149
+ // Ignore permission errors
150
+ }
151
+
152
+ return results;
153
+ }
154
+
155
+ /**
156
+ * Build a tree string from scan results
157
+ */
158
+ buildTreeString(items, maxItems = 100) {
159
+ let tree = '';
160
+ let count = 0;
161
+
162
+ for (const item of items) {
163
+ if (count >= maxItems) {
164
+ tree += `\n... and ${items.length - count} more items`;
165
+ break;
166
+ }
167
+
168
+ const indent = ' '.repeat(item.depth);
169
+ const icon = item.type === 'dir' ? '📁' : '📄';
170
+ tree += `${indent}${icon} ${item.name}\n`;
171
+ count++;
172
+ }
173
+
174
+ return tree;
175
+ }
176
+
177
+ /**
178
+ * Load priority files
179
+ */
180
+ loadPriorityFiles() {
181
+ for (const file of PRIORITY_FILES) {
182
+ const filePath = path.join(this.projectDir, file);
183
+ if (fs.existsSync(filePath)) {
184
+ const result = this.fileManager.readFile(filePath);
185
+ if (result.success) {
186
+ this.loadedFiles.set(file, result.content);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Load a specific file into context
194
+ */
195
+ loadFile(filePath) {
196
+ const relativePath = path.isAbsolute(filePath)
197
+ ? path.relative(this.projectDir, filePath)
198
+ : filePath;
199
+
200
+ if (this.loadedFiles.has(relativePath)) {
201
+ return { success: true, alreadyLoaded: true };
202
+ }
203
+
204
+ const result = this.fileManager.readFile(filePath);
205
+ if (result.success) {
206
+ this.loadedFiles.set(relativePath, result.content);
207
+ return { success: true, path: relativePath };
208
+ }
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * Unload a file from context
214
+ */
215
+ unloadFile(filePath) {
216
+ const relativePath = path.isAbsolute(filePath)
217
+ ? path.relative(this.projectDir, filePath)
218
+ : filePath;
219
+
220
+ if (this.loadedFiles.has(relativePath)) {
221
+ this.loadedFiles.delete(relativePath);
222
+ return { success: true };
223
+ }
224
+ return { success: false, error: 'File not in context' };
225
+ }
226
+
227
+ /**
228
+ * Reload a file in context (for watch mode)
229
+ */
230
+ reloadFile(filePath) {
231
+ const relativePath = path.isAbsolute(filePath)
232
+ ? path.relative(this.projectDir, filePath)
233
+ : filePath;
234
+
235
+ if (!this.loadedFiles.has(relativePath)) {
236
+ return { success: false, error: 'File not in context' };
237
+ }
238
+
239
+ const result = this.fileManager.readFile(filePath);
240
+ if (result.success) {
241
+ this.loadedFiles.set(relativePath, result.content);
242
+ return { success: true, path: relativePath };
243
+ }
244
+ return result;
245
+ }
246
+
247
+ /**
248
+ * Get list of loaded files
249
+ */
250
+ getLoadedFiles() {
251
+ return Array.from(this.loadedFiles.keys());
252
+ }
253
+
254
+ /**
255
+ * Clear all loaded files
256
+ */
257
+ clearFiles() {
258
+ this.loadedFiles.clear();
259
+ }
260
+
261
+ /**
262
+ * Build the full context string for AI
263
+ */
264
+ buildContext() {
265
+ // Load gitignore patterns
266
+ this.customIgnores = [...this.customIgnores, ...this.loadGitignore()];
267
+
268
+ // Load priority files first
269
+ this.loadPriorityFiles();
270
+
271
+ // Scan directory structure
272
+ const structure = this.scanDirectory();
273
+ const tree = this.buildTreeString(structure);
274
+
275
+ // Build context string
276
+ let context = '';
277
+
278
+ // Project info
279
+ const packageJson = this.loadedFiles.get('package.json');
280
+ if (packageJson) {
281
+ try {
282
+ const pkg = JSON.parse(packageJson);
283
+ context += `## Project: ${pkg.name || 'Unknown'}\n`;
284
+ if (pkg.description) context += `Description: ${pkg.description}\n`;
285
+ context += '\n';
286
+ } catch {
287
+ // Invalid JSON, skip
288
+ }
289
+ }
290
+
291
+ // Directory structure
292
+ context += `## Project Structure\n\`\`\`\n${tree}\`\`\`\n\n`;
293
+
294
+ // Loaded files
295
+ context += `## Files in Context (${this.loadedFiles.size})\n\n`;
296
+
297
+ for (const [filePath, content] of this.loadedFiles) {
298
+ const ext = path.extname(filePath).slice(1) || 'txt';
299
+ context += `### ${filePath}\n\`\`\`${ext}\n${content}\n\`\`\`\n\n`;
300
+ }
301
+
302
+ return context;
303
+ }
304
+
305
+ /**
306
+ * Get a summary of the context (for display)
307
+ */
308
+ getSummary() {
309
+ const structure = this.scanDirectory();
310
+ const files = structure.filter(i => i.type === 'file');
311
+ const dirs = structure.filter(i => i.type === 'dir');
312
+ const sourceFiles = files.filter(f => f.isSource);
313
+
314
+ return {
315
+ totalFiles: files.length,
316
+ totalDirs: dirs.length,
317
+ sourceFiles: sourceFiles.length,
318
+ loadedFiles: this.loadedFiles.size,
319
+ loadedFilesList: this.getLoadedFiles()
320
+ };
321
+ }
322
+ }
323
+
324
+ module.exports = ContextBuilder;