sapper-iq 1.1.13 → 1.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.1.13",
3
+ "version": "1.1.14",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env node
2
+ import ollama from 'ollama';
3
+ import fs from 'fs';
4
+ import { spawn } from 'child_process';
5
+ import chalk from 'chalk';
6
+ import ora from 'ora';
7
+ import readline from 'readline';
8
+ import { fileURLToPath } from 'url';
9
+ import { dirname, join } from 'path';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+
14
+ // Prevent process from exiting on unhandled errors
15
+ process.on('uncaughtException', (err) => {
16
+ console.error(chalk.red('\n❌ Uncaught exception:'), err.message);
17
+ });
18
+ process.on('unhandledRejection', (reason) => {
19
+ console.error(chalk.red('\n❌ Unhandled rejection:'), reason);
20
+ });
21
+
22
+ // Prevent Ctrl+C from killing the whole process
23
+ let ctrlCCount = 0;
24
+ process.on('SIGINT', () => {
25
+ ctrlCCount++;
26
+ if (ctrlCCount >= 2) {
27
+ console.log(chalk.red('\nForce quitting...'));
28
+ process.exit(1);
29
+ }
30
+ // Clear current line and move to new one - stops ghost output
31
+ process.stdout.clearLine(0);
32
+ process.stdout.cursorTo(0);
33
+ console.log(chalk.yellow('\nStopping AI stream... (Ctrl+C again to force quit)'));
34
+
35
+ // Reset terminal immediately
36
+ resetTerminal();
37
+ setTimeout(() => { ctrlCCount = 0; }, 2000); // Reset after 2 seconds
38
+ });
39
+
40
+ // Reset terminal state - fixes "ghost input" after shell commands or AI streaming
41
+ function resetTerminal() {
42
+ if (process.stdin.isTTY) {
43
+ try {
44
+ process.stdin.setRawMode(false); // Disable raw mode
45
+ process.stdin.pause(); // Pause the stream
46
+ process.stdin.resume(); // Resume to clear buffers
47
+ } catch (e) {
48
+ // Ignore errors if terminal is in weird state
49
+ }
50
+ }
51
+ }
52
+
53
+ // Initialize versioning
54
+ let CURRENT_VERSION = "1.1.0";
55
+ try {
56
+ const pkg = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8'));
57
+ CURRENT_VERSION = pkg.version;
58
+ } catch (e) {}
59
+
60
+ const spinner = ora();
61
+ const CONTEXT_FILE = '.sapper_context.json';
62
+
63
+ let stepMode = false;
64
+ let debugMode = false; // Toggle with /debug command
65
+ let rl = readline.createInterface({
66
+ input: process.stdin,
67
+ output: process.stdout,
68
+ terminal: true,
69
+ historySize: 100
70
+ });
71
+
72
+ function recreateReadline() {
73
+ if (rl) rl.close();
74
+ rl = readline.createInterface({
75
+ input: process.stdin,
76
+ output: process.stdout,
77
+ terminal: true,
78
+ historySize: 100
79
+ });
80
+ // Force resume stdin to keep process alive
81
+ process.stdin.resume();
82
+ }
83
+
84
+ async function safeQuestion(query) {
85
+ resetTerminal(); // Clear terminal state before asking
86
+ if (rl.closed) recreateReadline();
87
+
88
+ return new Promise((resolve) => {
89
+ rl.question(query, (answer) => {
90
+ resolve(answer ? answer.trim() : '');
91
+ });
92
+ });
93
+ }
94
+
95
+ // Directories to ignore when listing files
96
+ const IGNORE_DIRS = new Set([
97
+ 'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
98
+ '.next', '.nuxt', '__pycache__', '.cache', 'coverage',
99
+ '.idea', '.vscode', 'vendor', 'target', '.gradle'
100
+ ]);
101
+
102
+ // File extensions to include when scanning codebase
103
+ const CODE_EXTENSIONS = new Set([
104
+ '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs', '.rb', '.php',
105
+ '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala', '.vue', '.svelte',
106
+ '.css', '.scss', '.sass', '.less', '.html', '.htm', '.json', '.yaml', '.yml',
107
+ '.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh', '.sql', '.graphql',
108
+ '.env.example', '.gitignore', '.dockerignore', 'Dockerfile', 'Makefile',
109
+ '.prisma', '.proto'
110
+ ]);
111
+
112
+ // Max file size to include (skip large files like bundled/minified)
113
+ const MAX_FILE_SIZE = 100000; // 100KB per file
114
+ const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
115
+
116
+ // Scan entire codebase and return summary
117
+ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
118
+ if (depth > maxDepth) return { files: [], totalSize: 0 };
119
+
120
+ let files = [];
121
+ let totalSize = 0;
122
+
123
+ try {
124
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
125
+
126
+ for (const entry of entries) {
127
+ const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
128
+
129
+ // Skip ignored directories
130
+ if (entry.isDirectory()) {
131
+ if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
132
+ const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
133
+ files = files.concat(subResult.files);
134
+ totalSize += subResult.totalSize;
135
+ } else {
136
+ // Check if file should be included
137
+ const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
138
+ const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
139
+
140
+ if (!isCodeFile) continue;
141
+
142
+ try {
143
+ const stats = fs.statSync(fullPath);
144
+ if (stats.size > MAX_FILE_SIZE) {
145
+ files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
146
+ continue;
147
+ }
148
+ if (totalSize + stats.size > MAX_TOTAL_SCAN_SIZE) {
149
+ files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
150
+ continue;
151
+ }
152
+
153
+ const content = fs.readFileSync(fullPath, 'utf8');
154
+ files.push({ path: fullPath, size: stats.size, content });
155
+ totalSize += stats.size;
156
+ } catch (e) {
157
+ files.push({ path: fullPath, skipped: true, reason: e.message });
158
+ }
159
+ }
160
+ }
161
+ } catch (e) {
162
+ // Directory not readable
163
+ }
164
+
165
+ return { files, totalSize };
166
+ }
167
+
168
+ // Format scan results for AI context
169
+ function formatScanResults(scanResult) {
170
+ let output = `\n══════════════════════════════════════\n`;
171
+ output += `📁 CODEBASE SCAN (${scanResult.files.length} files, ~${Math.round(scanResult.totalSize/1024)}KB)\n`;
172
+ output += `══════════════════════════════════════\n\n`;
173
+
174
+ // First list all files
175
+ output += `FILE TREE:\n`;
176
+ for (const file of scanResult.files) {
177
+ if (file.skipped) {
178
+ output += ` ⏭️ ${file.path} (skipped: ${file.reason})\n`;
179
+ } else {
180
+ output += ` 📄 ${file.path} (${Math.round(file.size/1024)}KB)\n`;
181
+ }
182
+ }
183
+
184
+ output += `\n══════════════════════════════════════\n`;
185
+ output += `FILE CONTENTS:\n`;
186
+ output += `══════════════════════════════════════\n\n`;
187
+
188
+ // Then include contents
189
+ for (const file of scanResult.files) {
190
+ if (file.skipped) continue;
191
+ output += `┌─── ${file.path} ───\n`;
192
+ output += file.content;
193
+ if (!file.content.endsWith('\n')) output += '\n';
194
+ output += `└─── END ${file.path} ───\n\n`;
195
+ }
196
+
197
+ return output;
198
+ }
199
+
200
+ const tools = {
201
+ read: (path) => {
202
+ try { return fs.readFileSync(path.trim(), 'utf8'); }
203
+ catch (error) { return `Error reading file: ${error.message}`; }
204
+ },
205
+ patch: async (path, oldText, newText) => {
206
+ const trimmedPath = path.trim();
207
+ try {
208
+ const content = fs.readFileSync(trimmedPath, 'utf8');
209
+ if (!content.includes(oldText)) {
210
+ return `Error: Could not find the text to replace in ${trimmedPath}. Make sure oldText matches exactly (including whitespace).`;
211
+ }
212
+ const newContent = content.replace(oldText, newText);
213
+
214
+ // Show diff preview
215
+ console.log(chalk.yellow.bold(`\n[PATCH] ${trimmedPath}`));
216
+ console.log(chalk.red('- ' + oldText.split('\n').join('\n- ')));
217
+ console.log(chalk.green('+ ' + newText.split('\n').join('\n+ ')));
218
+
219
+ const confirm = await safeQuestion(chalk.yellow('Apply this patch? (y/n): '));
220
+ if (confirm.toLowerCase() === 'y') {
221
+ fs.writeFileSync(trimmedPath, newContent);
222
+ return `Successfully patched ${trimmedPath}`;
223
+ }
224
+ return 'Patch rejected by user.';
225
+ } catch (error) { return `Error patching file: ${error.message}`; }
226
+ },
227
+ write: async (path, content) => {
228
+ const trimmedPath = path.trim();
229
+ console.log(chalk.yellow.bold(`\n[WRITE] Sapper wants to write to: `) + chalk.white(trimmedPath));
230
+ console.log(chalk.gray(`Content preview (first 200 chars):\n${content?.substring(0, 200)}${content?.length > 200 ? '...' : ''}`));
231
+ const confirm = await safeQuestion(chalk.yellow('Allow write? (y/n): '));
232
+ if (confirm.toLowerCase() === 'y') {
233
+ try {
234
+ fs.writeFileSync(trimmedPath, content);
235
+ return `Successfully saved changes to ${trimmedPath}`;
236
+ } catch (error) { return `Error writing file: ${error.message}`; }
237
+ }
238
+ return "Write blocked by user.";
239
+ },
240
+ mkdir: (path) => {
241
+ try {
242
+ fs.mkdirSync(path.trim(), { recursive: true });
243
+ return `Directory created: ${path}`;
244
+ } catch (error) { return `Error creating directory: ${error.message}`; }
245
+ },
246
+ shell: async (cmd) => {
247
+ console.log(chalk.red.bold(`\n[SECURITY] Sapper wants to execute: `) + chalk.white(cmd));
248
+ const confirm = await safeQuestion(chalk.yellow('Allow? (y/n): '));
249
+ if (confirm.toLowerCase() === 'y') {
250
+ return new Promise((resolve) => {
251
+ const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
252
+ console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
253
+ const proc = spawn(useShell ? 'sh' : cmd.split(' ')[0], useShell ? ['-c', cmd] : cmd.split(' ').slice(1), {
254
+ stdio: 'inherit', shell: useShell
255
+ });
256
+ proc.on('close', (code) => {
257
+ // Crucial: give control back to Node
258
+ if (process.stdin.isTTY) {
259
+ try { process.stdin.setRawMode(false); } catch (e) {}
260
+ }
261
+ // Delay slightly to let terminal settle
262
+ setTimeout(() => {
263
+ recreateReadline();
264
+ resolve(`Command completed with code ${code}`);
265
+ }, 200);
266
+ });
267
+ });
268
+ }
269
+ return "Command blocked by user.";
270
+ },
271
+ list: (path) => {
272
+ try {
273
+ const dir = path.trim() || '.';
274
+ const entries = fs.readdirSync(dir);
275
+ // Filter out ignored directories
276
+ const filtered = entries.filter(entry => {
277
+ if (IGNORE_DIRS.has(entry)) return false;
278
+ // Also skip hidden files/folders (starting with .) except current dir
279
+ if (entry.startsWith('.') && entry !== '.') return false;
280
+ return true;
281
+ });
282
+ return filtered.length > 0 ? filtered.join('\n') : '(empty or all files filtered)';
283
+ } catch (e) { return `Error: ${e.message}`; }
284
+ },
285
+ search: (pattern) => {
286
+ return new Promise((resolve) => {
287
+ const excludeDirs = Array.from(IGNORE_DIRS).join(',');
288
+ // Use grep to search for pattern, excluding ignored directories
289
+ const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludeDirs}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
290
+
291
+ const proc = spawn('sh', ['-c', cmd], { cwd: process.cwd() });
292
+ let output = '';
293
+
294
+ proc.stdout.on('data', (data) => { output += data.toString(); });
295
+ proc.stderr.on('data', (data) => { output += data.toString(); });
296
+
297
+ proc.on('close', () => {
298
+ if (output.trim()) {
299
+ resolve(`Found matches:\n${output.trim()}`);
300
+ } else {
301
+ resolve(`No matches found for: ${pattern}`);
302
+ }
303
+ });
304
+ });
305
+ }
306
+ };
307
+
308
+ async function checkForUpdates() {
309
+ try {
310
+ const response = await fetch('https://registry.npmjs.org/sapper-iq/latest');
311
+ const data = await response.json();
312
+ const latestVersion = data.version;
313
+
314
+ if (latestVersion && latestVersion !== CURRENT_VERSION) {
315
+ console.log(chalk.yellow('🔄 UPDATE AVAILABLE!'));
316
+ console.log(chalk.gray(` Current: v${CURRENT_VERSION}`));
317
+ console.log(chalk.green(` Latest: v${latestVersion}`));
318
+ console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
319
+ }
320
+ } catch (error) {
321
+ // Silently fail if update check fails
322
+ }
323
+ }
324
+
325
+ async function runSapper() {
326
+ console.clear();
327
+ console.log(chalk.cyan.bold(` SAPPER v${CURRENT_VERSION} | Autonomous "OpenCode" Mode`));
328
+ console.log(chalk.gray(`📁 Working Directory: ${process.cwd()}\n`));
329
+
330
+ // Check for updates
331
+ await checkForUpdates();
332
+
333
+ let messages = [];
334
+ if (fs.existsSync(CONTEXT_FILE)) {
335
+ const resume = await safeQuestion(chalk.green('Resume previous session? (y/n): '));
336
+ if (resume.toLowerCase() === 'y') {
337
+ messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
338
+ } else {
339
+ // User said no - delete the old context file
340
+ fs.unlinkSync(CONTEXT_FILE);
341
+ console.log(chalk.gray('Starting fresh session...\n'));
342
+ }
343
+ }
344
+
345
+ const localModels = await ollama.list();
346
+ localModels.models.forEach((m, i) => console.log(`${i + 1}. ${m.name}`));
347
+ const choice = await safeQuestion(chalk.yellow('\nChoose model: '));
348
+ const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
349
+
350
+ if (messages.length === 0) {
351
+ messages = [{
352
+ role: 'system',
353
+ content: `You are Sapper, a coding assistant that ONLY does what the user asks.
354
+
355
+ GOLDEN RULE: Do EXACTLY what the user asks. Nothing more, nothing less.
356
+ - NEVER add features the user didn't ask for.
357
+ - ALWAYS confirm with the user before writing/patching files or running shell commands.
358
+ - KEEP responses concise and to the point.
359
+ TOOLS (use these to interact with files):
360
+
361
+ [TOOL:LIST]path[/TOOL]
362
+ → List files in a directory
363
+ → Example: [TOOL:LIST].[/TOOL]
364
+
365
+ [TOOL:READ]path[/TOOL]
366
+ → Read a file's contents
367
+ → Example: [TOOL:READ]./package.json[/TOOL]
368
+
369
+ [TOOL:WRITE]path]content[/TOOL]
370
+ → Create or overwrite a file (needs user confirmation)
371
+ → Example: [TOOL:WRITE]./index.js]console.log("hello")[/TOOL]
372
+
373
+ [TOOL:PATCH]path]old_text|||new_text[/TOOL]
374
+ → Replace specific text in a file (needs user confirmation)
375
+ → Example: [TOOL:PATCH]./app.js]old code|||new code[/TOOL]
376
+
377
+ [TOOL:SEARCH]pattern[/TOOL]
378
+ → Search for text across all files
379
+ → Example: [TOOL:SEARCH]function login[/TOOL]
380
+
381
+ [TOOL:SHELL]command[/TOOL]
382
+ → Run a terminal command (needs user confirmation)
383
+ → Example: [TOOL:SHELL]npm install express[/TOOL]
384
+
385
+ PATH RULES:
386
+ - Always use relative paths: ./file.js, ./src/app.js
387
+ - NEVER use absolute paths like /file.js
388
+ - Use . for current directory
389
+
390
+ WORKFLOW:
391
+ 1. Understand exactly what user wants
392
+ 2. Use LIST to see existing files if needed
393
+ 3. Use READ to check existing code if needed
394
+ 4. Use WRITE/PATCH to make changes
395
+ 5. Be concise in explanations
396
+
397
+ CRITICAL: Stay focused. If user asks for X, deliver X only.`
398
+ }];
399
+ }
400
+
401
+ // Main conversation loop - never exits unless user types 'exit'
402
+ while (true) {
403
+ try {
404
+ // Context size warning - large context causes hangs
405
+ const contextSize = JSON.stringify(messages).length;
406
+ if (contextSize > 32000) {
407
+ console.log(chalk.red.bold('\n⚠️ WARNING: Context is very large (~' + Math.round(contextSize/1024) + 'KB). Sapper might hang.'));
408
+ console.log(chalk.yellow('👉 Suggestion: Type /prune to keep only the latest analysis.'));
409
+ }
410
+
411
+ const input = await safeQuestion(chalk.blue.bold('\nIbrahim ➔ '));
412
+
413
+ if (input.toLowerCase() === 'exit') process.exit();
414
+
415
+ // Handle reset command
416
+ if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
417
+ if (fs.existsSync(CONTEXT_FILE)) {
418
+ fs.unlinkSync(CONTEXT_FILE);
419
+ console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
420
+ }
421
+ messages = [{
422
+ role: 'system',
423
+ content: messages[0].content // Keep system prompt
424
+ }];
425
+ continue;
426
+ }
427
+
428
+ // Handle prune command - summarize and clear old context
429
+ if (input.toLowerCase() === '/prune') {
430
+ if (messages.length <= 5) {
431
+ console.log(chalk.yellow('Context is already small, nothing to prune.'));
432
+ continue;
433
+ }
434
+
435
+ // 1. Capture the ORIGINAL detailed system prompt from the very first message
436
+ const originalSystemPrompt = messages[0];
437
+
438
+ // 2. Capture the last 4 messages (the most recent conversation)
439
+ const recentMessages = messages.slice(-4);
440
+
441
+ // 3. Rebuild the messages array starting with the ORIGINAL prompt
442
+ messages = [originalSystemPrompt, ...recentMessages];
443
+
444
+ // 4. Add reminder to stay in Agent Mode (not chatbot mode)
445
+ messages.push({
446
+ role: 'system',
447
+ content: `CONTEXT PRUNED. REMINDER: You are an AGENT, not a chatbot. You MUST use tools to take action:
448
+ - [TOOL:LIST]path[/TOOL] - List directory
449
+ - [TOOL:READ]path[/TOOL] - Read file
450
+ - [TOOL:SEARCH]pattern[/TOOL] - Search codebase
451
+ - [TOOL:WRITE]path]content[/TOOL] - Create/overwrite file
452
+ - [TOOL:PATCH]path]old|||new[/TOOL] - Edit file
453
+ - [TOOL:SHELL]command[/TOOL] - Run terminal command
454
+ Do NOT just display content. Actually WRITE files using the tool.`
455
+ });
456
+
457
+ // 5. Save to context file so it persists
458
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages));
459
+
460
+ console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
461
+ console.log(chalk.gray(`Context size: ${messages.length} messages\n`));
462
+ continue;
463
+ }
464
+
465
+ // Handle help command
466
+ if (input.toLowerCase() === '/help') {
467
+ console.log(chalk.cyan('\n📚 SAPPER COMMANDS:'));
468
+ console.log(chalk.white(' /scan') + chalk.gray(' - Scan entire codebase and add to context'));
469
+ console.log(chalk.white(' /reset, /clear') + chalk.gray(' - Clear all context and start fresh'));
470
+ console.log(chalk.white(' /prune') + chalk.gray(' - Remove old messages, keep last 4'));
471
+ console.log(chalk.white(' /context') + chalk.gray(' - Show current context size'));
472
+ console.log(chalk.white(' /debug') + chalk.gray(' - Toggle debug mode (shows regex analysis)'));
473
+ console.log(chalk.white(' /help') + chalk.gray(' - Show this help message'));
474
+ console.log(chalk.white(' exit') + chalk.gray(' - Exit Sapper\n'));
475
+ continue;
476
+ }
477
+
478
+ // Handle context size command
479
+ if (input.toLowerCase() === '/context') {
480
+ const contextSize = JSON.stringify(messages).length;
481
+ console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
482
+ if (contextSize > 50000) {
483
+ console.log(chalk.yellow('⚠️ Context is large! Consider using /prune'));
484
+ }
485
+ continue;
486
+ }
487
+
488
+ // Handle debug mode toggle
489
+ if (input.toLowerCase() === '/debug') {
490
+ debugMode = !debugMode;
491
+ console.log(chalk.magenta(`🔧 Debug mode: ${debugMode ? 'ON' : 'OFF'}`));
492
+ if (debugMode) {
493
+ console.log(chalk.gray(' Will show regex matching details after each AI response.'));
494
+ }
495
+ continue;
496
+ }
497
+
498
+ // Handle codebase scan command
499
+ if (input.toLowerCase() === '/scan') {
500
+ console.log(chalk.cyan('\n🔍 Scanning codebase...'));
501
+ const scanResult = scanCodebase('.');
502
+
503
+ if (scanResult.files.length === 0) {
504
+ console.log(chalk.yellow('No code files found in current directory.'));
505
+ continue;
506
+ }
507
+
508
+ const formattedScan = formatScanResults(scanResult);
509
+ const includedCount = scanResult.files.filter(f => !f.skipped).length;
510
+ const skippedCount = scanResult.files.filter(f => f.skipped).length;
511
+
512
+ console.log(chalk.green(`✅ Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`));
513
+ if (skippedCount > 0) {
514
+ console.log(chalk.yellow(`⏭️ Skipped ${skippedCount} files (too large or limit reached)`));
515
+ }
516
+
517
+ // Add scan to context
518
+ messages.push({
519
+ role: 'user',
520
+ content: `I've scanned the entire codebase. Here are all the files:\n${formattedScan}\n\nYou now have the full codebase context. Use this information to help me.`
521
+ });
522
+
523
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages));
524
+ console.log(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
525
+ continue;
526
+ }
527
+
528
+ messages.push({ role: 'user', content: input });
529
+
530
+ let toolRounds = 0; // Prevent infinite loops
531
+ const MAX_TOOL_ROUNDS = 20;
532
+
533
+ let active = true;
534
+ while (active) {
535
+ if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
536
+
537
+ spinner.start('Thinking...');
538
+ let response;
539
+ try {
540
+ response = await ollama.chat({ model: selectedModel, messages, stream: true });
541
+ } catch (ollamaError) {
542
+ spinner.stop();
543
+ console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
544
+ active = false;
545
+ continue;
546
+ }
547
+ spinner.stop();
548
+
549
+ let msg = '';
550
+ const MAX_RESPONSE_LENGTH = 29000; // Guard against infinite loops (increased for multi-file reads)
551
+
552
+ process.stdout.write(chalk.white('Sapper: '));
553
+ for await (const chunk of response) {
554
+ const content = chunk.message.content;
555
+ process.stdout.write(content);
556
+ msg += content;
557
+
558
+ if (msg.length > MAX_RESPONSE_LENGTH) {
559
+ console.log(chalk.red('\n\n⚠️ RESPONSE TOO LONG: Forcing stop to prevent infinite loop.'));
560
+ break;
561
+ }
562
+ }
563
+ console.log();
564
+ messages.push({ role: 'assistant', content: msg });
565
+
566
+ // Fixed regex: .+? (non-greedy) stops correctly before [/TOOL]
567
+ const toolMatches = [...msg.matchAll(/\[TOOL:(\w+)\](.+?)(?:\]([\s\S]*?))?\[\/TOOL\]/g)];
568
+
569
+ // Debug mode: show what regex sees
570
+ if (debugMode) {
571
+ console.log(chalk.magenta('\n═══ DEBUG: REGEX ANALYSIS ═══'));
572
+ console.log(chalk.gray(`Response length: ${msg.length} chars`));
573
+
574
+ // Check for tool-like patterns
575
+ const hasToolStart = msg.includes('[TOOL:');
576
+ const hasToolEnd = msg.includes('[/TOOL]');
577
+ const hasBrokenEnd = msg.includes('[/]') || msg.includes('[/WRITE]') || msg.includes('[/READ]');
578
+
579
+ console.log(chalk.gray(`Contains [TOOL:: ${hasToolStart ? chalk.green('YES') : chalk.red('NO')}`));
580
+ console.log(chalk.gray(`Contains [/TOOL]: ${hasToolEnd ? chalk.green('YES') : chalk.red('NO')}`));
581
+ if (hasBrokenEnd) {
582
+ console.log(chalk.red(`⚠️ Found broken closing tag: [/] or [/WRITE] etc.`));
583
+ }
584
+
585
+ console.log(chalk.gray(`Matches found: ${toolMatches.length}`));
586
+
587
+ if (toolMatches.length > 0) {
588
+ toolMatches.forEach((m, i) => {
589
+ console.log(chalk.cyan(` Match ${i+1}: type=${m[1]}, path=${m[2]?.substring(0,50)}...`));
590
+ });
591
+ } else if (hasToolStart) {
592
+ // Show the raw tool attempt for debugging
593
+ const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
594
+ if (toolAttempt) {
595
+ console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
596
+ console.log(chalk.gray(` "${toolAttempt[0].substring(0, 150)}..."`));
597
+ }
598
+ }
599
+ console.log(chalk.magenta('═══════════════════════════════\n'));
600
+ }
601
+
602
+ if (toolMatches.length > 0) {
603
+ toolRounds++;
604
+
605
+ // Prevent infinite tool loops
606
+ if (toolRounds >= MAX_TOOL_ROUNDS) {
607
+ console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Stopping auto-execution.`));
608
+ console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
609
+ resetTerminal(); // Ensure terminal is responsive
610
+ messages.push({
611
+ role: 'user',
612
+ content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
613
+ });
614
+ continue; // Let AI respond without tools
615
+ }
616
+
617
+ for (const match of toolMatches) {
618
+ const [_, type, path, content] = match;
619
+ console.log(chalk.cyan(`\n[ACTION] ${type} -> ${path}`));
620
+
621
+ let result;
622
+ if (type.toLowerCase() === 'list') result = tools.list(path);
623
+ else if (type.toLowerCase() === 'read') result = tools.read(path);
624
+ else if (type.toLowerCase() === 'mkdir') result = tools.mkdir(path);
625
+ else if (type.toLowerCase() === 'write') result = await tools.write(path, content);
626
+ else if (type.toLowerCase() === 'patch') {
627
+ // PATCH format: [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]
628
+ const parts = content?.split('|||');
629
+ if (parts && parts.length === 2) {
630
+ result = await tools.patch(path, parts[0], parts[1]);
631
+ } else {
632
+ result = 'Error: PATCH requires format [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]';
633
+ }
634
+ }
635
+ else if (type.toLowerCase() === 'search') result = await tools.search(path);
636
+ else if (type.toLowerCase() === 'shell') result = await tools.shell(path);
637
+
638
+ messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
639
+ }
640
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages));
641
+
642
+ if (toolMatches.length > 30) {
643
+ console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
644
+ }
645
+ } else {
646
+ // No tools found - check if malformed command
647
+ if (msg.includes('[TOOL:') && msg.includes('[/]')) {
648
+ console.log(chalk.red('\n❌ Malformed tool command detected!'));
649
+ messages.push({
650
+ role: 'user',
651
+ content: 'ERROR: Your tool command is malformed. Use [TOOL:TYPE]path]content[/TOOL] or [TOOL:TYPE]path[/TOOL]'
652
+ });
653
+ } else {
654
+ // Normal response - save and wait for next input
655
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages));
656
+ active = false;
657
+ spinner.stop(); // Ensure spinner is dead
658
+ resetTerminal(); // Force terminal back to normal state
659
+ process.stdout.write('\n'); // Force newline to break out of stream mode
660
+ }
661
+ }
662
+ }
663
+ } catch (error) {
664
+ console.error(chalk.red('\n❌ Error:'), error.message);
665
+ // Loop continues automatically
666
+ }
667
+ }
668
+ }
669
+
670
+ // Keep-alive interval - prevents Node from exiting when event loop is empty
671
+ setInterval(() => {}, 1000);
672
+
673
+ runSapper();
package/sapper.mjs CHANGED
@@ -60,6 +60,48 @@ try {
60
60
  const spinner = ora();
61
61
  const CONTEXT_FILE = '.sapper_context.json';
62
62
 
63
+ // ═══════════════════════════════════════════════════════════════
64
+ // FANCY UI HELPERS
65
+ // ═══════════════════════════════════════════════════════════════
66
+
67
+ const BANNER = `
68
+ ${chalk.cyan(' ███████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗ ')}
69
+ ${chalk.cyan(' ██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗')}
70
+ ${chalk.cyan(' ███████╗███████║██████╔╝██████╔╝█████╗ ██████╔╝')}
71
+ ${chalk.cyan(' ╚════██║██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗')}
72
+ ${chalk.cyan(' ███████║██║ ██║██║ ██║ ███████╗██║ ██║')}
73
+ ${chalk.cyan(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝')}
74
+ `;
75
+
76
+ function box(content, title = '', color = 'cyan') {
77
+ const lines = content.split('\n');
78
+ const maxLen = Math.max(...lines.map(l => l.length), title.length + 4);
79
+ const colorFn = chalk[color] || chalk.cyan;
80
+
81
+ let result = colorFn('╭' + (title ? `─ ${title} ` : '') + '─'.repeat(maxLen - title.length - (title ? 3 : 0)) + '╮') + '\n';
82
+ for (const line of lines) {
83
+ result += colorFn('│') + ' ' + line.padEnd(maxLen) + ' ' + colorFn('│') + '\n';
84
+ }
85
+ result += colorFn('╰' + '─'.repeat(maxLen + 2) + '╯');
86
+ return result;
87
+ }
88
+
89
+ function divider(char = '─', color = 'gray') {
90
+ const width = process.stdout.columns || 60;
91
+ return chalk[color](char.repeat(Math.min(width, 60)));
92
+ }
93
+
94
+ function statusBadge(text, type = 'info') {
95
+ const badges = {
96
+ info: chalk.bgCyan.black(` ${text} `),
97
+ success: chalk.bgGreen.black(` ${text} `),
98
+ warning: chalk.bgYellow.black(` ${text} `),
99
+ error: chalk.bgRed.white(` ${text} `),
100
+ action: chalk.bgMagenta.white(` ${text} `)
101
+ };
102
+ return badges[type] || badges.info;
103
+ }
104
+
63
105
  let stepMode = false;
64
106
  let debugMode = false; // Toggle with /debug command
65
107
  let rl = readline.createInterface({
@@ -212,11 +254,15 @@ const tools = {
212
254
  const newContent = content.replace(oldText, newText);
213
255
 
214
256
  // Show diff preview
215
- console.log(chalk.yellow.bold(`\n[PATCH] ${trimmedPath}`));
216
- console.log(chalk.red('- ' + oldText.split('\n').join('\n- ')));
217
- console.log(chalk.green('+ ' + newText.split('\n').join('\n+ ')));
257
+ console.log();
258
+ const diffContent =
259
+ `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
260
+ chalk.gray('─'.repeat(40)) + '\n' +
261
+ chalk.red('- ' + oldText.split('\n').join('\n- ')) + '\n' +
262
+ chalk.green('+ ' + newText.split('\n').join('\n+ '));
263
+ console.log(box(diffContent, '🔧 Patch', 'yellow'));
218
264
 
219
- const confirm = await safeQuestion(chalk.yellow('Apply this patch? (y/n): '));
265
+ const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
220
266
  if (confirm.toLowerCase() === 'y') {
221
267
  fs.writeFileSync(trimmedPath, newContent);
222
268
  return `Successfully patched ${trimmedPath}`;
@@ -226,9 +272,15 @@ const tools = {
226
272
  },
227
273
  write: async (path, content) => {
228
274
  const trimmedPath = path.trim();
229
- console.log(chalk.yellow.bold(`\n[WRITE] Sapper wants to write to: `) + chalk.white(trimmedPath));
230
- console.log(chalk.gray(`Content preview (first 200 chars):\n${content?.substring(0, 200)}${content?.length > 200 ? '...' : ''}`));
231
- const confirm = await safeQuestion(chalk.yellow('Allow write? (y/n): '));
275
+ console.log();
276
+ console.log(box(
277
+ `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
278
+ `${chalk.white('Size:')} ${content?.length || 0} chars\n` +
279
+ chalk.gray('─'.repeat(40)) + '\n' +
280
+ chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
281
+ '✏️ Write File', 'yellow'
282
+ ));
283
+ const confirm = await safeQuestion(chalk.yellow('\n↪ Allow write? ') + chalk.gray('(y/n): '));
232
284
  if (confirm.toLowerCase() === 'y') {
233
285
  try {
234
286
  fs.writeFileSync(trimmedPath, content);
@@ -244,8 +296,12 @@ const tools = {
244
296
  } catch (error) { return `Error creating directory: ${error.message}`; }
245
297
  },
246
298
  shell: async (cmd) => {
247
- console.log(chalk.red.bold(`\n[SECURITY] Sapper wants to execute: `) + chalk.white(cmd));
248
- const confirm = await safeQuestion(chalk.yellow('Allow? (y/n): '));
299
+ console.log();
300
+ console.log(box(
301
+ chalk.white.bold(cmd),
302
+ '🔐 Shell Command', 'red'
303
+ ));
304
+ const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
249
305
  if (confirm.toLowerCase() === 'y') {
250
306
  return new Promise((resolve) => {
251
307
  const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
@@ -324,27 +380,47 @@ async function checkForUpdates() {
324
380
 
325
381
  async function runSapper() {
326
382
  console.clear();
327
- console.log(chalk.cyan.bold(` SAPPER v${CURRENT_VERSION} | Autonomous "OpenCode" Mode`));
328
- console.log(chalk.gray(`📁 Working Directory: ${process.cwd()}\n`));
383
+ console.log(BANNER);
384
+ console.log(chalk.gray.dim(' ') + chalk.white.bold(`v${CURRENT_VERSION}`) + chalk.gray(' │ ') + chalk.cyan('Autonomous AI Coding Agent'));
385
+ console.log(chalk.gray.dim(' ') + chalk.gray('📁 ') + chalk.white(process.cwd()));
386
+ console.log();
387
+
388
+ // Quick tips box
389
+ console.log(box(
390
+ `${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for commands\n` +
391
+ `${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
392
+ `${chalk.yellow('💡')} Type ${chalk.cyan('exit')} to quit`,
393
+ 'Quick Tips', 'gray'
394
+ ));
395
+ console.log();
329
396
 
330
397
  // Check for updates
331
398
  await checkForUpdates();
332
399
 
333
400
  let messages = [];
334
401
  if (fs.existsSync(CONTEXT_FILE)) {
335
- const resume = await safeQuestion(chalk.green('Resume previous session? (y/n): '));
402
+ console.log();
403
+ console.log(box('Previous session found! Resume where you left off?', '📂 Session', 'green'));
404
+ const resume = await safeQuestion(chalk.green('\n↪ Resume? ') + chalk.gray('(y/n): '));
336
405
  if (resume.toLowerCase() === 'y') {
337
406
  messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
407
+ console.log(chalk.green(' ✓ Session restored\n'));
338
408
  } else {
339
- // User said no - delete the old context file
340
409
  fs.unlinkSync(CONTEXT_FILE);
341
- console.log(chalk.gray('Starting fresh session...\n'));
410
+ console.log(chalk.gray('Starting fresh...\n'));
342
411
  }
343
412
  }
344
413
 
345
414
  const localModels = await ollama.list();
346
- localModels.models.forEach((m, i) => console.log(`${i + 1}. ${m.name}`));
347
- const choice = await safeQuestion(chalk.yellow('\nChoose model: '));
415
+ console.log(divider());
416
+ console.log(statusBadge('MODELS', 'info') + chalk.gray(' Available Ollama models:\n'));
417
+ localModels.models.forEach((m, i) => {
418
+ const num = chalk.cyan.bold(`[${i + 1}]`);
419
+ const name = chalk.white(m.name);
420
+ console.log(` ${num} ${name}`);
421
+ });
422
+ console.log(divider());
423
+ const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
348
424
  const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
349
425
 
350
426
  if (messages.length === 0) {
@@ -404,11 +480,15 @@ CRITICAL: Stay focused. If user asks for X, deliver X only.`
404
480
  // Context size warning - large context causes hangs
405
481
  const contextSize = JSON.stringify(messages).length;
406
482
  if (contextSize > 32000) {
407
- console.log(chalk.red.bold('\n⚠️ WARNING: Context is very large (~' + Math.round(contextSize/1024) + 'KB). Sapper might hang.'));
408
- console.log(chalk.yellow('👉 Suggestion: Type /prune to keep only the latest analysis.'));
483
+ console.log();
484
+ console.log(box(
485
+ `Context is ${chalk.red.bold(Math.round(contextSize/1024) + 'KB')} - this may cause slowdowns!\n` +
486
+ `${chalk.yellow('Tip:')} Type ${chalk.cyan('/prune')} to reduce context size`,
487
+ '⚠️ Warning', 'yellow'
488
+ ));
409
489
  }
410
490
 
411
- const input = await safeQuestion(chalk.blue.bold('\nIbrahim '));
491
+ const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
412
492
 
413
493
  if (input.toLowerCase() === 'exit') process.exit();
414
494
 
@@ -464,14 +544,17 @@ Do NOT just display content. Actually WRITE files using the tool.`
464
544
 
465
545
  // Handle help command
466
546
  if (input.toLowerCase() === '/help') {
467
- console.log(chalk.cyan('\n📚 SAPPER COMMANDS:'));
468
- console.log(chalk.white(' /scan') + chalk.gray(' - Scan entire codebase and add to context'));
469
- console.log(chalk.white(' /reset, /clear') + chalk.gray(' - Clear all context and start fresh'));
470
- console.log(chalk.white(' /prune') + chalk.gray(' - Remove old messages, keep last 4'));
471
- console.log(chalk.white(' /context') + chalk.gray(' - Show current context size'));
472
- console.log(chalk.white(' /debug') + chalk.gray(' - Toggle debug mode (shows regex analysis)'));
473
- console.log(chalk.white(' /help') + chalk.gray(' - Show this help message'));
474
- console.log(chalk.white(' exit') + chalk.gray(' - Exit Sapper\n'));
547
+ console.log();
548
+ const helpContent =
549
+ `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
550
+ `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
551
+ `${chalk.cyan('/prune')} ${chalk.gray('│')} Keep only last 4 messages\n` +
552
+ `${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
553
+ `${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
554
+ `${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
555
+ `${chalk.cyan('exit')} ${chalk.gray('│')} Quit Sapper`;
556
+ console.log(box(helpContent, '📚 Commands', 'cyan'));
557
+ console.log();
475
558
  continue;
476
559
  }
477
560
 
@@ -549,7 +632,8 @@ Do NOT just display content. Actually WRITE files using the tool.`
549
632
  let msg = '';
550
633
  const MAX_RESPONSE_LENGTH = 29000; // Guard against infinite loops (increased for multi-file reads)
551
634
 
552
- process.stdout.write(chalk.white('Sapper: '));
635
+ console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
636
+ process.stdout.write(chalk.magenta('│ '));
553
637
  for await (const chunk of response) {
554
638
  const content = chunk.message.content;
555
639
  process.stdout.write(content);
@@ -616,7 +700,8 @@ Do NOT just display content. Actually WRITE files using the tool.`
616
700
 
617
701
  for (const match of toolMatches) {
618
702
  const [_, type, path, content] = match;
619
- console.log(chalk.cyan(`\n[ACTION] ${type} -> ${path}`));
703
+ console.log();
704
+ console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
620
705
 
621
706
  let result;
622
707
  if (type.toLowerCase() === 'list') result = tools.list(path);