sapper-iq 1.1.37 → 1.1.39

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.
@@ -1,1154 +0,0 @@
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
- import { marked } from 'marked';
11
- import TerminalRenderer from 'marked-terminal';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = dirname(__filename);
15
-
16
- // Prevent process from exiting on unhandled errors
17
- process.on('uncaughtException', (err) => {
18
- console.error(chalk.red('\n❌ Uncaught exception:'), err.message);
19
- });
20
- process.on('unhandledRejection', (reason) => {
21
- console.error(chalk.red('\n❌ Unhandled rejection:'), reason);
22
- });
23
-
24
- // Prevent Ctrl+C from killing the whole process
25
- let ctrlCCount = 0;
26
- process.on('SIGINT', () => {
27
- ctrlCCount++;
28
- if (ctrlCCount >= 2) {
29
- console.log(chalk.red('\nForce quitting...'));
30
- process.exit(1);
31
- }
32
- // Set flag to abort current stream
33
- abortStream = true;
34
-
35
- // Clear current line and move to new one - stops ghost output
36
- process.stdout.clearLine(0);
37
- process.stdout.cursorTo(0);
38
- console.log(chalk.yellow('\n⏹️ Stopping response... (Ctrl+C again to force quit)'));
39
-
40
- // Reset terminal immediately
41
- resetTerminal();
42
- setTimeout(() => { ctrlCCount = 0; }, 2000); // Reset after 2 seconds
43
- });
44
-
45
- // Reset terminal state - fixes "ghost input" after shell commands or AI streaming
46
- function resetTerminal() {
47
- if (process.stdin.isTTY) {
48
- try {
49
- process.stdin.setRawMode(false); // Disable raw mode
50
- process.stdin.pause(); // Pause the stream
51
- process.stdin.resume(); // Resume to clear buffers
52
- } catch (e) {
53
- // Ignore errors if terminal is in weird state
54
- }
55
- }
56
- }
57
-
58
- // Initialize versioning
59
- let CURRENT_VERSION = "1.1.0";
60
- try {
61
- const pkg = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8'));
62
- CURRENT_VERSION = pkg.version;
63
- } catch (e) {}
64
-
65
- const spinner = ora();
66
- const CONTEXT_FILE = '.sapper_context.json';
67
- const EMBEDDINGS_FILE = '.sapper_embeddings.json';
68
-
69
- // ═══════════════════════════════════════════════════════════════
70
- // EMBEDDINGS & SEMANTIC SEARCH
71
- // ═══════════════════════════════════════════════════════════════
72
-
73
- // Load or create embeddings store
74
- function loadEmbeddings() {
75
- try {
76
- if (fs.existsSync(EMBEDDINGS_FILE)) {
77
- return JSON.parse(fs.readFileSync(EMBEDDINGS_FILE, 'utf8'));
78
- }
79
- } catch (e) {}
80
- return { chunks: [] }; // { chunks: [{ text, embedding, timestamp }] }
81
- }
82
-
83
- function saveEmbeddings(embeddings) {
84
- fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(embeddings, null, 2));
85
- }
86
-
87
- // Get embedding from Ollama (returns null silently if model not available)
88
- async function getEmbedding(text, model = 'nomic-embed-text') {
89
- try {
90
- const response = await ollama.embeddings({ model, prompt: text });
91
- return response.embedding;
92
- } catch (e) {
93
- // Silently return null - caller handles missing embeddings
94
- return null;
95
- }
96
- }
97
-
98
- // Cosine similarity between two vectors
99
- function cosineSimilarity(a, b) {
100
- if (!a || !b || a.length !== b.length) return 0;
101
- let dotProduct = 0, normA = 0, normB = 0;
102
- for (let i = 0; i < a.length; i++) {
103
- dotProduct += a[i] * b[i];
104
- normA += a[i] * a[i];
105
- normB += b[i] * b[i];
106
- }
107
- return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
108
- }
109
-
110
- // Find most relevant chunks for a query
111
- async function findRelevantContext(query, embeddings, topK = 3) {
112
- const queryEmbedding = await getEmbedding(query);
113
- if (!queryEmbedding || embeddings.chunks.length === 0) return [];
114
-
115
- const scored = embeddings.chunks.map(chunk => ({
116
- ...chunk,
117
- score: cosineSimilarity(queryEmbedding, chunk.embedding)
118
- }));
119
-
120
- scored.sort((a, b) => b.score - a.score);
121
- return scored.slice(0, topK).filter(c => c.score > 0.5); // Only return if similarity > 0.5
122
- }
123
-
124
- // Add text to embeddings store
125
- async function addToEmbeddings(text, embeddings) {
126
- const embedding = await getEmbedding(text);
127
- if (embedding) {
128
- embeddings.chunks.push({
129
- text: text.substring(0, 2000), // Limit stored text
130
- embedding,
131
- timestamp: Date.now()
132
- });
133
- // Keep only last 100 chunks
134
- if (embeddings.chunks.length > 100) {
135
- embeddings.chunks = embeddings.chunks.slice(-100);
136
- }
137
- saveEmbeddings(embeddings);
138
- }
139
- }
140
-
141
- // ═══════════════════════════════════════════════════════════════
142
- // FANCY UI HELPERS
143
- // ═══════════════════════════════════════════════════════════════
144
-
145
- const BANNER = `
146
- ${chalk.cyan(' ███████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗ ')}
147
- ${chalk.cyan(' ██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗')}
148
- ${chalk.cyan(' ███████╗███████║██████╔╝██████╔╝█████╗ ██████╔╝')}
149
- ${chalk.cyan(' ╚════██║██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗')}
150
- ${chalk.cyan(' ███████║██║ ██║██║ ██║ ███████╗██║ ██║')}
151
- ${chalk.cyan(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝')}
152
- `;
153
-
154
- function box(content, title = '', color = 'cyan') {
155
- const lines = content.split('\n');
156
- const maxLen = Math.max(...lines.map(l => l.length), title.length + 4);
157
- const colorFn = chalk[color] || chalk.cyan;
158
-
159
- let result = colorFn('╭' + (title ? `─ ${title} ` : '') + '─'.repeat(maxLen - title.length - (title ? 3 : 0)) + '╮') + '\n';
160
- for (const line of lines) {
161
- result += colorFn('│') + ' ' + line.padEnd(maxLen) + ' ' + colorFn('│') + '\n';
162
- }
163
- result += colorFn('╰' + '─'.repeat(maxLen + 2) + '╯');
164
- return result;
165
- }
166
-
167
- function divider(char = '─', color = 'gray') {
168
- const width = process.stdout.columns || 60;
169
- return chalk[color](char.repeat(Math.min(width, 60)));
170
- }
171
-
172
- function statusBadge(text, type = 'info') {
173
- const badges = {
174
- info: chalk.bgCyan.black(` ${text} `),
175
- success: chalk.bgGreen.black(` ${text} `),
176
- warning: chalk.bgYellow.black(` ${text} `),
177
- error: chalk.bgRed.white(` ${text} `),
178
- action: chalk.bgMagenta.white(` ${text} `)
179
- };
180
- return badges[type] || badges.info;
181
- }
182
-
183
- // Configure marked with terminal renderer
184
- marked.setOptions({
185
- renderer: new TerminalRenderer({
186
- code: chalk.cyan,
187
- blockquote: chalk.gray.italic,
188
- html: chalk.gray,
189
- heading: chalk.bold.cyan,
190
- firstHeading: chalk.bold.cyan,
191
- hr: chalk.gray('─'.repeat(40)),
192
- listitem: chalk.yellow('• ') + '%s',
193
- table: chalk.white,
194
- paragraph: chalk.white,
195
- strong: chalk.bold.white,
196
- em: chalk.italic,
197
- codespan: chalk.cyan,
198
- del: chalk.strikethrough,
199
- link: chalk.underline.blue,
200
- href: chalk.gray
201
- })
202
- });
203
-
204
- // Render markdown to terminal
205
- function renderMarkdown(text) {
206
- try {
207
- return marked(text).trim();
208
- } catch (e) {
209
- return text; // Fallback to raw text
210
- }
211
- }
212
-
213
- let stepMode = false;
214
- let debugMode = false; // Toggle with /debug command
215
- let abortStream = false; // Flag to interrupt AI response
216
- let rl = readline.createInterface({
217
- input: process.stdin,
218
- output: process.stdout,
219
- terminal: true,
220
- historySize: 100
221
- });
222
-
223
- function recreateReadline() {
224
- if (rl) rl.close();
225
- rl = readline.createInterface({
226
- input: process.stdin,
227
- output: process.stdout,
228
- terminal: true,
229
- historySize: 100
230
- });
231
- // Force resume stdin to keep process alive
232
- process.stdin.resume();
233
- }
234
-
235
- async function safeQuestion(query) {
236
- resetTerminal(); // Clear terminal state before asking
237
- if (rl.closed) recreateReadline();
238
-
239
- return new Promise((resolve) => {
240
- rl.question(query, (answer) => {
241
- resolve(answer ? answer.trim() : '');
242
- });
243
- });
244
- }
245
-
246
- // Directories to ignore when listing files
247
- const IGNORE_DIRS = new Set([
248
- 'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
249
- '.next', '.nuxt', '__pycache__', '.cache', 'coverage',
250
- '.idea', '.vscode', 'vendor', 'target', '.gradle'
251
- ]);
252
-
253
- // File extensions to include when scanning codebase
254
- const CODE_EXTENSIONS = new Set([
255
- '.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs', '.rb', '.php',
256
- '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala', '.vue', '.svelte',
257
- '.css', '.scss', '.sass', '.less', '.html', '.htm', '.json', '.yaml', '.yml',
258
- '.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh', '.sql', '.graphql',
259
- '.env.example', '.gitignore', '.dockerignore', 'Dockerfile', 'Makefile',
260
- '.prisma', '.proto'
261
- ]);
262
-
263
- // Max file size to include (skip large files like bundled/minified)
264
- const MAX_FILE_SIZE = 100000; // 100KB per file
265
- const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
266
-
267
- // Scan entire codebase and return summary
268
- function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
269
- if (depth > maxDepth) return { files: [], totalSize: 0 };
270
-
271
- let files = [];
272
- let totalSize = 0;
273
-
274
- try {
275
- const entries = fs.readdirSync(dir, { withFileTypes: true });
276
-
277
- for (const entry of entries) {
278
- const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
279
-
280
- // Skip ignored directories
281
- if (entry.isDirectory()) {
282
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
283
- const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
284
- files = files.concat(subResult.files);
285
- totalSize += subResult.totalSize;
286
- } else {
287
- // Check if file should be included
288
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
289
- const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
290
-
291
- if (!isCodeFile) continue;
292
-
293
- try {
294
- const stats = fs.statSync(fullPath);
295
- if (stats.size > MAX_FILE_SIZE) {
296
- files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
297
- continue;
298
- }
299
- if (totalSize + stats.size > MAX_TOTAL_SCAN_SIZE) {
300
- files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
301
- continue;
302
- }
303
-
304
- const content = fs.readFileSync(fullPath, 'utf8');
305
- files.push({ path: fullPath, size: stats.size, content });
306
- totalSize += stats.size;
307
- } catch (e) {
308
- files.push({ path: fullPath, skipped: true, reason: e.message });
309
- }
310
- }
311
- }
312
- } catch (e) {
313
- // Directory not readable
314
- }
315
-
316
- return { files, totalSize };
317
- }
318
-
319
- // Scan directory for files (for @ file picker)
320
- function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
321
- let files = [];
322
- try {
323
- const entries = fs.readdirSync(dir, { withFileTypes: true });
324
- for (const entry of entries) {
325
- if (files.length >= maxFiles) break;
326
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
327
-
328
- const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
329
-
330
- if (entry.isDirectory()) {
331
- files.push({ path: fullPath + '/', isDir: true });
332
- // Recurse one level for common structures
333
- const subFiles = getFilesForPicker(`${dir}/${entry.name}`, fullPath, 20);
334
- files = files.concat(subFiles.slice(0, 15)); // Limit subdirectory files
335
- } else {
336
- const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
337
- if (CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name)) {
338
- try {
339
- const stats = fs.statSync(`${dir}/${entry.name}`);
340
- files.push({ path: fullPath, isDir: false, size: stats.size });
341
- } catch (e) {
342
- files.push({ path: fullPath, isDir: false, size: 0 });
343
- }
344
- }
345
- }
346
- }
347
- } catch (e) {}
348
- return files.slice(0, maxFiles);
349
- }
350
-
351
- // Interactive file picker
352
- async function pickFiles() {
353
- const files = getFilesForPicker('.', '', 50).filter(f => !f.isDir);
354
-
355
- if (files.length === 0) {
356
- console.log(chalk.yellow('No code files found in current directory.'));
357
- return [];
358
- }
359
-
360
- console.log();
361
- console.log(box(
362
- `Select files by number (e.g., ${chalk.cyan('1 3 5')} or ${chalk.cyan('1-5')} or ${chalk.cyan('all')})\n` +
363
- `Press ${chalk.cyan('Enter')} with no input to cancel`,
364
- '📎 File Picker', 'cyan'
365
- ));
366
- console.log();
367
-
368
- // Display files in columns
369
- files.forEach((file, i) => {
370
- const num = chalk.cyan.bold(`[${(i + 1).toString().padStart(2)}]`);
371
- const size = file.size ? chalk.gray(`(${Math.round(file.size/1024)}KB)`) : '';
372
- console.log(` ${num} ${chalk.white(file.path)} ${size}`);
373
- });
374
-
375
- console.log();
376
- const selection = await safeQuestion(chalk.cyan('Select files: '));
377
-
378
- if (!selection.trim()) {
379
- console.log(chalk.gray('Cancelled.'));
380
- return [];
381
- }
382
-
383
- // Parse selection
384
- const selectedFiles = [];
385
- const parts = selection.toLowerCase().split(/[\s,]+/);
386
-
387
- for (const part of parts) {
388
- if (part === 'all') {
389
- return files.map(f => f.path);
390
- }
391
-
392
- // Handle ranges like "1-5"
393
- const rangeMatch = part.match(/^(\d+)-(\d+)$/);
394
- if (rangeMatch) {
395
- const start = parseInt(rangeMatch[1]);
396
- const end = parseInt(rangeMatch[2]);
397
- for (let i = start; i <= end && i <= files.length; i++) {
398
- if (i >= 1) selectedFiles.push(files[i - 1].path);
399
- }
400
- } else {
401
- // Single number
402
- const num = parseInt(part);
403
- if (num >= 1 && num <= files.length) {
404
- selectedFiles.push(files[num - 1].path);
405
- }
406
- }
407
- }
408
-
409
- return [...new Set(selectedFiles)]; // Remove duplicates
410
- }
411
-
412
- // Format scan results for AI context
413
- function formatScanResults(scanResult) {
414
- let output = `\n══════════════════════════════════════\n`;
415
- output += `📁 CODEBASE SCAN (${scanResult.files.length} files, ~${Math.round(scanResult.totalSize/1024)}KB)\n`;
416
- output += `══════════════════════════════════════\n\n`;
417
-
418
- // First list all files
419
- output += `FILE TREE:\n`;
420
- for (const file of scanResult.files) {
421
- if (file.skipped) {
422
- output += ` ⏭️ ${file.path} (skipped: ${file.reason})\n`;
423
- } else {
424
- output += ` 📄 ${file.path} (${Math.round(file.size/1024)}KB)\n`;
425
- }
426
- }
427
-
428
- output += `\n══════════════════════════════════════\n`;
429
- output += `FILE CONTENTS:\n`;
430
- output += `══════════════════════════════════════\n\n`;
431
-
432
- // Then include contents
433
- for (const file of scanResult.files) {
434
- if (file.skipped) continue;
435
- output += `┌─── ${file.path} ───\n`;
436
- output += file.content;
437
- if (!file.content.endsWith('\n')) output += '\n';
438
- output += `└─── END ${file.path} ───\n\n`;
439
- }
440
-
441
- return output;
442
- }
443
-
444
- const tools = {
445
- read: (path) => {
446
- try { return fs.readFileSync(path.trim(), 'utf8'); }
447
- catch (error) { return `Error reading file: ${error.message}`; }
448
- },
449
- patch: async (path, oldText, newText) => {
450
- const trimmedPath = path.trim();
451
- try {
452
- const content = fs.readFileSync(trimmedPath, 'utf8');
453
- if (!content.includes(oldText)) {
454
- return `Error: Could not find the text to replace in ${trimmedPath}. Make sure oldText matches exactly (including whitespace).`;
455
- }
456
- const newContent = content.replace(oldText, newText);
457
-
458
- // Show diff preview
459
- console.log();
460
- const diffContent =
461
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
462
- chalk.gray('─'.repeat(40)) + '\n' +
463
- chalk.red('- ' + oldText.split('\n').join('\n- ')) + '\n' +
464
- chalk.green('+ ' + newText.split('\n').join('\n+ '));
465
- console.log(box(diffContent, '🔧 Patch', 'yellow'));
466
-
467
- const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
468
- if (confirm.toLowerCase() === 'y') {
469
- fs.writeFileSync(trimmedPath, newContent);
470
- return `Successfully patched ${trimmedPath}`;
471
- }
472
- return 'Patch rejected by user.';
473
- } catch (error) { return `Error patching file: ${error.message}`; }
474
- },
475
- write: async (path, content) => {
476
- const trimmedPath = path.trim();
477
- console.log();
478
- console.log(box(
479
- `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
480
- `${chalk.white('Size:')} ${content?.length || 0} chars\n` +
481
- chalk.gray('─'.repeat(40)) + '\n' +
482
- chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
483
- '✏️ Write File', 'yellow'
484
- ));
485
- const confirm = await safeQuestion(chalk.yellow('\n↪ Allow write? ') + chalk.gray('(y/n): '));
486
- if (confirm.toLowerCase() === 'y') {
487
- try {
488
- fs.writeFileSync(trimmedPath, content);
489
- return `Successfully saved changes to ${trimmedPath}`;
490
- } catch (error) { return `Error writing file: ${error.message}`; }
491
- }
492
- return "Write blocked by user.";
493
- },
494
- mkdir: (path) => {
495
- try {
496
- fs.mkdirSync(path.trim(), { recursive: true });
497
- return `Directory created: ${path}`;
498
- } catch (error) { return `Error creating directory: ${error.message}`; }
499
- },
500
- shell: async (cmd) => {
501
- console.log();
502
- console.log(box(
503
- chalk.white.bold(cmd),
504
- '🔐 Shell Command', 'red'
505
- ));
506
- const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
507
- if (confirm.toLowerCase() === 'y') {
508
- return new Promise((resolve) => {
509
- const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
510
- console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
511
- const proc = spawn(useShell ? 'sh' : cmd.split(' ')[0], useShell ? ['-c', cmd] : cmd.split(' ').slice(1), {
512
- stdio: 'inherit', shell: useShell
513
- });
514
- proc.on('close', (code) => {
515
- // Crucial: give control back to Node
516
- if (process.stdin.isTTY) {
517
- try { process.stdin.setRawMode(false); } catch (e) {}
518
- }
519
- // Delay slightly to let terminal settle
520
- setTimeout(() => {
521
- recreateReadline();
522
- resolve(`Command completed with code ${code}`);
523
- }, 200);
524
- });
525
- });
526
- }
527
- return "Command blocked by user.";
528
- },
529
- list: (path) => {
530
- try {
531
- let dir = path.trim() || '.';
532
- // If AI sends "/" (root), treat as current directory "."
533
- if (dir === '/') dir = '.';
534
- const entries = fs.readdirSync(dir);
535
- // Filter out ignored directories
536
- const filtered = entries.filter(entry => {
537
- if (IGNORE_DIRS.has(entry)) return false;
538
- // Also skip hidden files/folders (starting with .) except current dir
539
- if (entry.startsWith('.') && entry !== '.') return false;
540
- return true;
541
- });
542
- return filtered.length > 0 ? filtered.join('\n') : '(empty or all files filtered)';
543
- } catch (e) { return `Error: ${e.message}`; }
544
- },
545
- search: (pattern) => {
546
- return new Promise((resolve) => {
547
- const excludeDirs = Array.from(IGNORE_DIRS).join(',');
548
- // Use grep to search for pattern, excluding ignored directories
549
- 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`;
550
-
551
- const proc = spawn('sh', ['-c', cmd], { cwd: process.cwd() });
552
- let output = '';
553
-
554
- proc.stdout.on('data', (data) => { output += data.toString(); });
555
- proc.stderr.on('data', (data) => { output += data.toString(); });
556
-
557
- proc.on('close', () => {
558
- if (output.trim()) {
559
- resolve(`Found matches:\n${output.trim()}`);
560
- } else {
561
- resolve(`No matches found for: ${pattern}`);
562
- }
563
- });
564
- });
565
- }
566
- };
567
-
568
- async function checkForUpdates() {
569
- try {
570
- const response = await fetch('https://registry.npmjs.org/sapper-iq/latest');
571
- const data = await response.json();
572
- const latestVersion = data.version;
573
-
574
- if (latestVersion && latestVersion !== CURRENT_VERSION) {
575
- console.log(chalk.yellow('🔄 UPDATE AVAILABLE!'));
576
- console.log(chalk.gray(` Current: v${CURRENT_VERSION}`));
577
- console.log(chalk.green(` Latest: v${latestVersion}`));
578
- console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
579
- }
580
- } catch (error) {
581
- // Silently fail if update check fails
582
- }
583
- }
584
-
585
- async function runSapper() {
586
- console.clear();
587
- console.log(BANNER);
588
- console.log(chalk.gray.dim(' ') + chalk.white.bold(`v${CURRENT_VERSION}`) + chalk.gray(' │ ') + chalk.cyan('Autonomous AI Coding Agent'));
589
- console.log(chalk.gray.dim(' ') + chalk.gray('📁 ') + chalk.white(process.cwd()));
590
- console.log();
591
-
592
- // Quick tips box
593
- console.log(box(
594
- `${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
595
- `${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
596
- `${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
597
- 'Quick Tips', 'gray'
598
- ));
599
- console.log();
600
-
601
- // Check for updates
602
- await checkForUpdates();
603
-
604
- let messages = [];
605
- if (fs.existsSync(CONTEXT_FILE)) {
606
- console.log();
607
- console.log(box('Previous session found! Resume where you left off?', '📂 Session', 'green'));
608
- const resume = await safeQuestion(chalk.green('\n↪ Resume? ') + chalk.gray('(y/n): '));
609
- if (resume.toLowerCase() === 'y') {
610
- messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
611
- console.log(chalk.green(' ✓ Session restored\n'));
612
- } else {
613
- fs.unlinkSync(CONTEXT_FILE);
614
- console.log(chalk.gray(' ✓ Starting fresh...\n'));
615
- }
616
- }
617
-
618
- let localModels;
619
- try {
620
- localModels = await ollama.list();
621
- } catch (e) {
622
- console.error(chalk.red('\n❌ Cannot connect to Ollama!'));
623
- console.log(chalk.yellow(' Make sure Ollama is running: ') + chalk.cyan('ollama serve'));
624
- console.log(chalk.gray(' Or install from: https://ollama.ai\n'));
625
- process.exit(1);
626
- }
627
-
628
- if (!localModels.models || localModels.models.length === 0) {
629
- console.error(chalk.red('\n❌ No models found!'));
630
- console.log(chalk.yellow(' Pull a model first: ') + chalk.cyan('ollama pull llama3.2'));
631
- process.exit(1);
632
- }
633
-
634
- console.log(divider());
635
- console.log(statusBadge('MODELS', 'info') + chalk.gray(' Available Ollama models:\n'));
636
- localModels.models.forEach((m, i) => {
637
- const num = chalk.cyan.bold(`[${i + 1}]`);
638
- const name = chalk.white(m.name);
639
- console.log(` ${num} ${name}`);
640
- });
641
- console.log(divider());
642
- const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
643
- const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
644
-
645
- if (messages.length === 0) {
646
- messages = [{
647
- role: 'system',
648
- content: `You are Sapper, a high-level Autonomous Software Engineer.
649
- Your goal is to solve the user's request by interacting with the filesystem and shell.
650
-
651
- RULES:
652
- 1. EXPLORE FIRST: Use LIST and READ to understand the codebase before making changes.
653
- 2. THINK IN STEPS: Explain what you found and what you plan to do before executing tools.
654
- 3. BE PRECISE: When using PATCH, ensure the 'oldText' matches exactly.
655
- 4. VERIFY: After writing code, use the SHELL tool to run tests or linting.
656
- 5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.
657
-
658
- TOOL SYNTAX:
659
- - [TOOL:LIST]dir[/TOOL] - List directory contents
660
- - [TOOL:READ]file_path[/TOOL] - Read file contents
661
- - [TOOL:SEARCH]pattern[/TOOL] - Search codebase for pattern
662
- - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
663
- - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file
664
- - [TOOL:SHELL]command[/TOOL] - Run shell command`
665
- }];
666
- }
667
-
668
- // Main conversation loop - never exits unless user types 'exit'
669
- while (true) {
670
- try {
671
- // Context size warning - large context causes hangs
672
- const contextSize = JSON.stringify(messages).length;
673
- if (contextSize > 32000) {
674
- console.log();
675
- console.log(box(
676
- `Context is ${chalk.red.bold(Math.round(contextSize/1024) + 'KB')} - this may cause slowdowns!\n` +
677
- `${chalk.yellow('Tip:')} Type ${chalk.cyan('/prune')} to reduce context size`,
678
- '⚠️ Warning', 'yellow'
679
- ));
680
- }
681
-
682
- const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
683
-
684
- if (input.toLowerCase() === 'exit') process.exit();
685
-
686
- // Handle reset command
687
- if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
688
- if (fs.existsSync(CONTEXT_FILE)) {
689
- fs.unlinkSync(CONTEXT_FILE);
690
- console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
691
- }
692
- messages = [{
693
- role: 'system',
694
- content: messages[0].content // Keep system prompt
695
- }];
696
- continue;
697
- }
698
-
699
- // Handle prune command - AUTO-EMBED then clear old context
700
- if (input.toLowerCase() === '/prune') {
701
- if (messages.length <= 5) {
702
- console.log(chalk.yellow('Context is already small, nothing to prune.'));
703
- continue;
704
- }
705
-
706
- // 1. AUTO-EMBED: Save conversation to memory BEFORE pruning (silently skip if no model)
707
- const embeddings = loadEmbeddings();
708
-
709
- // Get messages that will be pruned (all except system and last 4)
710
- const messagesToEmbed = messages.slice(1, -4)
711
- .filter(m => m.role !== 'system')
712
- .map(m => m.content.substring(0, 500))
713
- .join('\n---\n');
714
-
715
- if (messagesToEmbed.length > 50) {
716
- try {
717
- const embedding = await getEmbedding(messagesToEmbed);
718
- if (embedding) {
719
- embeddings.chunks.push({
720
- text: messagesToEmbed.substring(0, 2000),
721
- embedding,
722
- timestamp: Date.now()
723
- });
724
- if (embeddings.chunks.length > 100) {
725
- embeddings.chunks = embeddings.chunks.slice(-100);
726
- }
727
- saveEmbeddings(embeddings);
728
- console.log(chalk.green(`🧠 Saved to memory! (${embeddings.chunks.length} memories)`));
729
- }
730
- } catch (e) {
731
- // Silently skip embedding if model not available - prune still works
732
- }
733
- }
734
-
735
- // 2. Capture the ORIGINAL detailed system prompt from the very first message
736
- const originalSystemPrompt = messages[0];
737
-
738
- // 3. Capture the last 4 messages (the most recent conversation)
739
- const recentMessages = messages.slice(-4);
740
-
741
- // 4. Rebuild the messages array starting with the ORIGINAL prompt
742
- messages = [originalSystemPrompt, ...recentMessages];
743
-
744
- // 4. Add reminder to stay in Agent Mode (not chatbot mode)
745
- messages.push({
746
- role: 'system',
747
- content: `CONTEXT PRUNED. REMINDER: You are Sapper, an Autonomous Software Engineer.
748
-
749
- RULES:
750
- 1. EXPLORE FIRST: Use LIST and READ before making changes.
751
- 2. THINK IN STEPS: Explain your plan before executing tools.
752
- 3. BE PRECISE: When using PATCH, ensure 'oldText' matches exactly.
753
- 4. VERIFY: Run tests or linting after writing code.
754
- 5. NO HALLUCINATIONS: Don't guess file contents.
755
-
756
- TOOL SYNTAX:
757
- - [TOOL:LIST]dir[/TOOL]
758
- - [TOOL:READ]file_path[/TOOL]
759
- - [TOOL:SEARCH]pattern[/TOOL]
760
- - [TOOL:WRITE]path:::content[/TOOL]
761
- - [TOOL:PATCH]path:::old|||new[/TOOL]
762
- - [TOOL:SHELL]command[/TOOL]`
763
- });
764
-
765
- // 5. Save to context file so it persists
766
- fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
767
-
768
- console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
769
- console.log(chalk.gray(`Context size: ${messages.length} messages\n`));
770
- continue;
771
- }
772
-
773
- // Handle help command
774
- if (input.toLowerCase() === '/help') {
775
- console.log();
776
- const helpContent =
777
- `${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
778
- `${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
779
- `${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
780
- `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
781
- `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
782
- `${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
783
- `${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
784
- `${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
785
- `${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
786
- `${chalk.cyan('exit')} ${chalk.gray('│')} Quit Sapper`;
787
- console.log(box(helpContent, '📚 Commands', 'cyan'));
788
- console.log();
789
- continue;
790
- }
791
-
792
- // Handle context size command
793
- if (input.toLowerCase() === '/context') {
794
- const contextSize = JSON.stringify(messages).length;
795
- console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
796
- if (contextSize > 50000) {
797
- console.log(chalk.yellow('⚠️ Context is large! Consider using /prune'));
798
- }
799
- continue;
800
- }
801
-
802
- // Handle debug mode toggle
803
- if (input.toLowerCase() === '/debug') {
804
- debugMode = !debugMode;
805
- console.log(chalk.magenta(`🔧 Debug mode: ${debugMode ? 'ON' : 'OFF'}`));
806
- if (debugMode) {
807
- console.log(chalk.gray(' Will show regex matching details after each AI response.'));
808
- }
809
- continue;
810
- }
811
-
812
- // Handle recall command - search embeddings
813
- if (input.toLowerCase().startsWith('/recall')) {
814
- const query = input.slice(7).trim();
815
- if (!query) {
816
- console.log(chalk.yellow('Usage: /recall <search query>'));
817
- continue;
818
- }
819
-
820
- const embeddings = loadEmbeddings();
821
- if (embeddings.chunks.length === 0) {
822
- console.log(chalk.yellow('No memories yet. Use /prune to auto-save conversations.'));
823
- continue;
824
- }
825
-
826
- console.log(chalk.cyan(`\n🔍 Searching memory for: "${query}"...`));
827
- const relevant = await findRelevantContext(query, embeddings, 3);
828
-
829
- if (relevant.length === 0) {
830
- console.log(chalk.yellow('No relevant memories found (or embedding model not available).'));
831
- console.log(chalk.gray('Tip: Run "ollama pull nomic-embed-text" for semantic search.'));
832
- } else {
833
- console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
834
- relevant.forEach((chunk, i) => {
835
- console.log(box(
836
- chalk.gray(chunk.text.substring(0, 300) + '...') + '\n' +
837
- chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
838
- `Memory ${i + 1}`, 'magenta'
839
- ));
840
- console.log();
841
- });
842
-
843
- // Optionally add to context
844
- const addToContext = await safeQuestion(chalk.yellow('Add to current context? ') + chalk.gray('(y/n): '));
845
- if (addToContext.toLowerCase() === 'y') {
846
- const contextAddition = relevant.map(c => c.text).join('\n---\n');
847
- messages.push({
848
- role: 'user',
849
- content: `Here is relevant context from memory:\n${contextAddition}\n\nUse this information to help me.`
850
- });
851
- console.log(chalk.green('✅ Added to context!'));
852
- }
853
- }
854
- continue;
855
- }
856
-
857
- // Handle codebase scan command
858
- if (input.toLowerCase() === '/scan') {
859
- console.log(chalk.cyan('\n🔍 Scanning codebase...'));
860
- const scanResult = scanCodebase('.');
861
-
862
- if (scanResult.files.length === 0) {
863
- console.log(chalk.yellow('No code files found in current directory.'));
864
- continue;
865
- }
866
-
867
- const formattedScan = formatScanResults(scanResult);
868
- const includedCount = scanResult.files.filter(f => !f.skipped).length;
869
- const skippedCount = scanResult.files.filter(f => f.skipped).length;
870
-
871
- console.log(chalk.green(`✅ Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`));
872
- if (skippedCount > 0) {
873
- console.log(chalk.yellow(`⏭️ Skipped ${skippedCount} files (too large or limit reached)`));
874
- }
875
-
876
- // Add scan to context
877
- messages.push({
878
- role: 'user',
879
- 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.`
880
- });
881
-
882
- fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
883
- console.log(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
884
- continue;
885
- }
886
-
887
- // Handle @ alone or /attach command - interactive file picker
888
- if (input.trim() === '@' || input.toLowerCase() === '/attach') {
889
- const selectedFiles = await pickFiles();
890
-
891
- if (selectedFiles.length === 0) continue;
892
-
893
- // Read and attach selected files
894
- const fileAttachments = [];
895
- for (const filePath of selectedFiles) {
896
- try {
897
- const stats = fs.statSync(filePath);
898
- if (stats.size > MAX_FILE_SIZE) {
899
- console.log(chalk.yellow(`⚠️ ${filePath} is too large, skipping`));
900
- continue;
901
- }
902
- const content = fs.readFileSync(filePath, 'utf8');
903
- fileAttachments.push({ path: filePath, content, size: stats.size });
904
- console.log(chalk.green(`📎 Attached: ${filePath}`));
905
- } catch (e) {
906
- console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
907
- }
908
- }
909
-
910
- if (fileAttachments.length === 0) continue;
911
-
912
- // Ask for the prompt to go with these files
913
- console.log();
914
- const prompt = await safeQuestion(chalk.cyan('Your prompt for these files: '));
915
-
916
- if (!prompt.trim()) {
917
- console.log(chalk.gray('Cancelled.'));
918
- continue;
919
- }
920
-
921
- // Build message with attachments
922
- let attachedContent = '\n\n══════════════════════════════════════\n';
923
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
924
- attachedContent += '══════════════════════════════════════\n\n';
925
-
926
- for (const file of fileAttachments) {
927
- attachedContent += `┌─── ${file.path} ───\n`;
928
- attachedContent += file.content;
929
- if (!file.content.endsWith('\n')) attachedContent += '\n';
930
- attachedContent += `└─── END ${file.path} ───\n\n`;
931
- }
932
-
933
- messages.push({ role: 'user', content: prompt + attachedContent });
934
- // Continue to AI response (don't use 'continue' here)
935
- } else {
936
- // Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
937
- let processedInput = input;
938
- const fileAttachments = [];
939
- const attachRegex = /@([\w.\/\-_]+)/g;
940
- let attachMatch;
941
-
942
- while ((attachMatch = attachRegex.exec(input)) !== null) {
943
- const filePath = attachMatch[1];
944
- try {
945
- if (fs.existsSync(filePath)) {
946
- const stats = fs.statSync(filePath);
947
- if (stats.isFile()) {
948
- if (stats.size > MAX_FILE_SIZE) {
949
- console.log(chalk.yellow(`⚠️ @${filePath} is too large (${Math.round(stats.size/1024)}KB), skipping`));
950
- } else {
951
- const content = fs.readFileSync(filePath, 'utf8');
952
- fileAttachments.push({ path: filePath, content, size: stats.size });
953
- console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
954
- }
955
- }
956
- } else {
957
- // Not a file - might be an @mention for something else, ignore
958
- }
959
- } catch (e) {
960
- console.log(chalk.yellow(`⚠️ Could not read @${filePath}: ${e.message}`));
961
- }
962
- }
963
-
964
- // Build the final message with attachments
965
- if (fileAttachments.length > 0) {
966
- let attachedContent = '\n\n══════════════════════════════════════\n';
967
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
968
- attachedContent += '══════════════════════════════════════\n\n';
969
-
970
- for (const file of fileAttachments) {
971
- attachedContent += `┌─── ${file.path} ───\n`;
972
- attachedContent += file.content;
973
- if (!file.content.endsWith('\n')) attachedContent += '\n';
974
- attachedContent += `└─── END ${file.path} ───\n\n`;
975
- }
976
-
977
- processedInput = input + attachedContent;
978
- }
979
-
980
- messages.push({ role: 'user', content: processedInput });
981
- } // End of else block for non-@ input
982
-
983
- let toolRounds = 0; // Prevent infinite loops
984
- const MAX_TOOL_ROUNDS = 20;
985
-
986
- let active = true;
987
- while (active) {
988
- if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
989
-
990
- spinner.start('Thinking...');
991
- let response;
992
- try {
993
- response = await ollama.chat({ model: selectedModel, messages, stream: true });
994
- } catch (ollamaError) {
995
- spinner.stop();
996
- console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
997
- active = false;
998
- continue;
999
- }
1000
- spinner.stop();
1001
-
1002
- let msg = '';
1003
- const MAX_RESPONSE_LENGTH = 29000; // Guard against infinite loops (increased for multi-file reads)
1004
- abortStream = false; // Reset abort flag before streaming
1005
-
1006
- console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
1007
- process.stdout.write(chalk.magenta('│ '));
1008
- for await (const chunk of response) {
1009
- // Check if user pressed Ctrl+C
1010
- if (abortStream) {
1011
- console.log(chalk.yellow('\n│ [Response interrupted]'));
1012
- break;
1013
- }
1014
-
1015
- const content = chunk.message.content;
1016
- process.stdout.write(content);
1017
- msg += content;
1018
-
1019
- if (msg.length > MAX_RESPONSE_LENGTH) {
1020
- console.log(chalk.red('\n\n⚠️ RESPONSE TOO LONG: Forcing stop to prevent infinite loop.'));
1021
- break;
1022
- }
1023
- }
1024
- console.log();
1025
-
1026
- // If response has markdown, show rendered version
1027
- const hasMarkdown = /\*\*|__|`|^#|^[-*] /m.test(msg);
1028
- if (hasMarkdown && !msg.includes('[TOOL:')) {
1029
- console.log(chalk.gray('─'.repeat(40)));
1030
- const rendered = renderMarkdown(msg);
1031
- const lines = rendered.split('\n');
1032
- for (const line of lines) {
1033
- console.log(chalk.magenta('│ ') + line);
1034
- }
1035
- console.log();
1036
- }
1037
-
1038
- messages.push({ role: 'assistant', content: msg });
1039
-
1040
- // Regex: supports both old format (path]content) and new format (path:::content)
1041
- const toolMatches = [...msg.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
1042
-
1043
- // Debug mode: show what regex sees
1044
- if (debugMode) {
1045
- console.log(chalk.magenta('\n═══ DEBUG: REGEX ANALYSIS ═══'));
1046
- console.log(chalk.gray(`Response length: ${msg.length} chars`));
1047
-
1048
- // Check for tool-like patterns
1049
- const hasToolStart = msg.includes('[TOOL:');
1050
- const hasToolEnd = msg.includes('[/TOOL]');
1051
- const hasBrokenEnd = msg.includes('[/]') || msg.includes('[/WRITE]') || msg.includes('[/READ]');
1052
-
1053
- console.log(chalk.gray(`Contains [TOOL:: ${hasToolStart ? chalk.green('YES') : chalk.red('NO')}`));
1054
- console.log(chalk.gray(`Contains [/TOOL]: ${hasToolEnd ? chalk.green('YES') : chalk.red('NO')}`));
1055
- if (hasBrokenEnd) {
1056
- console.log(chalk.red(`⚠️ Found broken closing tag: [/] or [/WRITE] etc.`));
1057
- }
1058
-
1059
- console.log(chalk.gray(`Matches found: ${toolMatches.length}`));
1060
-
1061
- if (toolMatches.length > 0) {
1062
- toolMatches.forEach((m, i) => {
1063
- console.log(chalk.cyan(` Match ${i+1}: type=${m[1]}, path=${m[2]?.substring(0,50)}...`));
1064
- });
1065
- } else if (hasToolStart) {
1066
- // Show the raw tool attempt for debugging
1067
- const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
1068
- if (toolAttempt) {
1069
- console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
1070
- console.log(chalk.gray(` "${toolAttempt[0].substring(0, 150)}..."`));
1071
- }
1072
- }
1073
- console.log(chalk.magenta('═══════════════════════════════\n'));
1074
- }
1075
-
1076
- if (toolMatches.length > 0) {
1077
- toolRounds++;
1078
-
1079
- // Prevent infinite tool loops
1080
- if (toolRounds >= MAX_TOOL_ROUNDS) {
1081
- console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Stopping auto-execution.`));
1082
- console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
1083
- resetTerminal(); // Ensure terminal is responsive
1084
- messages.push({
1085
- role: 'user',
1086
- content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
1087
- });
1088
- continue; // Let AI respond without tools
1089
- }
1090
-
1091
- for (const match of toolMatches) {
1092
- const [_, type, path, content] = match;
1093
- console.log();
1094
- console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
1095
-
1096
- let result;
1097
- if (type.toLowerCase() === 'list') result = tools.list(path);
1098
- else if (type.toLowerCase() === 'read') result = tools.read(path);
1099
- else if (type.toLowerCase() === 'mkdir') result = tools.mkdir(path);
1100
- else if (type.toLowerCase() === 'write') {
1101
- if (!content || content.trim() === '') {
1102
- result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
1103
- } else {
1104
- result = await tools.write(path, content);
1105
- }
1106
- }
1107
- else if (type.toLowerCase() === 'patch') {
1108
- // PATCH format: [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]
1109
- const parts = content?.split('|||');
1110
- if (parts && parts.length === 2) {
1111
- result = await tools.patch(path, parts[0], parts[1]);
1112
- } else {
1113
- result = 'Error: PATCH requires format [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]';
1114
- }
1115
- }
1116
- else if (type.toLowerCase() === 'search') result = await tools.search(path);
1117
- else if (type.toLowerCase() === 'shell') result = await tools.shell(path);
1118
-
1119
- messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
1120
- }
1121
- fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1122
-
1123
- if (toolMatches.length > 30) {
1124
- console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
1125
- }
1126
- } else {
1127
- // No tools found - check if malformed command
1128
- if (msg.includes('[TOOL:') && msg.includes('[/]')) {
1129
- console.log(chalk.red('\n❌ Malformed tool command detected!'));
1130
- messages.push({
1131
- role: 'user',
1132
- content: 'ERROR: Your tool command is malformed. Use [TOOL:TYPE]path]content[/TOOL] or [TOOL:TYPE]path[/TOOL]'
1133
- });
1134
- } else {
1135
- // Normal response - save and wait for next input
1136
- fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1137
- active = false;
1138
- spinner.stop(); // Ensure spinner is dead
1139
- resetTerminal(); // Force terminal back to normal state
1140
- process.stdout.write('\n'); // Force newline to break out of stream mode
1141
- }
1142
- }
1143
- }
1144
- } catch (error) {
1145
- console.error(chalk.red('\n❌ Error:'), error.message);
1146
- // Loop continues automatically
1147
- }
1148
- }
1149
- }
1150
-
1151
- // Keep-alive interval - prevents Node from exiting when event loop is empty
1152
- setInterval(() => {}, 1000);
1153
-
1154
- runSapper();