sapper-iq 1.1.12 → 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 +1 -1
- package/sapper copy 2.mjs +673 -0
- package/sapper.mjs +157 -84
package/package.json
CHANGED
|
@@ -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(
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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(
|
|
230
|
-
console.log(
|
|
231
|
-
|
|
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(
|
|
248
|
-
|
|
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,89 +380,97 @@ async function checkForUpdates() {
|
|
|
324
380
|
|
|
325
381
|
async function runSapper() {
|
|
326
382
|
console.clear();
|
|
327
|
-
console.log(
|
|
328
|
-
console.log(chalk.gray(
|
|
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
|
-
|
|
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
|
|
410
|
+
console.log(chalk.gray(' ✓ Starting fresh...\n'));
|
|
342
411
|
}
|
|
343
412
|
}
|
|
344
413
|
|
|
345
414
|
const localModels = await ollama.list();
|
|
346
|
-
|
|
347
|
-
|
|
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) {
|
|
351
427
|
messages = [{
|
|
352
428
|
role: 'system',
|
|
353
|
-
content: `You are Sapper, a
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
-
|
|
358
|
-
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
-
|
|
387
|
-
-
|
|
388
|
-
-
|
|
389
|
-
- MKDIR: Create directory
|
|
390
|
-
- SHELL: Run terminal command (requires confirmation)
|
|
391
|
-
|
|
392
|
-
SMART WORKFLOW:
|
|
393
|
-
1. For unknown codebases: [TOOL:SEARCH]main|index|app[/TOOL] to find entry points
|
|
394
|
-
2. To find where something is defined: [TOOL:SEARCH]function myFunc[/TOOL]
|
|
395
|
-
3. SEARCH returns file paths + line numbers - then READ specific files
|
|
396
|
-
|
|
397
|
-
PATCH vs WRITE:
|
|
398
|
-
- Use PATCH for small changes (1-10 lines): [TOOL:PATCH]path]old|||new[/TOOL]
|
|
399
|
-
- Use WRITE only for new files or complete rewrites
|
|
429
|
+
content: `You are Sapper, a coding assistant that ONLY does what the user asks.
|
|
430
|
+
|
|
431
|
+
GOLDEN RULE: Do EXACTLY what the user asks. Nothing more, nothing less.
|
|
432
|
+
- NEVER add features the user didn't ask for.
|
|
433
|
+
- ALWAYS confirm with the user before writing/patching files or running shell commands.
|
|
434
|
+
- KEEP responses concise and to the point.
|
|
435
|
+
TOOLS (use these to interact with files):
|
|
436
|
+
|
|
437
|
+
[TOOL:LIST]path[/TOOL]
|
|
438
|
+
→ List files in a directory
|
|
439
|
+
→ Example: [TOOL:LIST].[/TOOL]
|
|
440
|
+
|
|
441
|
+
[TOOL:READ]path[/TOOL]
|
|
442
|
+
→ Read a file's contents
|
|
443
|
+
→ Example: [TOOL:READ]./package.json[/TOOL]
|
|
444
|
+
|
|
445
|
+
[TOOL:WRITE]path]content[/TOOL]
|
|
446
|
+
→ Create or overwrite a file (needs user confirmation)
|
|
447
|
+
→ Example: [TOOL:WRITE]./index.js]console.log("hello")[/TOOL]
|
|
448
|
+
|
|
449
|
+
[TOOL:PATCH]path]old_text|||new_text[/TOOL]
|
|
450
|
+
→ Replace specific text in a file (needs user confirmation)
|
|
451
|
+
→ Example: [TOOL:PATCH]./app.js]old code|||new code[/TOOL]
|
|
452
|
+
|
|
453
|
+
[TOOL:SEARCH]pattern[/TOOL]
|
|
454
|
+
→ Search for text across all files
|
|
455
|
+
→ Example: [TOOL:SEARCH]function login[/TOOL]
|
|
456
|
+
|
|
457
|
+
[TOOL:SHELL]command[/TOOL]
|
|
458
|
+
→ Run a terminal command (needs user confirmation)
|
|
459
|
+
→ Example: [TOOL:SHELL]npm install express[/TOOL]
|
|
460
|
+
|
|
461
|
+
PATH RULES:
|
|
462
|
+
- Always use relative paths: ./file.js, ./src/app.js
|
|
463
|
+
- NEVER use absolute paths like /file.js
|
|
464
|
+
- Use . for current directory
|
|
400
465
|
|
|
401
466
|
WORKFLOW:
|
|
402
|
-
1.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
- BATCH READING: When asked to read multiple files, call ALL [TOOL:READ] commands in ONE response. Do NOT stop to analyze between files.`
|
|
467
|
+
1. Understand exactly what user wants
|
|
468
|
+
2. Use LIST to see existing files if needed
|
|
469
|
+
3. Use READ to check existing code if needed
|
|
470
|
+
4. Use WRITE/PATCH to make changes
|
|
471
|
+
5. Be concise in explanations
|
|
472
|
+
|
|
473
|
+
CRITICAL: Stay focused. If user asks for X, deliver X only.`
|
|
410
474
|
}];
|
|
411
475
|
}
|
|
412
476
|
|
|
@@ -416,11 +480,15 @@ IMPORTANT RULES:
|
|
|
416
480
|
// Context size warning - large context causes hangs
|
|
417
481
|
const contextSize = JSON.stringify(messages).length;
|
|
418
482
|
if (contextSize > 32000) {
|
|
419
|
-
console.log(
|
|
420
|
-
console.log(
|
|
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
|
+
));
|
|
421
489
|
}
|
|
422
490
|
|
|
423
|
-
const input = await safeQuestion(chalk.
|
|
491
|
+
const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
|
|
424
492
|
|
|
425
493
|
if (input.toLowerCase() === 'exit') process.exit();
|
|
426
494
|
|
|
@@ -476,14 +544,17 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
476
544
|
|
|
477
545
|
// Handle help command
|
|
478
546
|
if (input.toLowerCase() === '/help') {
|
|
479
|
-
console.log(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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();
|
|
487
558
|
continue;
|
|
488
559
|
}
|
|
489
560
|
|
|
@@ -561,7 +632,8 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
561
632
|
let msg = '';
|
|
562
633
|
const MAX_RESPONSE_LENGTH = 29000; // Guard against infinite loops (increased for multi-file reads)
|
|
563
634
|
|
|
564
|
-
|
|
635
|
+
console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
|
|
636
|
+
process.stdout.write(chalk.magenta('│ '));
|
|
565
637
|
for await (const chunk of response) {
|
|
566
638
|
const content = chunk.message.content;
|
|
567
639
|
process.stdout.write(content);
|
|
@@ -628,7 +700,8 @@ Do NOT just display content. Actually WRITE files using the tool.`
|
|
|
628
700
|
|
|
629
701
|
for (const match of toolMatches) {
|
|
630
702
|
const [_, type, path, content] = match;
|
|
631
|
-
console.log(
|
|
703
|
+
console.log();
|
|
704
|
+
console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
|
|
632
705
|
|
|
633
706
|
let result;
|
|
634
707
|
if (type.toLowerCase() === 'list') result = tools.list(path);
|