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
package/sapper.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import readline from 'readline';
|
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
9
|
import { dirname, join } from 'path';
|
|
10
10
|
import { marked } from 'marked';
|
|
11
|
-
import
|
|
11
|
+
import { markedTerminal } from 'marked-terminal';
|
|
12
12
|
import * as acorn from 'acorn';
|
|
13
13
|
|
|
14
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -26,7 +26,7 @@ process.on('unhandledRejection', (reason) => {
|
|
|
26
26
|
let ctrlCCount = 0;
|
|
27
27
|
process.on('SIGINT', () => {
|
|
28
28
|
ctrlCCount++;
|
|
29
|
-
if (ctrlCCount >=
|
|
29
|
+
if (ctrlCCount >= 3) {
|
|
30
30
|
console.log(chalk.red('\nForce quitting...'));
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
@@ -36,7 +36,11 @@ process.on('SIGINT', () => {
|
|
|
36
36
|
// Clear current line and move to new one - stops ghost output
|
|
37
37
|
process.stdout.clearLine(0);
|
|
38
38
|
process.stdout.cursorTo(0);
|
|
39
|
-
|
|
39
|
+
if (ctrlCCount >= 2) {
|
|
40
|
+
console.log(chalk.yellow('\n⏹️ Press Ctrl+C once more to force quit'));
|
|
41
|
+
} else {
|
|
42
|
+
console.log(UI.slate('\n⏹️ Stopped'));
|
|
43
|
+
}
|
|
40
44
|
|
|
41
45
|
// Reset terminal immediately
|
|
42
46
|
resetTerminal();
|
|
@@ -73,6 +77,226 @@ const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
|
|
|
73
77
|
const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
|
|
74
78
|
const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
|
|
75
79
|
const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
|
|
80
|
+
const AGENTS_DIR = `${SAPPER_DIR}/agents`;
|
|
81
|
+
const SKILLS_DIR = `${SAPPER_DIR}/skills`;
|
|
82
|
+
const LOGS_DIR = `${SAPPER_DIR}/logs`;
|
|
83
|
+
const SAPPERIGNORE_FILE = '.sapperignore';
|
|
84
|
+
|
|
85
|
+
// ═══════════════════════════════════════════════════════════════
|
|
86
|
+
// COMPREHENSIVE ACTIVITY LOGGER
|
|
87
|
+
// ═══════════════════════════════════════════════════════════════
|
|
88
|
+
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
89
|
+
const sessionLogFile = () => `${LOGS_DIR}/session-${sessionId}.md`;
|
|
90
|
+
const activityLog = []; // In-memory log for current session
|
|
91
|
+
|
|
92
|
+
function ensureLogsDir() {
|
|
93
|
+
ensureSapperDir();
|
|
94
|
+
if (!fs.existsSync(LOGS_DIR)) {
|
|
95
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Log entry types: user, ai, tool, system, error, file, shell, summary
|
|
100
|
+
function logEntry(type, data) {
|
|
101
|
+
const entry = {
|
|
102
|
+
timestamp: new Date().toISOString(),
|
|
103
|
+
elapsed: activityLog.length > 0
|
|
104
|
+
? Date.now() - new Date(activityLog[0].timestamp).getTime()
|
|
105
|
+
: 0,
|
|
106
|
+
type,
|
|
107
|
+
...data
|
|
108
|
+
};
|
|
109
|
+
activityLog.push(entry);
|
|
110
|
+
appendLogToFile(entry);
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatElapsed(ms) {
|
|
115
|
+
if (ms < 1000) return `${ms}ms`;
|
|
116
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
117
|
+
const mins = Math.floor(ms / 60000);
|
|
118
|
+
const secs = Math.floor((ms % 60000) / 1000);
|
|
119
|
+
return `${mins}m ${secs}s`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function appendLogToFile(entry) {
|
|
123
|
+
try {
|
|
124
|
+
ensureLogsDir();
|
|
125
|
+
const file = sessionLogFile();
|
|
126
|
+
const existed = fs.existsSync(file);
|
|
127
|
+
|
|
128
|
+
let line = '';
|
|
129
|
+
if (!existed) {
|
|
130
|
+
line += `# Sapper Session Log\n`;
|
|
131
|
+
line += `**Started:** ${new Date(entry.timestamp).toLocaleString()}\n`;
|
|
132
|
+
line += `**Working Directory:** \`${process.cwd()}\`\n\n`;
|
|
133
|
+
line += `---\n\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
137
|
+
const elapsed = formatElapsed(entry.elapsed);
|
|
138
|
+
|
|
139
|
+
switch (entry.type) {
|
|
140
|
+
case 'session_start':
|
|
141
|
+
line += `## 🚀 Session Started\n`;
|
|
142
|
+
line += `- **Model:** \`${entry.model}\`\n`;
|
|
143
|
+
line += `- **Resumed:** ${entry.resumed ? 'Yes' : 'No'}\n`;
|
|
144
|
+
line += `- **Context Messages:** ${entry.contextSize}\n\n`;
|
|
145
|
+
break;
|
|
146
|
+
case 'user':
|
|
147
|
+
line += `### 💬 User Input \`${time}\` _(+${elapsed})_\n`;
|
|
148
|
+
line += `\`\`\`\n${entry.message?.substring(0, 500)}${entry.message?.length > 500 ? '\n...' : ''}\n\`\`\`\n`;
|
|
149
|
+
if (entry.attachments?.length > 0) {
|
|
150
|
+
line += `📎 **Attached:** ${entry.attachments.join(', ')}\n`;
|
|
151
|
+
}
|
|
152
|
+
line += '\n';
|
|
153
|
+
break;
|
|
154
|
+
case 'ai':
|
|
155
|
+
line += `### 🤖 AI Response \`${time}\` _(+${elapsed})_\n`;
|
|
156
|
+
line += `- **Tokens:** ~${entry.charCount} chars\n`;
|
|
157
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
158
|
+
line += `- **Tools Used:** ${entry.toolCount || 0}\n`;
|
|
159
|
+
if (entry.interrupted) line += `- ⚠️ **Interrupted**\n`;
|
|
160
|
+
if (entry.repetitionStopped) line += `- ⚠️ **Stopped: repetitive output**\n`;
|
|
161
|
+
line += `\n<details><summary>Response preview</summary>\n\n`;
|
|
162
|
+
line += `${entry.preview?.substring(0, 800)}${entry.preview?.length > 800 ? '\n...' : ''}\n`;
|
|
163
|
+
line += `\n</details>\n\n`;
|
|
164
|
+
break;
|
|
165
|
+
case 'tool':
|
|
166
|
+
const statusIcon = entry.success ? '✅' : '❌';
|
|
167
|
+
line += `#### 🔧 Tool: \`${entry.toolType}\` ${statusIcon} \`${time}\`\n`;
|
|
168
|
+
line += `- **Target:** \`${entry.path}\`\n`;
|
|
169
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
170
|
+
if (entry.resultSize) line += `- **Result Size:** ${entry.resultSize} chars\n`;
|
|
171
|
+
if (entry.error) line += `- **Error:** ${entry.error}\n`;
|
|
172
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
173
|
+
line += '\n';
|
|
174
|
+
break;
|
|
175
|
+
case 'shell':
|
|
176
|
+
line += `#### 🖥️ Shell Command \`${time}\`\n`;
|
|
177
|
+
line += `\`\`\`bash\n${entry.command}\n\`\`\`\n`;
|
|
178
|
+
line += `- **Exit Code:** ${entry.exitCode ?? 'N/A'}\n`;
|
|
179
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
180
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
181
|
+
line += '\n';
|
|
182
|
+
break;
|
|
183
|
+
case 'file':
|
|
184
|
+
const fileIcon = entry.action === 'read' ? '📖' : entry.action === 'write' ? '✏️' : entry.action === 'patch' ? '🔧' : '📁';
|
|
185
|
+
line += `#### ${fileIcon} File: \`${entry.action}\` \`${time}\`\n`;
|
|
186
|
+
line += `- **Path:** \`${entry.path}\`\n`;
|
|
187
|
+
if (entry.size) line += `- **Size:** ${entry.size} bytes\n`;
|
|
188
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
189
|
+
line += '\n';
|
|
190
|
+
break;
|
|
191
|
+
case 'system':
|
|
192
|
+
line += `> ℹ️ **${entry.event}** \`${time}\` — ${entry.detail || ''}\n\n`;
|
|
193
|
+
break;
|
|
194
|
+
case 'error':
|
|
195
|
+
line += `> ❌ **Error** \`${time}\` — \`${entry.message}\`\n\n`;
|
|
196
|
+
break;
|
|
197
|
+
case 'summary':
|
|
198
|
+
line += `### 🧠 Context Summarized \`${time}\`\n`;
|
|
199
|
+
line += `- **Before:** ${entry.before}\n`;
|
|
200
|
+
line += `- **After:** ${entry.after}\n\n`;
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
line += `> ${entry.type}: ${JSON.stringify(entry)}\n\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fs.appendFileSync(file, line);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Silent fail - logging should never break the app
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Render the in-memory activity log to terminal with beautiful formatting
|
|
213
|
+
function renderActivityLog(count = 30) {
|
|
214
|
+
const entries = activityLog.slice(-count);
|
|
215
|
+
if (entries.length === 0) return chalk.yellow('No activity recorded yet.');
|
|
216
|
+
|
|
217
|
+
const width = Math.min(process.stdout.columns || 80, 90);
|
|
218
|
+
let output = '';
|
|
219
|
+
|
|
220
|
+
// Header
|
|
221
|
+
output += chalk.cyan.bold('\n╔' + '═'.repeat(width - 2) + '╗\n');
|
|
222
|
+
output += chalk.cyan.bold('║') + chalk.white.bold(' 📋 SAPPER ACTIVITY LOG').padEnd(width - 2) + chalk.cyan.bold('║\n');
|
|
223
|
+
output += chalk.cyan.bold('║') + chalk.gray(` Session: ${sessionId} | ${activityLog.length} events`).padEnd(width - 2) + chalk.cyan.bold('║\n');
|
|
224
|
+
output += chalk.cyan.bold('╠' + '═'.repeat(width - 2) + '╣\n');
|
|
225
|
+
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
228
|
+
const elapsed = formatElapsed(entry.elapsed);
|
|
229
|
+
const timeStr = chalk.gray(`${time} +${elapsed}`);
|
|
230
|
+
|
|
231
|
+
switch (entry.type) {
|
|
232
|
+
case 'session_start':
|
|
233
|
+
output += chalk.cyan.bold('║') + ` 🚀 ${chalk.green.bold('SESSION START')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
234
|
+
output += chalk.cyan.bold('║') + ` Model: ${chalk.cyan(entry.model)} | Context: ${entry.contextSize} msgs`.padEnd(width + 20) + '\n';
|
|
235
|
+
break;
|
|
236
|
+
case 'user':
|
|
237
|
+
output += chalk.cyan.bold('║') + ` 💬 ${chalk.blue.bold('USER')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
238
|
+
const preview = entry.message?.substring(0, 60)?.replace(/\n/g, ' ');
|
|
239
|
+
output += chalk.cyan.bold('║') + ` ${chalk.white(preview)}${entry.message?.length > 60 ? chalk.gray('...') : ''}`.padEnd(width + 20) + '\n';
|
|
240
|
+
if (entry.attachments?.length > 0) {
|
|
241
|
+
output += chalk.cyan.bold('║') + ` 📎 ${chalk.yellow(entry.attachments.join(', '))}`.padEnd(width + 20) + '\n';
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
case 'ai':
|
|
245
|
+
const aiStatus = entry.interrupted ? chalk.yellow('⚠️ INTERRUPTED') : entry.repetitionStopped ? chalk.red('⚠️ LOOP') : chalk.green(`~${entry.charCount} chars`);
|
|
246
|
+
output += chalk.cyan.bold('║') + ` 🤖 ${chalk.magenta.bold('AI')} ${timeStr} ${aiStatus}`.padEnd(width + 50) + '\n';
|
|
247
|
+
output += chalk.cyan.bold('║') + ` ⏱ ${chalk.gray(formatElapsed(entry.duration))} | 🔧 ${entry.toolCount || 0} tools`.padEnd(width + 20) + '\n';
|
|
248
|
+
break;
|
|
249
|
+
case 'tool':
|
|
250
|
+
const icon = entry.success ? chalk.green('✓') : chalk.red('✗');
|
|
251
|
+
output += chalk.cyan.bold('║') + ` ${icon} ${chalk.yellow.bold(entry.toolType)} → ${chalk.white(entry.path?.substring(0, 40))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
252
|
+
if (entry.error) {
|
|
253
|
+
output += chalk.cyan.bold('║') + ` ${chalk.red(entry.error.substring(0, 60))}`.padEnd(width + 20) + '\n';
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
case 'shell':
|
|
257
|
+
output += chalk.cyan.bold('║') + ` 🖥️ ${chalk.red.bold('SHELL')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
258
|
+
output += chalk.cyan.bold('║') + ` ${chalk.cyan('$ ' + entry.command?.substring(0, 55))}${entry.command?.length > 55 ? chalk.gray('...') : ''}`.padEnd(width + 20) + '\n';
|
|
259
|
+
output += chalk.cyan.bold('║') + ` Exit: ${entry.exitCode === 0 ? chalk.green(entry.exitCode) : chalk.red(entry.exitCode ?? '?')} | ⏱ ${chalk.gray(formatElapsed(entry.duration))}`.padEnd(width + 20) + '\n';
|
|
260
|
+
break;
|
|
261
|
+
case 'file':
|
|
262
|
+
const fIcon = { read: '📖', write: '✏️', patch: '🔧', list: '📂', mkdir: '📁' }[entry.action] || '📄';
|
|
263
|
+
output += chalk.cyan.bold('║') + ` ${fIcon} ${chalk.cyan(entry.action?.toUpperCase())} ${chalk.white(entry.path?.substring(0, 45))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
264
|
+
break;
|
|
265
|
+
case 'system':
|
|
266
|
+
output += chalk.cyan.bold('║') + ` ℹ️ ${chalk.gray(entry.event + (entry.detail ? ': ' + entry.detail.substring(0, 50) : ''))} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
267
|
+
break;
|
|
268
|
+
case 'error':
|
|
269
|
+
output += chalk.cyan.bold('║') + ` ❌ ${chalk.red.bold('ERROR')} ${chalk.red(entry.message?.substring(0, 50))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
270
|
+
break;
|
|
271
|
+
case 'summary':
|
|
272
|
+
output += chalk.cyan.bold('║') + ` 🧠 ${chalk.cyan.bold('SUMMARIZED')} ${entry.before} → ${entry.after} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
output += chalk.cyan.bold('║') + chalk.gray('─'.repeat(width - 2)).padEnd(width - 1) + '\n';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Footer
|
|
279
|
+
output += chalk.cyan.bold('╠' + '═'.repeat(width - 2) + '╣\n');
|
|
280
|
+
const stats = getSessionStats();
|
|
281
|
+
output += chalk.cyan.bold('║') + ` 📊 ${chalk.white(`Messages: ${stats.userMessages}↑ ${stats.aiMessages}↓`)} | ${chalk.yellow(`Tools: ${stats.toolCalls}`)} | ${chalk.red(`Shells: ${stats.shellCalls}`)} | ${chalk.cyan(`Errors: ${stats.errors}`)}`.padEnd(width + 50) + '\n';
|
|
282
|
+
output += chalk.cyan.bold('║') + ` 📁 Log: ${chalk.gray(sessionLogFile())}`.padEnd(width + 20) + '\n';
|
|
283
|
+
output += chalk.cyan.bold('╚' + '═'.repeat(width - 2) + '╝\n');
|
|
284
|
+
|
|
285
|
+
return output;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function getSessionStats() {
|
|
289
|
+
return {
|
|
290
|
+
userMessages: activityLog.filter(e => e.type === 'user').length,
|
|
291
|
+
aiMessages: activityLog.filter(e => e.type === 'ai').length,
|
|
292
|
+
toolCalls: activityLog.filter(e => e.type === 'tool').length,
|
|
293
|
+
shellCalls: activityLog.filter(e => e.type === 'shell').length,
|
|
294
|
+
errors: activityLog.filter(e => e.type === 'error').length,
|
|
295
|
+
totalDuration: activityLog.length > 0
|
|
296
|
+
? Date.now() - new Date(activityLog[0].timestamp).getTime()
|
|
297
|
+
: 0,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
76
300
|
|
|
77
301
|
// Ensure .sapper directory exists
|
|
78
302
|
function ensureSapperDir() {
|
|
@@ -81,6 +305,603 @@ function ensureSapperDir() {
|
|
|
81
305
|
}
|
|
82
306
|
}
|
|
83
307
|
|
|
308
|
+
// Default .sapperignore template — created on first run
|
|
309
|
+
const DEFAULT_SAPPERIGNORE = `# ═══════════════════════════════════════════════════════════════
|
|
310
|
+
# .sapperignore — Files and folders Sapper should ignore
|
|
311
|
+
# Works like .gitignore: one pattern per line, # for comments
|
|
312
|
+
# Edit this file to customize what Sapper skips
|
|
313
|
+
# ═══════════════════════════════════════════════════════════════
|
|
314
|
+
|
|
315
|
+
# ── Sapper internal ──
|
|
316
|
+
.sapper/
|
|
317
|
+
|
|
318
|
+
# ── Dependencies ──
|
|
319
|
+
node_modules/
|
|
320
|
+
vendor/
|
|
321
|
+
bower_components/
|
|
322
|
+
|
|
323
|
+
# ── Build outputs ──
|
|
324
|
+
dist/
|
|
325
|
+
build/
|
|
326
|
+
out/
|
|
327
|
+
.next/
|
|
328
|
+
.nuxt/
|
|
329
|
+
.output/
|
|
330
|
+
.vercel/
|
|
331
|
+
.netlify/
|
|
332
|
+
|
|
333
|
+
# ── Environment & secrets ──
|
|
334
|
+
.env
|
|
335
|
+
.env.*
|
|
336
|
+
!.env.example
|
|
337
|
+
*.pem
|
|
338
|
+
*.key
|
|
339
|
+
*.cert
|
|
340
|
+
|
|
341
|
+
# ── Version control ──
|
|
342
|
+
.git/
|
|
343
|
+
.svn/
|
|
344
|
+
.hg/
|
|
345
|
+
|
|
346
|
+
# ── IDE / Editor ──
|
|
347
|
+
.idea/
|
|
348
|
+
.vscode/
|
|
349
|
+
*.swp
|
|
350
|
+
*.swo
|
|
351
|
+
*~
|
|
352
|
+
|
|
353
|
+
# ── OS files ──
|
|
354
|
+
.DS_Store
|
|
355
|
+
Thumbs.db
|
|
356
|
+
desktop.ini
|
|
357
|
+
|
|
358
|
+
# ── Caches ──
|
|
359
|
+
.cache/
|
|
360
|
+
__pycache__/
|
|
361
|
+
*.pyc
|
|
362
|
+
.pytest_cache/
|
|
363
|
+
.mypy_cache/
|
|
364
|
+
|
|
365
|
+
# ── Coverage & tests ──
|
|
366
|
+
coverage/
|
|
367
|
+
.nyc_output/
|
|
368
|
+
htmlcov/
|
|
369
|
+
|
|
370
|
+
# ── Logs ──
|
|
371
|
+
*.log
|
|
372
|
+
npm-debug.log*
|
|
373
|
+
yarn-debug.log*
|
|
374
|
+
yarn-error.log*
|
|
375
|
+
|
|
376
|
+
# ── Lock files (large) ──
|
|
377
|
+
package-lock.json
|
|
378
|
+
yarn.lock
|
|
379
|
+
pnpm-lock.yaml
|
|
380
|
+
composer.lock
|
|
381
|
+
Gemfile.lock
|
|
382
|
+
Cargo.lock
|
|
383
|
+
|
|
384
|
+
# ── Compiled / binary / large ──
|
|
385
|
+
*.min.js
|
|
386
|
+
*.min.css
|
|
387
|
+
*.map
|
|
388
|
+
*.bundle.js
|
|
389
|
+
*.chunk.js
|
|
390
|
+
*.wasm
|
|
391
|
+
*.so
|
|
392
|
+
*.dylib
|
|
393
|
+
*.dll
|
|
394
|
+
*.exe
|
|
395
|
+
*.o
|
|
396
|
+
*.a
|
|
397
|
+
*.class
|
|
398
|
+
*.jar
|
|
399
|
+
*.war
|
|
400
|
+
*.zip
|
|
401
|
+
*.tar.gz
|
|
402
|
+
*.tgz
|
|
403
|
+
*.rar
|
|
404
|
+
*.7z
|
|
405
|
+
*.iso
|
|
406
|
+
*.dmg
|
|
407
|
+
|
|
408
|
+
# ── Media (large files) ──
|
|
409
|
+
*.mp4
|
|
410
|
+
*.mp3
|
|
411
|
+
*.avi
|
|
412
|
+
*.mov
|
|
413
|
+
*.mkv
|
|
414
|
+
*.wav
|
|
415
|
+
*.flac
|
|
416
|
+
*.png
|
|
417
|
+
*.jpg
|
|
418
|
+
*.jpeg
|
|
419
|
+
*.gif
|
|
420
|
+
*.bmp
|
|
421
|
+
*.ico
|
|
422
|
+
*.svg
|
|
423
|
+
*.webp
|
|
424
|
+
*.ttf
|
|
425
|
+
*.woff
|
|
426
|
+
*.woff2
|
|
427
|
+
*.eot
|
|
428
|
+
*.otf
|
|
429
|
+
*.pdf
|
|
430
|
+
|
|
431
|
+
# ── Database ──
|
|
432
|
+
*.sqlite
|
|
433
|
+
*.sqlite3
|
|
434
|
+
*.db
|
|
435
|
+
|
|
436
|
+
# ── Terraform / IaC ──
|
|
437
|
+
.terraform/
|
|
438
|
+
*.tfstate
|
|
439
|
+
*.tfstate.*
|
|
440
|
+
|
|
441
|
+
# ── Docker ──
|
|
442
|
+
*.tar
|
|
443
|
+
|
|
444
|
+
# ── Gradle / Maven ──
|
|
445
|
+
.gradle/
|
|
446
|
+
target/
|
|
447
|
+
`;
|
|
448
|
+
|
|
449
|
+
// Create .sapperignore if it doesn't exist (runs on startup)
|
|
450
|
+
function ensureSapperIgnore() {
|
|
451
|
+
if (!fs.existsSync(SAPPERIGNORE_FILE)) {
|
|
452
|
+
fs.writeFileSync(SAPPERIGNORE_FILE, DEFAULT_SAPPERIGNORE);
|
|
453
|
+
return true; // newly created
|
|
454
|
+
}
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Ensure agents and skills directories exist
|
|
459
|
+
function ensureAgentsDirs() {
|
|
460
|
+
ensureSapperDir();
|
|
461
|
+
if (!fs.existsSync(AGENTS_DIR)) {
|
|
462
|
+
fs.mkdirSync(AGENTS_DIR, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
if (!fs.existsSync(SKILLS_DIR)) {
|
|
465
|
+
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ═══════════════════════════════════════════════════════════════
|
|
470
|
+
// AGENTS & SKILLS SYSTEM (with YAML frontmatter support)
|
|
471
|
+
// ═══════════════════════════════════════════════════════════════
|
|
472
|
+
|
|
473
|
+
// Parse YAML-like frontmatter from markdown files
|
|
474
|
+
// Supports: --- key: value --- blocks at the top of .md files
|
|
475
|
+
// Returns { meta: {}, body: string }
|
|
476
|
+
function parseFrontmatter(rawContent) {
|
|
477
|
+
const content = rawContent.trim();
|
|
478
|
+
if (!content.startsWith('---')) {
|
|
479
|
+
// No frontmatter — legacy format, extract title from first # heading
|
|
480
|
+
const firstLine = content.split('\n')[0].replace(/^#\s*/, '').trim();
|
|
481
|
+
return {
|
|
482
|
+
meta: { name: firstLine, description: firstLine },
|
|
483
|
+
body: content
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Find closing ---
|
|
488
|
+
const endIndex = content.indexOf('---', 3);
|
|
489
|
+
if (endIndex === -1) {
|
|
490
|
+
// Malformed — treat entire content as body
|
|
491
|
+
const firstLine = content.split('\n')[0].replace(/^#\s*/, '').replace(/^---\s*/, '').trim();
|
|
492
|
+
return { meta: { name: firstLine }, body: content };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const frontmatterBlock = content.substring(3, endIndex).trim();
|
|
496
|
+
const body = content.substring(endIndex + 3).trim();
|
|
497
|
+
|
|
498
|
+
const meta = {};
|
|
499
|
+
for (const line of frontmatterBlock.split('\n')) {
|
|
500
|
+
const colonIdx = line.indexOf(':');
|
|
501
|
+
if (colonIdx === -1) continue;
|
|
502
|
+
const key = line.substring(0, colonIdx).trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
503
|
+
let value = line.substring(colonIdx + 1).trim();
|
|
504
|
+
|
|
505
|
+
// Strip surrounding quotes
|
|
506
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
507
|
+
value = value.slice(1, -1);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Parse arrays: [item1, item2]
|
|
511
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
512
|
+
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
meta[key] = value;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Ensure name fallback from body's first heading
|
|
519
|
+
if (!meta.name) {
|
|
520
|
+
const heading = body.match(/^#\s+(.+)/m);
|
|
521
|
+
meta.name = heading ? heading[1].trim() : 'Unnamed';
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { meta, body };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Map tool shorthand names from frontmatter to actual TOOL: names
|
|
528
|
+
const TOOL_NAME_MAP = {
|
|
529
|
+
'read': 'READ',
|
|
530
|
+
'write': 'WRITE',
|
|
531
|
+
'edit': 'PATCH',
|
|
532
|
+
'patch': 'PATCH',
|
|
533
|
+
'list': 'LIST',
|
|
534
|
+
'search': 'SEARCH',
|
|
535
|
+
'shell': 'SHELL',
|
|
536
|
+
'mkdir': 'MKDIR',
|
|
537
|
+
'todo': 'LIST', // alias — list tasks
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
function normalizeToolList(toolsValue) {
|
|
541
|
+
if (!toolsValue) return null; // null = all tools allowed
|
|
542
|
+
if (typeof toolsValue === 'string') {
|
|
543
|
+
toolsValue = toolsValue.split(',').map(s => s.trim());
|
|
544
|
+
}
|
|
545
|
+
if (!Array.isArray(toolsValue)) return null;
|
|
546
|
+
return toolsValue.map(t => TOOL_NAME_MAP[t.toLowerCase()] || t.toUpperCase()).filter(Boolean);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Load all agents from .sapper/agents/*.md (with frontmatter support)
|
|
550
|
+
function loadAgents() {
|
|
551
|
+
ensureAgentsDirs();
|
|
552
|
+
const agents = {};
|
|
553
|
+
try {
|
|
554
|
+
const files = fs.readdirSync(AGENTS_DIR);
|
|
555
|
+
for (const file of files) {
|
|
556
|
+
if (file.endsWith('.md')) {
|
|
557
|
+
const name = file.replace('.md', '').toLowerCase();
|
|
558
|
+
const rawContent = fs.readFileSync(join(AGENTS_DIR, file), 'utf8');
|
|
559
|
+
const { meta, body } = parseFrontmatter(rawContent);
|
|
560
|
+
agents[name] = {
|
|
561
|
+
name: meta.name || name,
|
|
562
|
+
file,
|
|
563
|
+
content: body, // body without frontmatter → injected into system prompt
|
|
564
|
+
description: meta.description || meta.name || name,
|
|
565
|
+
tools: normalizeToolList(meta.tools), // null = all, or ['READ','WRITE',...]
|
|
566
|
+
argumentHint: meta['argument-hint'] || null,
|
|
567
|
+
meta, // full parsed metadata
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} catch (e) {}
|
|
572
|
+
return agents;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Load all skills from .sapper/skills/*.md (with frontmatter support)
|
|
576
|
+
function loadSkills() {
|
|
577
|
+
ensureAgentsDirs();
|
|
578
|
+
const skills = {};
|
|
579
|
+
try {
|
|
580
|
+
const files = fs.readdirSync(SKILLS_DIR);
|
|
581
|
+
for (const file of files) {
|
|
582
|
+
if (file.endsWith('.md')) {
|
|
583
|
+
const name = file.replace('.md', '').toLowerCase();
|
|
584
|
+
const rawContent = fs.readFileSync(join(SKILLS_DIR, file), 'utf8');
|
|
585
|
+
const { meta, body } = parseFrontmatter(rawContent);
|
|
586
|
+
skills[name] = {
|
|
587
|
+
name: meta.name || name,
|
|
588
|
+
file,
|
|
589
|
+
content: body,
|
|
590
|
+
description: meta.description || meta.name || name,
|
|
591
|
+
argumentHint: meta['argument-hint'] || null,
|
|
592
|
+
meta,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch (e) {}
|
|
597
|
+
return skills;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Create default example agent on first run
|
|
601
|
+
function createDefaultAgentsAndSkills() {
|
|
602
|
+
ensureAgentsDirs();
|
|
603
|
+
|
|
604
|
+
const defaultAgents = {
|
|
605
|
+
'sapper-it': `---
|
|
606
|
+
name: "Sapper IT"
|
|
607
|
+
description: "Expert full-stack coding agent — handles web dev, architecture, debugging, DevOps, databases, APIs, and performance. Use for any coding task."
|
|
608
|
+
tools: [read, edit, write, list, search, shell]
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
# Sapper IT - Coding Agent
|
|
612
|
+
|
|
613
|
+
You are Sapper IT, an expert full-stack coding agent working within Sapper.
|
|
614
|
+
|
|
615
|
+
## Your Expertise
|
|
616
|
+
- Full-stack web development (frontend + backend)
|
|
617
|
+
- System architecture and design patterns
|
|
618
|
+
- Debugging, refactoring, and code review
|
|
619
|
+
- DevOps, CI/CD, and deployment
|
|
620
|
+
- Database design and optimization
|
|
621
|
+
- API development (REST, GraphQL)
|
|
622
|
+
- Performance optimization and security best practices
|
|
623
|
+
|
|
624
|
+
## Behavior
|
|
625
|
+
When the user asks for help, dive into the codebase using Sapper's tools. Read files, understand the structure, then make precise changes.
|
|
626
|
+
|
|
627
|
+
Be technical, thorough, and code-first. Always verify your changes work by running tests or builds.`,
|
|
628
|
+
|
|
629
|
+
'writer': `---
|
|
630
|
+
name: "Technical Writer"
|
|
631
|
+
description: "Documentation and writing agent — READMEs, API docs, tutorials, guides, and code comments. Use for any writing or documentation task."
|
|
632
|
+
tools: [read, edit, write, list, search]
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
# Technical Writer
|
|
636
|
+
|
|
637
|
+
You are an expert technical writer within Sapper.
|
|
638
|
+
|
|
639
|
+
## Your Expertise
|
|
640
|
+
- API documentation and developer guides
|
|
641
|
+
- README files and onboarding docs
|
|
642
|
+
- Architecture decision records (ADRs)
|
|
643
|
+
- Code comments, JSDoc/TSDoc annotations
|
|
644
|
+
- Tutorials, how-to guides, and changelogs
|
|
645
|
+
- Clear, structured, audience-aware writing
|
|
646
|
+
|
|
647
|
+
## Behavior
|
|
648
|
+
- Always READ the code first to understand what it does before writing docs
|
|
649
|
+
- Use examples and code snippets in documentation
|
|
650
|
+
- Keep language simple and scannable
|
|
651
|
+
- Match the project's existing documentation style
|
|
652
|
+
- Prefer concise bullet points over long paragraphs
|
|
653
|
+
|
|
654
|
+
## Workflow
|
|
655
|
+
1. LIST the project to understand structure
|
|
656
|
+
2. READ key files (README, package.json, main entry points)
|
|
657
|
+
3. Identify what needs documenting
|
|
658
|
+
4. WRITE or PATCH documentation files
|
|
659
|
+
5. Cross-reference with existing docs for consistency`,
|
|
660
|
+
|
|
661
|
+
'reviewer': `---
|
|
662
|
+
name: "Code Reviewer"
|
|
663
|
+
description: "Code review agent — analyzes code for bugs, security issues, performance, and best practices. Read-only: won't modify files."
|
|
664
|
+
tools: [read, list, search]
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
# Code Reviewer
|
|
668
|
+
|
|
669
|
+
You are a senior code reviewer within Sapper.
|
|
670
|
+
|
|
671
|
+
## Your Expertise
|
|
672
|
+
- Bug detection and logic errors
|
|
673
|
+
- Security vulnerability scanning (OWASP Top 10)
|
|
674
|
+
- Performance bottleneck identification
|
|
675
|
+
- Code style and best practices
|
|
676
|
+
- Architecture and design pattern review
|
|
677
|
+
- Dependency and import analysis
|
|
678
|
+
|
|
679
|
+
## Behavior
|
|
680
|
+
- You are READ-ONLY — analyze and report, never modify files
|
|
681
|
+
- Be specific: reference exact file paths and line numbers
|
|
682
|
+
- Categorize issues by severity: 🔴 Critical, 🟡 Warning, 🟢 Suggestion
|
|
683
|
+
- Provide the fix alongside the problem
|
|
684
|
+
- Check for: unused variables, error handling gaps, race conditions, SQL injection, XSS, hardcoded secrets
|
|
685
|
+
|
|
686
|
+
## Review Format
|
|
687
|
+
For each issue found:
|
|
688
|
+
\`\`\`
|
|
689
|
+
🔴/🟡/🟢 [Category] — file:line
|
|
690
|
+
Problem: What's wrong
|
|
691
|
+
Fix: How to fix it
|
|
692
|
+
\`\`\``
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const defaultSkills = {
|
|
696
|
+
'git-workflow': `---
|
|
697
|
+
name: git-workflow
|
|
698
|
+
description: "Git best practices — branching, commits, PRs, rebasing, conflict resolution. Use when working with version control."
|
|
699
|
+
argument-hint: "Describe the git operation (e.g., 'create feature branch', 'squash commits')"
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
# Git Workflow
|
|
703
|
+
|
|
704
|
+
Best practices for Git version control.
|
|
705
|
+
|
|
706
|
+
## Commit Messages
|
|
707
|
+
- Format: \`type(scope): description\`
|
|
708
|
+
- Types: feat, fix, docs, style, refactor, test, chore, perf
|
|
709
|
+
- Keep subject line under 72 characters
|
|
710
|
+
- Use imperative mood: "add feature" not "added feature"
|
|
711
|
+
- Examples:
|
|
712
|
+
- \`feat(auth): add JWT token refresh\`
|
|
713
|
+
- \`fix(api): handle null response from payment service\`
|
|
714
|
+
- \`docs(readme): add deployment instructions\`
|
|
715
|
+
|
|
716
|
+
## Branching Strategy
|
|
717
|
+
- \`main\` — production-ready code
|
|
718
|
+
- \`develop\` — integration branch
|
|
719
|
+
- \`feature/name\` — new features
|
|
720
|
+
- \`fix/name\` — bug fixes
|
|
721
|
+
- \`hotfix/name\` — urgent production fixes
|
|
722
|
+
|
|
723
|
+
## Common Operations
|
|
724
|
+
| Task | Command |
|
|
725
|
+
|------|---------|
|
|
726
|
+
| New feature branch | \`git checkout -b feature/name develop\` |
|
|
727
|
+
| Stage specific files | \`git add file1 file2\` |
|
|
728
|
+
| Interactive rebase | \`git rebase -i HEAD~N\` |
|
|
729
|
+
| Squash last N commits | \`git rebase -i HEAD~N\` then change pick to squash |
|
|
730
|
+
| Undo last commit (keep changes) | \`git reset --soft HEAD~1\` |
|
|
731
|
+
| Stash with message | \`git stash push -m "description"\` |
|
|
732
|
+
| Cherry-pick a commit | \`git cherry-pick <hash>\` |
|
|
733
|
+
|
|
734
|
+
## PR Checklist
|
|
735
|
+
- [ ] Branch is up to date with target branch
|
|
736
|
+
- [ ] Tests pass
|
|
737
|
+
- [ ] No console.log / debug statements
|
|
738
|
+
- [ ] Commit messages follow convention
|
|
739
|
+
- [ ] Documentation updated if needed`,
|
|
740
|
+
|
|
741
|
+
'node-project': `---
|
|
742
|
+
name: node-project
|
|
743
|
+
description: "Node.js project conventions — package.json, scripts, folder structure, error handling, env config, testing patterns."
|
|
744
|
+
argument-hint: "Describe what you need (e.g., 'setup express project', 'add testing')"
|
|
745
|
+
---
|
|
746
|
+
|
|
747
|
+
# Node.js Project Conventions
|
|
748
|
+
|
|
749
|
+
## Project Structure
|
|
750
|
+
\`\`\`
|
|
751
|
+
project/
|
|
752
|
+
├── src/
|
|
753
|
+
│ ├── index.js # Entry point
|
|
754
|
+
│ ├── routes/ # Route handlers
|
|
755
|
+
│ ├── controllers/ # Business logic
|
|
756
|
+
│ ├── models/ # Data models
|
|
757
|
+
│ ├── middleware/ # Express middleware
|
|
758
|
+
│ ├── services/ # External service integrations
|
|
759
|
+
│ └── utils/ # Helper functions
|
|
760
|
+
├── tests/
|
|
761
|
+
│ ├── unit/
|
|
762
|
+
│ └── integration/
|
|
763
|
+
├── config/
|
|
764
|
+
│ └── index.js # Environment-based config
|
|
765
|
+
├── .env.example
|
|
766
|
+
├── .gitignore
|
|
767
|
+
├── package.json
|
|
768
|
+
└── README.md
|
|
769
|
+
\`\`\`
|
|
770
|
+
|
|
771
|
+
## Package.json Scripts
|
|
772
|
+
\`\`\`json
|
|
773
|
+
{
|
|
774
|
+
"scripts": {
|
|
775
|
+
"start": "node src/index.js",
|
|
776
|
+
"dev": "nodemon src/index.js",
|
|
777
|
+
"test": "jest --coverage",
|
|
778
|
+
"test:watch": "jest --watch",
|
|
779
|
+
"lint": "eslint src/",
|
|
780
|
+
"lint:fix": "eslint src/ --fix"
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
\`\`\`
|
|
784
|
+
|
|
785
|
+
## Best Practices
|
|
786
|
+
- Use \`const\` by default, \`let\` when needed, never \`var\`
|
|
787
|
+
- Always handle async errors with try/catch or .catch()
|
|
788
|
+
- Use environment variables via dotenv, never hardcode secrets
|
|
789
|
+
- Validate input at API boundaries (use zod, joi, or express-validator)
|
|
790
|
+
- Use structured logging (pino or winston), not console.log in production
|
|
791
|
+
- Prefer async/await over callbacks and .then() chains
|
|
792
|
+
- Exit gracefully: handle SIGTERM and SIGINT`
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
let created = 0;
|
|
796
|
+
for (const [name, content] of Object.entries(defaultAgents)) {
|
|
797
|
+
const filePath = join(AGENTS_DIR, `${name}.md`);
|
|
798
|
+
if (!fs.existsSync(filePath)) {
|
|
799
|
+
fs.writeFileSync(filePath, content);
|
|
800
|
+
created++;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
for (const [name, content] of Object.entries(defaultSkills)) {
|
|
804
|
+
const filePath = join(SKILLS_DIR, `${name}.md`);
|
|
805
|
+
if (!fs.existsSync(filePath)) {
|
|
806
|
+
fs.writeFileSync(filePath, content);
|
|
807
|
+
created++;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return created;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Build the system prompt with optional agent and skills
|
|
814
|
+
// Global flag — set after model selection, read in buildSystemPrompt
|
|
815
|
+
let _useNativeToolsFlag = false;
|
|
816
|
+
|
|
817
|
+
function buildSystemPrompt(agentContent = null, skillContents = []) {
|
|
818
|
+
const now = new Date();
|
|
819
|
+
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
820
|
+
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
821
|
+
let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
|
|
822
|
+
You can help with ANY task - coding, writing, research, planning, analysis, and more.
|
|
823
|
+
Adapt your personality and expertise based on the active agent role and loaded skills.
|
|
824
|
+
|
|
825
|
+
CURRENT DATE AND TIME: ${dateStr}, ${timeStr}
|
|
826
|
+
|
|
827
|
+
RULES:
|
|
828
|
+
1. EXPLORE FIRST: Use list and read to understand files before making changes.
|
|
829
|
+
2. THINK IN STEPS: Explain what you found and what you plan to do before acting.
|
|
830
|
+
3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
|
|
831
|
+
4. VERIFY: After making changes, verify they work (run tests, check output, etc).
|
|
832
|
+
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`;
|
|
833
|
+
|
|
834
|
+
if (_useNativeToolsFlag) {
|
|
835
|
+
prompt += `
|
|
836
|
+
|
|
837
|
+
TOOLS:
|
|
838
|
+
You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
|
|
839
|
+
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, run_shell.
|
|
840
|
+
|
|
841
|
+
PATCH TIPS:
|
|
842
|
+
- For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
|
|
843
|
+
- Always read_file first to see exact content before using patch_file.
|
|
844
|
+
- If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.`;
|
|
845
|
+
} else {
|
|
846
|
+
prompt += `
|
|
847
|
+
|
|
848
|
+
TOOL SYNTAX (use these to interact with files and system):
|
|
849
|
+
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
850
|
+
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
851
|
+
- [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
|
|
852
|
+
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
853
|
+
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
|
|
854
|
+
- [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace a specific line by number (PREFERRED — more reliable)
|
|
855
|
+
- [TOOL:SHELL]command[/TOOL] - Run shell command
|
|
856
|
+
|
|
857
|
+
PATCH TIPS:
|
|
858
|
+
- PREFER the LINE:number mode when you know which line to change. It is much more reliable than text matching.
|
|
859
|
+
- Always READ the file first to see exact content before using PATCH.
|
|
860
|
+
- If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
|
|
861
|
+
|
|
862
|
+
You MUST use the [TOOL:...][/TOOL] syntax above to perform actions. This is how you interact with the filesystem and shell - there is no other way. When you want to read a file, output [TOOL:READ]path[/TOOL] in your response. When you want to list a directory, output [TOOL:LIST].[/TOOL]. Always actually use the tools - do not just describe what you would do.
|
|
863
|
+
Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
prompt += `
|
|
867
|
+
|
|
868
|
+
IMPORTANT CONTEXT:
|
|
869
|
+
- The current working directory is the user's project folder.
|
|
870
|
+
- Sapper has a built-in agent/skill system. Agents are managed via /agents, /agent create, /newagent commands - NOT by you creating files manually.
|
|
871
|
+
- Do NOT try to build agent frameworks, projects, or directory structures when the user mentions agents. The agent system is already built into Sapper.
|
|
872
|
+
- When the user asks you to do something, work within their current project directory.
|
|
873
|
+
- Use "." for the current directory when listing, not "/" or "agent/".
|
|
874
|
+
|
|
875
|
+
When no agent is active, you are a general-purpose assistant. When an agent role is loaded, fully adopt that role.`;
|
|
876
|
+
|
|
877
|
+
if (agentContent) {
|
|
878
|
+
prompt += `\n\n═══ ACTIVE AGENT ROLE ═══\n${agentContent}\n═══ END AGENT ROLE ═══\n\nIMPORTANT: You are now operating as the agent described above. Adopt its persona, expertise, and communication style while still having access to Sapper tools.`;
|
|
879
|
+
|
|
880
|
+
// If the active agent has tool restrictions, inform the AI
|
|
881
|
+
if (currentAgentTools && currentAgentTools.length > 0) {
|
|
882
|
+
const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
|
|
883
|
+
const forbidden = allTools.filter(t => !currentAgentTools.includes(t));
|
|
884
|
+
prompt += `\n\nTOOL RESTRICTION: This agent can ONLY use these tools: ${currentAgentTools.join(', ')}.
|
|
885
|
+
FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to use forbidden tools. If you need a forbidden tool, tell the user you cannot perform that action with your current role.`;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (skillContents.length > 0) {
|
|
890
|
+
prompt += `\n\n═══ LOADED SKILLS ═══`;
|
|
891
|
+
for (const skill of skillContents) {
|
|
892
|
+
prompt += `\n${skill}\n---`;
|
|
893
|
+
}
|
|
894
|
+
prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
return prompt;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Track active agent
|
|
901
|
+
let currentAgent = null; // null = default Sapper, or agent name string
|
|
902
|
+
let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
|
|
903
|
+
let loadedSkills = []; // array of skill names currently loaded
|
|
904
|
+
|
|
84
905
|
// Load config (settings like autoAttach)
|
|
85
906
|
function loadConfig() {
|
|
86
907
|
try {
|
|
@@ -89,7 +910,7 @@ function loadConfig() {
|
|
|
89
910
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
90
911
|
}
|
|
91
912
|
} catch (e) {}
|
|
92
|
-
return { autoAttach: true }; // Default: auto-attach
|
|
913
|
+
return { autoAttach: true, contextLimit: null }; // Default: auto-attach ON, no custom context limit
|
|
93
914
|
}
|
|
94
915
|
|
|
95
916
|
function saveConfig(config) {
|
|
@@ -100,6 +921,14 @@ function saveConfig(config) {
|
|
|
100
921
|
// Global config
|
|
101
922
|
let sapperConfig = loadConfig();
|
|
102
923
|
|
|
924
|
+
// Effective context length — user limit overrides model's reported size
|
|
925
|
+
function effectiveContextLength() {
|
|
926
|
+
if (sapperConfig.contextLimit && sapperConfig.contextLimit > 0) {
|
|
927
|
+
return sapperConfig.contextLimit;
|
|
928
|
+
}
|
|
929
|
+
return modelContextLength;
|
|
930
|
+
}
|
|
931
|
+
|
|
103
932
|
// ═══════════════════════════════════════════════════════════════
|
|
104
933
|
// WORKSPACE GRAPH - Track file relationships and summaries
|
|
105
934
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -210,9 +1039,10 @@ async function buildWorkspaceGraph(showProgress = true) {
|
|
|
210
1039
|
const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
211
1040
|
|
|
212
1041
|
if (entry.isDirectory()) {
|
|
213
|
-
if (
|
|
1042
|
+
if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
|
|
214
1043
|
scanDir(fullPath, depth + 1);
|
|
215
1044
|
} else {
|
|
1045
|
+
if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
|
|
216
1046
|
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : '';
|
|
217
1047
|
if (!CODE_EXTENSIONS.has(ext.toLowerCase())) continue;
|
|
218
1048
|
|
|
@@ -629,109 +1459,447 @@ async function addToEmbeddings(text, embeddings) {
|
|
|
629
1459
|
}
|
|
630
1460
|
|
|
631
1461
|
// ═══════════════════════════════════════════════════════════════
|
|
632
|
-
//
|
|
1462
|
+
// SMART CONTEXT SUMMARIZATION
|
|
633
1463
|
// ═══════════════════════════════════════════════════════════════
|
|
634
1464
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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;
|
|
1465
|
+
async function autoSummarizeContext(messages, model, force = false) {
|
|
1466
|
+
// Use real token-based threshold if we know the model's context length
|
|
1467
|
+
const estimatedTokens = estimateMessagesTokens(messages);
|
|
1468
|
+
const contextSize = JSON.stringify(messages).length;
|
|
648
1469
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1470
|
+
// Summarize when we hit 75% of effective context window (leave room for response)
|
|
1471
|
+
const ctxLen = effectiveContextLength();
|
|
1472
|
+
const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
|
|
1473
|
+
// Also keep the old byte-based check as a fallback
|
|
1474
|
+
const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
|
|
1475
|
+
(!ctxLen && contextSize > 32000);
|
|
1476
|
+
|
|
1477
|
+
if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
|
|
656
1478
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
1479
|
+
const usagePercent = ctxLen
|
|
1480
|
+
? Math.round((estimatedTokens / ctxLen) * 100)
|
|
1481
|
+
: Math.round((contextSize / 32000) * 100);
|
|
661
1482
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
};
|
|
670
|
-
return badges[type] || badges.info;
|
|
671
|
-
}
|
|
1483
|
+
console.log();
|
|
1484
|
+
console.log(box(
|
|
1485
|
+
`Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})\n` +
|
|
1486
|
+
`${chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`)}\n` +
|
|
1487
|
+
`${chalk.cyan('Auto-summarizing to stay within context window...')}`,
|
|
1488
|
+
'🧠 Context Window Management', 'cyan'
|
|
1489
|
+
));
|
|
672
1490
|
|
|
673
|
-
|
|
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
|
-
});
|
|
1491
|
+
const summarySpinner = ora('Summarizing conversation...').start();
|
|
693
1492
|
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1493
|
+
// Separate: system prompt, messages to summarize, recent messages to keep
|
|
1494
|
+
const systemPrompt = messages[0];
|
|
1495
|
+
const recentCount = 4;
|
|
1496
|
+
let recentMessages = messages.slice(-recentCount);
|
|
1497
|
+
let oldMessages = messages.slice(1, -recentCount);
|
|
1498
|
+
|
|
1499
|
+
// Smart selection: ensure we keep at least one tool-usage example in recent messages
|
|
1500
|
+
// This prevents the AI from "forgetting" how to use tools after summarization
|
|
1501
|
+
const hasToolExample = recentMessages.some(m =>
|
|
1502
|
+
m.role === 'assistant' && m.content.includes('[TOOL:') && m.content.includes('[/TOOL]')
|
|
1503
|
+
);
|
|
1504
|
+
if (!hasToolExample) {
|
|
1505
|
+
// Search backwards for the most recent assistant message that used tools
|
|
1506
|
+
for (let i = messages.length - recentCount - 1; i >= 1; i--) {
|
|
1507
|
+
if (messages[i].role === 'assistant' && messages[i].content.includes('[TOOL:') && messages[i].content.includes('[/TOOL]')) {
|
|
1508
|
+
// Include this tool-usage message and the user message before it + tool result after it
|
|
1509
|
+
const toolExampleMessages = [];
|
|
1510
|
+
if (i > 0 && messages[i - 1].role === 'user') toolExampleMessages.push(messages[i - 1]);
|
|
1511
|
+
toolExampleMessages.push(messages[i]);
|
|
1512
|
+
if (i + 1 < messages.length - recentCount && messages[i + 1].role === 'user' && messages[i + 1].content.startsWith('RESULT')) {
|
|
1513
|
+
toolExampleMessages.push(messages[i + 1]);
|
|
1514
|
+
}
|
|
1515
|
+
// Remove these from oldMessages and prepend to recentMessages
|
|
1516
|
+
const toolExampleIndices = new Set();
|
|
1517
|
+
for (let j = Math.max(1, i - 1); j <= Math.min(i + 1, messages.length - recentCount - 1); j++) {
|
|
1518
|
+
if (toolExampleMessages.includes(messages[j])) toolExampleIndices.add(j);
|
|
1519
|
+
}
|
|
1520
|
+
oldMessages = messages.slice(1, -recentCount).filter((_, idx) => !toolExampleIndices.has(idx + 1));
|
|
1521
|
+
recentMessages = [...toolExampleMessages, ...recentMessages];
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
700
1525
|
}
|
|
701
|
-
}
|
|
702
1526
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
input: process.stdin,
|
|
708
|
-
output: process.stdout,
|
|
709
|
-
terminal: true,
|
|
710
|
-
historySize: 100
|
|
711
|
-
});
|
|
1527
|
+
if (oldMessages.length < 2) {
|
|
1528
|
+
summarySpinner.stop();
|
|
1529
|
+
return messages; // Nothing meaningful to summarize
|
|
1530
|
+
}
|
|
712
1531
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
1532
|
+
// Build a condensed version of old messages for the summary request
|
|
1533
|
+
const conversationText = oldMessages
|
|
1534
|
+
.filter(m => m.role !== 'system')
|
|
1535
|
+
.map(m => {
|
|
1536
|
+
const role = m.role === 'user' ? 'User' : 'Assistant';
|
|
1537
|
+
// Truncate very long messages (file contents, scan results, etc.)
|
|
1538
|
+
const text = m.content.length > 1500
|
|
1539
|
+
? m.content.substring(0, 1500) + '\n... [truncated]'
|
|
1540
|
+
: m.content;
|
|
1541
|
+
return `${role}: ${text}`;
|
|
1542
|
+
})
|
|
1543
|
+
.join('\n\n');
|
|
724
1544
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1545
|
+
try {
|
|
1546
|
+
const summaryResponse = await ollama.chat({
|
|
1547
|
+
model,
|
|
1548
|
+
...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
|
|
1549
|
+
messages: [
|
|
1550
|
+
{
|
|
1551
|
+
role: 'system',
|
|
1552
|
+
content: `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
|
|
1553
|
+
- Key topics discussed and decisions made
|
|
1554
|
+
- Files that were read, created, or modified (with paths)
|
|
1555
|
+
- Important code changes or bugs found
|
|
1556
|
+
- Any pending tasks or open questions
|
|
1557
|
+
- Technical details that would be needed to continue the conversation
|
|
1558
|
+
- Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH) and on what files
|
|
1559
|
+
- The active agent role (if any) and loaded skills
|
|
1560
|
+
- Any tool usage patterns or workflows that were established
|
|
1561
|
+
|
|
1562
|
+
CRITICAL: The AI assistant uses tools with syntax like [TOOL:READ]path[/TOOL]. Make sure to note which tools were used so the assistant remembers to keep using them after this summary.
|
|
1563
|
+
|
|
1564
|
+
Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
role: 'user',
|
|
1568
|
+
content: `Summarize this conversation:\n\n${conversationText}`
|
|
1569
|
+
}
|
|
1570
|
+
],
|
|
1571
|
+
stream: false
|
|
732
1572
|
});
|
|
733
|
-
|
|
734
|
-
|
|
1573
|
+
|
|
1574
|
+
const summary = summaryResponse.message.content;
|
|
1575
|
+
|
|
1576
|
+
// Save old messages to embeddings before discarding
|
|
1577
|
+
const embeddings = loadEmbeddings();
|
|
1578
|
+
const textToEmbed = oldMessages
|
|
1579
|
+
.filter(m => m.role !== 'system')
|
|
1580
|
+
.map(m => m.content.substring(0, 500))
|
|
1581
|
+
.join('\n---\n');
|
|
1582
|
+
|
|
1583
|
+
if (textToEmbed.length > 50) {
|
|
1584
|
+
try {
|
|
1585
|
+
const embedding = await getEmbedding(textToEmbed);
|
|
1586
|
+
if (embedding) {
|
|
1587
|
+
embeddings.chunks.push({
|
|
1588
|
+
text: textToEmbed.substring(0, 2000),
|
|
1589
|
+
embedding,
|
|
1590
|
+
timestamp: Date.now()
|
|
1591
|
+
});
|
|
1592
|
+
if (embeddings.chunks.length > 100) {
|
|
1593
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
1594
|
+
}
|
|
1595
|
+
saveEmbeddings(embeddings);
|
|
1596
|
+
}
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
// Silently skip embedding if model not available
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Build agent role reminder if an agent is active
|
|
1603
|
+
const agentReminder = currentAgent ? `\nNote: You are currently operating as the "${currentAgent}" agent. Stay in character.` : '';
|
|
1604
|
+
const skillReminder = loadedSkills.length > 0 ? `\nLoaded skills: ${loadedSkills.join(', ')}. Apply this knowledge when relevant.` : '';
|
|
1605
|
+
|
|
1606
|
+
// Rebuild messages: system prompt + summary + tool reinforcement + recent messages
|
|
1607
|
+
const newMessages = [
|
|
1608
|
+
systemPrompt,
|
|
1609
|
+
{
|
|
1610
|
+
role: 'user',
|
|
1611
|
+
content: `[CONVERSATION SUMMARY - auto-generated]\n${summary}\n[END SUMMARY]\n\nUse this summary as context for our ongoing conversation. Continue using your tools (LIST, READ, WRITE, PATCH, SHELL, SEARCH) as needed.${agentReminder}${skillReminder}`
|
|
1612
|
+
},
|
|
1613
|
+
{
|
|
1614
|
+
role: 'assistant',
|
|
1615
|
+
content: _useNativeToolsFlag
|
|
1616
|
+
? `Understood. I have the conversation summary and will continue helping you. I'll use my tools (list_directory, read_file, write_file, patch_file, search_files, run_shell) as needed.\n\nWhat would you like me to do next?`
|
|
1617
|
+
: `Understood. I have the conversation summary and will continue helping you. I'll keep using my tools to explore files, make changes, and run commands as needed:\n- [TOOL:LIST] to browse directories\n- [TOOL:READ] to read files\n- [TOOL:WRITE] to create/overwrite files\n- [TOOL:PATCH] to edit existing files\n- [TOOL:SEARCH] to find patterns\n- [TOOL:SHELL] to run commands\n\nWhat would you like me to do next?`
|
|
1618
|
+
},
|
|
1619
|
+
...recentMessages
|
|
1620
|
+
];
|
|
1621
|
+
|
|
1622
|
+
// Save immediately
|
|
1623
|
+
ensureSapperDir();
|
|
1624
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(newMessages, null, 2));
|
|
1625
|
+
|
|
1626
|
+
const newSize = JSON.stringify(newMessages).length;
|
|
1627
|
+
const newTokens = estimateMessagesTokens(newMessages);
|
|
1628
|
+
summarySpinner.stop();
|
|
1629
|
+
console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
|
|
1630
|
+
if (ctxLen) {
|
|
1631
|
+
const newPercent = Math.round((newTokens / ctxLen) * 100);
|
|
1632
|
+
console.log(chalk.gray(` 📊 Context window usage: ${newPercent}% of ${ctxLen.toLocaleString()} tokens`));
|
|
1633
|
+
}
|
|
1634
|
+
if (embeddings.chunks.length > 0) {
|
|
1635
|
+
console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
|
|
1636
|
+
}
|
|
1637
|
+
logEntry('summary', {
|
|
1638
|
+
before: `~${estimatedTokens.toLocaleString()} tokens / ${messages.length} msgs`,
|
|
1639
|
+
after: `~${newTokens.toLocaleString()} tokens / ${newMessages.length} msgs`
|
|
1640
|
+
});
|
|
1641
|
+
console.log();
|
|
1642
|
+
|
|
1643
|
+
return newMessages;
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
summarySpinner.stop();
|
|
1646
|
+
console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
|
|
1647
|
+
console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
|
|
1648
|
+
return messages; // Return unchanged on failure
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1653
|
+
// FANCY UI HELPERS
|
|
1654
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1655
|
+
|
|
1656
|
+
const UI = {
|
|
1657
|
+
accent: chalk.hex('#7cc4ff'),
|
|
1658
|
+
accentSoft: chalk.hex('#b8d9ff'),
|
|
1659
|
+
mint: chalk.hex('#9ad7b3'),
|
|
1660
|
+
gold: chalk.hex('#d8bc7a'),
|
|
1661
|
+
coral: chalk.hex('#de9d8f'),
|
|
1662
|
+
slate: chalk.hex('#8a95a6'),
|
|
1663
|
+
ink: chalk.hex('#e6ebf2'),
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
const BOX_TONES = {
|
|
1667
|
+
cyan: UI.accent,
|
|
1668
|
+
green: UI.mint,
|
|
1669
|
+
yellow: UI.gold,
|
|
1670
|
+
red: UI.coral,
|
|
1671
|
+
magenta: chalk.hex('#b7b9ff'),
|
|
1672
|
+
gray: UI.slate,
|
|
1673
|
+
blue: chalk.hex('#8fb6ff'),
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
const BADGE_STYLES = {
|
|
1677
|
+
info: UI.accent,
|
|
1678
|
+
success: UI.mint,
|
|
1679
|
+
warning: UI.gold,
|
|
1680
|
+
error: UI.coral,
|
|
1681
|
+
action: chalk.hex('#9bbcff'),
|
|
1682
|
+
neutral: UI.slate,
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
const ANSI_PATTERN = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001B\\))/g;
|
|
1686
|
+
|
|
1687
|
+
function stripAnsi(value = '') {
|
|
1688
|
+
return String(value).replace(ANSI_PATTERN, '');
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function visibleLength(value = '') {
|
|
1692
|
+
return stripAnsi(value).length;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function terminalWidth(max = 98) {
|
|
1696
|
+
return Math.max(48, Math.min(max, process.stdout.columns || 88));
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function toneColor(tone = 'cyan') {
|
|
1700
|
+
return BOX_TONES[tone] || chalk.cyan;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function padAnsi(value = '', width = 0) {
|
|
1704
|
+
return `${value}${' '.repeat(Math.max(0, width - visibleLength(value)))}`;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function formatBytes(bytes = 0) {
|
|
1708
|
+
if (!bytes || bytes < 1024) return `${bytes || 0} B`;
|
|
1709
|
+
|
|
1710
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
1711
|
+
let size = bytes / 1024;
|
|
1712
|
+
let unitIndex = 0;
|
|
1713
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
1714
|
+
size /= 1024;
|
|
1715
|
+
unitIndex++;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
const precision = size >= 100 ? 0 : size >= 10 ? 1 : 2;
|
|
1719
|
+
return `${size.toFixed(precision)} ${units[unitIndex]}`;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function formatRelativeTime(value) {
|
|
1723
|
+
if (!value) return 'unknown';
|
|
1724
|
+
|
|
1725
|
+
const delta = Math.max(0, Date.now() - new Date(value).getTime());
|
|
1726
|
+
const units = [
|
|
1727
|
+
['d', 24 * 60 * 60 * 1000],
|
|
1728
|
+
['h', 60 * 60 * 1000],
|
|
1729
|
+
['m', 60 * 1000],
|
|
1730
|
+
];
|
|
1731
|
+
|
|
1732
|
+
for (const [label, size] of units) {
|
|
1733
|
+
const amount = Math.floor(delta / size);
|
|
1734
|
+
if (amount >= 1) return `${amount}${label} ago`;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
return 'just now';
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
const BANNER = [
|
|
1741
|
+
`${chalk.hex('#c8ecff').bold('Sapper')} ${UI.slate('terminal workspace')}`,
|
|
1742
|
+
UI.slate('Local models, live tools, and focused coding in one loop')
|
|
1743
|
+
].join('\n');
|
|
1744
|
+
|
|
1745
|
+
function box(content, title = '', tone = 'cyan', options = {}) {
|
|
1746
|
+
const width = Math.max(28, Math.min(options.width || terminalWidth(72), terminalWidth(72)));
|
|
1747
|
+
const header = title ? `${toneColor(tone).bold(title)}\n${divider('─', tone, width)}\n` : '';
|
|
1748
|
+
return `${header}${String(content ?? '')}\n${divider('─', tone, width)}`;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
function divider(char = '─', tone = 'gray', width = terminalWidth(70)) {
|
|
1752
|
+
return toneColor(tone)(char.repeat(Math.max(12, width)));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function sectionTitle(title, subtitle = '', tone = 'cyan') {
|
|
1756
|
+
return `${toneColor(tone).bold(title)}${subtitle ? ` ${UI.slate(subtitle)}` : ''}`;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function statusBadge(text, type = 'info') {
|
|
1760
|
+
const badge = BADGE_STYLES[type] || BADGE_STYLES.info;
|
|
1761
|
+
return badge(`[${text}]`);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function keyValue(label, value, width = 12) {
|
|
1765
|
+
return `${padAnsi(UI.slate(label), width)} ${value}`;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function commandRow(command, description, width = 18) {
|
|
1769
|
+
return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
function meter(current = 0, total = 0, width = 20) {
|
|
1773
|
+
if (!total || total <= 0) return UI.slate('░'.repeat(width));
|
|
1774
|
+
|
|
1775
|
+
const ratio = Math.max(0, Math.min(1, current / total));
|
|
1776
|
+
const filled = Math.round(ratio * width);
|
|
1777
|
+
const colorFn = ratio >= 0.85 ? toneColor('red') : ratio >= 0.65 ? toneColor('yellow') : toneColor('green');
|
|
1778
|
+
return `${colorFn('█'.repeat(filled))}${UI.slate('░'.repeat(Math.max(0, width - filled)))}`;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function ellipsis(text = '', max = 48) {
|
|
1782
|
+
const plain = String(text);
|
|
1783
|
+
if (plain.length <= max) return plain;
|
|
1784
|
+
return `${plain.slice(0, Math.max(0, max - 1))}…`;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function promptShell(label, detail = '') {
|
|
1788
|
+
return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
function confirmPrompt(label, type = 'warning') {
|
|
1792
|
+
const colors = {
|
|
1793
|
+
info: UI.accent,
|
|
1794
|
+
success: UI.mint,
|
|
1795
|
+
warning: UI.gold,
|
|
1796
|
+
error: UI.coral,
|
|
1797
|
+
action: chalk.hex('#8fb6ff'),
|
|
1798
|
+
neutral: UI.slate,
|
|
1799
|
+
};
|
|
1800
|
+
const colorFn = colors[type] || UI.gold;
|
|
1801
|
+
return colorFn(`\n${label}? `) + UI.slate('[y/N] ');
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
// Configure marked with terminal renderer
|
|
1805
|
+
marked.use(markedTerminal({
|
|
1806
|
+
code: chalk.cyan,
|
|
1807
|
+
blockquote: chalk.gray.italic,
|
|
1808
|
+
html: chalk.gray,
|
|
1809
|
+
heading: chalk.bold.cyan,
|
|
1810
|
+
firstHeading: chalk.bold.cyan,
|
|
1811
|
+
table: chalk.white,
|
|
1812
|
+
tableOptions: {
|
|
1813
|
+
chars: {
|
|
1814
|
+
top: '─', 'top-mid': '┬', 'top-left': '┌', 'top-right': '┐',
|
|
1815
|
+
bottom: '─', 'bottom-mid': '┴', 'bottom-left': '└', 'bottom-right': '┘',
|
|
1816
|
+
left: '│', 'left-mid': '├', mid: '─', 'mid-mid': '┼',
|
|
1817
|
+
right: '│', 'right-mid': '┤', middle: '│'
|
|
1818
|
+
},
|
|
1819
|
+
style: { head: ['cyan', 'bold'], border: ['gray'] }
|
|
1820
|
+
},
|
|
1821
|
+
paragraph: chalk.white,
|
|
1822
|
+
strong: chalk.bold.white,
|
|
1823
|
+
em: chalk.italic,
|
|
1824
|
+
codespan: chalk.cyan,
|
|
1825
|
+
del: chalk.strikethrough,
|
|
1826
|
+
link: chalk.underline.blue,
|
|
1827
|
+
href: chalk.gray,
|
|
1828
|
+
showSectionPrefix: true,
|
|
1829
|
+
reflowText: true,
|
|
1830
|
+
width: Math.min(process.stdout.columns || 80, 120)
|
|
1831
|
+
}));
|
|
1832
|
+
|
|
1833
|
+
// Render markdown to terminal
|
|
1834
|
+
function renderMarkdown(text) {
|
|
1835
|
+
try {
|
|
1836
|
+
return marked(text).trim();
|
|
1837
|
+
} catch (e) {
|
|
1838
|
+
return text; // Fallback to raw text
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
let stepMode = false;
|
|
1843
|
+
let debugMode = false; // Toggle with /debug command
|
|
1844
|
+
let abortStream = false; // Flag to interrupt AI response
|
|
1845
|
+
|
|
1846
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1847
|
+
// REAL CONTEXT WINDOW TRACKING
|
|
1848
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1849
|
+
let modelContextLength = null; // Detected from ollama.show() model_info
|
|
1850
|
+
let lastPromptTokens = 0; // prompt_eval_count from last response
|
|
1851
|
+
let lastEvalTokens = 0; // eval_count from last response
|
|
1852
|
+
|
|
1853
|
+
// Estimate token count from text (~4 chars per token for English, ~3 for code)
|
|
1854
|
+
// This is a rough heuristic - actual counts come from Ollama response stats
|
|
1855
|
+
function estimateTokens(text) {
|
|
1856
|
+
if (!text) return 0;
|
|
1857
|
+
// Count code blocks separately (denser tokens)
|
|
1858
|
+
const codeBlocks = text.match(/```[\s\S]*?```/g) || [];
|
|
1859
|
+
let codeChars = codeBlocks.reduce((sum, b) => sum + b.length, 0);
|
|
1860
|
+
let textChars = text.length - codeChars;
|
|
1861
|
+
return Math.ceil(textChars / 4 + codeChars / 3.5);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Estimate total tokens for the messages array
|
|
1865
|
+
function estimateMessagesTokens(messages) {
|
|
1866
|
+
let total = 0;
|
|
1867
|
+
for (const m of messages) {
|
|
1868
|
+
// Each message has ~4 tokens of overhead (role, formatting)
|
|
1869
|
+
total += 4;
|
|
1870
|
+
total += estimateTokens(m.content);
|
|
1871
|
+
}
|
|
1872
|
+
return total;
|
|
1873
|
+
}
|
|
1874
|
+
let rl = readline.createInterface({
|
|
1875
|
+
input: process.stdin,
|
|
1876
|
+
output: process.stdout,
|
|
1877
|
+
terminal: true,
|
|
1878
|
+
historySize: 100
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
function recreateReadline() {
|
|
1882
|
+
if (rl) rl.close();
|
|
1883
|
+
rl = readline.createInterface({
|
|
1884
|
+
input: process.stdin,
|
|
1885
|
+
output: process.stdout,
|
|
1886
|
+
terminal: true,
|
|
1887
|
+
historySize: 100
|
|
1888
|
+
});
|
|
1889
|
+
// Force resume stdin to keep process alive
|
|
1890
|
+
process.stdin.resume();
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
async function safeQuestion(query) {
|
|
1894
|
+
resetTerminal(); // Clear terminal state before asking
|
|
1895
|
+
if (rl.closed) recreateReadline();
|
|
1896
|
+
|
|
1897
|
+
return new Promise((resolve) => {
|
|
1898
|
+
rl.question(query, (answer) => {
|
|
1899
|
+
resolve(answer ? answer.trim() : '');
|
|
1900
|
+
});
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
735
1903
|
|
|
736
1904
|
// Directories to ignore when listing files
|
|
737
1905
|
const IGNORE_DIRS = new Set([
|
|
@@ -753,6 +1921,157 @@ const CODE_EXTENSIONS = new Set([
|
|
|
753
1921
|
// Max file size to include (skip large files like bundled/minified)
|
|
754
1922
|
const MAX_FILE_SIZE = 100000; // 100KB per file
|
|
755
1923
|
const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
|
|
1924
|
+
const MAX_URL_SIZE = 200000; // 200KB max for fetched web pages
|
|
1925
|
+
|
|
1926
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1927
|
+
// URL FETCHING — Read web pages and learn from them
|
|
1928
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1929
|
+
import https from 'https';
|
|
1930
|
+
import http from 'http';
|
|
1931
|
+
|
|
1932
|
+
// Fetch a URL and return extracted text content
|
|
1933
|
+
function fetchUrl(url, timeout = 15000) {
|
|
1934
|
+
return new Promise((resolve, reject) => {
|
|
1935
|
+
const lib = url.startsWith('https') ? https : http;
|
|
1936
|
+
const req = lib.get(url, {
|
|
1937
|
+
headers: {
|
|
1938
|
+
'User-Agent': 'Sapper-AI/1.0',
|
|
1939
|
+
'Accept': 'text/html,application/json,text/plain,*/*'
|
|
1940
|
+
},
|
|
1941
|
+
timeout
|
|
1942
|
+
}, (res) => {
|
|
1943
|
+
// Follow redirects (up to 3)
|
|
1944
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
1945
|
+
const redirectUrl = res.headers.location.startsWith('http')
|
|
1946
|
+
? res.headers.location
|
|
1947
|
+
: new URL(res.headers.location, url).href;
|
|
1948
|
+
return fetchUrl(redirectUrl, timeout).then(resolve).catch(reject);
|
|
1949
|
+
}
|
|
1950
|
+
if (res.statusCode !== 200) {
|
|
1951
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
let data = '';
|
|
1955
|
+
let size = 0;
|
|
1956
|
+
res.on('data', (chunk) => {
|
|
1957
|
+
size += chunk.length;
|
|
1958
|
+
if (size > MAX_URL_SIZE) {
|
|
1959
|
+
res.destroy();
|
|
1960
|
+
reject(new Error(`Page too large (>${Math.round(MAX_URL_SIZE/1024)}KB)`));
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
data += chunk;
|
|
1964
|
+
});
|
|
1965
|
+
res.on('end', () => resolve(data));
|
|
1966
|
+
res.on('error', reject);
|
|
1967
|
+
});
|
|
1968
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
|
|
1969
|
+
req.on('error', reject);
|
|
1970
|
+
});
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// Strip HTML tags and extract readable text
|
|
1974
|
+
function htmlToText(html) {
|
|
1975
|
+
let text = html;
|
|
1976
|
+
// Remove script and style blocks entirely
|
|
1977
|
+
text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
1978
|
+
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
1979
|
+
text = text.replace(/<nav[\s\S]*?<\/nav>/gi, '');
|
|
1980
|
+
text = text.replace(/<footer[\s\S]*?<\/footer>/gi, '');
|
|
1981
|
+
text = text.replace(/<header[\s\S]*?<\/header>/gi, '');
|
|
1982
|
+
// Convert common block elements to newlines
|
|
1983
|
+
text = text.replace(/<\/?(p|div|br|h[1-6]|li|tr|td|th|blockquote|pre|hr)[^>]*>/gi, '\n');
|
|
1984
|
+
// Remove all other HTML tags
|
|
1985
|
+
text = text.replace(/<[^>]+>/g, '');
|
|
1986
|
+
// Decode common HTML entities
|
|
1987
|
+
text = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1988
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
|
1989
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n));
|
|
1990
|
+
// Clean up whitespace
|
|
1991
|
+
text = text.replace(/[ \t]+/g, ' ');
|
|
1992
|
+
text = text.replace(/\n\s*\n/g, '\n\n');
|
|
1993
|
+
text = text.trim();
|
|
1994
|
+
// Limit to reasonable size
|
|
1995
|
+
if (text.length > 50000) {
|
|
1996
|
+
text = text.substring(0, 50000) + '\n\n[... content truncated at 50KB ...]';
|
|
1997
|
+
}
|
|
1998
|
+
return text;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Detect URLs in text
|
|
2002
|
+
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
2003
|
+
|
|
2004
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2005
|
+
// .sapperignore SUPPORT — like .gitignore for Sapper
|
|
2006
|
+
// ═══════════════════════════════════════════════════════════════
|
|
2007
|
+
|
|
2008
|
+
// Parse .sapperignore patterns (glob-like, one per line, # comments)
|
|
2009
|
+
function loadSapperIgnorePatterns() {
|
|
2010
|
+
const patterns = [];
|
|
2011
|
+
try {
|
|
2012
|
+
if (fs.existsSync(SAPPERIGNORE_FILE)) {
|
|
2013
|
+
const lines = fs.readFileSync(SAPPERIGNORE_FILE, 'utf8').split('\n');
|
|
2014
|
+
for (const rawLine of lines) {
|
|
2015
|
+
const line = rawLine.trim();
|
|
2016
|
+
if (!line || line.startsWith('#')) continue;
|
|
2017
|
+
// Track negation patterns (lines starting with !)
|
|
2018
|
+
const negate = line.startsWith('!');
|
|
2019
|
+
const pattern = negate ? line.slice(1) : line;
|
|
2020
|
+
patterns.push({ pattern, negate });
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
} catch (e) {
|
|
2024
|
+
// Silent fail — ignore file is optional
|
|
2025
|
+
}
|
|
2026
|
+
return patterns;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
let _sapperIgnorePatterns = null;
|
|
2030
|
+
function getSapperIgnorePatterns() {
|
|
2031
|
+
if (_sapperIgnorePatterns === null) {
|
|
2032
|
+
_sapperIgnorePatterns = loadSapperIgnorePatterns();
|
|
2033
|
+
}
|
|
2034
|
+
return _sapperIgnorePatterns;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Reload patterns (call when .sapperignore changes)
|
|
2038
|
+
function reloadSapperIgnore() {
|
|
2039
|
+
_sapperIgnorePatterns = null;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Convert a .sapperignore glob pattern to a regex
|
|
2043
|
+
function ignorePatternToRegex(pattern) {
|
|
2044
|
+
// Remove trailing slashes (directory markers)
|
|
2045
|
+
let p = pattern.replace(/\/+$/, '');
|
|
2046
|
+
// Escape regex special chars except * and ?
|
|
2047
|
+
p = p.replace(/([.+^${}()|[\]\\])/g, '\\$1');
|
|
2048
|
+
// Convert glob wildcards
|
|
2049
|
+
p = p.replace(/\*\*/g, '<<<GLOBSTAR>>>');
|
|
2050
|
+
p = p.replace(/\*/g, '[^/]*');
|
|
2051
|
+
p = p.replace(/<<<GLOBSTAR>>>/g, '.*');
|
|
2052
|
+
p = p.replace(/\?/g, '[^/]');
|
|
2053
|
+
// Match the whole name or path
|
|
2054
|
+
return new RegExp(`(^|/)${p}($|/)`, 'i');
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Check if a file/dir name or path should be ignored
|
|
2058
|
+
function shouldIgnore(nameOrPath) {
|
|
2059
|
+
// Always check built-in IGNORE_DIRS first (fast path)
|
|
2060
|
+
const baseName = nameOrPath.includes('/') ? nameOrPath.split('/').pop() : nameOrPath;
|
|
2061
|
+
if (IGNORE_DIRS.has(baseName)) return true;
|
|
2062
|
+
|
|
2063
|
+
const patterns = getSapperIgnorePatterns();
|
|
2064
|
+
if (patterns.length === 0) return false;
|
|
2065
|
+
|
|
2066
|
+
let ignored = false;
|
|
2067
|
+
for (const { pattern, negate } of patterns) {
|
|
2068
|
+
const regex = ignorePatternToRegex(pattern);
|
|
2069
|
+
if (regex.test(nameOrPath) || regex.test(baseName)) {
|
|
2070
|
+
ignored = !negate;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return ignored;
|
|
2074
|
+
}
|
|
756
2075
|
|
|
757
2076
|
// Scan entire codebase and return summary
|
|
758
2077
|
function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
|
|
@@ -767,14 +2086,15 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
|
|
|
767
2086
|
for (const entry of entries) {
|
|
768
2087
|
const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
769
2088
|
|
|
770
|
-
// Skip ignored directories
|
|
2089
|
+
// Skip ignored directories and files (respects .sapperignore)
|
|
771
2090
|
if (entry.isDirectory()) {
|
|
772
|
-
if (
|
|
2091
|
+
if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
|
|
773
2092
|
const subResult = scanCodebase(fullPath, depth + 1, maxDepth);
|
|
774
2093
|
files = files.concat(subResult.files);
|
|
775
2094
|
totalSize += subResult.totalSize;
|
|
776
2095
|
} else {
|
|
777
2096
|
// Check if file should be included
|
|
2097
|
+
if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
|
|
778
2098
|
const ext = entry.name.includes('.') ? '.' + entry.name.split('.').pop() : entry.name;
|
|
779
2099
|
const isCodeFile = CODE_EXTENSIONS.has(ext.toLowerCase()) || CODE_EXTENSIONS.has(entry.name);
|
|
780
2100
|
|
|
@@ -813,7 +2133,7 @@ function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
|
|
|
813
2133
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
814
2134
|
for (const entry of entries) {
|
|
815
2135
|
if (files.length >= maxFiles) break;
|
|
816
|
-
if (
|
|
2136
|
+
if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
|
|
817
2137
|
|
|
818
2138
|
const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
819
2139
|
|
|
@@ -861,8 +2181,9 @@ async function pickFiles() {
|
|
|
861
2181
|
// Clear screen and move cursor to top
|
|
862
2182
|
console.clear();
|
|
863
2183
|
console.log(box(
|
|
864
|
-
`${
|
|
865
|
-
'
|
|
2184
|
+
`${statusBadge('Move', 'info')} ↑ ↓ ${statusBadge('Toggle', 'success')} space ${statusBadge('All', 'warning')} a\n` +
|
|
2185
|
+
`${statusBadge('Confirm', 'success')} enter ${statusBadge('Cancel', 'error')} q / esc`,
|
|
2186
|
+
'Attach Files', 'cyan'
|
|
866
2187
|
));
|
|
867
2188
|
console.log();
|
|
868
2189
|
|
|
@@ -893,7 +2214,7 @@ async function pickFiles() {
|
|
|
893
2214
|
}
|
|
894
2215
|
|
|
895
2216
|
console.log();
|
|
896
|
-
|
|
2217
|
+
console.log(`${statusBadge('Selected', 'action')} ${chalk.white(`${selected.size} file${selected.size !== 1 ? 's' : ''}`)}`);
|
|
897
2218
|
};
|
|
898
2219
|
|
|
899
2220
|
return new Promise((resolve) => {
|
|
@@ -992,6 +2313,114 @@ function formatScanResults(scanResult) {
|
|
|
992
2313
|
return output;
|
|
993
2314
|
}
|
|
994
2315
|
|
|
2316
|
+
// Interactive model picker with keyboard navigation
|
|
2317
|
+
async function pickModel(models) {
|
|
2318
|
+
if (!models || models.length === 0) return null;
|
|
2319
|
+
|
|
2320
|
+
let cursor = 0;
|
|
2321
|
+
const pageSize = Math.max(5, Math.min(8, (process.stdout.rows || 24) - 14));
|
|
2322
|
+
|
|
2323
|
+
if (process.stdin.isTTY) {
|
|
2324
|
+
process.stdin.setRawMode(true);
|
|
2325
|
+
}
|
|
2326
|
+
process.stdin.resume();
|
|
2327
|
+
|
|
2328
|
+
const render = () => {
|
|
2329
|
+
const current = models[cursor];
|
|
2330
|
+
console.clear();
|
|
2331
|
+
console.log(BANNER);
|
|
2332
|
+
console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
|
|
2333
|
+
console.log(divider());
|
|
2334
|
+
console.log(sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'));
|
|
2335
|
+
console.log();
|
|
2336
|
+
|
|
2337
|
+
const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
|
|
2338
|
+
const endIdx = Math.min(startIdx + pageSize, models.length);
|
|
2339
|
+
|
|
2340
|
+
if (startIdx > 0) {
|
|
2341
|
+
console.log(UI.slate(' ↑ more models'));
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
2345
|
+
const model = models[i];
|
|
2346
|
+
const isActive = i === cursor;
|
|
2347
|
+
const marker = isActive ? UI.accent('›') : UI.slate(' ');
|
|
2348
|
+
const index = isActive ? UI.accent(String(i + 1).padStart(2, '0')) : UI.slate(String(i + 1).padStart(2, '0'));
|
|
2349
|
+
const name = isActive ? UI.accentSoft.bold(ellipsis(model.name, 40)) : chalk.white(ellipsis(model.name, 40));
|
|
2350
|
+
const meta = [
|
|
2351
|
+
model.size ? formatBytes(model.size) : null,
|
|
2352
|
+
model.modified_at ? formatRelativeTime(model.modified_at) : null,
|
|
2353
|
+
model.details?.parameter_size || null,
|
|
2354
|
+
].filter(Boolean).join(' · ');
|
|
2355
|
+
|
|
2356
|
+
console.log(`${marker} ${index} ${name}`);
|
|
2357
|
+
if (meta) {
|
|
2358
|
+
console.log(` ${UI.slate(meta)}`);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (endIdx < models.length) {
|
|
2363
|
+
console.log(UI.slate(' ↓ more models'));
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
|
|
2367
|
+
const quant = current.details?.quantization_level || current.details?.quantization || 'default';
|
|
2368
|
+
console.log();
|
|
2369
|
+
console.log(box(
|
|
2370
|
+
`${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
|
|
2371
|
+
`${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
|
|
2372
|
+
`${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
|
|
2373
|
+
`${keyValue('Profile', UI.ink(family), 10)}\n` +
|
|
2374
|
+
`${keyValue('Quant', UI.ink(quant), 10)}`,
|
|
2375
|
+
'Preview', 'gray'
|
|
2376
|
+
));
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
return new Promise((resolve) => {
|
|
2380
|
+
render();
|
|
2381
|
+
|
|
2382
|
+
const cleanup = () => {
|
|
2383
|
+
process.stdin.removeListener('data', onKeypress);
|
|
2384
|
+
if (process.stdin.isTTY) {
|
|
2385
|
+
process.stdin.setRawMode(false);
|
|
2386
|
+
}
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
const onKeypress = (chunk, key) => {
|
|
2390
|
+
if (!key) {
|
|
2391
|
+
const str = chunk.toString();
|
|
2392
|
+
if (str === '\x1b[A') key = { name: 'up' };
|
|
2393
|
+
else if (str === '\x1b[B') key = { name: 'down' };
|
|
2394
|
+
else if (str === '\r' || str === '\n') key = { name: 'return' };
|
|
2395
|
+
else if (str === '\x1b' || str === 'q') key = { name: 'escape' };
|
|
2396
|
+
else if (str === 'j') key = { name: 'down' };
|
|
2397
|
+
else if (str === 'k') key = { name: 'up' };
|
|
2398
|
+
else if (str === '\x03') key = { name: 'c', ctrl: true };
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
if (!key) return;
|
|
2402
|
+
|
|
2403
|
+
if (key.name === 'up') {
|
|
2404
|
+
cursor = cursor > 0 ? cursor - 1 : models.length - 1;
|
|
2405
|
+
render();
|
|
2406
|
+
} else if (key.name === 'down') {
|
|
2407
|
+
cursor = cursor < models.length - 1 ? cursor + 1 : 0;
|
|
2408
|
+
render();
|
|
2409
|
+
} else if (key.name === 'return') {
|
|
2410
|
+
cleanup();
|
|
2411
|
+
console.log(UI.slate(`\nUsing ${models[cursor].name}`));
|
|
2412
|
+
resolve(models[cursor].name);
|
|
2413
|
+
} else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
2414
|
+
cleanup();
|
|
2415
|
+
console.log(UI.slate(`\nUsing ${models[cursor].name}`));
|
|
2416
|
+
resolve(models[cursor].name);
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
|
|
2420
|
+
process.stdin.on('data', onKeypress);
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
|
|
995
2424
|
const tools = {
|
|
996
2425
|
read: (path) => {
|
|
997
2426
|
try { return fs.readFileSync(path.trim(), 'utf8'); }
|
|
@@ -1001,21 +2430,100 @@ const tools = {
|
|
|
1001
2430
|
const trimmedPath = path.trim();
|
|
1002
2431
|
try {
|
|
1003
2432
|
const content = fs.readFileSync(trimmedPath, 'utf8');
|
|
1004
|
-
|
|
1005
|
-
|
|
2433
|
+
|
|
2434
|
+
// --- Line-number mode: LINE:15|||new text ---
|
|
2435
|
+
const lineMatch = oldText.match(/^LINE:(\d+)$/);
|
|
2436
|
+
if (lineMatch) {
|
|
2437
|
+
const lineNum = parseInt(lineMatch[1], 10);
|
|
2438
|
+
const lines = content.split('\n');
|
|
2439
|
+
if (lineNum < 1 || lineNum > lines.length) {
|
|
2440
|
+
return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
|
|
2441
|
+
}
|
|
2442
|
+
const oldLine = lines[lineNum - 1];
|
|
2443
|
+
lines[lineNum - 1] = newText;
|
|
2444
|
+
const newContent = lines.join('\n');
|
|
2445
|
+
console.log();
|
|
2446
|
+
const diffContent =
|
|
2447
|
+
`${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
|
|
2448
|
+
`${keyValue('Line', chalk.white(String(lineNum)), 8)}\n` +
|
|
2449
|
+
`${UI.slate('Preview')}\n` +
|
|
2450
|
+
chalk.red('- ' + oldLine) + '\n' +
|
|
2451
|
+
chalk.green('+ ' + newText);
|
|
2452
|
+
console.log(box(diffContent, 'Patch Review', 'yellow'));
|
|
2453
|
+
const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
|
|
2454
|
+
if (confirm.toLowerCase() === 'y') {
|
|
2455
|
+
fs.writeFileSync(trimmedPath, newContent);
|
|
2456
|
+
return `Successfully patched line ${lineNum} of ${trimmedPath}`;
|
|
2457
|
+
}
|
|
2458
|
+
return 'Patch rejected by user.';
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// --- Exact match (try as-is first, then trimmed) ---
|
|
2462
|
+
let matchedOld = oldText;
|
|
2463
|
+
let newContent;
|
|
2464
|
+
if (content.includes(oldText)) {
|
|
2465
|
+
newContent = content.replace(oldText, newText);
|
|
2466
|
+
} else if (content.includes(oldText.trim())) {
|
|
2467
|
+
// Trimmed fallback — match what's actually in the file
|
|
2468
|
+
matchedOld = oldText.trim();
|
|
2469
|
+
newContent = content.replace(matchedOld, newText.trim());
|
|
2470
|
+
console.log(chalk.gray(' ℹ️ Matched after trimming whitespace'));
|
|
2471
|
+
} else {
|
|
2472
|
+
// --- Fuzzy fallback: normalize whitespace + strip emoji ---
|
|
2473
|
+
const normalize = (s) => s.replace(/[\u{1F000}-\u{1FFFF}]/gu, '').replace(/\s+/g, ' ').trim();
|
|
2474
|
+
const normalizedOld = normalize(oldText);
|
|
2475
|
+
const lines = content.split('\n');
|
|
2476
|
+
let bestMatch = null;
|
|
2477
|
+
let bestScore = 0;
|
|
2478
|
+
// Sliding window search over lines
|
|
2479
|
+
const oldLines = oldText.trim().split('\n');
|
|
2480
|
+
for (let i = 0; i <= lines.length - oldLines.length; i++) {
|
|
2481
|
+
const window = lines.slice(i, i + oldLines.length).join('\n');
|
|
2482
|
+
const normalizedWindow = normalize(window);
|
|
2483
|
+
if (normalizedWindow === normalizedOld) {
|
|
2484
|
+
bestMatch = { start: i, count: oldLines.length, text: window };
|
|
2485
|
+
bestScore = 1;
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
// Simple similarity: shared words ratio
|
|
2489
|
+
const oldWords = new Set(normalizedOld.split(' '));
|
|
2490
|
+
const winWords = new Set(normalizedWindow.split(' '));
|
|
2491
|
+
const shared = [...oldWords].filter(w => winWords.has(w)).length;
|
|
2492
|
+
const score = shared / Math.max(oldWords.size, winWords.size);
|
|
2493
|
+
if (score > bestScore && score >= 0.7) {
|
|
2494
|
+
bestScore = score;
|
|
2495
|
+
bestMatch = { start: i, count: oldLines.length, text: window };
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
if (bestMatch && bestScore >= 0.7) {
|
|
2500
|
+
matchedOld = bestMatch.text;
|
|
2501
|
+
newContent = content.replace(matchedOld, newText.trim());
|
|
2502
|
+
console.log(chalk.gray(` ℹ️ Fuzzy match (${(bestScore * 100).toFixed(0)}% similarity) at line ${bestMatch.start + 1}`));
|
|
2503
|
+
} else {
|
|
2504
|
+
// Show nearby lines to help AI on next attempt
|
|
2505
|
+
const keyword = oldText.split('\n')[0].trim().substring(0, 40);
|
|
2506
|
+
const nearby = lines.map((l, i) => ({ line: i + 1, text: l }))
|
|
2507
|
+
.filter(l => l.text.includes(keyword.substring(0, 15)))
|
|
2508
|
+
.slice(0, 3)
|
|
2509
|
+
.map(l => ` Line ${l.line}: ${l.text.substring(0, 80)}`)
|
|
2510
|
+
.join('\n');
|
|
2511
|
+
return `Error: Could not find the text to replace in ${trimmedPath}.\n` +
|
|
2512
|
+
(nearby ? `Nearby matches:\n${nearby}\n` : '') +
|
|
2513
|
+
`Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
|
|
2514
|
+
}
|
|
1006
2515
|
}
|
|
1007
|
-
const newContent = content.replace(oldText, newText);
|
|
1008
2516
|
|
|
1009
2517
|
// Show diff preview
|
|
1010
2518
|
console.log();
|
|
1011
2519
|
const diffContent =
|
|
1012
|
-
`${
|
|
1013
|
-
|
|
1014
|
-
chalk.red('- ' +
|
|
1015
|
-
chalk.green('+ ' + newText.split('\n').join('\n+ '));
|
|
1016
|
-
console.log(box(diffContent, '
|
|
2520
|
+
`${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
|
|
2521
|
+
`${UI.slate('Preview')}\n` +
|
|
2522
|
+
chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
|
|
2523
|
+
chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
|
|
2524
|
+
console.log(box(diffContent, 'Patch Review', 'yellow'));
|
|
1017
2525
|
|
|
1018
|
-
const confirm = await safeQuestion(
|
|
2526
|
+
const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
|
|
1019
2527
|
if (confirm.toLowerCase() === 'y') {
|
|
1020
2528
|
fs.writeFileSync(trimmedPath, newContent);
|
|
1021
2529
|
return `Successfully patched ${trimmedPath}`;
|
|
@@ -1027,13 +2535,13 @@ const tools = {
|
|
|
1027
2535
|
const trimmedPath = path.trim();
|
|
1028
2536
|
console.log();
|
|
1029
2537
|
console.log(box(
|
|
1030
|
-
`${
|
|
1031
|
-
`${
|
|
1032
|
-
|
|
2538
|
+
`${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
|
|
2539
|
+
`${keyValue('Size', chalk.white((content?.length || 0) + ' chars'), 8)}\n` +
|
|
2540
|
+
`${UI.slate('Preview')}\n` +
|
|
1033
2541
|
chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
|
|
1034
|
-
'
|
|
2542
|
+
'Write Review', 'yellow'
|
|
1035
2543
|
));
|
|
1036
|
-
const confirm = await safeQuestion(
|
|
2544
|
+
const confirm = await safeQuestion(confirmPrompt('Allow file write', 'warning'));
|
|
1037
2545
|
if (confirm.toLowerCase() === 'y') {
|
|
1038
2546
|
try {
|
|
1039
2547
|
fs.writeFileSync(trimmedPath, content);
|
|
@@ -1051,16 +2559,28 @@ const tools = {
|
|
|
1051
2559
|
shell: async (cmd) => {
|
|
1052
2560
|
console.log();
|
|
1053
2561
|
console.log(box(
|
|
1054
|
-
chalk.white.
|
|
1055
|
-
'
|
|
2562
|
+
`${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
|
|
2563
|
+
`${UI.slate('Command')}\n${chalk.white.bold(cmd)}`,
|
|
2564
|
+
'Shell Approval', 'red'
|
|
1056
2565
|
));
|
|
1057
|
-
const confirm = await safeQuestion(
|
|
2566
|
+
const confirm = await safeQuestion(confirmPrompt('Run shell command', 'error'));
|
|
1058
2567
|
if (confirm.toLowerCase() === 'y') {
|
|
1059
2568
|
return new Promise((resolve) => {
|
|
1060
|
-
const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
|
|
2569
|
+
const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
|
|
1061
2570
|
console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
|
|
1062
|
-
const proc = spawn(
|
|
1063
|
-
|
|
2571
|
+
const proc = spawn('sh', ['-c', cmd], {
|
|
2572
|
+
cwd: process.cwd()
|
|
2573
|
+
});
|
|
2574
|
+
let output = '';
|
|
2575
|
+
proc.stdout.on('data', (data) => {
|
|
2576
|
+
const text = data.toString();
|
|
2577
|
+
output += text;
|
|
2578
|
+
process.stdout.write(text); // Still show to user in real-time
|
|
2579
|
+
});
|
|
2580
|
+
proc.stderr.on('data', (data) => {
|
|
2581
|
+
const text = data.toString();
|
|
2582
|
+
output += text;
|
|
2583
|
+
process.stderr.write(text); // Still show errors to user
|
|
1064
2584
|
});
|
|
1065
2585
|
proc.on('close', (code) => {
|
|
1066
2586
|
// Crucial: give control back to Node
|
|
@@ -1070,7 +2590,13 @@ const tools = {
|
|
|
1070
2590
|
// Delay slightly to let terminal settle
|
|
1071
2591
|
setTimeout(() => {
|
|
1072
2592
|
recreateReadline();
|
|
1073
|
-
|
|
2593
|
+
// Return actual output to AI, truncated if too long
|
|
2594
|
+
const maxOutput = 10000;
|
|
2595
|
+
let result = output.trim();
|
|
2596
|
+
if (result.length > maxOutput) {
|
|
2597
|
+
result = result.substring(0, maxOutput) + '\n... (output truncated)';
|
|
2598
|
+
}
|
|
2599
|
+
resolve(result || `Command completed with exit code ${code}`);
|
|
1074
2600
|
}, 200);
|
|
1075
2601
|
});
|
|
1076
2602
|
});
|
|
@@ -1083,9 +2609,9 @@ const tools = {
|
|
|
1083
2609
|
// If AI sends "/" (root), treat as current directory "."
|
|
1084
2610
|
if (dir === '/') dir = '.';
|
|
1085
2611
|
const entries = fs.readdirSync(dir);
|
|
1086
|
-
// Filter out ignored directories
|
|
2612
|
+
// Filter out ignored files/directories (respects .sapperignore)
|
|
1087
2613
|
const filtered = entries.filter(entry => {
|
|
1088
|
-
if (
|
|
2614
|
+
if (shouldIgnore(entry)) return false;
|
|
1089
2615
|
// Also skip hidden files/folders (starting with .) except current dir
|
|
1090
2616
|
if (entry.startsWith('.') && entry !== '.') return false;
|
|
1091
2617
|
return true;
|
|
@@ -1095,7 +2621,12 @@ const tools = {
|
|
|
1095
2621
|
},
|
|
1096
2622
|
search: (pattern) => {
|
|
1097
2623
|
return new Promise((resolve) => {
|
|
1098
|
-
|
|
2624
|
+
// Build exclude dirs from IGNORE_DIRS + .sapperignore directory patterns
|
|
2625
|
+
const allIgnoreDirs = new Set(IGNORE_DIRS);
|
|
2626
|
+
for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
|
|
2627
|
+
if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
|
|
2628
|
+
}
|
|
2629
|
+
const excludeDirs = Array.from(allIgnoreDirs).join(',');
|
|
1099
2630
|
// Use grep to search for pattern, excluding ignored directories
|
|
1100
2631
|
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
2632
|
|
|
@@ -1123,10 +2654,8 @@ async function checkForUpdates() {
|
|
|
1123
2654
|
const latestVersion = data.version;
|
|
1124
2655
|
|
|
1125
2656
|
if (latestVersion && latestVersion !== CURRENT_VERSION) {
|
|
1126
|
-
console.log(
|
|
1127
|
-
console.log(
|
|
1128
|
-
console.log(chalk.green(` Latest: v${latestVersion}`));
|
|
1129
|
-
console.log(chalk.cyan(' Run: npm update -g sapper-iq\n'));
|
|
2657
|
+
console.log(UI.gold(`Update available: v${CURRENT_VERSION} -> v${latestVersion}`));
|
|
2658
|
+
console.log(UI.slate('Run npm update -g sapper-iq\n'));
|
|
1130
2659
|
}
|
|
1131
2660
|
} catch (error) {
|
|
1132
2661
|
// Silently fail if update check fails
|
|
@@ -1136,22 +2665,31 @@ async function checkForUpdates() {
|
|
|
1136
2665
|
async function runSapper() {
|
|
1137
2666
|
console.clear();
|
|
1138
2667
|
console.log(BANNER);
|
|
1139
|
-
console.log(
|
|
1140
|
-
console.log(
|
|
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
|
-
));
|
|
2668
|
+
console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
|
|
2669
|
+
console.log(divider());
|
|
2670
|
+
console.log(sectionTitle('Quick start', '@file attach · /help commands · /agents modes', 'gray'));
|
|
1150
2671
|
console.log();
|
|
1151
2672
|
|
|
1152
2673
|
// Check for updates
|
|
1153
2674
|
await checkForUpdates();
|
|
1154
2675
|
|
|
2676
|
+
// Ensure .sapperignore exists (create default on first run)
|
|
2677
|
+
const sapperIgnoreCreated = ensureSapperIgnore();
|
|
2678
|
+
if (sapperIgnoreCreated) {
|
|
2679
|
+
console.log(chalk.green('📋 Created .sapperignore') + chalk.gray(' — edit it to customize ignored files'));
|
|
2680
|
+
} else {
|
|
2681
|
+
// Reload patterns in case file was modified since last run
|
|
2682
|
+
reloadSapperIgnore();
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
// Ensure config file exists with defaults, or reload user's config
|
|
2686
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
2687
|
+
saveConfig(sapperConfig);
|
|
2688
|
+
} else {
|
|
2689
|
+
// Reload in case user edited config.json manually
|
|
2690
|
+
sapperConfig = loadConfig();
|
|
2691
|
+
}
|
|
2692
|
+
|
|
1155
2693
|
// Auto-load or build workspace graph
|
|
1156
2694
|
let workspace = loadWorkspaceGraph();
|
|
1157
2695
|
if (!workspace.indexed) {
|
|
@@ -1169,15 +2707,33 @@ async function runSapper() {
|
|
|
1169
2707
|
}
|
|
1170
2708
|
}
|
|
1171
2709
|
|
|
1172
|
-
//
|
|
1173
|
-
|
|
1174
|
-
|
|
2710
|
+
// Initialize agents and skills
|
|
2711
|
+
const newlyCreated = createDefaultAgentsAndSkills();
|
|
2712
|
+
const agents = loadAgents();
|
|
2713
|
+
const skills = loadSkills();
|
|
2714
|
+
const agentCount = Object.keys(agents).length;
|
|
2715
|
+
const skillCount = Object.keys(skills).length;
|
|
2716
|
+
const workspaceFileCount = Object.keys(workspace.files).length;
|
|
2717
|
+
const workspaceSymbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
2718
|
+
const workspaceAgeMinutes = workspace.indexed
|
|
2719
|
+
? Math.max(0, Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60))
|
|
2720
|
+
: 0;
|
|
2721
|
+
const startupLines = [
|
|
2722
|
+
`${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
|
|
2723
|
+
`${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
|
|
2724
|
+
`${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
|
|
2725
|
+
];
|
|
2726
|
+
if (newlyCreated > 0) {
|
|
2727
|
+
startupLines.push(UI.slate(`${newlyCreated} default agents or skills created in .sapper/`));
|
|
2728
|
+
}
|
|
2729
|
+
console.log(box(startupLines.join('\n'), 'Workspace', 'gray'));
|
|
2730
|
+
console.log();
|
|
1175
2731
|
|
|
1176
2732
|
let messages = [];
|
|
1177
2733
|
if (fs.existsSync(CONTEXT_FILE)) {
|
|
1178
|
-
console.log();
|
|
1179
|
-
console.log(
|
|
1180
|
-
const resume = await safeQuestion(
|
|
2734
|
+
console.log(divider());
|
|
2735
|
+
console.log(UI.ink('Previous session found in .sapper/context.json'));
|
|
2736
|
+
const resume = await safeQuestion(confirmPrompt('Resume session', 'success'));
|
|
1181
2737
|
if (resume.toLowerCase() === 'y') {
|
|
1182
2738
|
messages = JSON.parse(fs.readFileSync(CONTEXT_FILE, 'utf8'));
|
|
1183
2739
|
console.log(chalk.green(' ✓ Session restored\n'));
|
|
@@ -1218,165 +2774,307 @@ async function runSapper() {
|
|
|
1218
2774
|
process.exit(1);
|
|
1219
2775
|
}
|
|
1220
2776
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
2777
|
+
const selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
|
|
2778
|
+
|
|
2779
|
+
// ─── Detect model capabilities & context window ───────────────────
|
|
2780
|
+
let useNativeTools = false;
|
|
2781
|
+
let toolModeLabel = 'tool detection unavailable';
|
|
2782
|
+
let contextLabel = '4,096 tokens (fallback)';
|
|
2783
|
+
try {
|
|
2784
|
+
const modelInfo = await ollama.show({ model: selectedModel });
|
|
2785
|
+
if (modelInfo.capabilities && modelInfo.capabilities.includes('tools')) {
|
|
2786
|
+
useNativeTools = true;
|
|
2787
|
+
toolModeLabel = 'native tool calling';
|
|
2788
|
+
} else {
|
|
2789
|
+
toolModeLabel = 'text markers';
|
|
2790
|
+
}
|
|
2791
|
+
// Extract context window size from model_info
|
|
2792
|
+
// Different model families use different keys: llama.context_length, qwen2.context_length, etc.
|
|
2793
|
+
if (modelInfo.model_info) {
|
|
2794
|
+
for (const [key, value] of Object.entries(modelInfo.model_info)) {
|
|
2795
|
+
if (key.endsWith('.context_length') && typeof value === 'number') {
|
|
2796
|
+
modelContextLength = value;
|
|
2797
|
+
break;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
// Fallback: parse from parameters string (e.g. "num_ctx 4096")
|
|
2802
|
+
if (!modelContextLength && modelInfo.parameters) {
|
|
2803
|
+
const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
|
|
2804
|
+
if (match) modelContextLength = parseInt(match[1]);
|
|
2805
|
+
}
|
|
2806
|
+
if (modelContextLength) {
|
|
2807
|
+
contextLabel = `${modelContextLength.toLocaleString()} tokens`;
|
|
2808
|
+
} else {
|
|
2809
|
+
modelContextLength = 4096; // Conservative default
|
|
2810
|
+
contextLabel = '4,096 tokens (default)';
|
|
2811
|
+
}
|
|
2812
|
+
} catch (e) {
|
|
2813
|
+
modelContextLength = 4096;
|
|
2814
|
+
toolModeLabel = 'default mode';
|
|
2815
|
+
contextLabel = '4,096 tokens (fallback)';
|
|
2816
|
+
}
|
|
2817
|
+
// Show custom limit if set
|
|
2818
|
+
const effectiveCtx = effectiveContextLength();
|
|
2819
|
+
if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
|
|
2820
|
+
contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
|
|
2821
|
+
}
|
|
2822
|
+
console.log(box(
|
|
2823
|
+
`${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
|
|
2824
|
+
`${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
|
|
2825
|
+
`${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
|
|
2826
|
+
'Session', 'cyan'
|
|
2827
|
+
));
|
|
2828
|
+
console.log();
|
|
2829
|
+
_useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
|
|
2830
|
+
|
|
2831
|
+
// Native Ollama tool definitions (used when useNativeTools=true)
|
|
2832
|
+
const nativeToolDefs = [
|
|
2833
|
+
{
|
|
2834
|
+
type: 'function',
|
|
2835
|
+
function: {
|
|
2836
|
+
name: 'list_directory',
|
|
2837
|
+
description: 'List the contents of a directory. Use "." for current directory.',
|
|
2838
|
+
parameters: {
|
|
2839
|
+
type: 'object',
|
|
2840
|
+
properties: {
|
|
2841
|
+
path: { type: 'string', description: 'Directory path to list' }
|
|
2842
|
+
},
|
|
2843
|
+
required: ['path']
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
},
|
|
2847
|
+
{
|
|
2848
|
+
type: 'function',
|
|
2849
|
+
function: {
|
|
2850
|
+
name: 'read_file',
|
|
2851
|
+
description: 'Read the full contents of a file',
|
|
2852
|
+
parameters: {
|
|
2853
|
+
type: 'object',
|
|
2854
|
+
properties: {
|
|
2855
|
+
path: { type: 'string', description: 'File path to read' }
|
|
2856
|
+
},
|
|
2857
|
+
required: ['path']
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
},
|
|
2861
|
+
{
|
|
2862
|
+
type: 'function',
|
|
2863
|
+
function: {
|
|
2864
|
+
name: 'search_files',
|
|
2865
|
+
description: 'Search for a pattern across project files',
|
|
2866
|
+
parameters: {
|
|
2867
|
+
type: 'object',
|
|
2868
|
+
properties: {
|
|
2869
|
+
pattern: { type: 'string', description: 'Search pattern (text or regex)' }
|
|
2870
|
+
},
|
|
2871
|
+
required: ['pattern']
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
},
|
|
2875
|
+
{
|
|
2876
|
+
type: 'function',
|
|
2877
|
+
function: {
|
|
2878
|
+
name: 'write_file',
|
|
2879
|
+
description: 'Create or overwrite a file with new content',
|
|
2880
|
+
parameters: {
|
|
2881
|
+
type: 'object',
|
|
2882
|
+
properties: {
|
|
2883
|
+
path: { type: 'string', description: 'File path to write' },
|
|
2884
|
+
content: { type: 'string', description: 'Content to write to the file' }
|
|
2885
|
+
},
|
|
2886
|
+
required: ['path', 'content']
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
},
|
|
2890
|
+
{
|
|
2891
|
+
type: 'function',
|
|
2892
|
+
function: {
|
|
2893
|
+
name: 'patch_file',
|
|
2894
|
+
description: 'Edit an existing file by replacing old text with new text. Prefer line_number mode.',
|
|
2895
|
+
parameters: {
|
|
2896
|
+
type: 'object',
|
|
2897
|
+
properties: {
|
|
2898
|
+
path: { type: 'string', description: 'File path to patch' },
|
|
2899
|
+
old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
|
|
2900
|
+
new_text: { type: 'string', description: 'Replacement text' }
|
|
2901
|
+
},
|
|
2902
|
+
required: ['path', 'old_text', 'new_text']
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
},
|
|
2906
|
+
{
|
|
2907
|
+
type: 'function',
|
|
2908
|
+
function: {
|
|
2909
|
+
name: 'create_directory',
|
|
2910
|
+
description: 'Create a directory (recursive)',
|
|
2911
|
+
parameters: {
|
|
2912
|
+
type: 'object',
|
|
2913
|
+
properties: {
|
|
2914
|
+
path: { type: 'string', description: 'Directory path to create' }
|
|
2915
|
+
},
|
|
2916
|
+
required: ['path']
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
},
|
|
2920
|
+
{
|
|
2921
|
+
type: 'function',
|
|
2922
|
+
function: {
|
|
2923
|
+
name: 'run_shell',
|
|
2924
|
+
description: 'Execute a shell command in the project directory',
|
|
2925
|
+
parameters: {
|
|
2926
|
+
type: 'object',
|
|
2927
|
+
properties: {
|
|
2928
|
+
command: { type: 'string', description: 'Shell command to execute' }
|
|
2929
|
+
},
|
|
2930
|
+
required: ['command']
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
];
|
|
1231
2935
|
|
|
1232
2936
|
if (messages.length === 0) {
|
|
1233
2937
|
messages = [{
|
|
1234
2938
|
role: 'system',
|
|
1235
|
-
content:
|
|
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`
|
|
2939
|
+
content: buildSystemPrompt()
|
|
1252
2940
|
}];
|
|
1253
2941
|
}
|
|
1254
2942
|
|
|
2943
|
+
// Log session start
|
|
2944
|
+
logEntry('session_start', {
|
|
2945
|
+
model: selectedModel,
|
|
2946
|
+
resumed: messages.length > 1,
|
|
2947
|
+
contextSize: messages.length
|
|
2948
|
+
});
|
|
2949
|
+
|
|
1255
2950
|
// Main conversation loop - never exits unless user types 'exit'
|
|
1256
2951
|
while (true) {
|
|
1257
2952
|
try {
|
|
1258
|
-
// Context size
|
|
1259
|
-
const
|
|
1260
|
-
|
|
2953
|
+
// Context size check - auto-summarize when approaching effective context limit
|
|
2954
|
+
const estimatedTokens = estimateMessagesTokens(messages);
|
|
2955
|
+
const ctxLen = effectiveContextLength();
|
|
2956
|
+
const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
|
|
2957
|
+
if (estimatedTokens > tokenThreshold) {
|
|
2958
|
+
messages = await autoSummarizeContext(messages, selectedModel);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// Build prompt label with active agent/skills
|
|
2962
|
+
const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
|
|
2963
|
+
const promptParts = [
|
|
2964
|
+
statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
|
|
2965
|
+
currentAgent ? statusBadge(`/${currentAgent}`, 'info') : statusBadge('default', 'neutral'),
|
|
2966
|
+
];
|
|
2967
|
+
if (loadedSkills.length > 0) {
|
|
2968
|
+
promptParts.push(statusBadge(`${loadedSkills.length} skill${loadedSkills.length !== 1 ? 's' : ''}`, 'success'));
|
|
2969
|
+
}
|
|
2970
|
+
if (contextPercent !== null) {
|
|
2971
|
+
const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
|
|
2972
|
+
promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
const promptDetail = ctxLen
|
|
2976
|
+
? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
|
|
2977
|
+
: UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
|
|
2978
|
+
|
|
2979
|
+
const input = await safeQuestion(`\n${promptShell(promptParts.join(' '), promptDetail)}`);
|
|
2980
|
+
|
|
2981
|
+
// Block empty prompts
|
|
2982
|
+
if (!input.trim()) {
|
|
2983
|
+
continue;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// Clear readline echo to prevent duplicate display
|
|
2987
|
+
{
|
|
2988
|
+
const promptWidth = visibleLength(promptParts.join(' ')) + 4; // account for prompt chars
|
|
2989
|
+
const totalLen = promptWidth + input.length;
|
|
2990
|
+
const lines = Math.ceil(totalLen / (process.stdout.columns || 80));
|
|
2991
|
+
for (let i = 0; i < lines; i++) {
|
|
2992
|
+
process.stdout.write('\x1B[1A\x1B[2K');
|
|
2993
|
+
}
|
|
2994
|
+
// Reprint clean version
|
|
2995
|
+
const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
|
|
2996
|
+
console.log(UI.accent('› ') + chalk.white(preview));
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
if (input.toLowerCase() === 'exit') {
|
|
3000
|
+
const stats = getSessionStats();
|
|
3001
|
+
logEntry('system', { event: 'Session End', detail: `Duration: ${formatElapsed(stats.totalDuration)}, ${stats.userMessages} messages, ${stats.toolCalls} tools` });
|
|
1261
3002
|
console.log();
|
|
1262
3003
|
console.log(box(
|
|
1263
|
-
|
|
1264
|
-
`${chalk.
|
|
1265
|
-
'
|
|
3004
|
+
`${chalk.white('Duration:')} ${chalk.cyan(formatElapsed(stats.totalDuration))}\n` +
|
|
3005
|
+
`${chalk.white('Messages:')} ${chalk.blue(stats.userMessages + '↑')} ${chalk.magenta(stats.aiMessages + '↓')}\n` +
|
|
3006
|
+
`${chalk.white('Tools:')} ${chalk.yellow(stats.toolCalls)} | ${chalk.white('Shells:')} ${chalk.red(stats.shellCalls)}\n` +
|
|
3007
|
+
`${chalk.white('Log saved:')} ${chalk.gray(sessionLogFile())}`,
|
|
3008
|
+
'👋 Session Summary', 'cyan'
|
|
1266
3009
|
));
|
|
3010
|
+
console.log();
|
|
3011
|
+
process.exit();
|
|
1267
3012
|
}
|
|
1268
3013
|
|
|
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
3014
|
// Handle reset command
|
|
1274
3015
|
if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
|
|
1275
3016
|
if (fs.existsSync(CONTEXT_FILE)) {
|
|
1276
3017
|
fs.unlinkSync(CONTEXT_FILE);
|
|
1277
3018
|
console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
|
|
1278
3019
|
}
|
|
3020
|
+
currentAgent = null;
|
|
3021
|
+
currentAgentTools = null;
|
|
3022
|
+
loadedSkills = [];
|
|
1279
3023
|
messages = [{
|
|
1280
3024
|
role: 'system',
|
|
1281
|
-
content:
|
|
3025
|
+
content: buildSystemPrompt() // Reset to default prompt
|
|
1282
3026
|
}];
|
|
3027
|
+
logEntry('system', { event: 'Context Reset', detail: 'All context cleared, starting fresh' });
|
|
1283
3028
|
continue;
|
|
1284
3029
|
}
|
|
1285
3030
|
|
|
1286
|
-
// Handle prune command -
|
|
3031
|
+
// Handle prune command - smart AI summary then clear old context
|
|
1287
3032
|
if (input.toLowerCase() === '/prune') {
|
|
1288
3033
|
if (messages.length <= 5) {
|
|
1289
3034
|
console.log(chalk.yellow('Context is already small, nothing to prune.'));
|
|
1290
3035
|
continue;
|
|
1291
3036
|
}
|
|
1292
3037
|
|
|
1293
|
-
|
|
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`));
|
|
3038
|
+
messages = await autoSummarizeContext(messages, selectedModel, true);
|
|
1358
3039
|
continue;
|
|
1359
3040
|
}
|
|
1360
3041
|
|
|
1361
3042
|
// Handle help command
|
|
1362
3043
|
if (input.toLowerCase() === '/help') {
|
|
1363
3044
|
console.log();
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
console.log(
|
|
3045
|
+
console.log(sectionTitle('Core', 'daily workflow', 'cyan'));
|
|
3046
|
+
console.log(commandRow('@ or /attach', 'Pick files to attach interactively'));
|
|
3047
|
+
console.log(commandRow('@file', 'Attach a file inline, for example @src/app.js'));
|
|
3048
|
+
console.log(commandRow('/scan', 'Scan the codebase into context'));
|
|
3049
|
+
console.log(commandRow('/index', 'Rebuild the workspace graph'));
|
|
3050
|
+
console.log(commandRow('/graph file', 'Show related files from the graph'));
|
|
3051
|
+
console.log(commandRow('/symbol name', 'Search indexed functions and classes'));
|
|
3052
|
+
console.log(commandRow('/auto', 'Toggle automatic related-file attach'));
|
|
3053
|
+
console.log();
|
|
3054
|
+
console.log(sectionTitle('Context', 'memory and visibility', 'cyan'));
|
|
3055
|
+
console.log(commandRow('/recall', 'Search memory for relevant context'));
|
|
3056
|
+
console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
|
|
3057
|
+
console.log(commandRow('/reset /clear', 'Clear all current context'));
|
|
3058
|
+
console.log(commandRow('/prune', 'Summarize long context and store memory'));
|
|
3059
|
+
console.log(commandRow('/context', 'Inspect token usage and model window'));
|
|
3060
|
+
console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
|
|
3061
|
+
console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
|
|
3062
|
+
console.log(commandRow('/log', 'Show the session activity timeline'));
|
|
3063
|
+
console.log(commandRow('/log stats', 'Show session statistics'));
|
|
3064
|
+
console.log(commandRow('/log file', 'Show log file path and history'));
|
|
3065
|
+
console.log(commandRow('/help', 'Open this command view again'));
|
|
3066
|
+
console.log(commandRow('exit', 'Quit Sapper'));
|
|
3067
|
+
console.log();
|
|
3068
|
+
console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
|
|
3069
|
+
console.log(commandRow('/agents', 'List available agents'));
|
|
3070
|
+
console.log(commandRow('/skills', 'List available skills'));
|
|
3071
|
+
console.log(commandRow('/agentname', 'Switch to an agent such as /reviewer'));
|
|
3072
|
+
console.log(commandRow('/default', 'Return to the default Sapper role'));
|
|
3073
|
+
console.log(commandRow('/use skill', 'Load a skill into the session'));
|
|
3074
|
+
console.log(commandRow('/unload skill', 'Unload a previously loaded skill'));
|
|
3075
|
+
console.log(commandRow('/newagent', 'Create a new agent'));
|
|
3076
|
+
console.log(commandRow('/newskill', 'Create a new skill'));
|
|
3077
|
+
console.log(divider());
|
|
1380
3078
|
console.log();
|
|
1381
3079
|
continue;
|
|
1382
3080
|
}
|
|
@@ -1450,115 +3148,562 @@ TOOL SYNTAX:
|
|
|
1450
3148
|
// Offer to add file to context
|
|
1451
3149
|
if (results.length > 0) {
|
|
1452
3150
|
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}`));
|
|
3151
|
+
const addToCtx = await safeQuestion(chalk.yellow('Add first match file to context? ') + chalk.gray('(y/n): '));
|
|
3152
|
+
if (addToCtx.toLowerCase() === 'y') {
|
|
3153
|
+
const targetFile = results[0].file;
|
|
3154
|
+
try {
|
|
3155
|
+
const content = fs.readFileSync(targetFile, 'utf8');
|
|
3156
|
+
messages.push({
|
|
3157
|
+
role: 'user',
|
|
3158
|
+
content: `Here is ${targetFile} (contains ${results[0].type} "${results[0].name}" at line ${results[0].line}):\n\n${content}`
|
|
3159
|
+
});
|
|
3160
|
+
console.log(chalk.green(`✅ Added ${targetFile} to context`));
|
|
3161
|
+
} catch (e) {
|
|
3162
|
+
console.log(chalk.red(`Could not read ${targetFile}`));
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
continue;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
// Handle graph command - show related files
|
|
3170
|
+
if (input.toLowerCase().startsWith('/graph')) {
|
|
3171
|
+
const targetFile = input.slice(6).trim();
|
|
3172
|
+
if (!targetFile) {
|
|
3173
|
+
// Show workspace overview
|
|
3174
|
+
console.log(formatWorkspaceSummary(workspace));
|
|
3175
|
+
continue;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
// Find file (support partial match)
|
|
3179
|
+
const matchingFile = Object.keys(workspace.files).find(f =>
|
|
3180
|
+
f === targetFile || f.endsWith('/' + targetFile) || f.endsWith(targetFile)
|
|
3181
|
+
);
|
|
3182
|
+
|
|
3183
|
+
if (!matchingFile) {
|
|
3184
|
+
console.log(chalk.yellow(`File not found in index: ${targetFile}`));
|
|
3185
|
+
console.log(chalk.gray('Tip: Run /index to refresh workspace graph'));
|
|
3186
|
+
continue;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
const fileInfo = workspace.files[matchingFile];
|
|
3190
|
+
const related = getRelatedFiles(matchingFile, workspace);
|
|
3191
|
+
|
|
3192
|
+
console.log();
|
|
3193
|
+
console.log(box(
|
|
3194
|
+
`${chalk.white('File:')} ${chalk.cyan(matchingFile)}\n` +
|
|
3195
|
+
`${chalk.white('Size:')} ${Math.round(fileInfo.size/1024)}KB\n` +
|
|
3196
|
+
`${chalk.white('Exports:')} ${fileInfo.exports?.join(', ') || 'none'}\n` +
|
|
3197
|
+
`${chalk.white('Imports:')} ${fileInfo.imports?.join(', ') || 'none'}\n` +
|
|
3198
|
+
chalk.gray('─'.repeat(40)) + '\n' +
|
|
3199
|
+
`${chalk.white('Related files:')}\n` +
|
|
3200
|
+
(related.length > 0
|
|
3201
|
+
? related.map(r => ` 📄 ${r}`).join('\n')
|
|
3202
|
+
: chalk.gray(' (no related files found)')),
|
|
3203
|
+
'🔗 File Graph', 'cyan'
|
|
3204
|
+
));
|
|
3205
|
+
console.log();
|
|
3206
|
+
|
|
3207
|
+
// Offer to add to context
|
|
3208
|
+
if (related.length > 0) {
|
|
3209
|
+
const addRelated = await safeQuestion(chalk.yellow('Add this file + related to context? ') + chalk.gray('(y/n): '));
|
|
3210
|
+
if (addRelated.toLowerCase() === 'y') {
|
|
3211
|
+
let contextContent = `\n📄 ${matchingFile}:\n`;
|
|
3212
|
+
contextContent += fs.readFileSync(matchingFile, 'utf8');
|
|
3213
|
+
|
|
3214
|
+
for (const relFile of related.slice(0, 5)) { // Limit to 5 related
|
|
3215
|
+
try {
|
|
3216
|
+
contextContent += `\n\n📄 ${relFile} (related):\n`;
|
|
3217
|
+
contextContent += fs.readFileSync(relFile, 'utf8');
|
|
3218
|
+
} catch (e) {}
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
messages.push({
|
|
3222
|
+
role: 'user',
|
|
3223
|
+
content: `Here is ${matchingFile} and its related files:\n${contextContent}\n\nUse this context to help me.`
|
|
3224
|
+
});
|
|
3225
|
+
console.log(chalk.green(`✅ Added ${matchingFile} + ${Math.min(related.length, 5)} related files to context`));
|
|
3226
|
+
}
|
|
3227
|
+
}
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
// Handle auto-attach toggle
|
|
3232
|
+
if (input.toLowerCase() === '/auto') {
|
|
3233
|
+
sapperConfig.autoAttach = !sapperConfig.autoAttach;
|
|
3234
|
+
saveConfig(sapperConfig);
|
|
3235
|
+
console.log(chalk.cyan(`\n🔗 Auto-attach related files: ${sapperConfig.autoAttach ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
3236
|
+
if (sapperConfig.autoAttach) {
|
|
3237
|
+
console.log(chalk.gray(' When you @file, related imports will be auto-included.'));
|
|
3238
|
+
} else {
|
|
3239
|
+
console.log(chalk.gray(' Only explicitly mentioned files will be attached.'));
|
|
3240
|
+
}
|
|
3241
|
+
continue;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
// Handle context size command
|
|
3245
|
+
// Handle /ctx command — view or set context window limit
|
|
3246
|
+
if (input.toLowerCase().startsWith('/ctx')) {
|
|
3247
|
+
const arg = input.substring(4).trim();
|
|
3248
|
+
if (arg === 'reset' || arg === 'auto') {
|
|
3249
|
+
sapperConfig.contextLimit = null;
|
|
3250
|
+
saveConfig(sapperConfig);
|
|
3251
|
+
console.log(chalk.green(`✅ Context limit reset to model default (${modelContextLength ? modelContextLength.toLocaleString() : 'auto'} tokens)`));
|
|
3252
|
+
} else if (arg) {
|
|
3253
|
+
// Parse number with optional k/K suffix (e.g. 64k, 32768)
|
|
3254
|
+
let limit = null;
|
|
3255
|
+
const kMatch = arg.match(/^(\d+\.?\d*)\s*[kK]$/);
|
|
3256
|
+
if (kMatch) {
|
|
3257
|
+
limit = Math.round(parseFloat(kMatch[1]) * 1024);
|
|
3258
|
+
} else {
|
|
3259
|
+
limit = parseInt(arg);
|
|
3260
|
+
}
|
|
3261
|
+
if (!limit || limit < 1024) {
|
|
3262
|
+
console.log(chalk.yellow('Usage: /ctx <tokens> — e.g. /ctx 64k, /ctx 32768, /ctx reset'));
|
|
3263
|
+
console.log(chalk.gray(' Minimum: 1024 tokens'));
|
|
3264
|
+
} else {
|
|
3265
|
+
sapperConfig.contextLimit = limit;
|
|
3266
|
+
saveConfig(sapperConfig);
|
|
3267
|
+
const effective = effectiveContextLength();
|
|
3268
|
+
console.log(chalk.green(`✅ Context limit set to ${chalk.white.bold(effective.toLocaleString())} tokens`));
|
|
3269
|
+
if (modelContextLength && limit < modelContextLength) {
|
|
3270
|
+
console.log(chalk.gray(` Model supports ${modelContextLength.toLocaleString()} but will use ${limit.toLocaleString()} (saves RAM)`));
|
|
3271
|
+
} else if (modelContextLength && limit > modelContextLength) {
|
|
3272
|
+
console.log(chalk.yellow(` ⚠ Limit exceeds model's ${modelContextLength.toLocaleString()} context — may cause errors`));
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
} else {
|
|
3276
|
+
// Show current setting
|
|
3277
|
+
const effective = effectiveContextLength();
|
|
3278
|
+
const custom = sapperConfig.contextLimit;
|
|
3279
|
+
const lines = [
|
|
3280
|
+
`model default ${chalk.white(modelContextLength ? modelContextLength.toLocaleString() : 'unknown')} tokens`,
|
|
3281
|
+
`custom limit ${custom ? chalk.cyan.bold(custom.toLocaleString() + ' tokens') : UI.slate('not set (using model default)')}`,
|
|
3282
|
+
`effective ${chalk.white.bold(effective ? effective.toLocaleString() + ' tokens' : 'unknown')}`,
|
|
3283
|
+
];
|
|
3284
|
+
console.log();
|
|
3285
|
+
console.log(box(lines.join('\n'), 'Context Limit', 'cyan'));
|
|
3286
|
+
console.log(UI.slate(' Set: /ctx 64k | /ctx 32768 | /ctx reset'));
|
|
3287
|
+
}
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
if (input.toLowerCase() === '/context') {
|
|
3292
|
+
const contextSize = JSON.stringify(messages).length;
|
|
3293
|
+
const estTokens = estimateMessagesTokens(messages);
|
|
3294
|
+
const ctxLen = effectiveContextLength();
|
|
3295
|
+
const contextLines = [
|
|
3296
|
+
`messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
|
|
3297
|
+
];
|
|
3298
|
+
if (ctxLen) {
|
|
3299
|
+
const usagePercent = Math.round((estTokens / ctxLen) * 100);
|
|
3300
|
+
const threshold = Math.floor(ctxLen * 0.75);
|
|
3301
|
+
const limitLabel = sapperConfig.contextLimit
|
|
3302
|
+
? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
|
|
3303
|
+
: `${ctxLen.toLocaleString()} tokens`;
|
|
3304
|
+
contextLines.push(`limit ${chalk.white(limitLabel)} ${UI.slate('·')} usage ${chalk.white(usagePercent + '%')}`);
|
|
3305
|
+
contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
|
|
3306
|
+
}
|
|
3307
|
+
if (lastPromptTokens > 0) {
|
|
3308
|
+
contextLines.push(`last turn ${UI.slate(`${lastPromptTokens.toLocaleString()} prompt • ${lastEvalTokens.toLocaleString()} response`)}`);
|
|
3309
|
+
}
|
|
3310
|
+
console.log();
|
|
3311
|
+
console.log(box(contextLines.join('\n'), 'Context', 'gray'));
|
|
3312
|
+
continue;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
// Handle debug mode toggle
|
|
3316
|
+
if (input.toLowerCase() === '/debug') {
|
|
3317
|
+
debugMode = !debugMode;
|
|
3318
|
+
console.log(chalk.magenta(`🔧 Debug mode: ${debugMode ? 'ON' : 'OFF'}`));
|
|
3319
|
+
if (debugMode) {
|
|
3320
|
+
console.log(chalk.gray(' Will show regex matching details after each AI response.'));
|
|
3321
|
+
}
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// Handle /log command - show activity log
|
|
3326
|
+
if (input.toLowerCase().startsWith('/log')) {
|
|
3327
|
+
const parts = input.split(' ');
|
|
3328
|
+
const count = parseInt(parts[1]) || 30;
|
|
3329
|
+
|
|
3330
|
+
if (parts[1] === 'file') {
|
|
3331
|
+
// Show log file path
|
|
3332
|
+
console.log(chalk.cyan(`\n📁 Log file: ${chalk.white(sessionLogFile())}`));
|
|
3333
|
+
if (fs.existsSync(sessionLogFile())) {
|
|
3334
|
+
const size = fs.statSync(sessionLogFile()).size;
|
|
3335
|
+
console.log(chalk.gray(` Size: ${Math.round(size / 1024)}KB`));
|
|
3336
|
+
}
|
|
3337
|
+
// List all log files
|
|
3338
|
+
try {
|
|
3339
|
+
ensureLogsDir();
|
|
3340
|
+
const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.endsWith('.md')).sort().reverse();
|
|
3341
|
+
if (logFiles.length > 0) {
|
|
3342
|
+
console.log(chalk.cyan(`\n📋 All session logs:`));
|
|
3343
|
+
logFiles.slice(0, 10).forEach((f, i) => {
|
|
3344
|
+
const stats = fs.statSync(`${LOGS_DIR}/${f}`);
|
|
3345
|
+
const isCurrent = f === `session-${sessionId}.md`;
|
|
3346
|
+
const label = isCurrent ? chalk.green(' ← current') : '';
|
|
3347
|
+
console.log(chalk.gray(` ${i + 1}. `) + chalk.white(f) + chalk.gray(` (${Math.round(stats.size / 1024)}KB)`) + label);
|
|
3348
|
+
});
|
|
3349
|
+
if (logFiles.length > 10) {
|
|
3350
|
+
console.log(chalk.gray(` ... and ${logFiles.length - 10} more`));
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
} catch (e) {}
|
|
3354
|
+
continue;
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
if (parts[1] === 'stats') {
|
|
3358
|
+
// Show session statistics
|
|
3359
|
+
const stats = getSessionStats();
|
|
3360
|
+
console.log();
|
|
3361
|
+
console.log(box(
|
|
3362
|
+
`${chalk.white('Session Duration:')} ${chalk.cyan(formatElapsed(stats.totalDuration))}\n` +
|
|
3363
|
+
`${chalk.white('User Messages:')} ${chalk.blue.bold(stats.userMessages)}\n` +
|
|
3364
|
+
`${chalk.white('AI Responses:')} ${chalk.magenta.bold(stats.aiMessages)}\n` +
|
|
3365
|
+
`${chalk.white('Tool Calls:')} ${chalk.yellow.bold(stats.toolCalls)}\n` +
|
|
3366
|
+
`${chalk.white('Shell Commands:')} ${chalk.red.bold(stats.shellCalls)}\n` +
|
|
3367
|
+
`${chalk.white('Errors:')} ${stats.errors > 0 ? chalk.red.bold(stats.errors) : chalk.green.bold(stats.errors)}\n` +
|
|
3368
|
+
`${chalk.white('Log Events:')} ${chalk.gray(activityLog.length + ' total')}`,
|
|
3369
|
+
'📊 Session Stats', 'cyan'
|
|
3370
|
+
));
|
|
3371
|
+
console.log();
|
|
3372
|
+
continue;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
if (parts[1] === 'view' && parts[2]) {
|
|
3376
|
+
// View a specific log file
|
|
3377
|
+
try {
|
|
3378
|
+
const logPath = `${LOGS_DIR}/${parts[2]}`;
|
|
3379
|
+
if (fs.existsSync(logPath)) {
|
|
3380
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
3381
|
+
console.log(renderMarkdown(content));
|
|
3382
|
+
} else {
|
|
3383
|
+
console.log(chalk.yellow(`Log file not found: ${parts[2]}`));
|
|
3384
|
+
}
|
|
3385
|
+
} catch (e) {
|
|
3386
|
+
console.log(chalk.red(`Error reading log: ${e.message}`));
|
|
3387
|
+
}
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
// Default: show activity timeline
|
|
3392
|
+
console.log(renderActivityLog(count));
|
|
3393
|
+
continue;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// ═══════════════════════════════════════════════════════════
|
|
3397
|
+
// AGENT & SKILL COMMANDS
|
|
3398
|
+
// ═══════════════════════════════════════════════════════════
|
|
3399
|
+
|
|
3400
|
+
// Handle /agents command - list available agents or create one
|
|
3401
|
+
if (input.toLowerCase() === '/agents' || input.toLowerCase() === '/agent') {
|
|
3402
|
+
const currentAgents = loadAgents();
|
|
3403
|
+
const agentNames = Object.keys(currentAgents);
|
|
3404
|
+
if (agentNames.length === 0) {
|
|
3405
|
+
console.log(chalk.yellow('\nNo agents found. Create one with /newagent or /agents create <name> <description>'));
|
|
3406
|
+
} else {
|
|
3407
|
+
console.log();
|
|
3408
|
+
let agentList = '';
|
|
3409
|
+
for (const [name, agent] of Object.entries(currentAgents)) {
|
|
3410
|
+
const active = currentAgent === name ? chalk.green(' ◀ ACTIVE') : '';
|
|
3411
|
+
const toolsBadge = agent.tools ? chalk.gray(` [${agent.tools.join(', ')}]`) : chalk.gray(' [all tools]');
|
|
3412
|
+
agentList += `${chalk.cyan('/' + name)} ${chalk.gray('─')} ${chalk.white(agent.description)}${toolsBadge}${active}\n`;
|
|
3413
|
+
if (agent.argumentHint) {
|
|
3414
|
+
agentList += ` ${chalk.gray('💡 ' + agent.argumentHint)}\n`;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
agentList += `\n${chalk.gray('Usage:')} ${chalk.cyan('/agentname prompt')} to switch & chat`;
|
|
3418
|
+
agentList += `\n${chalk.gray('Create:')} ${chalk.cyan('/agents create <name> <description>')}`;
|
|
3419
|
+
agentList += `\n${chalk.gray('Format:')} Supports YAML frontmatter (name, description, tools, argument-hint)`;
|
|
3420
|
+
console.log(box(agentList.trim(), '🤖 Available Agents', 'cyan'));
|
|
3421
|
+
}
|
|
3422
|
+
console.log();
|
|
3423
|
+
continue;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// Handle /agents create <name> <description> - quick agent creation
|
|
3427
|
+
if (input.toLowerCase().startsWith('/agents create ')) {
|
|
3428
|
+
const rest = input.slice('/agents create '.length).trim();
|
|
3429
|
+
const parts = rest.split(/\s+/);
|
|
3430
|
+
const agentName = (parts[0] || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
3431
|
+
const description = parts.slice(1).join(' ').trim();
|
|
3432
|
+
|
|
3433
|
+
if (!agentName) {
|
|
3434
|
+
console.log(chalk.yellow('\nUsage: /agents create <name> <description>'));
|
|
3435
|
+
console.log(chalk.gray('Example: /agents create salesmanager handles sales strategies and customer relations'));
|
|
3436
|
+
continue;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
ensureAgentsDirs();
|
|
3440
|
+
const agentFile = join(AGENTS_DIR, `${agentName}.md`);
|
|
3441
|
+
if (fs.existsSync(agentFile)) {
|
|
3442
|
+
console.log(chalk.yellow(`\nAgent "${agentName}" already exists. Edit it at: ${agentFile}`));
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
const agentTitle = agentName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
3447
|
+
const agentMd = `---\nname: "${agentTitle}"\ndescription: "${description || agentTitle + ' assistant'}"\ntools: [read, edit, write, list, search, shell]\n---\n\n# ${agentTitle}\n\nYou are a ${agentTitle} AI assistant working within Sapper.\n${description ? `Your role: ${description}\n` : ''}\nAdapt your responses to match this role. Use Sapper's tools (file read/write, shell commands, search) when needed to assist the user.\n`;
|
|
3448
|
+
|
|
3449
|
+
fs.writeFileSync(agentFile, agentMd);
|
|
3450
|
+
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
3451
|
+
console.log(chalk.gray(` File: ${agentFile}`));
|
|
3452
|
+
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
3453
|
+
continue;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
// Handle /skills command - list available skills
|
|
3457
|
+
if (input.toLowerCase() === '/skills') {
|
|
3458
|
+
const currentSkills = loadSkills();
|
|
3459
|
+
const skillNames = Object.keys(currentSkills);
|
|
3460
|
+
if (skillNames.length === 0) {
|
|
3461
|
+
console.log(chalk.yellow('\nNo skills found. Create one with /newskill'));
|
|
3462
|
+
} else {
|
|
3463
|
+
console.log();
|
|
3464
|
+
let skillList = '';
|
|
3465
|
+
for (const [name, skill] of Object.entries(currentSkills)) {
|
|
3466
|
+
const loaded = loadedSkills.includes(name) ? chalk.green(' ◀ LOADED') : '';
|
|
3467
|
+
skillList += `${chalk.cyan(name)} ${chalk.gray('─')} ${chalk.white(skill.description)}${loaded}\n`;
|
|
3468
|
+
if (skill.argumentHint) {
|
|
3469
|
+
skillList += ` ${chalk.gray('💡 ' + skill.argumentHint)}\n`;
|
|
1465
3470
|
}
|
|
1466
3471
|
}
|
|
3472
|
+
skillList += `\n${chalk.gray('Usage:')} ${chalk.cyan('/use skillname')} to load a skill`;
|
|
3473
|
+
skillList += `\n${chalk.gray('Format:')} Supports YAML frontmatter (name, description, argument-hint)`;
|
|
3474
|
+
console.log(box(skillList.trim(), '📘 Available Skills', 'cyan'));
|
|
1467
3475
|
}
|
|
3476
|
+
console.log();
|
|
1468
3477
|
continue;
|
|
1469
3478
|
}
|
|
1470
3479
|
|
|
1471
|
-
// Handle
|
|
1472
|
-
if (input.toLowerCase()
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
3480
|
+
// Handle /default command - switch back to default Sapper
|
|
3481
|
+
if (input.toLowerCase() === '/default') {
|
|
3482
|
+
currentAgent = null;
|
|
3483
|
+
currentAgentTools = null;
|
|
3484
|
+
// Rebuild system prompt without agent
|
|
3485
|
+
const skillContents = loadedSkills.map(s => {
|
|
3486
|
+
const allSkills = loadSkills();
|
|
3487
|
+
return allSkills[s]?.content || '';
|
|
3488
|
+
}).filter(Boolean);
|
|
3489
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(null, skillContents) };
|
|
3490
|
+
console.log(chalk.green('\n✅ Switched back to default Sapper mode (all tools enabled)'));
|
|
3491
|
+
continue;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// Handle /use command - load a skill
|
|
3495
|
+
if (input.toLowerCase().startsWith('/use ')) {
|
|
3496
|
+
const skillName = input.slice(5).trim().toLowerCase();
|
|
3497
|
+
const currentSkills = loadSkills();
|
|
3498
|
+
|
|
3499
|
+
if (!currentSkills[skillName]) {
|
|
3500
|
+
console.log(chalk.yellow(`\n❌ Skill "${skillName}" not found.`));
|
|
3501
|
+
console.log(chalk.gray(`Available: ${Object.keys(currentSkills).join(', ') || 'none (create with /newskill)'}`));
|
|
1477
3502
|
continue;
|
|
1478
3503
|
}
|
|
1479
3504
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
3505
|
+
if (loadedSkills.includes(skillName)) {
|
|
3506
|
+
console.log(chalk.yellow(`\nSkill "${skillName}" is already loaded.`));
|
|
3507
|
+
continue;
|
|
3508
|
+
}
|
|
1484
3509
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
3510
|
+
loadedSkills.push(skillName);
|
|
3511
|
+
|
|
3512
|
+
// Rebuild system prompt with current agent + all loaded skills
|
|
3513
|
+
const agentContent = currentAgent ? currentSkills[currentAgent]?.content || loadAgents()[currentAgent]?.content : null;
|
|
3514
|
+
const skillContents = loadedSkills.map(s => currentSkills[s]?.content || '').filter(Boolean);
|
|
3515
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agentContent, skillContents) };
|
|
3516
|
+
|
|
3517
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" loaded!`));
|
|
3518
|
+
console.log(chalk.gray(` Active skills: ${loadedSkills.join(', ')}`));
|
|
3519
|
+
continue;
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
// Handle /unload command - unload a skill
|
|
3523
|
+
if (input.toLowerCase().startsWith('/unload ')) {
|
|
3524
|
+
const skillName = input.slice(8).trim().toLowerCase();
|
|
3525
|
+
|
|
3526
|
+
if (!loadedSkills.includes(skillName)) {
|
|
3527
|
+
console.log(chalk.yellow(`\nSkill "${skillName}" is not loaded.`));
|
|
3528
|
+
console.log(chalk.gray(`Loaded skills: ${loadedSkills.join(', ') || 'none'}`));
|
|
1488
3529
|
continue;
|
|
1489
3530
|
}
|
|
1490
3531
|
|
|
1491
|
-
|
|
1492
|
-
const related = getRelatedFiles(matchingFile, workspace);
|
|
3532
|
+
loadedSkills = loadedSkills.filter(s => s !== skillName);
|
|
1493
3533
|
|
|
3534
|
+
// Rebuild system prompt
|
|
3535
|
+
const allSkills = loadSkills();
|
|
3536
|
+
const agentContent = currentAgent ? loadAgents()[currentAgent]?.content : null;
|
|
3537
|
+
const skillContents = loadedSkills.map(s => allSkills[s]?.content || '').filter(Boolean);
|
|
3538
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agentContent, skillContents) };
|
|
3539
|
+
|
|
3540
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" unloaded.`));
|
|
3541
|
+
if (loadedSkills.length > 0) {
|
|
3542
|
+
console.log(chalk.gray(` Remaining skills: ${loadedSkills.join(', ')}`));
|
|
3543
|
+
}
|
|
3544
|
+
continue;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
// Handle /newagent command - create a new agent
|
|
3548
|
+
if (input.toLowerCase() === '/newagent') {
|
|
1494
3549
|
console.log();
|
|
1495
3550
|
console.log(box(
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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'
|
|
3551
|
+
`Create a custom agent with its own persona and expertise.\n` +
|
|
3552
|
+
`The agent file will be saved in ${chalk.cyan('.sapper/agents/')}`,
|
|
3553
|
+
'🤖 New Agent', 'cyan'
|
|
1506
3554
|
));
|
|
1507
|
-
console.log();
|
|
1508
3555
|
|
|
1509
|
-
|
|
1510
|
-
if (
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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
|
-
}
|
|
3556
|
+
const agentName = await safeQuestion(chalk.cyan('\nAgent name (lowercase, no spaces): '));
|
|
3557
|
+
if (!agentName.trim() || !/^[a-z0-9_-]+$/.test(agentName.trim())) {
|
|
3558
|
+
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
3559
|
+
continue;
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
const agentFile = join(AGENTS_DIR, `${agentName.trim()}.md`);
|
|
3563
|
+
if (fs.existsSync(agentFile)) {
|
|
3564
|
+
console.log(chalk.yellow(`Agent "${agentName}" already exists. Edit it at: ${agentFile}`));
|
|
3565
|
+
continue;
|
|
1529
3566
|
}
|
|
3567
|
+
|
|
3568
|
+
const agentTitle = await safeQuestion(chalk.cyan('Agent title/role: '));
|
|
3569
|
+
const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
|
|
3570
|
+
const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
|
|
3571
|
+
const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,search,shell: '));
|
|
3572
|
+
|
|
3573
|
+
const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
|
|
3574
|
+
const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
|
|
3575
|
+
const agentMd = `---\nname: "${agentTitle.trim() || agentName}"\ndescription: "${agentExpertise.trim() || agentTitle.trim() || agentName}"\n${toolsLine}\n---\n\n# ${agentTitle.trim() || agentName}\n\nYou are a ${agentTitle.trim() || agentName} AI assistant working within Sapper.\n\n## Your Expertise\n${expertiseList}\n\n## Communication Style\n${agentStyle.trim() || 'Professional and helpful'}.\n\nWhen the user asks for help, leverage your expertise and Sapper's tools to provide comprehensive assistance.\n`;
|
|
3576
|
+
|
|
3577
|
+
fs.writeFileSync(agentFile, agentMd);
|
|
3578
|
+
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
3579
|
+
console.log(chalk.gray(` File: ${agentFile}`));
|
|
3580
|
+
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
1530
3581
|
continue;
|
|
1531
3582
|
}
|
|
1532
3583
|
|
|
1533
|
-
// Handle
|
|
1534
|
-
if (input.toLowerCase() === '/
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
3584
|
+
// Handle /newskill command - create a new skill
|
|
3585
|
+
if (input.toLowerCase() === '/newskill') {
|
|
3586
|
+
console.log();
|
|
3587
|
+
console.log(box(
|
|
3588
|
+
`Create a custom skill with domain knowledge.\n` +
|
|
3589
|
+
`The skill file will be saved in ${chalk.cyan('.sapper/skills/')}`,
|
|
3590
|
+
'📘 New Skill', 'cyan'
|
|
3591
|
+
));
|
|
3592
|
+
|
|
3593
|
+
const skillName = await safeQuestion(chalk.cyan('\nSkill name (lowercase, no spaces): '));
|
|
3594
|
+
if (!skillName.trim() || !/^[a-z0-9_-]+$/.test(skillName.trim())) {
|
|
3595
|
+
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
3596
|
+
continue;
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
const skillFile = join(SKILLS_DIR, `${skillName.trim()}.md`);
|
|
3600
|
+
if (fs.existsSync(skillFile)) {
|
|
3601
|
+
console.log(chalk.yellow(`Skill "${skillName}" already exists. Edit it at: ${skillFile}`));
|
|
3602
|
+
continue;
|
|
1542
3603
|
}
|
|
3604
|
+
|
|
3605
|
+
const skillTitle = await safeQuestion(chalk.cyan('Skill title: '));
|
|
3606
|
+
const skillDesc = await safeQuestion(chalk.cyan('Brief description (for /skills listing): '));
|
|
3607
|
+
const skillArgHint = await safeQuestion(chalk.cyan('Argument hint (optional, e.g. "Describe what to do"): '));
|
|
3608
|
+
const skillBody = await safeQuestion(chalk.cyan('Skill knowledge (or Enter for template): '));
|
|
3609
|
+
|
|
3610
|
+
const descLine = skillDesc.trim() || skillTitle.trim() || skillName;
|
|
3611
|
+
const argHintLine = skillArgHint.trim() ? `\nargument-hint: "${skillArgHint.trim()}"` : '';
|
|
3612
|
+
|
|
3613
|
+
const skillMd = skillBody.trim()
|
|
3614
|
+
? `---\nname: ${skillTitle.trim() || skillName}\ndescription: "${descLine}"${argHintLine}\n---\n\n# ${skillTitle.trim() || skillName}\n\n${skillBody.trim()}\n`
|
|
3615
|
+
: `---\nname: ${skillTitle.trim() || skillName}\ndescription: "${descLine}"${argHintLine}\n---\n\n# ${skillTitle.trim() || skillName}\n\nBest practices and knowledge for ${skillTitle.trim() || skillName}:\n- [Add your knowledge points here]\n- [Add patterns and conventions]\n- [Add common solutions]\n\n## Commands Reference\n| User says | Action |\n|-----------|--------|\n| "example command" | What the AI should do |\n\n## Procedures\n- [Add step-by-step procedures here]\n`;
|
|
3616
|
+
|
|
3617
|
+
fs.writeFileSync(skillFile, skillMd);
|
|
3618
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" created!`));
|
|
3619
|
+
console.log(chalk.gray(` File: ${skillFile}`));
|
|
3620
|
+
console.log(chalk.cyan(` Load it: /use ${skillName}`));
|
|
1543
3621
|
continue;
|
|
1544
3622
|
}
|
|
1545
3623
|
|
|
1546
|
-
// Handle
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
3624
|
+
// Handle /agentname - detect if input matches an agent name
|
|
3625
|
+
let agentHandled = false;
|
|
3626
|
+
{
|
|
3627
|
+
const currentAgents = loadAgents();
|
|
3628
|
+
const inputLower = input.toLowerCase();
|
|
3629
|
+
|
|
3630
|
+
// Check if input starts with /agentname (e.g., /salesmanager how do I sell?)
|
|
3631
|
+
if (inputLower.startsWith('/') && !inputLower.startsWith('//')) {
|
|
3632
|
+
const firstSpace = input.indexOf(' ');
|
|
3633
|
+
const cmdPart = firstSpace > 0 ? inputLower.slice(1, firstSpace) : inputLower.slice(1);
|
|
3634
|
+
|
|
3635
|
+
if (currentAgents[cmdPart]) {
|
|
3636
|
+
const agent = currentAgents[cmdPart];
|
|
3637
|
+
const prompt = firstSpace > 0 ? input.slice(firstSpace + 1).trim() : '';
|
|
3638
|
+
|
|
3639
|
+
// Switch to this agent
|
|
3640
|
+
currentAgent = cmdPart;
|
|
3641
|
+
currentAgentTools = agent.tools; // null = all tools, or ['READ','WRITE',...]
|
|
3642
|
+
|
|
3643
|
+
// Rebuild system prompt with agent + any loaded skills
|
|
3644
|
+
const skillContents = loadedSkills.map(s => {
|
|
3645
|
+
const allSkills = loadSkills();
|
|
3646
|
+
return allSkills[s]?.content || '';
|
|
3647
|
+
}).filter(Boolean);
|
|
3648
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agent.content, skillContents) };
|
|
3649
|
+
|
|
3650
|
+
console.log();
|
|
3651
|
+
console.log(box(
|
|
3652
|
+
`${statusBadge('Active Agent', 'action')} ${chalk.white('/' + cmdPart)}\n` +
|
|
3653
|
+
`${keyValue('Role', chalk.white(agent.description), 8)}\n` +
|
|
3654
|
+
`${keyValue('Tools', agent.tools ? UI.slate(agent.tools.join(', ')) : UI.slate('all tools'), 8)}`,
|
|
3655
|
+
'Agent Mode', 'magenta'
|
|
3656
|
+
));
|
|
3657
|
+
|
|
3658
|
+
if (!prompt) {
|
|
3659
|
+
console.log(UI.slate('Type your prompt to chat with this agent.'));
|
|
3660
|
+
continue; // Just switched, no prompt to send
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
// Has a prompt - inject it as user message and let AI respond
|
|
3664
|
+
messages.push({ role: 'user', content: prompt });
|
|
3665
|
+
agentHandled = true;
|
|
3666
|
+
// Don't continue - fall through to the AI response loop below
|
|
3667
|
+
}
|
|
1552
3668
|
}
|
|
1553
|
-
continue;
|
|
1554
3669
|
}
|
|
1555
3670
|
|
|
1556
|
-
// Handle
|
|
1557
|
-
if (input.toLowerCase()
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
console.log(chalk.gray('
|
|
3671
|
+
// Handle /fetch command - fetch a URL and add to context
|
|
3672
|
+
if (input.toLowerCase().startsWith('/fetch')) {
|
|
3673
|
+
const url = input.slice(6).trim();
|
|
3674
|
+
if (!url || !url.match(/^https?:\/\//)) {
|
|
3675
|
+
console.log(chalk.yellow('Usage: /fetch <url>'));
|
|
3676
|
+
console.log(chalk.gray(' Example: /fetch https://docs.example.com/api'));
|
|
3677
|
+
continue;
|
|
3678
|
+
}
|
|
3679
|
+
try {
|
|
3680
|
+
const fetchSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
|
|
3681
|
+
const rawContent = await fetchUrl(url);
|
|
3682
|
+
fetchSpinner.stop();
|
|
3683
|
+
|
|
3684
|
+
const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
|
|
3685
|
+
const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
|
|
3686
|
+
let text;
|
|
3687
|
+
if (isJson) {
|
|
3688
|
+
try { text = JSON.stringify(JSON.parse(rawContent), null, 2); } catch { text = rawContent; }
|
|
3689
|
+
} else if (isHtml) {
|
|
3690
|
+
text = htmlToText(rawContent);
|
|
3691
|
+
} else {
|
|
3692
|
+
text = rawContent;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3695
|
+
if (text.trim().length > 0) {
|
|
3696
|
+
const webContent = `\n\n══════════════════════════════════════\n🌐 WEB PAGE CONTENT\n══════════════════════════════════════\n\nURL: ${url}\n\n${text}\n`;
|
|
3697
|
+
messages.push({ role: 'user', content: `I fetched this web page for reference:\n${webContent}\n\nUse this information to help me.` });
|
|
3698
|
+
ensureSapperDir();
|
|
3699
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
3700
|
+
console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
|
|
3701
|
+
console.log(chalk.gray('📝 Added to context. AI can now reference this page.\n'));
|
|
3702
|
+
} else {
|
|
3703
|
+
console.log(chalk.yellow('⚠️ No readable content found on that page.'));
|
|
3704
|
+
}
|
|
3705
|
+
} catch (e) {
|
|
3706
|
+
console.log(chalk.yellow(`⚠️ Could not fetch: ${e.message}`));
|
|
1562
3707
|
}
|
|
1563
3708
|
continue;
|
|
1564
3709
|
}
|
|
@@ -1639,6 +3784,8 @@ TOOL SYNTAX:
|
|
|
1639
3784
|
continue;
|
|
1640
3785
|
}
|
|
1641
3786
|
|
|
3787
|
+
// Skip input processing if agent already handled it
|
|
3788
|
+
if (!agentHandled) {
|
|
1642
3789
|
// Handle @ alone or /attach command - interactive file picker
|
|
1643
3790
|
if (input.trim() === '@' || input.toLowerCase() === '/attach') {
|
|
1644
3791
|
const selectedFiles = await pickFiles();
|
|
@@ -1649,14 +3796,24 @@ TOOL SYNTAX:
|
|
|
1649
3796
|
const fileAttachments = [];
|
|
1650
3797
|
for (const filePath of selectedFiles) {
|
|
1651
3798
|
try {
|
|
3799
|
+
// Check .sapperignore
|
|
3800
|
+
if (shouldIgnore(filePath)) {
|
|
3801
|
+
console.log(chalk.yellow(`⚠️ ${filePath} is in .sapperignore — skipped`));
|
|
3802
|
+
continue;
|
|
3803
|
+
}
|
|
1652
3804
|
const stats = fs.statSync(filePath);
|
|
1653
3805
|
if (stats.size > MAX_FILE_SIZE) {
|
|
1654
|
-
console.log(chalk.
|
|
3806
|
+
console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
|
|
3807
|
+
console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
|
|
3808
|
+
console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
|
|
3809
|
+
console.log(chalk.yellow(` File: ${filePath}`));
|
|
3810
|
+
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(MAX_FILE_SIZE/1024)}KB)`));
|
|
3811
|
+
console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
|
|
1655
3812
|
continue;
|
|
1656
3813
|
}
|
|
1657
3814
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
1658
3815
|
fileAttachments.push({ path: filePath, content, size: stats.size });
|
|
1659
|
-
console.log(chalk.green(`📎 Attached: ${filePath}`));
|
|
3816
|
+
console.log(chalk.green(`📎 Attached: ${filePath} (${Math.round(stats.size/1024)}KB)`));
|
|
1660
3817
|
} catch (e) {
|
|
1661
3818
|
console.log(chalk.yellow(`⚠️ Could not read ${filePath}`));
|
|
1662
3819
|
}
|
|
@@ -1698,10 +3855,19 @@ TOOL SYNTAX:
|
|
|
1698
3855
|
const filePath = attachMatch[1];
|
|
1699
3856
|
try {
|
|
1700
3857
|
if (fs.existsSync(filePath)) {
|
|
3858
|
+
// Check .sapperignore
|
|
3859
|
+
if (shouldIgnore(filePath)) {
|
|
3860
|
+
console.log(chalk.yellow(`⚠️ @${filePath} is in .sapperignore — skipped`));
|
|
3861
|
+
continue;
|
|
3862
|
+
}
|
|
1701
3863
|
const stats = fs.statSync(filePath);
|
|
1702
3864
|
if (stats.isFile()) {
|
|
1703
3865
|
if (stats.size > MAX_FILE_SIZE) {
|
|
1704
|
-
console.log(chalk.
|
|
3866
|
+
console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
|
|
3867
|
+
console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
|
|
3868
|
+
console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
|
|
3869
|
+
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(MAX_FILE_SIZE/1024)}KB limit`));
|
|
3870
|
+
console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
|
|
1705
3871
|
} else {
|
|
1706
3872
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
1707
3873
|
fileAttachments.push({ path: filePath, content, size: stats.size });
|
|
@@ -1749,47 +3915,178 @@ TOOL SYNTAX:
|
|
|
1749
3915
|
processedInput = input + attachedContent;
|
|
1750
3916
|
}
|
|
1751
3917
|
|
|
3918
|
+
// ── Detect and fetch URLs in the message ──
|
|
3919
|
+
const urlMatches = input.match(URL_REGEX);
|
|
3920
|
+
if (urlMatches && urlMatches.length > 0) {
|
|
3921
|
+
const uniqueUrls = [...new Set(urlMatches)].slice(0, 5); // Max 5 URLs
|
|
3922
|
+
const urlContents = [];
|
|
3923
|
+
|
|
3924
|
+
for (const url of uniqueUrls) {
|
|
3925
|
+
try {
|
|
3926
|
+
const urlSpinner = ora({ text: chalk.cyan(`🌐 Fetching ${url}...`), spinner: 'dots' }).start();
|
|
3927
|
+
const rawContent = await fetchUrl(url);
|
|
3928
|
+
urlSpinner.stop();
|
|
3929
|
+
|
|
3930
|
+
// Detect content type
|
|
3931
|
+
const isJson = rawContent.trim().startsWith('{') || rawContent.trim().startsWith('[');
|
|
3932
|
+
const isHtml = rawContent.trim().startsWith('<') || rawContent.includes('<html');
|
|
3933
|
+
|
|
3934
|
+
let text;
|
|
3935
|
+
if (isJson) {
|
|
3936
|
+
// Pretty-print JSON
|
|
3937
|
+
try { text = JSON.stringify(JSON.parse(rawContent), null, 2); }
|
|
3938
|
+
catch { text = rawContent; }
|
|
3939
|
+
} else if (isHtml) {
|
|
3940
|
+
text = htmlToText(rawContent);
|
|
3941
|
+
} else {
|
|
3942
|
+
text = rawContent; // Plain text, markdown, etc.
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
if (text.trim().length > 0) {
|
|
3946
|
+
urlContents.push({ url, content: text, size: text.length });
|
|
3947
|
+
console.log(chalk.green(`🌐 Fetched: ${url} (${Math.round(text.length/1024)}KB)`));
|
|
3948
|
+
} else {
|
|
3949
|
+
console.log(chalk.yellow(`⚠️ ${url} — no readable content`));
|
|
3950
|
+
}
|
|
3951
|
+
} catch (e) {
|
|
3952
|
+
console.log(chalk.yellow(`⚠️ Could not fetch ${url}: ${e.message}`));
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
if (urlContents.length > 0) {
|
|
3957
|
+
let urlAttached = '\n\n══════════════════════════════════════\n';
|
|
3958
|
+
urlAttached += `🌐 FETCHED WEB PAGES (${urlContents.length})\n`;
|
|
3959
|
+
urlAttached += '══════════════════════════════════════\n\n';
|
|
3960
|
+
|
|
3961
|
+
for (const page of urlContents) {
|
|
3962
|
+
urlAttached += `┌─── ${page.url} ───\n`;
|
|
3963
|
+
urlAttached += page.content;
|
|
3964
|
+
if (!page.content.endsWith('\n')) urlAttached += '\n';
|
|
3965
|
+
urlAttached += `└─── END ${page.url} ───\n\n`;
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
processedInput = processedInput + urlAttached;
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
|
|
1752
3972
|
messages.push({ role: 'user', content: processedInput });
|
|
3973
|
+
|
|
3974
|
+
// Log user input
|
|
3975
|
+
logEntry('user', {
|
|
3976
|
+
message: processedInput,
|
|
3977
|
+
attachments: fileAttachments.map(f => f.path)
|
|
3978
|
+
});
|
|
3979
|
+
|
|
1753
3980
|
} // End of else block for non-@ input
|
|
3981
|
+
} // End of if (!agentHandled)
|
|
1754
3982
|
|
|
1755
3983
|
let toolRounds = 0; // Prevent infinite loops
|
|
1756
3984
|
const MAX_TOOL_ROUNDS = 20;
|
|
3985
|
+
const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
|
|
3986
|
+
const MAX_PATCH_RETRIES = 3;
|
|
1757
3987
|
|
|
1758
3988
|
let active = true;
|
|
1759
3989
|
while (active) {
|
|
1760
3990
|
if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
|
|
1761
3991
|
|
|
1762
3992
|
spinner.start('Thinking...');
|
|
3993
|
+
const aiStartTime = Date.now();
|
|
1763
3994
|
let response;
|
|
1764
3995
|
try {
|
|
1765
|
-
|
|
3996
|
+
// Build chat options — pass native tools when supported
|
|
3997
|
+
const chatOpts = { model: selectedModel, messages, stream: true };
|
|
3998
|
+
if (effectiveContextLength()) {
|
|
3999
|
+
chatOpts.options = { num_ctx: effectiveContextLength() };
|
|
4000
|
+
}
|
|
4001
|
+
// Enable thinking for reasoning models (deepseek-r1, qwq, etc.)
|
|
4002
|
+
chatOpts.think = true;
|
|
4003
|
+
if (useNativeTools) {
|
|
4004
|
+
// Filter tool defs by agent restrictions if any
|
|
4005
|
+
if (currentAgentTools) {
|
|
4006
|
+
const toolNameMap = {
|
|
4007
|
+
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
4008
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
|
|
4009
|
+
};
|
|
4010
|
+
chatOpts.tools = nativeToolDefs.filter(t =>
|
|
4011
|
+
currentAgentTools.includes(toolNameMap[t.function.name])
|
|
4012
|
+
);
|
|
4013
|
+
} else {
|
|
4014
|
+
chatOpts.tools = nativeToolDefs;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
response = await ollama.chat(chatOpts);
|
|
1766
4018
|
} catch (ollamaError) {
|
|
1767
4019
|
spinner.stop();
|
|
1768
4020
|
console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
|
|
4021
|
+
logEntry('error', { message: `Ollama error: ${ollamaError.message}` });
|
|
1769
4022
|
active = false;
|
|
1770
4023
|
continue;
|
|
1771
4024
|
}
|
|
1772
4025
|
spinner.stop();
|
|
1773
4026
|
|
|
1774
4027
|
let msg = '';
|
|
4028
|
+
let thinkMsg = ''; // Thinking/reasoning content from thinking models
|
|
1775
4029
|
const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
|
|
1776
4030
|
let lastChunkTime = Date.now();
|
|
1777
4031
|
let repetitionCount = 0;
|
|
1778
4032
|
let lastContent = '';
|
|
4033
|
+
let wasInterrupted = false;
|
|
4034
|
+
let wasRepetitionStopped = false;
|
|
4035
|
+
let nativeToolCalls = []; // Collect native tool_calls from streaming chunks
|
|
1779
4036
|
abortStream = false; // Reset abort flag before streaming
|
|
4037
|
+
let chunkPromptTokens = 0; // Track actual tokens from Ollama
|
|
4038
|
+
let chunkEvalTokens = 0;
|
|
4039
|
+
let isThinking = false; // Track if we're currently in thinking mode
|
|
4040
|
+
const genStartTime = Date.now(); // Track generation elapsed time
|
|
4041
|
+
let genTokenCount = 0; // Count response tokens as they stream
|
|
1780
4042
|
|
|
1781
|
-
console.log(
|
|
1782
|
-
process.stdout.write(chalk.magenta('│ '));
|
|
4043
|
+
console.log(sectionTitle('Sapper', selectedModel, 'cyan'));
|
|
1783
4044
|
for await (const chunk of response) {
|
|
1784
4045
|
// Check if user pressed Ctrl+C
|
|
1785
4046
|
if (abortStream) {
|
|
1786
|
-
console.log(
|
|
4047
|
+
console.log(UI.slate('\n[response interrupted]'));
|
|
4048
|
+
wasInterrupted = true;
|
|
1787
4049
|
break;
|
|
1788
4050
|
}
|
|
1789
4051
|
|
|
4052
|
+
// Handle thinking/reasoning content (deepseek-r1, qwq, etc.)
|
|
4053
|
+
const thinking = chunk.message.thinking;
|
|
4054
|
+
if (thinking) {
|
|
4055
|
+
if (!isThinking) {
|
|
4056
|
+
isThinking = true;
|
|
4057
|
+
process.stdout.write(`\n${UI.slate.italic(' ◇ Thinking')}\n${UI.slate(' │ ')}`);
|
|
4058
|
+
}
|
|
4059
|
+
// Live-stream thinking — dim italic, wrap at line breaks
|
|
4060
|
+
const lines = thinking.split('\n');
|
|
4061
|
+
for (let li = 0; li < lines.length; li++) {
|
|
4062
|
+
if (li > 0) process.stdout.write(`\n${UI.slate(' │ ')}`);
|
|
4063
|
+
process.stdout.write(UI.slate.italic(lines[li]));
|
|
4064
|
+
}
|
|
4065
|
+
thinkMsg += thinking;
|
|
4066
|
+
}
|
|
4067
|
+
|
|
1790
4068
|
const content = chunk.message.content;
|
|
1791
|
-
|
|
1792
|
-
|
|
4069
|
+
if (content) {
|
|
4070
|
+
if (isThinking) {
|
|
4071
|
+
isThinking = false;
|
|
4072
|
+
process.stdout.write(`\n${UI.slate(' └─')}\n\n`);
|
|
4073
|
+
}
|
|
4074
|
+
msg += content;
|
|
4075
|
+
genTokenCount++;
|
|
4076
|
+
// Show live progress with timer, tokens, and interrupt hint
|
|
4077
|
+
const elapsed = ((Date.now() - genStartTime) / 1000).toFixed(1);
|
|
4078
|
+
const tps = genTokenCount / Math.max((Date.now() - genStartTime) / 1000, 0.1);
|
|
4079
|
+
process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s`)} ${UI.slate.italic('Ctrl+C to stop')}`);
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
// Capture token stats from the final chunk (done: true)
|
|
4083
|
+
if (chunk.prompt_eval_count) chunkPromptTokens = chunk.prompt_eval_count;
|
|
4084
|
+
if (chunk.eval_count) chunkEvalTokens = chunk.eval_count;
|
|
4085
|
+
|
|
4086
|
+
// Collect native tool_calls (arrive in chunks, usually the final one)
|
|
4087
|
+
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
|
4088
|
+
nativeToolCalls.push(...chunk.message.tool_calls);
|
|
4089
|
+
}
|
|
1793
4090
|
|
|
1794
4091
|
// Smart loop detection: check for repetitive content patterns
|
|
1795
4092
|
if (msg.length > 10000) {
|
|
@@ -1801,6 +4098,7 @@ TOOL SYNTAX:
|
|
|
1801
4098
|
repetitionCount++;
|
|
1802
4099
|
if (repetitionCount > 3) {
|
|
1803
4100
|
console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
|
|
4101
|
+
wasRepetitionStopped = true;
|
|
1804
4102
|
break;
|
|
1805
4103
|
}
|
|
1806
4104
|
} else {
|
|
@@ -1814,25 +4112,174 @@ TOOL SYNTAX:
|
|
|
1814
4112
|
// Don't break - just warn. User can Ctrl+C if needed
|
|
1815
4113
|
}
|
|
1816
4114
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
4115
|
+
// Clear progress line and render formatted markdown
|
|
4116
|
+
process.stdout.write('\r\x1b[K');
|
|
4117
|
+
if (msg.trim()) {
|
|
4118
|
+
console.log(renderMarkdown(msg));
|
|
4119
|
+
} else {
|
|
4120
|
+
console.log();
|
|
4121
|
+
}
|
|
1820
4122
|
|
|
1821
|
-
//
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
4123
|
+
// Update global token tracking from actual Ollama response
|
|
4124
|
+
if (chunkPromptTokens > 0) {
|
|
4125
|
+
lastPromptTokens = chunkPromptTokens;
|
|
4126
|
+
lastEvalTokens = chunkEvalTokens;
|
|
4127
|
+
const totalTokens = chunkPromptTokens + chunkEvalTokens;
|
|
4128
|
+
const ctxLenDisplay = effectiveContextLength();
|
|
4129
|
+
if (ctxLenDisplay) {
|
|
4130
|
+
const usagePercent = Math.round((totalTokens / ctxLenDisplay) * 100);
|
|
4131
|
+
const thinkNote = thinkMsg ? ` · ${UI.slate.italic(`${thinkMsg.length.toLocaleString()} chars thinking`)}` : '';
|
|
4132
|
+
console.log(`${meter(totalTokens, ctxLenDisplay, 22)} ${UI.slate(`${chunkPromptTokens.toLocaleString()} prompt · ${chunkEvalTokens.toLocaleString()} response · ${usagePercent}% of context`)}${thinkNote}`);
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
console.log(divider('─', 'gray', 56));
|
|
4136
|
+
|
|
4137
|
+
const aiDuration = Date.now() - aiStartTime;
|
|
4138
|
+
// Build assistant message — include tool_calls and thinking if present
|
|
4139
|
+
const assistantMsg = { role: 'assistant', content: msg };
|
|
4140
|
+
if (thinkMsg) {
|
|
4141
|
+
assistantMsg.thinking = thinkMsg;
|
|
4142
|
+
}
|
|
4143
|
+
if (nativeToolCalls.length > 0) {
|
|
4144
|
+
assistantMsg.tool_calls = nativeToolCalls;
|
|
4145
|
+
}
|
|
4146
|
+
messages.push(assistantMsg);
|
|
4147
|
+
|
|
4148
|
+
// If interrupted, skip tool processing — go straight back to prompt
|
|
4149
|
+
if (wasInterrupted) {
|
|
4150
|
+
ensureSapperDir();
|
|
4151
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
4152
|
+
active = false;
|
|
4153
|
+
resetTerminal();
|
|
4154
|
+
continue;
|
|
4155
|
+
}
|
|
4156
|
+
|
|
4157
|
+
// Log AI response
|
|
4158
|
+
logEntry('ai', {
|
|
4159
|
+
charCount: msg.length,
|
|
4160
|
+
duration: aiDuration,
|
|
4161
|
+
toolCount: nativeToolCalls.length || 0, // Updated below if text-marker tools found
|
|
4162
|
+
interrupted: wasInterrupted,
|
|
4163
|
+
repetitionStopped: wasRepetitionStopped,
|
|
4164
|
+
preview: msg.replace(/\[TOOL:[^\]]*\][\s\S]*?\[\/TOOL\]/g, '[tool call]')
|
|
4165
|
+
});
|
|
4166
|
+
|
|
4167
|
+
// ═══ NATIVE TOOL CALLS HANDLER ═══════════════════════════════════
|
|
4168
|
+
if (nativeToolCalls.length > 0) {
|
|
4169
|
+
toolRounds++;
|
|
4170
|
+
let hitToolLimit = false;
|
|
4171
|
+
if (toolRounds >= MAX_TOOL_ROUNDS) {
|
|
4172
|
+
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Processing remaining tools then stopping.`));
|
|
4173
|
+
hitToolLimit = true;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// Map native function names to tool executors
|
|
4177
|
+
const nativeToolNameMap = {
|
|
4178
|
+
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
4179
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
|
|
4180
|
+
};
|
|
4181
|
+
|
|
4182
|
+
for (const tc of nativeToolCalls) {
|
|
4183
|
+
const fn = tc.function;
|
|
4184
|
+
const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
|
|
4185
|
+
const args = fn.arguments || {};
|
|
4186
|
+
|
|
4187
|
+
// Enforce agent tool restrictions
|
|
4188
|
+
if (currentAgentTools && !currentAgentTools.includes(toolType)) {
|
|
4189
|
+
console.log(chalk.yellow(`\n⚠️ Tool ${toolType} blocked — not in agent's allowed tools`));
|
|
4190
|
+
messages.push({ role: 'tool', content: `Error: Tool ${toolType} is not allowed for the current agent.`, tool_name: fn.name });
|
|
4191
|
+
continue;
|
|
4192
|
+
}
|
|
4193
|
+
|
|
4194
|
+
const displayPath = args.path || args.pattern || args.command || '';
|
|
4195
|
+
console.log();
|
|
4196
|
+
console.log(statusBadge(toolType, 'action') + chalk.gray(' → ') + chalk.white(displayPath));
|
|
4197
|
+
|
|
4198
|
+
const toolStart = Date.now();
|
|
4199
|
+
let result;
|
|
4200
|
+
let toolSuccess = true;
|
|
4201
|
+
|
|
4202
|
+
try {
|
|
4203
|
+
switch (fn.name) {
|
|
4204
|
+
case 'list_directory':
|
|
4205
|
+
result = tools.list(args.path);
|
|
4206
|
+
logEntry('file', { action: 'list', path: args.path });
|
|
4207
|
+
break;
|
|
4208
|
+
case 'read_file':
|
|
4209
|
+
result = tools.read(args.path);
|
|
4210
|
+
logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
|
|
4211
|
+
break;
|
|
4212
|
+
case 'search_files':
|
|
4213
|
+
result = await tools.search(args.pattern);
|
|
4214
|
+
logEntry('tool', { toolType: 'SEARCH', path: args.pattern, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
4215
|
+
break;
|
|
4216
|
+
case 'write_file':
|
|
4217
|
+
result = await tools.write(args.path, args.content);
|
|
4218
|
+
logEntry('file', { action: 'write', path: args.path, size: args.content?.length || 0, userApproved: result.includes('Successfully') });
|
|
4219
|
+
break;
|
|
4220
|
+
case 'patch_file': {
|
|
4221
|
+
const patchKey = args.path?.trim();
|
|
4222
|
+
if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
|
|
4223
|
+
result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. Use read_file to see exact content, then try write_file instead.`;
|
|
4224
|
+
toolSuccess = false;
|
|
4225
|
+
} else {
|
|
4226
|
+
result = await tools.patch(args.path, args.old_text, args.new_text);
|
|
4227
|
+
if (result.includes('Successfully')) {
|
|
4228
|
+
patchFailures[patchKey] = 0;
|
|
4229
|
+
} else if (result.startsWith('Error:')) {
|
|
4230
|
+
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
4231
|
+
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES})`;
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
logEntry('file', { action: 'patch', path: args.path, userApproved: result.includes('Successfully') });
|
|
4235
|
+
break;
|
|
4236
|
+
}
|
|
4237
|
+
case 'create_directory':
|
|
4238
|
+
result = tools.mkdir(args.path);
|
|
4239
|
+
logEntry('file', { action: 'mkdir', path: args.path });
|
|
4240
|
+
break;
|
|
4241
|
+
case 'run_shell':
|
|
4242
|
+
result = await tools.shell(args.command);
|
|
4243
|
+
logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
|
|
4244
|
+
break;
|
|
4245
|
+
default:
|
|
4246
|
+
result = `Unknown tool: ${fn.name}`;
|
|
4247
|
+
toolSuccess = false;
|
|
4248
|
+
}
|
|
4249
|
+
} catch (toolError) {
|
|
4250
|
+
result = `Error executing ${fn.name}: ${toolError.message}`;
|
|
4251
|
+
toolSuccess = false;
|
|
4252
|
+
logEntry('error', { message: result });
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
// Feed result back as tool role message (Ollama native format)
|
|
4256
|
+
messages.push({ role: 'tool', content: String(result), tool_name: fn.name });
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
// Save context
|
|
4260
|
+
ensureSapperDir();
|
|
4261
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
4262
|
+
|
|
4263
|
+
if (hitToolLimit) {
|
|
4264
|
+
resetTerminal();
|
|
4265
|
+
messages.push({ role: 'user', content: 'STOP using tools now. Provide your analysis based on what you have.' });
|
|
4266
|
+
}
|
|
4267
|
+
continue; // Loop back for AI to process tool results
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
// ═══ TEXT-MARKER TOOL PARSING (fallback for models without native tool support) ═══
|
|
4271
|
+
// Strip markdown code blocks before tool parsing to avoid executing tool examples
|
|
4272
|
+
let msgForToolParsing = msg.replace(/```[\s\S]*?```/g, '');
|
|
4273
|
+
|
|
4274
|
+
// Check for unclosed tool calls and auto-close them instead of burning AI rounds
|
|
4275
|
+
const hasUnclosedTool = msgForToolParsing.includes('[TOOL:') && !msgForToolParsing.includes('[/TOOL]');
|
|
1826
4276
|
if (hasUnclosedTool) {
|
|
1827
|
-
console.log(chalk.yellow('\n⚠️ Unclosed tool detected
|
|
1828
|
-
|
|
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
|
|
4277
|
+
console.log(chalk.yellow('\n⚠️ Unclosed tool detected — auto-closing with [/TOOL]'));
|
|
4278
|
+
msgForToolParsing += '[/TOOL]';
|
|
1835
4279
|
}
|
|
4280
|
+
|
|
4281
|
+
// Regex: supports both old format (path]content) and new format (path:::content)
|
|
4282
|
+
const toolMatches = [...msgForToolParsing.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
|
|
1836
4283
|
|
|
1837
4284
|
// Debug mode: show what regex sees
|
|
1838
4285
|
if (debugMode) {
|
|
@@ -1870,45 +4317,105 @@ TOOL SYNTAX:
|
|
|
1870
4317
|
if (toolMatches.length > 0) {
|
|
1871
4318
|
toolRounds++;
|
|
1872
4319
|
|
|
1873
|
-
//
|
|
4320
|
+
// Track if we hit the tool limit — still process this round's tools, then stop
|
|
4321
|
+
let hitToolLimit = false;
|
|
1874
4322
|
if (toolRounds >= MAX_TOOL_ROUNDS) {
|
|
1875
|
-
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds).
|
|
4323
|
+
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Processing remaining tools then stopping.`));
|
|
1876
4324
|
console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
|
|
1877
|
-
|
|
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
|
|
4325
|
+
hitToolLimit = true;
|
|
1883
4326
|
}
|
|
1884
4327
|
|
|
4328
|
+
// Update the AI log entry with tool count
|
|
4329
|
+
if (activityLog.length > 0) {
|
|
4330
|
+
const lastAiLog = [...activityLog].reverse().find(e => e.type === 'ai');
|
|
4331
|
+
if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
1885
4334
|
for (const match of toolMatches) {
|
|
1886
4335
|
const [_, type, path, content] = match;
|
|
4336
|
+
|
|
4337
|
+
// Enforce tool restrictions from active agent
|
|
4338
|
+
if (currentAgentTools && !currentAgentTools.includes(type.toUpperCase())) {
|
|
4339
|
+
console.log();
|
|
4340
|
+
console.log(chalk.yellow(`⚠️ Tool ${type.toUpperCase()} blocked — not in agent's allowed tools: [${currentAgentTools.join(', ')}]`));
|
|
4341
|
+
const result = `Error: Tool ${type.toUpperCase()} is not allowed for the current agent. Allowed tools: ${currentAgentTools.join(', ')}. Use only the allowed tools.`;
|
|
4342
|
+
messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
|
|
4343
|
+
logEntry('tool', { toolType: type.toUpperCase(), path, duration: 0, success: false, error: 'blocked by agent tool restriction' });
|
|
4344
|
+
continue;
|
|
4345
|
+
}
|
|
4346
|
+
|
|
1887
4347
|
console.log();
|
|
1888
4348
|
console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
|
|
1889
4349
|
|
|
4350
|
+
const toolStart = Date.now();
|
|
1890
4351
|
let result;
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
4352
|
+
let toolSuccess = true;
|
|
4353
|
+
if (type.toLowerCase() === 'list') {
|
|
4354
|
+
result = tools.list(path);
|
|
4355
|
+
logEntry('file', { action: 'list', path });
|
|
4356
|
+
}
|
|
4357
|
+
else if (type.toLowerCase() === 'read') {
|
|
4358
|
+
result = tools.read(path);
|
|
4359
|
+
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
4360
|
+
}
|
|
4361
|
+
else if (type.toLowerCase() === 'mkdir') {
|
|
4362
|
+
result = tools.mkdir(path);
|
|
4363
|
+
logEntry('file', { action: 'mkdir', path });
|
|
4364
|
+
}
|
|
1894
4365
|
else if (type.toLowerCase() === 'write') {
|
|
1895
4366
|
if (!content || content.trim() === '') {
|
|
1896
4367
|
result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
|
|
4368
|
+
toolSuccess = false;
|
|
1897
4369
|
} else {
|
|
1898
4370
|
result = await tools.write(path, content);
|
|
4371
|
+
const approved = result.includes('Successfully');
|
|
4372
|
+
logEntry('file', { action: 'write', path, size: content.length, userApproved: approved });
|
|
1899
4373
|
}
|
|
1900
4374
|
}
|
|
1901
4375
|
else if (type.toLowerCase() === 'patch') {
|
|
1902
|
-
// PATCH format: [TOOL:PATCH]path
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
4376
|
+
// PATCH format: [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL]
|
|
4377
|
+
// Also supports line mode: [TOOL:PATCH]path:::LINE:15|||new text[/TOOL]
|
|
4378
|
+
const patchKey = path.trim();
|
|
4379
|
+
if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
|
|
4380
|
+
result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. STOP retrying PATCH on this file. Instead, use [TOOL:READ]${patchKey}[/TOOL] to see exact content, then either use LINE:number mode (e.g. [TOOL:PATCH]${patchKey}:::LINE:42|||new text[/TOOL]) or use [TOOL:WRITE] to rewrite the file.`;
|
|
4381
|
+
toolSuccess = false;
|
|
4382
|
+
logEntry('file', { action: 'patch', path, userApproved: false });
|
|
1906
4383
|
} else {
|
|
1907
|
-
|
|
4384
|
+
// Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
|
|
4385
|
+
let parts = content?.split('|||');
|
|
4386
|
+
if (!parts || parts.length !== 2) {
|
|
4387
|
+
parts = content?.split('||:');
|
|
4388
|
+
}
|
|
4389
|
+
if (parts && parts.length === 2) {
|
|
4390
|
+
result = await tools.patch(path, parts[0], parts[1]);
|
|
4391
|
+
const approved = result.includes('Successfully');
|
|
4392
|
+
if (!approved && result.startsWith('Error:')) {
|
|
4393
|
+
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
4394
|
+
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES} — after ${MAX_PATCH_RETRIES} failures, PATCH will be blocked on this file)`;
|
|
4395
|
+
} else if (approved) {
|
|
4396
|
+
patchFailures[patchKey] = 0; // Reset on success
|
|
4397
|
+
}
|
|
4398
|
+
logEntry('file', { action: 'patch', path, userApproved: approved });
|
|
4399
|
+
} else {
|
|
4400
|
+
result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
|
|
4401
|
+
toolSuccess = false;
|
|
4402
|
+
}
|
|
1908
4403
|
}
|
|
1909
4404
|
}
|
|
1910
|
-
else if (type.toLowerCase() === 'search')
|
|
1911
|
-
|
|
4405
|
+
else if (type.toLowerCase() === 'search') {
|
|
4406
|
+
result = await tools.search(path);
|
|
4407
|
+
logEntry('tool', { toolType: 'SEARCH', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
4408
|
+
}
|
|
4409
|
+
else if (type.toLowerCase() === 'shell') {
|
|
4410
|
+
result = await tools.shell(path);
|
|
4411
|
+
const approved = !result.includes('blocked');
|
|
4412
|
+
logEntry('shell', { command: path, duration: Date.now() - toolStart, userApproved: approved, exitCode: result.match(/code (\d+)/)?.[1] ?? null });
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4415
|
+
// Log tool execution (for non-shell, non-file specific ones)
|
|
4416
|
+
if (!['list', 'read', 'mkdir', 'write', 'patch', 'search', 'shell'].includes(type.toLowerCase())) {
|
|
4417
|
+
logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
|
|
4418
|
+
}
|
|
1912
4419
|
|
|
1913
4420
|
messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
|
|
1914
4421
|
}
|
|
@@ -1918,6 +4425,15 @@ TOOL SYNTAX:
|
|
|
1918
4425
|
if (toolMatches.length > 30) {
|
|
1919
4426
|
console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
|
|
1920
4427
|
}
|
|
4428
|
+
|
|
4429
|
+
// If tool limit was reached, stop after processing this round
|
|
4430
|
+
if (hitToolLimit) {
|
|
4431
|
+
resetTerminal();
|
|
4432
|
+
messages.push({
|
|
4433
|
+
role: 'user',
|
|
4434
|
+
content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
|
|
4435
|
+
});
|
|
4436
|
+
}
|
|
1921
4437
|
} else {
|
|
1922
4438
|
// No tools found - check if malformed command
|
|
1923
4439
|
if (msg.includes('[TOOL:') && msg.includes('[/]')) {
|
|
@@ -1939,6 +4455,7 @@ TOOL SYNTAX:
|
|
|
1939
4455
|
}
|
|
1940
4456
|
} catch (error) {
|
|
1941
4457
|
console.error(chalk.red('\n❌ Error:'), error.message);
|
|
4458
|
+
logEntry('error', { message: error.message });
|
|
1942
4459
|
// Loop continues automatically
|
|
1943
4460
|
}
|
|
1944
4461
|
}
|