sapper-iq 1.1.35 → 1.1.37
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/.sapper/agents/reviewer.md +32 -0
- package/.sapper/agents/sapper-it.md +23 -0
- package/.sapper/agents/writer.md +31 -0
- package/.sapper/config.json +4 -0
- package/.sapper/context.json +14 -0
- package/.sapper/logs/session-2026-04-06T06-20-07.md +29 -0
- package/.sapper/skills/git-workflow.md +44 -0
- package/.sapper/skills/node-project.md +52 -0
- package/.sapper/workspace.json +52 -0
- package/.sapperignore +137 -0
- package/{sapper copy 3.mjs → old/sapper copy 3.mjs } +44 -105
- package/old/sapper copy4.mjs +1950 -0
- package/package.json +3 -2
- package/sapper-ui.mjs +1987 -0
- package/sapper.mjs +2907 -390
- /package/{sapper copy 2.mjs → old/sapper copy 2.mjs} +0 -0
- /package/{sapper copy.mjs → old/sapper copy.mjs} +0 -0
|
@@ -0,0 +1,1950 @@
|
|
|
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
|
+
import * as acorn from 'acorn';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
|
|
17
|
+
// Prevent process from exiting on unhandled errors
|
|
18
|
+
process.on('uncaughtException', (err) => {
|
|
19
|
+
console.error(chalk.red('\n❌ Uncaught exception:'), err.message);
|
|
20
|
+
});
|
|
21
|
+
process.on('unhandledRejection', (reason) => {
|
|
22
|
+
console.error(chalk.red('\n❌ Unhandled rejection:'), reason);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Prevent Ctrl+C from killing the whole process
|
|
26
|
+
let ctrlCCount = 0;
|
|
27
|
+
process.on('SIGINT', () => {
|
|
28
|
+
ctrlCCount++;
|
|
29
|
+
if (ctrlCCount >= 2) {
|
|
30
|
+
console.log(chalk.red('\nForce quitting...'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
// Set flag to abort current stream
|
|
34
|
+
abortStream = true;
|
|
35
|
+
|
|
36
|
+
// Clear current line and move to new one - stops ghost output
|
|
37
|
+
process.stdout.clearLine(0);
|
|
38
|
+
process.stdout.cursorTo(0);
|
|
39
|
+
console.log(chalk.yellow('\n⏹️ Stopping response... (Ctrl+C again to force quit)'));
|
|
40
|
+
|
|
41
|
+
// Reset terminal immediately
|
|
42
|
+
resetTerminal();
|
|
43
|
+
setTimeout(() => { ctrlCCount = 0; }, 2000); // Reset after 2 seconds
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Reset terminal state - fixes "ghost input" after shell commands or AI streaming
|
|
47
|
+
function resetTerminal() {
|
|
48
|
+
if (process.stdin.isTTY) {
|
|
49
|
+
try {
|
|
50
|
+
process.stdin.setRawMode(false); // Disable raw mode
|
|
51
|
+
process.stdin.pause(); // Pause the stream
|
|
52
|
+
process.stdin.resume(); // Resume to clear buffers
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// Ignore errors if terminal is in weird state
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Initialize versioning
|
|
60
|
+
let CURRENT_VERSION = "1.1.0";
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(fs.readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
|
63
|
+
CURRENT_VERSION = pkg.version;
|
|
64
|
+
} catch (e) {}
|
|
65
|
+
|
|
66
|
+
const spinner = ora();
|
|
67
|
+
|
|
68
|
+
// ═══════════════════════════════════════════════════════════════
|
|
69
|
+
// SAPPER MEMORY FOLDER - All persistent data in one place
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════
|
|
71
|
+
const SAPPER_DIR = '.sapper';
|
|
72
|
+
const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
|
|
73
|
+
const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
|
|
74
|
+
const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
|
|
75
|
+
const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
|
|
76
|
+
|
|
77
|
+
// Ensure .sapper directory exists
|
|
78
|
+
function ensureSapperDir() {
|
|
79
|
+
if (!fs.existsSync(SAPPER_DIR)) {
|
|
80
|
+
fs.mkdirSync(SAPPER_DIR, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Load config (settings like autoAttach)
|
|
85
|
+
function loadConfig() {
|
|
86
|
+
try {
|
|
87
|
+
ensureSapperDir();
|
|
88
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
89
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
} catch (e) {}
|
|
92
|
+
return { autoAttach: true }; // Default: auto-attach related files is ON
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveConfig(config) {
|
|
96
|
+
ensureSapperDir();
|
|
97
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Global config
|
|
101
|
+
let sapperConfig = loadConfig();
|
|
102
|
+
|
|
103
|
+
// ═══════════════════════════════════════════════════════════════
|
|
104
|
+
// WORKSPACE GRAPH - Track file relationships and summaries
|
|
105
|
+
// ═══════════════════════════════════════════════════════════════
|
|
106
|
+
|
|
107
|
+
function loadWorkspaceGraph() {
|
|
108
|
+
try {
|
|
109
|
+
ensureSapperDir();
|
|
110
|
+
if (fs.existsSync(WORKSPACE_FILE)) {
|
|
111
|
+
return JSON.parse(fs.readFileSync(WORKSPACE_FILE, 'utf8'));
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {}
|
|
114
|
+
return { indexed: null, files: {}, graph: {} };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveWorkspaceGraph(workspace) {
|
|
118
|
+
ensureSapperDir();
|
|
119
|
+
fs.writeFileSync(WORKSPACE_FILE, JSON.stringify(workspace, null, 2));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extract imports/requires from file content
|
|
123
|
+
function extractDependencies(content, filePath) {
|
|
124
|
+
const deps = new Set();
|
|
125
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
126
|
+
|
|
127
|
+
// JavaScript/TypeScript imports
|
|
128
|
+
if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
129
|
+
// import ... from '...'
|
|
130
|
+
const importMatches = content.matchAll(/import\s+.*?\s+from\s+['"]([^'"]+)['"]/g);
|
|
131
|
+
for (const m of importMatches) deps.add(m[1]);
|
|
132
|
+
|
|
133
|
+
// require('...')
|
|
134
|
+
const requireMatches = content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
|
|
135
|
+
for (const m of requireMatches) deps.add(m[1]);
|
|
136
|
+
|
|
137
|
+
// dynamic import('...')
|
|
138
|
+
const dynImportMatches = content.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g);
|
|
139
|
+
for (const m of dynImportMatches) deps.add(m[1]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Python imports
|
|
143
|
+
if (ext === 'py') {
|
|
144
|
+
const fromImports = content.matchAll(/from\s+([.\w]+)\s+import/g);
|
|
145
|
+
for (const m of fromImports) deps.add(m[1]);
|
|
146
|
+
|
|
147
|
+
const imports = content.matchAll(/^import\s+([.\w]+)/gm);
|
|
148
|
+
for (const m of imports) deps.add(m[1]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Filter to only local imports (starting with . or no package scope)
|
|
152
|
+
return Array.from(deps).filter(d => d.startsWith('.') || d.startsWith('/'));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Extract exports from file
|
|
156
|
+
function extractExports(content, filePath) {
|
|
157
|
+
const exports = new Set();
|
|
158
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
159
|
+
|
|
160
|
+
if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
161
|
+
// export function/class/const name
|
|
162
|
+
const namedExports = content.matchAll(/export\s+(?:function|class|const|let|var|async function)\s+(\w+)/g);
|
|
163
|
+
for (const m of namedExports) exports.add(m[1]);
|
|
164
|
+
|
|
165
|
+
// export { name }
|
|
166
|
+
const bracketExports = content.matchAll(/export\s*\{([^}]+)\}/g);
|
|
167
|
+
for (const m of bracketExports) {
|
|
168
|
+
m[1].split(',').forEach(e => {
|
|
169
|
+
const name = e.trim().split(/\s+as\s+/)[0].trim();
|
|
170
|
+
if (name) exports.add(name);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// export default
|
|
175
|
+
if (content.includes('export default')) exports.add('default');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return Array.from(exports);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Resolve relative import to actual file path
|
|
182
|
+
function resolveImportPath(importPath, fromFile) {
|
|
183
|
+
if (!importPath.startsWith('.')) return null;
|
|
184
|
+
|
|
185
|
+
const fromDir = dirname(fromFile);
|
|
186
|
+
let resolved = join(fromDir, importPath).replace(/\\/g, '/');
|
|
187
|
+
|
|
188
|
+
// Try common extensions
|
|
189
|
+
const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '.mjs', '/index.js', '/index.ts'];
|
|
190
|
+
for (const ext of extensions) {
|
|
191
|
+
const fullPath = resolved + ext;
|
|
192
|
+
if (fs.existsSync(fullPath)) {
|
|
193
|
+
return fullPath.replace(/^\.\//, '');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build workspace graph from codebase
|
|
200
|
+
async function buildWorkspaceGraph(showProgress = true) {
|
|
201
|
+
const workspace = { indexed: new Date().toISOString(), files: {}, graph: {} };
|
|
202
|
+
|
|
203
|
+
function scanDir(dir, depth = 0) {
|
|
204
|
+
if (depth > 5) return;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
208
|
+
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
211
|
+
|
|
212
|
+
if (entry.isDirectory()) {
|
|
213
|
+
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
214
|
+
scanDir(fullPath, depth + 1);
|
|
215
|
+
} else {
|
|
216
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
217
|
+
if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const stats = fs.statSync(fullPath);
|
|
221
|
+
if (stats.size > MAX_FILE_SIZE) continue;
|
|
222
|
+
|
|
223
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
224
|
+
const deps = extractDependencies(content, fullPath);
|
|
225
|
+
const exports = extractExports(content, fullPath);
|
|
226
|
+
|
|
227
|
+
// Generate brief summary (first meaningful lines)
|
|
228
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.trim().startsWith('//') && !l.trim().startsWith('#'));
|
|
229
|
+
const summary = lines.slice(0, 3).join(' ').substring(0, 150);
|
|
230
|
+
|
|
231
|
+
workspace.files[fullPath] = {
|
|
232
|
+
size: stats.size,
|
|
233
|
+
modified: stats.mtime.toISOString(),
|
|
234
|
+
imports: deps,
|
|
235
|
+
exports: exports,
|
|
236
|
+
symbols: parseFileSymbols(content, fullPath), // AST-extracted symbols
|
|
237
|
+
summary: summary || '(no summary)'
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Build dependency graph
|
|
241
|
+
workspace.graph[fullPath] = [];
|
|
242
|
+
for (const dep of deps) {
|
|
243
|
+
const resolved = resolveImportPath(dep, fullPath);
|
|
244
|
+
if (resolved) {
|
|
245
|
+
workspace.graph[fullPath].push(resolved);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
scanDir('.');
|
|
255
|
+
saveWorkspaceGraph(workspace);
|
|
256
|
+
return workspace;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Get related files for a given file (imports + files that import it)
|
|
260
|
+
function getRelatedFiles(filePath, workspace, depth = 1) {
|
|
261
|
+
const related = new Set();
|
|
262
|
+
|
|
263
|
+
// Direct imports
|
|
264
|
+
const imports = workspace.graph[filePath] || [];
|
|
265
|
+
imports.forEach(f => related.add(f));
|
|
266
|
+
|
|
267
|
+
// Files that import this file (reverse lookup)
|
|
268
|
+
for (const [file, deps] of Object.entries(workspace.graph)) {
|
|
269
|
+
if (deps.includes(filePath)) {
|
|
270
|
+
related.add(file);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Second level if depth > 1
|
|
275
|
+
if (depth > 1) {
|
|
276
|
+
const firstLevel = Array.from(related);
|
|
277
|
+
for (const f of firstLevel) {
|
|
278
|
+
const secondImports = workspace.graph[f] || [];
|
|
279
|
+
secondImports.forEach(sf => related.add(sf));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
related.delete(filePath); // Don't include self
|
|
284
|
+
return Array.from(related);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Format workspace summary for AI context
|
|
288
|
+
function formatWorkspaceSummary(workspace) {
|
|
289
|
+
const fileCount = Object.keys(workspace.files).length;
|
|
290
|
+
let output = `\n📊 WORKSPACE INDEX (${fileCount} files)\n`;
|
|
291
|
+
output += '═'.repeat(40) + '\n\n';
|
|
292
|
+
|
|
293
|
+
// Group files by directory
|
|
294
|
+
const byDir = {};
|
|
295
|
+
for (const [path, info] of Object.entries(workspace.files)) {
|
|
296
|
+
const dir = dirname(path) || '.';
|
|
297
|
+
if (!byDir[dir]) byDir[dir] = [];
|
|
298
|
+
byDir[dir].push({ path, ...info });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
for (const [dir, files] of Object.entries(byDir)) {
|
|
302
|
+
output += `📁 ${dir}/\n`;
|
|
303
|
+
for (const f of files.slice(0, 10)) { // Limit per directory
|
|
304
|
+
const name = f.path.split('/').pop();
|
|
305
|
+
const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
|
|
306
|
+
output += ` 📄 ${name}${exportList}\n`;
|
|
307
|
+
}
|
|
308
|
+
if (files.length > 10) output += ` ... and ${files.length - 10} more\n`;
|
|
309
|
+
output += '\n';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return output;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ═══════════════════════════════════════════════════════════════
|
|
316
|
+
// AST PARSING - Extract symbols (functions, classes, variables)
|
|
317
|
+
// ═══════════════════════════════════════════════════════════════
|
|
318
|
+
|
|
319
|
+
// Parse JavaScript/TypeScript file and extract symbols
|
|
320
|
+
function parseFileSymbols(content, filePath) {
|
|
321
|
+
const symbols = [];
|
|
322
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
323
|
+
|
|
324
|
+
// Only parse JS/TS files with acorn
|
|
325
|
+
if (!['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
326
|
+
// For other languages, use regex-based extraction
|
|
327
|
+
return extractSymbolsWithRegex(content, filePath);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
// Parse with acorn (use loose parsing to handle more syntax)
|
|
332
|
+
const ast = acorn.parse(content, {
|
|
333
|
+
ecmaVersion: 'latest',
|
|
334
|
+
sourceType: 'module',
|
|
335
|
+
locations: true,
|
|
336
|
+
allowHashBang: true,
|
|
337
|
+
allowAwaitOutsideFunction: true,
|
|
338
|
+
allowImportExportEverywhere: true,
|
|
339
|
+
// Be lenient with errors
|
|
340
|
+
onComment: () => {},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Walk the AST to extract symbols
|
|
344
|
+
function walk(node, parentName = null) {
|
|
345
|
+
if (!node || typeof node !== 'object') return;
|
|
346
|
+
|
|
347
|
+
switch (node.type) {
|
|
348
|
+
case 'FunctionDeclaration':
|
|
349
|
+
if (node.id?.name) {
|
|
350
|
+
symbols.push({
|
|
351
|
+
type: 'function',
|
|
352
|
+
name: node.id.name,
|
|
353
|
+
line: node.loc?.start?.line || 0,
|
|
354
|
+
params: node.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
|
|
355
|
+
async: node.async || false,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
|
|
360
|
+
case 'ClassDeclaration':
|
|
361
|
+
if (node.id?.name) {
|
|
362
|
+
symbols.push({
|
|
363
|
+
type: 'class',
|
|
364
|
+
name: node.id.name,
|
|
365
|
+
line: node.loc?.start?.line || 0,
|
|
366
|
+
extends: node.superClass?.name || null,
|
|
367
|
+
});
|
|
368
|
+
// Extract methods
|
|
369
|
+
if (node.body?.body) {
|
|
370
|
+
for (const member of node.body.body) {
|
|
371
|
+
if (member.type === 'MethodDefinition' && member.key?.name) {
|
|
372
|
+
symbols.push({
|
|
373
|
+
type: 'method',
|
|
374
|
+
name: `${node.id.name}.${member.key.name}`,
|
|
375
|
+
line: member.loc?.start?.line || 0,
|
|
376
|
+
kind: member.kind, // 'constructor', 'method', 'get', 'set'
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
|
|
384
|
+
case 'VariableDeclaration':
|
|
385
|
+
for (const decl of node.declarations || []) {
|
|
386
|
+
if (decl.id?.name) {
|
|
387
|
+
// Check if it's a function expression or arrow function
|
|
388
|
+
const init = decl.init;
|
|
389
|
+
if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
|
|
390
|
+
symbols.push({
|
|
391
|
+
type: 'function',
|
|
392
|
+
name: decl.id.name,
|
|
393
|
+
line: node.loc?.start?.line || 0,
|
|
394
|
+
params: init.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
|
|
395
|
+
async: init.async || false,
|
|
396
|
+
arrow: init.type === 'ArrowFunctionExpression',
|
|
397
|
+
});
|
|
398
|
+
} else {
|
|
399
|
+
symbols.push({
|
|
400
|
+
type: 'variable',
|
|
401
|
+
name: decl.id.name,
|
|
402
|
+
line: node.loc?.start?.line || 0,
|
|
403
|
+
kind: node.kind, // 'const', 'let', 'var'
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
|
|
410
|
+
case 'ExportNamedDeclaration':
|
|
411
|
+
if (node.declaration) {
|
|
412
|
+
walk(node.declaration, parentName);
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
|
|
416
|
+
case 'ExportDefaultDeclaration':
|
|
417
|
+
if (node.declaration) {
|
|
418
|
+
if (node.declaration.id?.name) {
|
|
419
|
+
symbols.push({
|
|
420
|
+
type: node.declaration.type === 'ClassDeclaration' ? 'class' : 'function',
|
|
421
|
+
name: node.declaration.id.name,
|
|
422
|
+
line: node.loc?.start?.line || 0,
|
|
423
|
+
exported: 'default',
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Recursively walk children
|
|
431
|
+
for (const key in node) {
|
|
432
|
+
if (key === 'loc' || key === 'range') continue;
|
|
433
|
+
const child = node[key];
|
|
434
|
+
if (Array.isArray(child)) {
|
|
435
|
+
child.forEach(c => walk(c, parentName));
|
|
436
|
+
} else if (child && typeof child === 'object') {
|
|
437
|
+
walk(child, parentName);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
walk(ast);
|
|
443
|
+
|
|
444
|
+
} catch (e) {
|
|
445
|
+
// If AST parsing fails, fall back to regex
|
|
446
|
+
return extractSymbolsWithRegex(content, filePath);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return symbols;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Fallback: extract symbols using regex (for non-JS or when AST fails)
|
|
453
|
+
function extractSymbolsWithRegex(content, filePath) {
|
|
454
|
+
const symbols = [];
|
|
455
|
+
const lines = content.split('\n');
|
|
456
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
457
|
+
|
|
458
|
+
// JavaScript/TypeScript patterns
|
|
459
|
+
if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
460
|
+
const funcPattern = /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g;
|
|
461
|
+
const classPattern = /(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/g;
|
|
462
|
+
const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
|
|
463
|
+
const methodPattern = /^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/gm;
|
|
464
|
+
|
|
465
|
+
let match;
|
|
466
|
+
while ((match = funcPattern.exec(content))) {
|
|
467
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
468
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
469
|
+
}
|
|
470
|
+
while ((match = classPattern.exec(content))) {
|
|
471
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
472
|
+
symbols.push({ type: 'class', name: match[1], line, extends: match[2] });
|
|
473
|
+
}
|
|
474
|
+
while ((match = arrowPattern.exec(content))) {
|
|
475
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
476
|
+
symbols.push({ type: 'function', name: match[1], line, arrow: true });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Python patterns
|
|
481
|
+
if (ext === 'py') {
|
|
482
|
+
const funcPattern = /^(?:async\s+)?def\s+(\w+)\s*\(/gm;
|
|
483
|
+
const classPattern = /^class\s+(\w+)(?:\s*\([^)]*\))?:/gm;
|
|
484
|
+
|
|
485
|
+
let match;
|
|
486
|
+
while ((match = funcPattern.exec(content))) {
|
|
487
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
488
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
489
|
+
}
|
|
490
|
+
while ((match = classPattern.exec(content))) {
|
|
491
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
492
|
+
symbols.push({ type: 'class', name: match[1], line });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Java/C#/Go patterns
|
|
497
|
+
if (['java', 'cs', 'go'].includes(ext)) {
|
|
498
|
+
const funcPattern = /(?:public|private|protected|static|func)?\s*(?:\w+\s+)?(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{/g;
|
|
499
|
+
const classPattern = /(?:public\s+)?(?:class|struct|interface)\s+(\w+)/g;
|
|
500
|
+
|
|
501
|
+
let match;
|
|
502
|
+
while ((match = funcPattern.exec(content))) {
|
|
503
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
504
|
+
if (!['if', 'for', 'while', 'switch', 'catch'].includes(match[1])) {
|
|
505
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
while ((match = classPattern.exec(content))) {
|
|
509
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
510
|
+
symbols.push({ type: 'class', name: match[1], line });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return symbols;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Search for symbol across workspace
|
|
518
|
+
function searchSymbol(query, workspace) {
|
|
519
|
+
const results = [];
|
|
520
|
+
const queryLower = query.toLowerCase();
|
|
521
|
+
|
|
522
|
+
for (const [filePath, fileInfo] of Object.entries(workspace.files)) {
|
|
523
|
+
if (!fileInfo.symbols) continue;
|
|
524
|
+
|
|
525
|
+
for (const symbol of fileInfo.symbols) {
|
|
526
|
+
if (symbol.name.toLowerCase().includes(queryLower)) {
|
|
527
|
+
results.push({
|
|
528
|
+
...symbol,
|
|
529
|
+
file: filePath,
|
|
530
|
+
score: symbol.name.toLowerCase() === queryLower ? 100 :
|
|
531
|
+
symbol.name.toLowerCase().startsWith(queryLower) ? 80 : 50
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Sort by score (exact match first) then by name
|
|
538
|
+
results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
539
|
+
return results;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Format symbol for display
|
|
543
|
+
function formatSymbol(symbol) {
|
|
544
|
+
const icon = symbol.type === 'function' ? '𝑓' :
|
|
545
|
+
symbol.type === 'class' ? '◆' :
|
|
546
|
+
symbol.type === 'method' ? '○' :
|
|
547
|
+
symbol.type === 'variable' ? '◇' : '•';
|
|
548
|
+
|
|
549
|
+
let desc = `${icon} ${symbol.name}`;
|
|
550
|
+
if (symbol.params !== undefined) desc += `(${symbol.params})`;
|
|
551
|
+
if (symbol.async) desc = 'async ' + desc;
|
|
552
|
+
if (symbol.extends) desc += ` extends ${symbol.extends}`;
|
|
553
|
+
|
|
554
|
+
return desc;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ═══════════════════════════════════════════════════════════════
|
|
558
|
+
// EMBEDDINGS & SEMANTIC SEARCH
|
|
559
|
+
// ═══════════════════════════════════════════════════════════════
|
|
560
|
+
|
|
561
|
+
// Load or create embeddings store
|
|
562
|
+
function loadEmbeddings() {
|
|
563
|
+
try {
|
|
564
|
+
ensureSapperDir();
|
|
565
|
+
if (fs.existsSync(EMBEDDINGS_FILE)) {
|
|
566
|
+
return JSON.parse(fs.readFileSync(EMBEDDINGS_FILE, 'utf8'));
|
|
567
|
+
}
|
|
568
|
+
} catch (e) {}
|
|
569
|
+
return { chunks: [] }; // { chunks: [{ text, embedding, timestamp }] }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function saveEmbeddings(embeddings) {
|
|
573
|
+
ensureSapperDir();
|
|
574
|
+
fs.writeFileSync(EMBEDDINGS_FILE, JSON.stringify(embeddings, null, 2));
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Get embedding from Ollama (returns null silently if model not available)
|
|
578
|
+
async function getEmbedding(text, model = 'nomic-embed-text') {
|
|
579
|
+
try {
|
|
580
|
+
const response = await ollama.embeddings({ model, prompt: text });
|
|
581
|
+
return response.embedding;
|
|
582
|
+
} catch (e) {
|
|
583
|
+
// Silently return null - caller handles missing embeddings
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Cosine similarity between two vectors
|
|
589
|
+
function cosineSimilarity(a, b) {
|
|
590
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
591
|
+
let dotProduct = 0, normA = 0, normB = 0;
|
|
592
|
+
for (let i = 0; i < a.length; i++) {
|
|
593
|
+
dotProduct += a[i] * b[i];
|
|
594
|
+
normA += a[i] * a[i];
|
|
595
|
+
normB += b[i] * b[i];
|
|
596
|
+
}
|
|
597
|
+
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Find most relevant chunks for a query
|
|
601
|
+
async function findRelevantContext(query, embeddings, topK = 3) {
|
|
602
|
+
const queryEmbedding = await getEmbedding(query);
|
|
603
|
+
if (!queryEmbedding || embeddings.chunks.length === 0) return [];
|
|
604
|
+
|
|
605
|
+
const scored = embeddings.chunks.map(chunk => ({
|
|
606
|
+
...chunk,
|
|
607
|
+
score: cosineSimilarity(queryEmbedding, chunk.embedding)
|
|
608
|
+
}));
|
|
609
|
+
|
|
610
|
+
scored.sort((a, b) => b.score - a.score);
|
|
611
|
+
return scored.slice(0, topK).filter(c => c.score > 0.5); // Only return if similarity > 0.5
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Add text to embeddings store
|
|
615
|
+
async function addToEmbeddings(text, embeddings) {
|
|
616
|
+
const embedding = await getEmbedding(text);
|
|
617
|
+
if (embedding) {
|
|
618
|
+
embeddings.chunks.push({
|
|
619
|
+
text: text.substring(0, 2000), // Limit stored text
|
|
620
|
+
embedding,
|
|
621
|
+
timestamp: Date.now()
|
|
622
|
+
});
|
|
623
|
+
// Keep only last 100 chunks
|
|
624
|
+
if (embeddings.chunks.length > 100) {
|
|
625
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
626
|
+
}
|
|
627
|
+
saveEmbeddings(embeddings);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ═══════════════════════════════════════════════════════════════
|
|
632
|
+
// FANCY UI HELPERS
|
|
633
|
+
// ═══════════════════════════════════════════════════════════════
|
|
634
|
+
|
|
635
|
+
const BANNER = `
|
|
636
|
+
${chalk.cyan(' ███████╗ █████╗ ██████╗ ██████╗ ███████╗██████╗ ')}
|
|
637
|
+
${chalk.cyan(' ██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗')}
|
|
638
|
+
${chalk.cyan(' ███████╗███████║██████╔╝██████╔╝█████╗ ██████╔╝')}
|
|
639
|
+
${chalk.cyan(' ╚════██║██╔══██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗')}
|
|
640
|
+
${chalk.cyan(' ███████║██║ ██║██║ ██║ ███████╗██║ ██║')}
|
|
641
|
+
${chalk.cyan(' ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝')}
|
|
642
|
+
`;
|
|
643
|
+
|
|
644
|
+
function box(content, title = '', color = 'cyan') {
|
|
645
|
+
const lines = content.split('\n');
|
|
646
|
+
const maxLen = Math.max(...lines.map(l => l.length), title.length + 4);
|
|
647
|
+
const colorFn = chalk[color] || chalk.cyan;
|
|
648
|
+
|
|
649
|
+
let result = colorFn('╭' + (title ? `─ ${title} ` : '') + '─'.repeat(maxLen - title.length - (title ? 3 : 0)) + '╮') + '\n';
|
|
650
|
+
for (const line of lines) {
|
|
651
|
+
result += colorFn('│') + ' ' + line.padEnd(maxLen) + ' ' + colorFn('│') + '\n';
|
|
652
|
+
}
|
|
653
|
+
result += colorFn('╰' + '─'.repeat(maxLen + 2) + '╯');
|
|
654
|
+
return result;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function divider(char = '─', color = 'gray') {
|
|
658
|
+
const width = process.stdout.columns || 60;
|
|
659
|
+
return chalk[color](char.repeat(Math.min(width, 60)));
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function statusBadge(text, type = 'info') {
|
|
663
|
+
const badges = {
|
|
664
|
+
info: chalk.bgCyan.black(` ${text} `),
|
|
665
|
+
success: chalk.bgGreen.black(` ${text} `),
|
|
666
|
+
warning: chalk.bgYellow.black(` ${text} `),
|
|
667
|
+
error: chalk.bgRed.white(` ${text} `),
|
|
668
|
+
action: chalk.bgMagenta.white(` ${text} `)
|
|
669
|
+
};
|
|
670
|
+
return badges[type] || badges.info;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Configure marked with terminal renderer
|
|
674
|
+
marked.setOptions({
|
|
675
|
+
renderer: new TerminalRenderer({
|
|
676
|
+
code: chalk.cyan,
|
|
677
|
+
blockquote: chalk.gray.italic,
|
|
678
|
+
html: chalk.gray,
|
|
679
|
+
heading: chalk.bold.cyan,
|
|
680
|
+
firstHeading: chalk.bold.cyan,
|
|
681
|
+
hr: chalk.gray('─'.repeat(40)),
|
|
682
|
+
listitem: chalk.yellow('• ') + '%s',
|
|
683
|
+
table: chalk.white,
|
|
684
|
+
paragraph: chalk.white,
|
|
685
|
+
strong: chalk.bold.white,
|
|
686
|
+
em: chalk.italic,
|
|
687
|
+
codespan: chalk.cyan,
|
|
688
|
+
del: chalk.strikethrough,
|
|
689
|
+
link: chalk.underline.blue,
|
|
690
|
+
href: chalk.gray
|
|
691
|
+
})
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// Render markdown to terminal
|
|
695
|
+
function renderMarkdown(text) {
|
|
696
|
+
try {
|
|
697
|
+
return marked(text).trim();
|
|
698
|
+
} catch (e) {
|
|
699
|
+
return text; // Fallback to raw text
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let stepMode = false;
|
|
704
|
+
let debugMode = false; // Toggle with /debug command
|
|
705
|
+
let abortStream = false; // Flag to interrupt AI response
|
|
706
|
+
let rl = readline.createInterface({
|
|
707
|
+
input: process.stdin,
|
|
708
|
+
output: process.stdout,
|
|
709
|
+
terminal: true,
|
|
710
|
+
historySize: 100
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
function recreateReadline() {
|
|
714
|
+
if (rl) rl.close();
|
|
715
|
+
rl = readline.createInterface({
|
|
716
|
+
input: process.stdin,
|
|
717
|
+
output: process.stdout,
|
|
718
|
+
terminal: true,
|
|
719
|
+
historySize: 100
|
|
720
|
+
});
|
|
721
|
+
// Force resume stdin to keep process alive
|
|
722
|
+
process.stdin.resume();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function safeQuestion(query) {
|
|
726
|
+
resetTerminal(); // Clear terminal state before asking
|
|
727
|
+
if (rl.closed) recreateReadline();
|
|
728
|
+
|
|
729
|
+
return new Promise((resolve) => {
|
|
730
|
+
rl.question(query, (answer) => {
|
|
731
|
+
resolve(answer ? answer.trim() : '');
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Directories to ignore when listing files
|
|
737
|
+
const IGNORE_DIRS = new Set([
|
|
738
|
+
'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
|
|
739
|
+
'.next', '.nuxt', '__pycache__', '.cache', 'coverage',
|
|
740
|
+
'.idea', '.vscode', 'vendor', 'target', '.gradle'
|
|
741
|
+
]);
|
|
742
|
+
|
|
743
|
+
// File extensions to include when scanning codebase
|
|
744
|
+
const CODE_EXTENSIONS = new Set([
|
|
745
|
+
'.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go', '.rs', '.rb', '.php',
|
|
746
|
+
'.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala', '.vue', '.svelte',
|
|
747
|
+
'.css', '.scss', '.sass', '.less', '.html', '.htm', '.json', '.yaml', '.yml',
|
|
748
|
+
'.toml', '.xml', '.md', '.txt', '.sh', '.bash', '.zsh', '.sql', '.graphql',
|
|
749
|
+
'.env.example', '.gitignore', '.dockerignore', 'Dockerfile', 'Makefile',
|
|
750
|
+
'.prisma', '.proto'
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
// Max file size to include (skip large files like bundled/minified)
|
|
754
|
+
const MAX_FILE_SIZE = 100000; // 100KB per file
|
|
755
|
+
const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
|
|
756
|
+
|
|
757
|
+
// Scan entire codebase and return summary
|
|
758
|
+
function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
|
|
759
|
+
if (depth > maxDepth) return { files: [], totalSize: 0 };
|
|
760
|
+
|
|
761
|
+
let files = [];
|
|
762
|
+
let totalSize = 0;
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
766
|
+
|
|
767
|
+
for (const entry of entries) {
|
|
768
|
+
const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
769
|
+
|
|
770
|
+
// Skip ignored directories
|
|
771
|
+
if (entry.isDirectory()) {
|
|
772
|
+
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
773
|
+
const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
|
|
774
|
+
files = files.concat(subResult.files);
|
|
775
|
+
totalSize += subResult.totalSize;
|
|
776
|
+
} else {
|
|
777
|
+
// Check if file should be included
|
|
778
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
|
|
779
|
+
const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
|
|
780
|
+
|
|
781
|
+
if (!isCodeFile) continue;
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
const stats = fs.statSync(fullPath);
|
|
785
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
786
|
+
files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (totalSize + stats.size > MAX_TOTAL_SCAN_SIZE) {
|
|
790
|
+
files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
795
|
+
files.push({ path: fullPath, size: stats.size, content });
|
|
796
|
+
totalSize += stats.size;
|
|
797
|
+
} catch (e) {
|
|
798
|
+
files.push({ path: fullPath, skipped: true, reason: e.message });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
} catch (e) {
|
|
803
|
+
// Directory not readable
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return { files, totalSize };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Scan directory for files (for @ file picker)
|
|
810
|
+
function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
|
|
811
|
+
let files = [];
|
|
812
|
+
try {
|
|
813
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
if (files.length >= maxFiles) break;
|
|
816
|
+
if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
817
|
+
|
|
818
|
+
const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
819
|
+
|
|
820
|
+
if (entry.isDirectory()) {
|
|
821
|
+
files.push({ path: fullPath + '/', isDir: true });
|
|
822
|
+
// Recurse one level for common structures
|
|
823
|
+
const subFiles = getFilesForPicker(`${dir}/${entry.name}`, fullPath, 20);
|
|
824
|
+
files = files.concat(subFiles.slice(0, 15)); // Limit subdirectory files
|
|
825
|
+
} else {
|
|
826
|
+
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
827
|
+
if (CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name)) {
|
|
828
|
+
try {
|
|
829
|
+
const stats = fs.statSync(`${dir}/${entry.name}`);
|
|
830
|
+
files.push({ path: fullPath, isDir: false, size: stats.size });
|
|
831
|
+
} catch (e) {
|
|
832
|
+
files.push({ path: fullPath, isDir: false, size: 0 });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} catch (e) {}
|
|
838
|
+
return files.slice(0, maxFiles);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Interactive file picker with arrow keys
|
|
842
|
+
async function pickFiles() {
|
|
843
|
+
const files = getFilesForPicker('.', '', 50).filter(f => !f.isDir);
|
|
844
|
+
|
|
845
|
+
if (files.length === 0) {
|
|
846
|
+
console.log(chalk.yellow('No code files found in current directory.'));
|
|
847
|
+
return [];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const selected = new Set();
|
|
851
|
+
let cursor = 0;
|
|
852
|
+
const pageSize = Math.min(15, process.stdout.rows - 10 || 15);
|
|
853
|
+
|
|
854
|
+
// Enable raw mode for key capture
|
|
855
|
+
if (process.stdin.isTTY) {
|
|
856
|
+
process.stdin.setRawMode(true);
|
|
857
|
+
}
|
|
858
|
+
process.stdin.resume();
|
|
859
|
+
|
|
860
|
+
const renderList = () => {
|
|
861
|
+
// Clear screen and move cursor to top
|
|
862
|
+
console.clear();
|
|
863
|
+
console.log(box(
|
|
864
|
+
`${chalk.cyan('↑↓')} Navigate ${chalk.cyan('Space')} Toggle ${chalk.cyan('a')} All ${chalk.cyan('Enter')} Confirm ${chalk.cyan('q/Esc')} Cancel`,
|
|
865
|
+
'📎 Select Files', 'cyan'
|
|
866
|
+
));
|
|
867
|
+
console.log();
|
|
868
|
+
|
|
869
|
+
// Calculate visible range (pagination)
|
|
870
|
+
const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), files.length - pageSize));
|
|
871
|
+
const endIdx = Math.min(startIdx + pageSize, files.length);
|
|
872
|
+
|
|
873
|
+
// Show scroll indicator if needed
|
|
874
|
+
if (startIdx > 0) {
|
|
875
|
+
console.log(chalk.gray(' ↑ more files above...'));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
879
|
+
const file = files[i];
|
|
880
|
+
const isSelected = selected.has(i);
|
|
881
|
+
const isCursor = i === cursor;
|
|
882
|
+
|
|
883
|
+
const checkbox = isSelected ? chalk.green('◉') : chalk.gray('○');
|
|
884
|
+
const prefix = isCursor ? chalk.cyan('▸ ') : ' ';
|
|
885
|
+
const name = isCursor ? chalk.cyan.bold(file.path) : chalk.white(file.path);
|
|
886
|
+
const size = file.size ? chalk.gray(` (${Math.round(file.size/1024)}KB)`) : '';
|
|
887
|
+
|
|
888
|
+
console.log(`${prefix}${checkbox} ${name}${size}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (endIdx < files.length) {
|
|
892
|
+
console.log(chalk.gray(' ↓ more files below...'));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
console.log();
|
|
896
|
+
console.log(chalk.gray(` Selected: ${selected.size} file${selected.size !== 1 ? 's' : ''}`));
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
return new Promise((resolve) => {
|
|
900
|
+
renderList();
|
|
901
|
+
|
|
902
|
+
const onKeypress = (chunk, key) => {
|
|
903
|
+
if (!key) {
|
|
904
|
+
// Handle raw chunk for arrow keys
|
|
905
|
+
const str = chunk.toString();
|
|
906
|
+
if (str === '\x1b[A') key = { name: 'up' };
|
|
907
|
+
else if (str === '\x1b[B') key = { name: 'down' };
|
|
908
|
+
else if (str === '\x1b[C') key = { name: 'right' };
|
|
909
|
+
else if (str === '\x1b[D') key = { name: 'left' };
|
|
910
|
+
else if (str === ' ') key = { name: 'space' };
|
|
911
|
+
else if (str === '\r' || str === '\n') key = { name: 'return' };
|
|
912
|
+
else if (str === '\x1b' || str === 'q') key = { name: 'escape' };
|
|
913
|
+
else if (str === 'a' || str === 'A') key = { name: 'a' };
|
|
914
|
+
else if (str === '\x03') key = { name: 'c', ctrl: true }; // Ctrl+C
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!key) return;
|
|
918
|
+
|
|
919
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
920
|
+
cursor = cursor > 0 ? cursor - 1 : files.length - 1;
|
|
921
|
+
renderList();
|
|
922
|
+
} else if (key.name === 'down' || key.name === 'j') {
|
|
923
|
+
cursor = cursor < files.length - 1 ? cursor + 1 : 0;
|
|
924
|
+
renderList();
|
|
925
|
+
} else if (key.name === 'space' || key.name === 'right') {
|
|
926
|
+
if (selected.has(cursor)) {
|
|
927
|
+
selected.delete(cursor);
|
|
928
|
+
} else {
|
|
929
|
+
selected.add(cursor);
|
|
930
|
+
}
|
|
931
|
+
renderList();
|
|
932
|
+
} else if (key.name === 'a') {
|
|
933
|
+
// Toggle all
|
|
934
|
+
if (selected.size === files.length) {
|
|
935
|
+
selected.clear();
|
|
936
|
+
} else {
|
|
937
|
+
for (let i = 0; i < files.length; i++) selected.add(i);
|
|
938
|
+
}
|
|
939
|
+
renderList();
|
|
940
|
+
} else if (key.name === 'return') {
|
|
941
|
+
cleanup();
|
|
942
|
+
const selectedFiles = Array.from(selected).map(i => files[i].path);
|
|
943
|
+
console.log(chalk.green(`\n✓ Selected ${selectedFiles.length} file${selectedFiles.length !== 1 ? 's' : ''}`));
|
|
944
|
+
resolve(selectedFiles);
|
|
945
|
+
} else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
946
|
+
cleanup();
|
|
947
|
+
console.log(chalk.gray('\nCancelled.'));
|
|
948
|
+
resolve([]);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
const cleanup = () => {
|
|
953
|
+
process.stdin.removeListener('data', onKeypress);
|
|
954
|
+
if (process.stdin.isTTY) {
|
|
955
|
+
process.stdin.setRawMode(false);
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
process.stdin.on('data', onKeypress);
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Format scan results for AI context
|
|
964
|
+
function formatScanResults(scanResult) {
|
|
965
|
+
let output = `\n══════════════════════════════════════\n`;
|
|
966
|
+
output += `📁 CODEBASE SCAN (${scanResult.files.length} files, ~${Math.round(scanResult.totalSize/1024)}KB)\n`;
|
|
967
|
+
output += `══════════════════════════════════════\n\n`;
|
|
968
|
+
|
|
969
|
+
// First list all files
|
|
970
|
+
output += `FILE TREE:\n`;
|
|
971
|
+
for (const file of scanResult.files) {
|
|
972
|
+
if (file.skipped) {
|
|
973
|
+
output += ` ⏭️ ${file.path} (skipped: ${file.reason})\n`;
|
|
974
|
+
} else {
|
|
975
|
+
output += ` 📄 ${file.path} (${Math.round(file.size/1024)}KB)\n`;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
output += `\n══════════════════════════════════════\n`;
|
|
980
|
+
output += `FILE CONTENTS:\n`;
|
|
981
|
+
output += `══════════════════════════════════════\n\n`;
|
|
982
|
+
|
|
983
|
+
// Then include contents
|
|
984
|
+
for (const file of scanResult.files) {
|
|
985
|
+
if (file.skipped) continue;
|
|
986
|
+
output += `┌─── ${file.path} ───\n`;
|
|
987
|
+
output += file.content;
|
|
988
|
+
if (!file.content.endsWith('\n')) output += '\n';
|
|
989
|
+
output += `└─── END ${file.path} ───\n\n`;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return output;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const tools = {
|
|
996
|
+
read: (path) => {
|
|
997
|
+
try { return fs.readFileSync(path.trim(), 'utf8'); }
|
|
998
|
+
catch (error) { return `Error reading file: ${error.message}`; }
|
|
999
|
+
},
|
|
1000
|
+
patch: async (path, oldText, newText) => {
|
|
1001
|
+
const trimmedPath = path.trim();
|
|
1002
|
+
try {
|
|
1003
|
+
const content = fs.readFileSync(trimmedPath, 'utf8');
|
|
1004
|
+
if (!content.includes(oldText)) {
|
|
1005
|
+
return `Error: Could not find the text to replace in ${trimmedPath}. Make sure oldText matches exactly (including whitespace).`;
|
|
1006
|
+
}
|
|
1007
|
+
const newContent = content.replace(oldText, newText);
|
|
1008
|
+
|
|
1009
|
+
// Show diff preview
|
|
1010
|
+
console.log();
|
|
1011
|
+
const diffContent =
|
|
1012
|
+
`${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
|
|
1013
|
+
chalk.gray('─'.repeat(40)) + '\n' +
|
|
1014
|
+
chalk.red('- ' + oldText.split('\n').join('\n- ')) + '\n' +
|
|
1015
|
+
chalk.green('+ ' + newText.split('\n').join('\n+ '));
|
|
1016
|
+
console.log(box(diffContent, '🔧 Patch', 'yellow'));
|
|
1017
|
+
|
|
1018
|
+
const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
|
|
1019
|
+
if (confirm.toLowerCase() === 'y') {
|
|
1020
|
+
fs.writeFileSync(trimmedPath, newContent);
|
|
1021
|
+
return `Successfully patched ${trimmedPath}`;
|
|
1022
|
+
}
|
|
1023
|
+
return 'Patch rejected by user.';
|
|
1024
|
+
} catch (error) { return `Error patching file: ${error.message}`; }
|
|
1025
|
+
},
|
|
1026
|
+
write: async (path, content) => {
|
|
1027
|
+
const trimmedPath = path.trim();
|
|
1028
|
+
console.log();
|
|
1029
|
+
console.log(box(
|
|
1030
|
+
`${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
|
|
1031
|
+
`${chalk.white('Size:')} ${content?.length || 0} chars\n` +
|
|
1032
|
+
chalk.gray('─'.repeat(40)) + '\n' +
|
|
1033
|
+
chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
|
|
1034
|
+
'✏️ Write File', 'yellow'
|
|
1035
|
+
));
|
|
1036
|
+
const confirm = await safeQuestion(chalk.yellow('\n↪ Allow write? ') + chalk.gray('(y/n): '));
|
|
1037
|
+
if (confirm.toLowerCase() === 'y') {
|
|
1038
|
+
try {
|
|
1039
|
+
fs.writeFileSync(trimmedPath, content);
|
|
1040
|
+
return `Successfully saved changes to ${trimmedPath}`;
|
|
1041
|
+
} catch (error) { return `Error writing file: ${error.message}`; }
|
|
1042
|
+
}
|
|
1043
|
+
return "Write blocked by user.";
|
|
1044
|
+
},
|
|
1045
|
+
mkdir: (path) => {
|
|
1046
|
+
try {
|
|
1047
|
+
fs.mkdirSync(path.trim(), { recursive: true });
|
|
1048
|
+
return `Directory created: ${path}`;
|
|
1049
|
+
} catch (error) { return `Error creating directory: ${error.message}`; }
|
|
1050
|
+
},
|
|
1051
|
+
shell: async (cmd) => {
|
|
1052
|
+
console.log();
|
|
1053
|
+
console.log(box(
|
|
1054
|
+
chalk.white.bold(cmd),
|
|
1055
|
+
'🔐 Shell Command', 'red'
|
|
1056
|
+
));
|
|
1057
|
+
const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
|
|
1058
|
+
if (confirm.toLowerCase() === 'y') {
|
|
1059
|
+
return new Promise((resolve) => {
|
|
1060
|
+
const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
|
|
1061
|
+
console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
|
|
1062
|
+
const proc = spawn(useShell ? 'sh' : cmd.split(' ')[0], useShell ? ['-c', cmd] : cmd.split(' ').slice(1), {
|
|
1063
|
+
stdio: 'inherit', shell: useShell
|
|
1064
|
+
});
|
|
1065
|
+
proc.on('close', (code) => {
|
|
1066
|
+
// Crucial: give control back to Node
|
|
1067
|
+
if (process.stdin.isTTY) {
|
|
1068
|
+
try { process.stdin.setRawMode(false); } catch (e) {}
|
|
1069
|
+
}
|
|
1070
|
+
// Delay slightly to let terminal settle
|
|
1071
|
+
setTimeout(() => {
|
|
1072
|
+
recreateReadline();
|
|
1073
|
+
resolve(`Command completed with code ${code}`);
|
|
1074
|
+
}, 200);
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return "Command blocked by user.";
|
|
1079
|
+
},
|
|
1080
|
+
list: (path) => {
|
|
1081
|
+
try {
|
|
1082
|
+
let dir = path.trim() || '.';
|
|
1083
|
+
// If AI sends "/" (root), treat as current directory "."
|
|
1084
|
+
if (dir === '/') dir = '.';
|
|
1085
|
+
const entries = fs.readdirSync(dir);
|
|
1086
|
+
// Filter out ignored directories
|
|
1087
|
+
const filtered = entries.filter(entry => {
|
|
1088
|
+
if (IGNORE_DIRS.has(entry)) return false;
|
|
1089
|
+
// Also skip hidden files/folders (starting with .) except current dir
|
|
1090
|
+
if (entry.startsWith('.') && entry !== '.') return false;
|
|
1091
|
+
return true;
|
|
1092
|
+
});
|
|
1093
|
+
return filtered.length > 0 ? filtered.join('\n') : '(empty or all files filtered)';
|
|
1094
|
+
} catch (e) { return `Error: ${e.message}`; }
|
|
1095
|
+
},
|
|
1096
|
+
search: (pattern) => {
|
|
1097
|
+
return new Promise((resolve) => {
|
|
1098
|
+
const excludeDirs = Array.from(IGNORE_DIRS).join(',');
|
|
1099
|
+
// Use grep to search for pattern, excluding ignored directories
|
|
1100
|
+
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`;
|
|
1101
|
+
|
|
1102
|
+
const proc = spawn('sh', ['-c', cmd], { cwd: process.cwd() });
|
|
1103
|
+
let output = '';
|
|
1104
|
+
|
|
1105
|
+
proc.stdout.on('data', (data) => { output += data.toString(); });
|
|
1106
|
+
proc.stderr.on('data', (data) => { output += data.toString(); });
|
|
1107
|
+
|
|
1108
|
+
proc.on('close', () => {
|
|
1109
|
+
if (output.trim()) {
|
|
1110
|
+
resolve(`Found matches:\n${output.trim()}`);
|
|
1111
|
+
} else {
|
|
1112
|
+
resolve(`No matches found for: ${pattern}`);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
async function checkForUpdates() {
|
|
1120
|
+
try {
|
|
1121
|
+
const response = await fetch('https://registry.npmjs.org/sapper-iq/latest');
|
|
1122
|
+
const data = await response.json();
|
|
1123
|
+
const latestVersion = data.version;
|
|
1124
|
+
|
|
1125
|
+
if (latestVersion && latestVersion !== CURRENT_VERSION) {
|
|
1126
|
+
console.log(chalk.yellow('🔄 UPDATE AVAILABLE!'));
|
|
1127
|
+
console.log(chalk.gray(` Current: v${CURRENT_VERSION}`));
|
|
1128
|
+
console.log(chalk.green(` Latest: v${latestVersion}`));
|
|
1129
|
+
console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
|
|
1130
|
+
}
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
// Silently fail if update check fails
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function runSapper() {
|
|
1137
|
+
console.clear();
|
|
1138
|
+
console.log(BANNER);
|
|
1139
|
+
console.log(chalk.gray.dim(' ') + chalk.white.bold(`v${CURRENT_VERSION}`) + chalk.gray(' │ ') + chalk.cyan('Autonomous AI Coding Agent'));
|
|
1140
|
+
console.log(chalk.gray.dim(' ') + chalk.gray('📁 ') + chalk.white(process.cwd()));
|
|
1141
|
+
console.log();
|
|
1142
|
+
|
|
1143
|
+
// Quick tips box
|
|
1144
|
+
console.log(box(
|
|
1145
|
+
`${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
|
|
1146
|
+
`${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
|
|
1147
|
+
`${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
|
|
1148
|
+
'Quick Tips', 'gray'
|
|
1149
|
+
));
|
|
1150
|
+
console.log();
|
|
1151
|
+
|
|
1152
|
+
// Check for updates
|
|
1153
|
+
await checkForUpdates();
|
|
1154
|
+
|
|
1155
|
+
// Auto-load or build workspace graph
|
|
1156
|
+
let workspace = loadWorkspaceGraph();
|
|
1157
|
+
if (!workspace.indexed) {
|
|
1158
|
+
console.log(chalk.cyan('📊 Building workspace index with AST parsing...'));
|
|
1159
|
+
workspace = await buildWorkspaceGraph();
|
|
1160
|
+
const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
1161
|
+
console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files, ${totalSymbols} symbols\n`));
|
|
1162
|
+
} else {
|
|
1163
|
+
const fileCount = Object.keys(workspace.files).length;
|
|
1164
|
+
const symbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
1165
|
+
const indexAge = Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60);
|
|
1166
|
+
console.log(chalk.gray(`📊 Workspace: ${fileCount} files, ${symbolCount} symbols (${indexAge}m ago)`));
|
|
1167
|
+
if (indexAge > 60) {
|
|
1168
|
+
console.log(chalk.yellow(` Tip: Run /index to refresh`));
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Show memory status
|
|
1173
|
+
console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
|
|
1174
|
+
console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)\n`));
|
|
1175
|
+
|
|
1176
|
+
let messages = [];
|
|
1177
|
+
if (fs.existsSync(CONTEXT_FILE)) {
|
|
1178
|
+
console.log();
|
|
1179
|
+
console.log(box('Previous session found! Resume where you left off?', '📂 Session', 'green'));
|
|
1180
|
+
const resume = await safeQuestion(chalk.green('\n↪ Resume? ') + chalk.gray('(y/n): '));
|
|
1181
|
+
if (resume.toLowerCase() === 'y') {
|
|
1182
|
+
messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
|
|
1183
|
+
console.log(chalk.green(' ✓ Session restored\n'));
|
|
1184
|
+
} else {
|
|
1185
|
+
fs.unlinkSync(CONTEXT_FILE);
|
|
1186
|
+
console.log(chalk.gray(' ✓ Starting fresh...\n'));
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Migrate old files to new .sapper/ folder
|
|
1191
|
+
const oldFiles = ['.sapper_context.json', '.sapper_embeddings.json', '.sapper_workspace.json'];
|
|
1192
|
+
for (const oldFile of oldFiles) {
|
|
1193
|
+
if (fs.existsSync(oldFile)) {
|
|
1194
|
+
ensureSapperDir();
|
|
1195
|
+
const newFile = `${SAPPER_DIR}/${oldFile.replace('.sapper_', '').replace('_', '.')}`;
|
|
1196
|
+
if (!fs.existsSync(newFile)) {
|
|
1197
|
+
fs.renameSync(oldFile, newFile);
|
|
1198
|
+
console.log(chalk.gray(`📦 Migrated ${oldFile} → ${newFile}`));
|
|
1199
|
+
} else {
|
|
1200
|
+
fs.unlinkSync(oldFile);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
let localModels;
|
|
1206
|
+
try {
|
|
1207
|
+
localModels = await ollama.list();
|
|
1208
|
+
} catch (e) {
|
|
1209
|
+
console.error(chalk.red('\n❌ Cannot connect to Ollama!'));
|
|
1210
|
+
console.log(chalk.yellow(' Make sure Ollama is running: ') + chalk.cyan('ollama serve'));
|
|
1211
|
+
console.log(chalk.gray(' Or install from: https://ollama.ai\n'));
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (!localModels.models || localModels.models.length === 0) {
|
|
1216
|
+
console.error(chalk.red('\n❌ No models found!'));
|
|
1217
|
+
console.log(chalk.yellow(' Pull a model first: ') + chalk.cyan('ollama pull llama3.2'));
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
console.log(divider());
|
|
1222
|
+
console.log(statusBadge('MODELS', 'info') + chalk.gray(' Available Ollama models:\n'));
|
|
1223
|
+
localModels.models.forEach((m, i) => {
|
|
1224
|
+
const num = chalk.cyan.bold(`[${i + 1}]`);
|
|
1225
|
+
const name = chalk.white(m.name);
|
|
1226
|
+
console.log(` ${num} ${name}`);
|
|
1227
|
+
});
|
|
1228
|
+
console.log(divider());
|
|
1229
|
+
const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
|
|
1230
|
+
const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
|
|
1231
|
+
|
|
1232
|
+
if (messages.length === 0) {
|
|
1233
|
+
messages = [{
|
|
1234
|
+
role: 'system',
|
|
1235
|
+
content: `You are Sapper, a high-level Autonomous Software Engineer.
|
|
1236
|
+
Your goal is to solve the user's request by interacting with the filesystem and shell.
|
|
1237
|
+
|
|
1238
|
+
RULES:
|
|
1239
|
+
1. EXPLORE FIRST: Use LIST and READ to understand the codebase before making changes.
|
|
1240
|
+
2. THINK IN STEPS: Explain what you found and what you plan to do before executing tools.
|
|
1241
|
+
3. BE PRECISE: When using PATCH, ensure the 'oldText' matches exactly.
|
|
1242
|
+
4. VERIFY: After writing code, use the SHELL tool to run tests or linting.
|
|
1243
|
+
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.
|
|
1244
|
+
|
|
1245
|
+
TOOL SYNTAX:
|
|
1246
|
+
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
1247
|
+
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
1248
|
+
- [TOOL:SEARCH]pattern[/TOOL] - Search codebase for pattern
|
|
1249
|
+
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
1250
|
+
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file
|
|
1251
|
+
- [TOOL:SHELL]command[/TOOL] - Run shell command`
|
|
1252
|
+
}];
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Main conversation loop - never exits unless user types 'exit'
|
|
1256
|
+
while (true) {
|
|
1257
|
+
try {
|
|
1258
|
+
// Context size warning - large context causes hangs
|
|
1259
|
+
const contextSize = JSON.stringify(messages).length;
|
|
1260
|
+
if (contextSize > 32000) {
|
|
1261
|
+
console.log();
|
|
1262
|
+
console.log(box(
|
|
1263
|
+
`Context is ${chalk.red.bold(Math.round(contextSize/1024) + 'KB')} - this may cause slowdowns!\n` +
|
|
1264
|
+
`${chalk.yellow('Tip:')} Type ${chalk.cyan('/prune')} to reduce context size`,
|
|
1265
|
+
'⚠️ Warning', 'yellow'
|
|
1266
|
+
));
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
|
|
1270
|
+
|
|
1271
|
+
if (input.toLowerCase() === 'exit') process.exit();
|
|
1272
|
+
|
|
1273
|
+
// Handle reset command
|
|
1274
|
+
if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
|
|
1275
|
+
if (fs.existsSync(CONTEXT_FILE)) {
|
|
1276
|
+
fs.unlinkSync(CONTEXT_FILE);
|
|
1277
|
+
console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
|
|
1278
|
+
}
|
|
1279
|
+
messages = [{
|
|
1280
|
+
role: 'system',
|
|
1281
|
+
content: messages[0].content // Keep system prompt
|
|
1282
|
+
}];
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// Handle prune command - AUTO-EMBED then clear old context
|
|
1287
|
+
if (input.toLowerCase() === '/prune') {
|
|
1288
|
+
if (messages.length <= 5) {
|
|
1289
|
+
console.log(chalk.yellow('Context is already small, nothing to prune.'));
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// 1. AUTO-EMBED: Save conversation to memory BEFORE pruning (silently skip if no model)
|
|
1294
|
+
const embeddings = loadEmbeddings();
|
|
1295
|
+
|
|
1296
|
+
// Get messages that will be pruned (all except system and last 4)
|
|
1297
|
+
const messagesToEmbed = messages.slice(1, -4)
|
|
1298
|
+
.filter(m => m.role !== 'system')
|
|
1299
|
+
.map(m => m.content.substring(0, 500))
|
|
1300
|
+
.join('\n---\n');
|
|
1301
|
+
|
|
1302
|
+
if (messagesToEmbed.length > 50) {
|
|
1303
|
+
try {
|
|
1304
|
+
const embedding = await getEmbedding(messagesToEmbed);
|
|
1305
|
+
if (embedding) {
|
|
1306
|
+
embeddings.chunks.push({
|
|
1307
|
+
text: messagesToEmbed.substring(0, 2000),
|
|
1308
|
+
embedding,
|
|
1309
|
+
timestamp: Date.now()
|
|
1310
|
+
});
|
|
1311
|
+
if (embeddings.chunks.length > 100) {
|
|
1312
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
1313
|
+
}
|
|
1314
|
+
saveEmbeddings(embeddings);
|
|
1315
|
+
console.log(chalk.green(`🧠 Saved to memory! (${embeddings.chunks.length} memories)`));
|
|
1316
|
+
}
|
|
1317
|
+
} catch (e) {
|
|
1318
|
+
// Silently skip embedding if model not available - prune still works
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// 2. Capture the ORIGINAL detailed system prompt from the very first message
|
|
1323
|
+
const originalSystemPrompt = messages[0];
|
|
1324
|
+
|
|
1325
|
+
// 3. Capture the last 4 messages (the most recent conversation)
|
|
1326
|
+
const recentMessages = messages.slice(-4);
|
|
1327
|
+
|
|
1328
|
+
// 4. Rebuild the messages array starting with the ORIGINAL prompt
|
|
1329
|
+
messages = [originalSystemPrompt, ...recentMessages];
|
|
1330
|
+
|
|
1331
|
+
// 4. Add reminder to stay in Agent Mode (not chatbot mode)
|
|
1332
|
+
messages.push({
|
|
1333
|
+
role: 'system',
|
|
1334
|
+
content: `CONTEXT PRUNED. REMINDER: You are Sapper, an Autonomous Software Engineer.
|
|
1335
|
+
|
|
1336
|
+
RULES:
|
|
1337
|
+
1. EXPLORE FIRST: Use LIST and READ before making changes.
|
|
1338
|
+
2. THINK IN STEPS: Explain your plan before executing tools.
|
|
1339
|
+
3. BE PRECISE: When using PATCH, ensure 'oldText' matches exactly.
|
|
1340
|
+
4. VERIFY: Run tests or linting after writing code.
|
|
1341
|
+
5. NO HALLUCINATIONS: Don't guess file contents.
|
|
1342
|
+
|
|
1343
|
+
TOOL SYNTAX:
|
|
1344
|
+
- [TOOL:LIST]dir[/TOOL]
|
|
1345
|
+
- [TOOL:READ]file_path[/TOOL]
|
|
1346
|
+
- [TOOL:SEARCH]pattern[/TOOL]
|
|
1347
|
+
- [TOOL:WRITE]path:::content[/TOOL]
|
|
1348
|
+
- [TOOL:PATCH]path:::old|||new[/TOOL]
|
|
1349
|
+
- [TOOL:SHELL]command[/TOOL]`
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
// 5. Save to context file so it persists
|
|
1353
|
+
ensureSapperDir();
|
|
1354
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
1355
|
+
|
|
1356
|
+
console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
|
|
1357
|
+
console.log(chalk.gray(`Context size: ${messages.length} messages\n`));
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Handle help command
|
|
1362
|
+
if (input.toLowerCase() === '/help') {
|
|
1363
|
+
console.log();
|
|
1364
|
+
const helpContent =
|
|
1365
|
+
`${chalk.cyan('@')} or ${chalk.cyan('/attach')} ${chalk.gray('│')} Pick files to attach (interactive)\n` +
|
|
1366
|
+
`${chalk.cyan('@file')} ${chalk.gray('│')} Attach file inline (e.g., @src/app.js)\n` +
|
|
1367
|
+
`${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
|
|
1368
|
+
`${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
|
|
1369
|
+
`${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
|
|
1370
|
+
`${chalk.cyan('/symbol name')} ${chalk.gray('│')} Search functions/classes\n` +
|
|
1371
|
+
`${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
|
|
1372
|
+
`${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
|
|
1373
|
+
`${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
|
|
1374
|
+
`${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
|
|
1375
|
+
`${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
|
|
1376
|
+
`${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
|
|
1377
|
+
`${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
|
|
1378
|
+
`${chalk.cyan('exit')} ${chalk.gray('│')} Quit Sapper`;
|
|
1379
|
+
console.log(box(helpContent, '📚 Commands', 'cyan'));
|
|
1380
|
+
console.log();
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Handle index command - rebuild workspace graph
|
|
1385
|
+
if (input.toLowerCase() === '/index') {
|
|
1386
|
+
console.log(chalk.cyan('\n📊 Rebuilding workspace index with AST parsing...'));
|
|
1387
|
+
workspace = await buildWorkspaceGraph();
|
|
1388
|
+
const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
1389
|
+
console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files`));
|
|
1390
|
+
console.log(chalk.gray(` 📦 ${totalSymbols} symbols (functions, classes, variables)`));
|
|
1391
|
+
console.log(chalk.gray(` 🔗 ${Object.values(workspace.graph).flat().length} dependencies tracked\n`));
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Handle symbol search command
|
|
1396
|
+
if (input.toLowerCase().startsWith('/symbol')) {
|
|
1397
|
+
const query = input.slice(7).trim();
|
|
1398
|
+
if (!query) {
|
|
1399
|
+
// Show all symbols summary
|
|
1400
|
+
const allSymbols = [];
|
|
1401
|
+
for (const [file, info] of Object.entries(workspace.files)) {
|
|
1402
|
+
for (const sym of info.symbols || []) {
|
|
1403
|
+
allSymbols.push({ ...sym, file });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Group by type
|
|
1408
|
+
const functions = allSymbols.filter(s => s.type === 'function');
|
|
1409
|
+
const classes = allSymbols.filter(s => s.type === 'class');
|
|
1410
|
+
const methods = allSymbols.filter(s => s.type === 'method');
|
|
1411
|
+
|
|
1412
|
+
console.log();
|
|
1413
|
+
console.log(box(
|
|
1414
|
+
`${chalk.cyan('Functions:')} ${functions.length}\n` +
|
|
1415
|
+
`${chalk.cyan('Classes:')} ${classes.length}\n` +
|
|
1416
|
+
`${chalk.cyan('Methods:')} ${methods.length}\n` +
|
|
1417
|
+
chalk.gray('─'.repeat(30)) + '\n' +
|
|
1418
|
+
chalk.gray('Usage: /symbol <name> to search'),
|
|
1419
|
+
'📦 Symbol Index', 'cyan'
|
|
1420
|
+
));
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
console.log(chalk.cyan(`\n🔍 Searching for: "${query}"...\n`));
|
|
1425
|
+
const results = searchSymbol(query, workspace);
|
|
1426
|
+
|
|
1427
|
+
if (results.length === 0) {
|
|
1428
|
+
console.log(chalk.yellow(`No symbols found matching "${query}"`));
|
|
1429
|
+
console.log(chalk.gray('Tip: Run /index to refresh symbol index'));
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
console.log(chalk.green(`Found ${results.length} symbol${results.length !== 1 ? 's' : ''}:\n`));
|
|
1434
|
+
|
|
1435
|
+
for (const sym of results.slice(0, 15)) {
|
|
1436
|
+
const typeIcon = sym.type === 'function' ? chalk.yellow('𝑓') :
|
|
1437
|
+
sym.type === 'class' ? chalk.blue('◆') :
|
|
1438
|
+
sym.type === 'method' ? chalk.cyan('○') : chalk.gray('◇');
|
|
1439
|
+
const asyncTag = sym.async ? chalk.magenta('async ') : '';
|
|
1440
|
+
const params = sym.params !== undefined ? chalk.gray(`(${sym.params})`) : '';
|
|
1441
|
+
|
|
1442
|
+
console.log(` ${typeIcon} ${asyncTag}${chalk.white.bold(sym.name)}${params}`);
|
|
1443
|
+
console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
if (results.length > 15) {
|
|
1447
|
+
console.log(chalk.gray(`\n ... and ${results.length - 15} more`));
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Offer to add file to context
|
|
1451
|
+
if (results.length > 0) {
|
|
1452
|
+
console.log();
|
|
1453
|
+
const addToCtx = await safeQuestion(chalk.yellow('Add first match file to context? ') + chalk.gray('(y/n): '));
|
|
1454
|
+
if (addToCtx.toLowerCase() === 'y') {
|
|
1455
|
+
const targetFile = results[0].file;
|
|
1456
|
+
try {
|
|
1457
|
+
const content = fs.readFileSync(targetFile, 'utf8');
|
|
1458
|
+
messages.push({
|
|
1459
|
+
role: 'user',
|
|
1460
|
+
content: `Here is ${targetFile} (contains ${results[0].type} "${results[0].name}" at line ${results[0].line}):\n\n${content}`
|
|
1461
|
+
});
|
|
1462
|
+
console.log(chalk.green(`✅ Added ${targetFile} to context`));
|
|
1463
|
+
} catch (e) {
|
|
1464
|
+
console.log(chalk.red(`Could not read ${targetFile}`));
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Handle graph command - show related files
|
|
1472
|
+
if (input.toLowerCase().startsWith('/graph')) {
|
|
1473
|
+
const targetFile = input.slice(6).trim();
|
|
1474
|
+
if (!targetFile) {
|
|
1475
|
+
// Show workspace overview
|
|
1476
|
+
console.log(formatWorkspaceSummary(workspace));
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Find file (support partial match)
|
|
1481
|
+
const matchingFile = Object.keys(workspace.files).find(f =>
|
|
1482
|
+
f === targetFile || f.endsWith('/' + targetFile) || f.endsWith(targetFile)
|
|
1483
|
+
);
|
|
1484
|
+
|
|
1485
|
+
if (!matchingFile) {
|
|
1486
|
+
console.log(chalk.yellow(`File not found in index: ${targetFile}`));
|
|
1487
|
+
console.log(chalk.gray('Tip: Run /index to refresh workspace graph'));
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const fileInfo = workspace.files[matchingFile];
|
|
1492
|
+
const related = getRelatedFiles(matchingFile, workspace);
|
|
1493
|
+
|
|
1494
|
+
console.log();
|
|
1495
|
+
console.log(box(
|
|
1496
|
+
`${chalk.white('File:')} ${chalk.cyan(matchingFile)}\n` +
|
|
1497
|
+
`${chalk.white('Size:')} ${Math.round(fileInfo.size/1024)}KB\n` +
|
|
1498
|
+
`${chalk.white('Exports:')} ${fileInfo.exports?.join(', ') || 'none'}\n` +
|
|
1499
|
+
`${chalk.white('Imports:')} ${fileInfo.imports?.join(', ') || 'none'}\n` +
|
|
1500
|
+
chalk.gray('─'.repeat(40)) + '\n' +
|
|
1501
|
+
`${chalk.white('Related files:')}\n` +
|
|
1502
|
+
(related.length > 0
|
|
1503
|
+
? related.map(r => ` 📄 ${r}`).join('\n')
|
|
1504
|
+
: chalk.gray(' (no related files found)')),
|
|
1505
|
+
'🔗 File Graph', 'cyan'
|
|
1506
|
+
));
|
|
1507
|
+
console.log();
|
|
1508
|
+
|
|
1509
|
+
// Offer to add to context
|
|
1510
|
+
if (related.length > 0) {
|
|
1511
|
+
const addRelated = await safeQuestion(chalk.yellow('Add this file + related to context? ') + chalk.gray('(y/n): '));
|
|
1512
|
+
if (addRelated.toLowerCase() === 'y') {
|
|
1513
|
+
let contextContent = `\n📄 ${matchingFile}:\n`;
|
|
1514
|
+
contextContent += fs.readFileSync(matchingFile, 'utf8');
|
|
1515
|
+
|
|
1516
|
+
for (const relFile of related.slice(0, 5)) { // Limit to 5 related
|
|
1517
|
+
try {
|
|
1518
|
+
contextContent += `\n\n📄 ${relFile} (related):\n`;
|
|
1519
|
+
contextContent += fs.readFileSync(relFile, 'utf8');
|
|
1520
|
+
} catch (e) {}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
messages.push({
|
|
1524
|
+
role: 'user',
|
|
1525
|
+
content: `Here is ${matchingFile} and its related files:\n${contextContent}\n\nUse this context to help me.`
|
|
1526
|
+
});
|
|
1527
|
+
console.log(chalk.green(`✅ Added ${matchingFile} + ${Math.min(related.length, 5)} related files to context`));
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// Handle auto-attach toggle
|
|
1534
|
+
if (input.toLowerCase() === '/auto') {
|
|
1535
|
+
sapperConfig.autoAttach = !sapperConfig.autoAttach;
|
|
1536
|
+
saveConfig(sapperConfig);
|
|
1537
|
+
console.log(chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
1538
|
+
if (sapperConfig.autoAttach) {
|
|
1539
|
+
console.log(chalk.gray(' When you @file, related imports will be auto-included.'));
|
|
1540
|
+
} else {
|
|
1541
|
+
console.log(chalk.gray(' Only explicitly mentioned files will be attached.'));
|
|
1542
|
+
}
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Handle context size command
|
|
1547
|
+
if (input.toLowerCase() === '/context') {
|
|
1548
|
+
const contextSize = JSON.stringify(messages).length;
|
|
1549
|
+
console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
|
|
1550
|
+
if (contextSize > 50000) {
|
|
1551
|
+
console.log(chalk.yellow('⚠️ Context is large! Consider using /prune'));
|
|
1552
|
+
}
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Handle debug mode toggle
|
|
1557
|
+
if (input.toLowerCase() === '/debug') {
|
|
1558
|
+
debugMode = !debugMode;
|
|
1559
|
+
console.log(chalk.magenta(`🔧 Debug mode: ${debugMode ? 'ON' : 'OFF'}`));
|
|
1560
|
+
if (debugMode) {
|
|
1561
|
+
console.log(chalk.gray(' Will show regex matching details after each AI response.'));
|
|
1562
|
+
}
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// Handle recall command - search embeddings
|
|
1567
|
+
if (input.toLowerCase().startsWith('/recall')) {
|
|
1568
|
+
const query = input.slice(7).trim();
|
|
1569
|
+
if (!query) {
|
|
1570
|
+
console.log(chalk.yellow('Usage: /recall <search query>'));
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const embeddings = loadEmbeddings();
|
|
1575
|
+
if (embeddings.chunks.length === 0) {
|
|
1576
|
+
console.log(chalk.yellow('No memories yet. Use /prune to auto-save conversations.'));
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
console.log(chalk.cyan(`\n🔍 Searching memory for: "${query}"...`));
|
|
1581
|
+
const relevant = await findRelevantContext(query, embeddings, 3);
|
|
1582
|
+
|
|
1583
|
+
if (relevant.length === 0) {
|
|
1584
|
+
console.log(chalk.yellow('No relevant memories found (or embedding model not available).'));
|
|
1585
|
+
console.log(chalk.gray('Tip: Run "ollama pull nomic-embed-text" for semantic search.'));
|
|
1586
|
+
} else {
|
|
1587
|
+
console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
|
|
1588
|
+
relevant.forEach((chunk, i) => {
|
|
1589
|
+
console.log(box(
|
|
1590
|
+
chalk.gray(chunk.text.substring(0, 300) + '...') + '\n' +
|
|
1591
|
+
chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
|
|
1592
|
+
`Memory ${i + 1}`, 'magenta'
|
|
1593
|
+
));
|
|
1594
|
+
console.log();
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
// Optionally add to context
|
|
1598
|
+
const addToContext = await safeQuestion(chalk.yellow('Add to current context? ') + chalk.gray('(y/n): '));
|
|
1599
|
+
if (addToContext.toLowerCase() === 'y') {
|
|
1600
|
+
const contextAddition = relevant.map(c => c.text).join('\n---\n');
|
|
1601
|
+
messages.push({
|
|
1602
|
+
role: 'user',
|
|
1603
|
+
content: `Here is relevant context from memory:\n${contextAddition}\n\nUse this information to help me.`
|
|
1604
|
+
});
|
|
1605
|
+
console.log(chalk.green('✅ Added to context!'));
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
continue;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Handle codebase scan command
|
|
1612
|
+
if (input.toLowerCase() === '/scan') {
|
|
1613
|
+
console.log(chalk.cyan('\n🔍 Scanning codebase...'));
|
|
1614
|
+
const scanResult = scanCodebase('.');
|
|
1615
|
+
|
|
1616
|
+
if (scanResult.files.length === 0) {
|
|
1617
|
+
console.log(chalk.yellow('No code files found in current directory.'));
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const formattedScan = formatScanResults(scanResult);
|
|
1622
|
+
const includedCount = scanResult.files.filter(f => !f.skipped).length;
|
|
1623
|
+
const skippedCount = scanResult.files.filter(f => f.skipped).length;
|
|
1624
|
+
|
|
1625
|
+
console.log(chalk.green(`✅ Scanned ${includedCount} files (~${Math.round(scanResult.totalSize/1024)}KB)`));
|
|
1626
|
+
if (skippedCount > 0) {
|
|
1627
|
+
console.log(chalk.yellow(`⏭️ Skipped ${skippedCount} files (too large or limit reached)`));
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Add scan to context
|
|
1631
|
+
messages.push({
|
|
1632
|
+
role: 'user',
|
|
1633
|
+
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.`
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
ensureSapperDir();
|
|
1637
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
1638
|
+
console.log(chalk.gray('📝 Codebase added to context. AI now has full picture.\n'));
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Handle @ alone or /attach command - interactive file picker
|
|
1643
|
+
if (input.trim() === '@' || input.toLowerCase() === '/attach') {
|
|
1644
|
+
const selectedFiles = await pickFiles();
|
|
1645
|
+
|
|
1646
|
+
if (selectedFiles.length === 0) continue;
|
|
1647
|
+
|
|
1648
|
+
// Read and attach selected files
|
|
1649
|
+
const fileAttachments = [];
|
|
1650
|
+
for (const filePath of selectedFiles) {
|
|
1651
|
+
try {
|
|
1652
|
+
const stats = fs.statSync(filePath);
|
|
1653
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
1654
|
+
console.log(chalk.yellow(`⚠️ ${filePath} is too large, skipping`));
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1658
|
+
fileAttachments.push({ path: filePath, content, size: stats.size });
|
|
1659
|
+
console.log(chalk.green(`📎 Attached: ${filePath}`));
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
if (fileAttachments.length === 0) continue;
|
|
1666
|
+
|
|
1667
|
+
// Ask for the prompt to go with these files
|
|
1668
|
+
console.log();
|
|
1669
|
+
const prompt = await safeQuestion(chalk.cyan('Your prompt for these files: '));
|
|
1670
|
+
|
|
1671
|
+
if (!prompt.trim()) {
|
|
1672
|
+
console.log(chalk.gray('Cancelled.'));
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Build message with attachments
|
|
1677
|
+
let attachedContent = '\n\n══════════════════════════════════════\n';
|
|
1678
|
+
attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
|
|
1679
|
+
attachedContent += '══════════════════════════════════════\n\n';
|
|
1680
|
+
|
|
1681
|
+
for (const file of fileAttachments) {
|
|
1682
|
+
attachedContent += `┌─── ${file.path} ───\n`;
|
|
1683
|
+
attachedContent += file.content;
|
|
1684
|
+
if (!file.content.endsWith('\n')) attachedContent += '\n';
|
|
1685
|
+
attachedContent += `└─── END ${file.path} ───\n\n`;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
messages.push({ role: 'user', content: prompt + attachedContent });
|
|
1689
|
+
// Continue to AI response (don't use 'continue' here)
|
|
1690
|
+
} else {
|
|
1691
|
+
// Process @file attachments in prompt (e.g., "analyze @package.json" or "fix @src/index.js")
|
|
1692
|
+
let processedInput = input;
|
|
1693
|
+
const fileAttachments = [];
|
|
1694
|
+
const attachRegex = /@([\w.\/\-_]+)/g;
|
|
1695
|
+
let attachMatch;
|
|
1696
|
+
|
|
1697
|
+
while ((attachMatch = attachRegex.exec(input)) !== null) {
|
|
1698
|
+
const filePath = attachMatch[1];
|
|
1699
|
+
try {
|
|
1700
|
+
if (fs.existsSync(filePath)) {
|
|
1701
|
+
const stats = fs.statSync(filePath);
|
|
1702
|
+
if (stats.isFile()) {
|
|
1703
|
+
if (stats.size > MAX_FILE_SIZE) {
|
|
1704
|
+
console.log(chalk.yellow(`⚠️ @${filePath} is too large (${Math.round(stats.size/1024)}KB), skipping`));
|
|
1705
|
+
} else {
|
|
1706
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
1707
|
+
fileAttachments.push({ path: filePath, content, size: stats.size });
|
|
1708
|
+
console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
|
|
1709
|
+
|
|
1710
|
+
// Auto-include related files from workspace graph (up to 3) - if enabled
|
|
1711
|
+
if (sapperConfig.autoAttach) {
|
|
1712
|
+
const related = getRelatedFiles(filePath, workspace, 1);
|
|
1713
|
+
for (const relFile of related.slice(0, 3)) {
|
|
1714
|
+
try {
|
|
1715
|
+
if (!fileAttachments.some(f => f.path === relFile)) {
|
|
1716
|
+
const relStats = fs.statSync(relFile);
|
|
1717
|
+
if (relStats.size <= MAX_FILE_SIZE) {
|
|
1718
|
+
const relContent = fs.readFileSync(relFile, 'utf8');
|
|
1719
|
+
fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
|
|
1720
|
+
console.log(chalk.gray(` ↳ +${relFile} (related)`));
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
} catch (e) {}
|
|
1724
|
+
}
|
|
1725
|
+
} // end if autoAttach
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
} else {
|
|
1729
|
+
// Not a file - might be an @mention for something else, ignore
|
|
1730
|
+
}
|
|
1731
|
+
} catch (e) {
|
|
1732
|
+
console.log(chalk.yellow(`⚠️ Could not read @${filePath}: ${e.message}`));
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Build the final message with attachments
|
|
1737
|
+
if (fileAttachments.length > 0) {
|
|
1738
|
+
let attachedContent = '\n\n══════════════════════════════════════\n';
|
|
1739
|
+
attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
|
|
1740
|
+
attachedContent += '══════════════════════════════════════\n\n';
|
|
1741
|
+
|
|
1742
|
+
for (const file of fileAttachments) {
|
|
1743
|
+
attachedContent += `┌─── ${file.path} ───\n`;
|
|
1744
|
+
attachedContent += file.content;
|
|
1745
|
+
if (!file.content.endsWith('\n')) attachedContent += '\n';
|
|
1746
|
+
attachedContent += `└─── END ${file.path} ───\n\n`;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
processedInput = input + attachedContent;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
messages.push({ role: 'user', content: processedInput });
|
|
1753
|
+
} // End of else block for non-@ input
|
|
1754
|
+
|
|
1755
|
+
let toolRounds = 0; // Prevent infinite loops
|
|
1756
|
+
const MAX_TOOL_ROUNDS = 20;
|
|
1757
|
+
|
|
1758
|
+
let active = true;
|
|
1759
|
+
while (active) {
|
|
1760
|
+
if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
|
|
1761
|
+
|
|
1762
|
+
spinner.start('Thinking...');
|
|
1763
|
+
let response;
|
|
1764
|
+
try {
|
|
1765
|
+
response = await ollama.chat({ model: selectedModel, messages, stream: true });
|
|
1766
|
+
} catch (ollamaError) {
|
|
1767
|
+
spinner.stop();
|
|
1768
|
+
console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
|
|
1769
|
+
active = false;
|
|
1770
|
+
continue;
|
|
1771
|
+
}
|
|
1772
|
+
spinner.stop();
|
|
1773
|
+
|
|
1774
|
+
let msg = '';
|
|
1775
|
+
const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
|
|
1776
|
+
let lastChunkTime = Date.now();
|
|
1777
|
+
let repetitionCount = 0;
|
|
1778
|
+
let lastContent = '';
|
|
1779
|
+
abortStream = false; // Reset abort flag before streaming
|
|
1780
|
+
|
|
1781
|
+
console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
|
|
1782
|
+
process.stdout.write(chalk.magenta('│ '));
|
|
1783
|
+
for await (const chunk of response) {
|
|
1784
|
+
// Check if user pressed Ctrl+C
|
|
1785
|
+
if (abortStream) {
|
|
1786
|
+
console.log(chalk.yellow('\n│ [Response interrupted]'));
|
|
1787
|
+
break;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const content = chunk.message.content;
|
|
1791
|
+
process.stdout.write(content);
|
|
1792
|
+
msg += content;
|
|
1793
|
+
|
|
1794
|
+
// Smart loop detection: check for repetitive content patterns
|
|
1795
|
+
if (msg.length > 10000) {
|
|
1796
|
+
const recentContent = msg.slice(-500);
|
|
1797
|
+
const previousContent = msg.slice(-1000, -500);
|
|
1798
|
+
|
|
1799
|
+
// If last 500 chars are very similar to previous 500, might be looping
|
|
1800
|
+
if (recentContent === previousContent) {
|
|
1801
|
+
repetitionCount++;
|
|
1802
|
+
if (repetitionCount > 3) {
|
|
1803
|
+
console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
|
|
1804
|
+
break;
|
|
1805
|
+
}
|
|
1806
|
+
} else {
|
|
1807
|
+
repetitionCount = 0;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Hard limit as final safety net
|
|
1812
|
+
if (msg.length > MAX_RESPONSE_LENGTH) {
|
|
1813
|
+
console.log(chalk.yellow('\n\n⚠️ Response very long (100KB+). Continuing... (Ctrl+C to stop)'));
|
|
1814
|
+
// Don't break - just warn. User can Ctrl+C if needed
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
console.log(chalk.magenta('└─────────────────────────────────────'));
|
|
1818
|
+
|
|
1819
|
+
messages.push({ role: 'assistant', content: msg });
|
|
1820
|
+
|
|
1821
|
+
// Regex: supports both old format (path]content) and new format (path:::content)
|
|
1822
|
+
const toolMatches = [...msg.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
|
|
1823
|
+
|
|
1824
|
+
// Check for unclosed tool calls (AI started a tool but didn't close it)
|
|
1825
|
+
const hasUnclosedTool = msg.includes('[TOOL:') && !msg.includes('[/TOOL]');
|
|
1826
|
+
if (hasUnclosedTool) {
|
|
1827
|
+
console.log(chalk.yellow('\n⚠️ Unclosed tool detected! AI forgot [/TOOL] closing tag.'));
|
|
1828
|
+
console.log(chalk.gray(' Asking AI to complete the tool call...\n'));
|
|
1829
|
+
|
|
1830
|
+
messages.push({
|
|
1831
|
+
role: 'user',
|
|
1832
|
+
content: 'ERROR: Your tool call is incomplete - you forgot to add [/TOOL] at the end. Please complete the tool call by providing the closing [/TOOL] tag. If you were writing a file, just output [/TOOL] to close it.'
|
|
1833
|
+
});
|
|
1834
|
+
continue; // Let AI respond with the closing tag
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// Debug mode: show what regex sees
|
|
1838
|
+
if (debugMode) {
|
|
1839
|
+
console.log(chalk.magenta('\n═══ DEBUG: REGEX ANALYSIS ═══'));
|
|
1840
|
+
console.log(chalk.gray(`Response length: ${msg.length} chars`));
|
|
1841
|
+
|
|
1842
|
+
// Check for tool-like patterns
|
|
1843
|
+
const hasToolStart = msg.includes('[TOOL:');
|
|
1844
|
+
const hasToolEnd = msg.includes('[/TOOL]');
|
|
1845
|
+
const hasBrokenEnd = msg.includes('[/]') || msg.includes('[/WRITE]') || msg.includes('[/READ]');
|
|
1846
|
+
|
|
1847
|
+
console.log(chalk.gray(`Contains [TOOL:: ${hasToolStart ? chalk.green('YES') : chalk.red('NO')}`));
|
|
1848
|
+
console.log(chalk.gray(`Contains [/TOOL]: ${hasToolEnd ? chalk.green('YES') : chalk.red('NO')}`));
|
|
1849
|
+
if (hasBrokenEnd) {
|
|
1850
|
+
console.log(chalk.red(`⚠️ Found broken closing tag: [/] or [/WRITE] etc.`));
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
console.log(chalk.gray(`Matches found: ${toolMatches.length}`));
|
|
1854
|
+
|
|
1855
|
+
if (toolMatches.length > 0) {
|
|
1856
|
+
toolMatches.forEach((m, i) => {
|
|
1857
|
+
console.log(chalk.cyan(` Match ${i+1}: type=${m[1]}, path=${m[2]?.substring(0,50)}...`));
|
|
1858
|
+
});
|
|
1859
|
+
} else if (hasToolStart) {
|
|
1860
|
+
// Show the raw tool attempt for debugging
|
|
1861
|
+
const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
|
|
1862
|
+
if (toolAttempt) {
|
|
1863
|
+
console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
|
|
1864
|
+
console.log(chalk.gray(` "${toolAttempt[0].substring(0, 150)}..."`));
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
console.log(chalk.magenta('═══════════════════════════════\n'));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (toolMatches.length > 0) {
|
|
1871
|
+
toolRounds++;
|
|
1872
|
+
|
|
1873
|
+
// Prevent infinite tool loops
|
|
1874
|
+
if (toolRounds >= MAX_TOOL_ROUNDS) {
|
|
1875
|
+
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Stopping auto-execution.`));
|
|
1876
|
+
console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
|
|
1877
|
+
resetTerminal(); // Ensure terminal is responsive
|
|
1878
|
+
messages.push({
|
|
1879
|
+
role: 'user',
|
|
1880
|
+
content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
|
|
1881
|
+
});
|
|
1882
|
+
continue; // Let AI respond without tools
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
for (const match of toolMatches) {
|
|
1886
|
+
const [_, type, path, content] = match;
|
|
1887
|
+
console.log();
|
|
1888
|
+
console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
|
|
1889
|
+
|
|
1890
|
+
let result;
|
|
1891
|
+
if (type.toLowerCase() === 'list') result = tools.list(path);
|
|
1892
|
+
else if (type.toLowerCase() === 'read') result = tools.read(path);
|
|
1893
|
+
else if (type.toLowerCase() === 'mkdir') result = tools.mkdir(path);
|
|
1894
|
+
else if (type.toLowerCase() === 'write') {
|
|
1895
|
+
if (!content || content.trim() === '') {
|
|
1896
|
+
result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
|
|
1897
|
+
} else {
|
|
1898
|
+
result = await tools.write(path, content);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
else if (type.toLowerCase() === 'patch') {
|
|
1902
|
+
// PATCH format: [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]
|
|
1903
|
+
const parts = content?.split('|||');
|
|
1904
|
+
if (parts && parts.length === 2) {
|
|
1905
|
+
result = await tools.patch(path, parts[0], parts[1]);
|
|
1906
|
+
} else {
|
|
1907
|
+
result = 'Error: PATCH requires format [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]';
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
else if (type.toLowerCase() === 'search') result = await tools.search(path);
|
|
1911
|
+
else if (type.toLowerCase() === 'shell') result = await tools.shell(path);
|
|
1912
|
+
|
|
1913
|
+
messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
|
|
1914
|
+
}
|
|
1915
|
+
ensureSapperDir();
|
|
1916
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
1917
|
+
|
|
1918
|
+
if (toolMatches.length > 30) {
|
|
1919
|
+
console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
|
|
1920
|
+
}
|
|
1921
|
+
} else {
|
|
1922
|
+
// No tools found - check if malformed command
|
|
1923
|
+
if (msg.includes('[TOOL:') && msg.includes('[/]')) {
|
|
1924
|
+
console.log(chalk.red('\n❌ Malformed tool command detected!'));
|
|
1925
|
+
messages.push({
|
|
1926
|
+
role: 'user',
|
|
1927
|
+
content: 'ERROR: Your tool command is malformed. Use [TOOL:TYPE]path]content[/TOOL] or [TOOL:TYPE]path[/TOOL]'
|
|
1928
|
+
});
|
|
1929
|
+
} else {
|
|
1930
|
+
// Normal response - save and wait for next input
|
|
1931
|
+
ensureSapperDir();
|
|
1932
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
1933
|
+
active = false;
|
|
1934
|
+
spinner.stop(); // Ensure spinner is dead
|
|
1935
|
+
resetTerminal(); // Force terminal back to normal state
|
|
1936
|
+
process.stdout.write('\n'); // Force newline to break out of stream mode
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
console.error(chalk.red('\n❌ Error:'), error.message);
|
|
1942
|
+
// Loop continues automatically
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// Keep-alive interval - prevents Node from exiting when event loop is empty
|
|
1948
|
+
setInterval(() => {}, 1000);
|
|
1949
|
+
|
|
1950
|
+
runSapper();
|