sapper-iq 1.1.34 → 1.1.36
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 copy 3.mjs → old/sapper copy 3.mjs } +44 -105
- package/old/sapper copy4.mjs +1950 -0
- package/package.json +4 -2
- package/sapper-ui.mjs +1933 -0
- package/sapper.mjs +2087 -146
- /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
|
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import { dirname, join } from 'path';
|
|
10
10
|
import { marked } from 'marked';
|
|
11
11
|
import TerminalRenderer from 'marked-terminal';
|
|
12
|
+
import * as acorn from 'acorn';
|
|
12
13
|
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = dirname(__filename);
|
|
@@ -72,6 +73,225 @@ const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
|
|
|
72
73
|
const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
|
|
73
74
|
const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
|
|
74
75
|
const CONFIG_FILE = `${SAPPER_DIR}/config.json`;
|
|
76
|
+
const AGENTS_DIR = `${SAPPER_DIR}/agents`;
|
|
77
|
+
const SKILLS_DIR = `${SAPPER_DIR}/skills`;
|
|
78
|
+
const LOGS_DIR = `${SAPPER_DIR}/logs`;
|
|
79
|
+
|
|
80
|
+
// ═══════════════════════════════════════════════════════════════
|
|
81
|
+
// COMPREHENSIVE ACTIVITY LOGGER
|
|
82
|
+
// ═══════════════════════════════════════════════════════════════
|
|
83
|
+
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
84
|
+
const sessionLogFile = () => `${LOGS_DIR}/session-${sessionId}.md`;
|
|
85
|
+
const activityLog = []; // In-memory log for current session
|
|
86
|
+
|
|
87
|
+
function ensureLogsDir() {
|
|
88
|
+
ensureSapperDir();
|
|
89
|
+
if (!fs.existsSync(LOGS_DIR)) {
|
|
90
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Log entry types: user, ai, tool, system, error, file, shell, summary
|
|
95
|
+
function logEntry(type, data) {
|
|
96
|
+
const entry = {
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
elapsed: activityLog.length > 0
|
|
99
|
+
? Date.now() - new Date(activityLog[0].timestamp).getTime()
|
|
100
|
+
: 0,
|
|
101
|
+
type,
|
|
102
|
+
...data
|
|
103
|
+
};
|
|
104
|
+
activityLog.push(entry);
|
|
105
|
+
appendLogToFile(entry);
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatElapsed(ms) {
|
|
110
|
+
if (ms < 1000) return `${ms}ms`;
|
|
111
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
112
|
+
const mins = Math.floor(ms / 60000);
|
|
113
|
+
const secs = Math.floor((ms % 60000) / 1000);
|
|
114
|
+
return `${mins}m ${secs}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function appendLogToFile(entry) {
|
|
118
|
+
try {
|
|
119
|
+
ensureLogsDir();
|
|
120
|
+
const file = sessionLogFile();
|
|
121
|
+
const existed = fs.existsSync(file);
|
|
122
|
+
|
|
123
|
+
let line = '';
|
|
124
|
+
if (!existed) {
|
|
125
|
+
line += `# Sapper Session Log\n`;
|
|
126
|
+
line += `**Started:** ${new Date(entry.timestamp).toLocaleString()}\n`;
|
|
127
|
+
line += `**Working Directory:** \`${process.cwd()}\`\n\n`;
|
|
128
|
+
line += `---\n\n`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
132
|
+
const elapsed = formatElapsed(entry.elapsed);
|
|
133
|
+
|
|
134
|
+
switch (entry.type) {
|
|
135
|
+
case 'session_start':
|
|
136
|
+
line += `## 🚀 Session Started\n`;
|
|
137
|
+
line += `- **Model:** \`${entry.model}\`\n`;
|
|
138
|
+
line += `- **Resumed:** ${entry.resumed ? 'Yes' : 'No'}\n`;
|
|
139
|
+
line += `- **Context Messages:** ${entry.contextSize}\n\n`;
|
|
140
|
+
break;
|
|
141
|
+
case 'user':
|
|
142
|
+
line += `### 💬 User Input \`${time}\` _(+${elapsed})_\n`;
|
|
143
|
+
line += `\`\`\`\n${entry.message?.substring(0, 500)}${entry.message?.length > 500 ? '\n...' : ''}\n\`\`\`\n`;
|
|
144
|
+
if (entry.attachments?.length > 0) {
|
|
145
|
+
line += `📎 **Attached:** ${entry.attachments.join(', ')}\n`;
|
|
146
|
+
}
|
|
147
|
+
line += '\n';
|
|
148
|
+
break;
|
|
149
|
+
case 'ai':
|
|
150
|
+
line += `### 🤖 AI Response \`${time}\` _(+${elapsed})_\n`;
|
|
151
|
+
line += `- **Tokens:** ~${entry.charCount} chars\n`;
|
|
152
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
153
|
+
line += `- **Tools Used:** ${entry.toolCount || 0}\n`;
|
|
154
|
+
if (entry.interrupted) line += `- ⚠️ **Interrupted**\n`;
|
|
155
|
+
if (entry.repetitionStopped) line += `- ⚠️ **Stopped: repetitive output**\n`;
|
|
156
|
+
line += `\n<details><summary>Response preview</summary>\n\n`;
|
|
157
|
+
line += `${entry.preview?.substring(0, 800)}${entry.preview?.length > 800 ? '\n...' : ''}\n`;
|
|
158
|
+
line += `\n</details>\n\n`;
|
|
159
|
+
break;
|
|
160
|
+
case 'tool':
|
|
161
|
+
const statusIcon = entry.success ? '✅' : '❌';
|
|
162
|
+
line += `#### 🔧 Tool: \`${entry.toolType}\` ${statusIcon} \`${time}\`\n`;
|
|
163
|
+
line += `- **Target:** \`${entry.path}\`\n`;
|
|
164
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
165
|
+
if (entry.resultSize) line += `- **Result Size:** ${entry.resultSize} chars\n`;
|
|
166
|
+
if (entry.error) line += `- **Error:** ${entry.error}\n`;
|
|
167
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
168
|
+
line += '\n';
|
|
169
|
+
break;
|
|
170
|
+
case 'shell':
|
|
171
|
+
line += `#### 🖥️ Shell Command \`${time}\`\n`;
|
|
172
|
+
line += `\`\`\`bash\n${entry.command}\n\`\`\`\n`;
|
|
173
|
+
line += `- **Exit Code:** ${entry.exitCode ?? 'N/A'}\n`;
|
|
174
|
+
line += `- **Duration:** ${formatElapsed(entry.duration)}\n`;
|
|
175
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
176
|
+
line += '\n';
|
|
177
|
+
break;
|
|
178
|
+
case 'file':
|
|
179
|
+
const fileIcon = entry.action === 'read' ? '📖' : entry.action === 'write' ? '✏️' : entry.action === 'patch' ? '🔧' : '📁';
|
|
180
|
+
line += `#### ${fileIcon} File: \`${entry.action}\` \`${time}\`\n`;
|
|
181
|
+
line += `- **Path:** \`${entry.path}\`\n`;
|
|
182
|
+
if (entry.size) line += `- **Size:** ${entry.size} bytes\n`;
|
|
183
|
+
if (entry.userApproved !== undefined) line += `- **User Approved:** ${entry.userApproved ? 'Yes' : 'No'}\n`;
|
|
184
|
+
line += '\n';
|
|
185
|
+
break;
|
|
186
|
+
case 'system':
|
|
187
|
+
line += `> ℹ️ **${entry.event}** \`${time}\` — ${entry.detail || ''}\n\n`;
|
|
188
|
+
break;
|
|
189
|
+
case 'error':
|
|
190
|
+
line += `> ❌ **Error** \`${time}\` — \`${entry.message}\`\n\n`;
|
|
191
|
+
break;
|
|
192
|
+
case 'summary':
|
|
193
|
+
line += `### 🧠 Context Summarized \`${time}\`\n`;
|
|
194
|
+
line += `- **Before:** ${entry.before}\n`;
|
|
195
|
+
line += `- **After:** ${entry.after}\n\n`;
|
|
196
|
+
break;
|
|
197
|
+
default:
|
|
198
|
+
line += `> ${entry.type}: ${JSON.stringify(entry)}\n\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fs.appendFileSync(file, line);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Silent fail - logging should never break the app
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Render the in-memory activity log to terminal with beautiful formatting
|
|
208
|
+
function renderActivityLog(count = 30) {
|
|
209
|
+
const entries = activityLog.slice(-count);
|
|
210
|
+
if (entries.length === 0) return chalk.yellow('No activity recorded yet.');
|
|
211
|
+
|
|
212
|
+
const width = Math.min(process.stdout.columns || 80, 90);
|
|
213
|
+
let output = '';
|
|
214
|
+
|
|
215
|
+
// Header
|
|
216
|
+
output += chalk.cyan.bold('\n╔' + '═'.repeat(width - 2) + '╗\n');
|
|
217
|
+
output += chalk.cyan.bold('║') + chalk.white.bold(' 📋 SAPPER ACTIVITY LOG').padEnd(width - 2) + chalk.cyan.bold('║\n');
|
|
218
|
+
output += chalk.cyan.bold('║') + chalk.gray(` Session: ${sessionId} | ${activityLog.length} events`).padEnd(width - 2) + chalk.cyan.bold('║\n');
|
|
219
|
+
output += chalk.cyan.bold('╠' + '═'.repeat(width - 2) + '╣\n');
|
|
220
|
+
|
|
221
|
+
for (const entry of entries) {
|
|
222
|
+
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
223
|
+
const elapsed = formatElapsed(entry.elapsed);
|
|
224
|
+
const timeStr = chalk.gray(`${time} +${elapsed}`);
|
|
225
|
+
|
|
226
|
+
switch (entry.type) {
|
|
227
|
+
case 'session_start':
|
|
228
|
+
output += chalk.cyan.bold('║') + ` 🚀 ${chalk.green.bold('SESSION START')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
229
|
+
output += chalk.cyan.bold('║') + ` Model: ${chalk.cyan(entry.model)} | Context: ${entry.contextSize} msgs`.padEnd(width + 20) + '\n';
|
|
230
|
+
break;
|
|
231
|
+
case 'user':
|
|
232
|
+
output += chalk.cyan.bold('║') + ` 💬 ${chalk.blue.bold('USER')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
233
|
+
const preview = entry.message?.substring(0, 60)?.replace(/\n/g, ' ');
|
|
234
|
+
output += chalk.cyan.bold('║') + ` ${chalk.white(preview)}${entry.message?.length > 60 ? chalk.gray('...') : ''}`.padEnd(width + 20) + '\n';
|
|
235
|
+
if (entry.attachments?.length > 0) {
|
|
236
|
+
output += chalk.cyan.bold('║') + ` 📎 ${chalk.yellow(entry.attachments.join(', '))}`.padEnd(width + 20) + '\n';
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case 'ai':
|
|
240
|
+
const aiStatus = entry.interrupted ? chalk.yellow('⚠️ INTERRUPTED') : entry.repetitionStopped ? chalk.red('⚠️ LOOP') : chalk.green(`~${entry.charCount} chars`);
|
|
241
|
+
output += chalk.cyan.bold('║') + ` 🤖 ${chalk.magenta.bold('AI')} ${timeStr} ${aiStatus}`.padEnd(width + 50) + '\n';
|
|
242
|
+
output += chalk.cyan.bold('║') + ` ⏱ ${chalk.gray(formatElapsed(entry.duration))} | 🔧 ${entry.toolCount || 0} tools`.padEnd(width + 20) + '\n';
|
|
243
|
+
break;
|
|
244
|
+
case 'tool':
|
|
245
|
+
const icon = entry.success ? chalk.green('✓') : chalk.red('✗');
|
|
246
|
+
output += chalk.cyan.bold('║') + ` ${icon} ${chalk.yellow.bold(entry.toolType)} → ${chalk.white(entry.path?.substring(0, 40))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
247
|
+
if (entry.error) {
|
|
248
|
+
output += chalk.cyan.bold('║') + ` ${chalk.red(entry.error.substring(0, 60))}`.padEnd(width + 20) + '\n';
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
case 'shell':
|
|
252
|
+
output += chalk.cyan.bold('║') + ` 🖥️ ${chalk.red.bold('SHELL')} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
253
|
+
output += chalk.cyan.bold('║') + ` ${chalk.cyan('$ ' + entry.command?.substring(0, 55))}${entry.command?.length > 55 ? chalk.gray('...') : ''}`.padEnd(width + 20) + '\n';
|
|
254
|
+
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';
|
|
255
|
+
break;
|
|
256
|
+
case 'file':
|
|
257
|
+
const fIcon = { read: '📖', write: '✏️', patch: '🔧', list: '📂', mkdir: '📁' }[entry.action] || '📄';
|
|
258
|
+
output += chalk.cyan.bold('║') + ` ${fIcon} ${chalk.cyan(entry.action?.toUpperCase())} ${chalk.white(entry.path?.substring(0, 45))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
259
|
+
break;
|
|
260
|
+
case 'system':
|
|
261
|
+
output += chalk.cyan.bold('║') + ` ℹ️ ${chalk.gray(entry.event + (entry.detail ? ': ' + entry.detail.substring(0, 50) : ''))} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
262
|
+
break;
|
|
263
|
+
case 'error':
|
|
264
|
+
output += chalk.cyan.bold('║') + ` ❌ ${chalk.red.bold('ERROR')} ${chalk.red(entry.message?.substring(0, 50))} ${timeStr}`.padEnd(width + 40) + '\n';
|
|
265
|
+
break;
|
|
266
|
+
case 'summary':
|
|
267
|
+
output += chalk.cyan.bold('║') + ` 🧠 ${chalk.cyan.bold('SUMMARIZED')} ${entry.before} → ${entry.after} ${timeStr}`.padEnd(width + 30) + '\n';
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
output += chalk.cyan.bold('║') + chalk.gray('─'.repeat(width - 2)).padEnd(width - 1) + '\n';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Footer
|
|
274
|
+
output += chalk.cyan.bold('╠' + '═'.repeat(width - 2) + '╣\n');
|
|
275
|
+
const stats = getSessionStats();
|
|
276
|
+
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';
|
|
277
|
+
output += chalk.cyan.bold('║') + ` 📁 Log: ${chalk.gray(sessionLogFile())}`.padEnd(width + 20) + '\n';
|
|
278
|
+
output += chalk.cyan.bold('╚' + '═'.repeat(width - 2) + '╝\n');
|
|
279
|
+
|
|
280
|
+
return output;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getSessionStats() {
|
|
284
|
+
return {
|
|
285
|
+
userMessages: activityLog.filter(e => e.type === 'user').length,
|
|
286
|
+
aiMessages: activityLog.filter(e => e.type === 'ai').length,
|
|
287
|
+
toolCalls: activityLog.filter(e => e.type === 'tool').length,
|
|
288
|
+
shellCalls: activityLog.filter(e => e.type === 'shell').length,
|
|
289
|
+
errors: activityLog.filter(e => e.type === 'error').length,
|
|
290
|
+
totalDuration: activityLog.length > 0
|
|
291
|
+
? Date.now() - new Date(activityLog[0].timestamp).getTime()
|
|
292
|
+
: 0,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
75
295
|
|
|
76
296
|
// Ensure .sapper directory exists
|
|
77
297
|
function ensureSapperDir() {
|
|
@@ -80,6 +300,453 @@ function ensureSapperDir() {
|
|
|
80
300
|
}
|
|
81
301
|
}
|
|
82
302
|
|
|
303
|
+
// Ensure agents and skills directories exist
|
|
304
|
+
function ensureAgentsDirs() {
|
|
305
|
+
ensureSapperDir();
|
|
306
|
+
if (!fs.existsSync(AGENTS_DIR)) {
|
|
307
|
+
fs.mkdirSync(AGENTS_DIR, { recursive: true });
|
|
308
|
+
}
|
|
309
|
+
if (!fs.existsSync(SKILLS_DIR)) {
|
|
310
|
+
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ═══════════════════════════════════════════════════════════════
|
|
315
|
+
// AGENTS & SKILLS SYSTEM (with YAML frontmatter support)
|
|
316
|
+
// ═══════════════════════════════════════════════════════════════
|
|
317
|
+
|
|
318
|
+
// Parse YAML-like frontmatter from markdown files
|
|
319
|
+
// Supports: --- key: value --- blocks at the top of .md files
|
|
320
|
+
// Returns { meta: {}, body: string }
|
|
321
|
+
function parseFrontmatter(rawContent) {
|
|
322
|
+
const content = rawContent.trim();
|
|
323
|
+
if (!content.startsWith('---')) {
|
|
324
|
+
// No frontmatter — legacy format, extract title from first # heading
|
|
325
|
+
const firstLine = content.split('\n')[0].replace(/^#\s*/, '').trim();
|
|
326
|
+
return {
|
|
327
|
+
meta: { name: firstLine, description: firstLine },
|
|
328
|
+
body: content
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Find closing ---
|
|
333
|
+
const endIndex = content.indexOf('---', 3);
|
|
334
|
+
if (endIndex === -1) {
|
|
335
|
+
// Malformed — treat entire content as body
|
|
336
|
+
const firstLine = content.split('\n')[0].replace(/^#\s*/, '').replace(/^---\s*/, '').trim();
|
|
337
|
+
return { meta: { name: firstLine }, body: content };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const frontmatterBlock = content.substring(3, endIndex).trim();
|
|
341
|
+
const body = content.substring(endIndex + 3).trim();
|
|
342
|
+
|
|
343
|
+
const meta = {};
|
|
344
|
+
for (const line of frontmatterBlock.split('\n')) {
|
|
345
|
+
const colonIdx = line.indexOf(':');
|
|
346
|
+
if (colonIdx === -1) continue;
|
|
347
|
+
const key = line.substring(0, colonIdx).trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
348
|
+
let value = line.substring(colonIdx + 1).trim();
|
|
349
|
+
|
|
350
|
+
// Strip surrounding quotes
|
|
351
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
352
|
+
value = value.slice(1, -1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Parse arrays: [item1, item2]
|
|
356
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
357
|
+
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
meta[key] = value;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Ensure name fallback from body's first heading
|
|
364
|
+
if (!meta.name) {
|
|
365
|
+
const heading = body.match(/^#\s+(.+)/m);
|
|
366
|
+
meta.name = heading ? heading[1].trim() : 'Unnamed';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { meta, body };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Map tool shorthand names from frontmatter to actual TOOL: names
|
|
373
|
+
const TOOL_NAME_MAP = {
|
|
374
|
+
'read': 'READ',
|
|
375
|
+
'write': 'WRITE',
|
|
376
|
+
'edit': 'PATCH',
|
|
377
|
+
'patch': 'PATCH',
|
|
378
|
+
'list': 'LIST',
|
|
379
|
+
'search': 'SEARCH',
|
|
380
|
+
'shell': 'SHELL',
|
|
381
|
+
'mkdir': 'MKDIR',
|
|
382
|
+
'todo': 'LIST', // alias — list tasks
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
function normalizeToolList(toolsValue) {
|
|
386
|
+
if (!toolsValue) return null; // null = all tools allowed
|
|
387
|
+
if (typeof toolsValue === 'string') {
|
|
388
|
+
toolsValue = toolsValue.split(',').map(s => s.trim());
|
|
389
|
+
}
|
|
390
|
+
if (!Array.isArray(toolsValue)) return null;
|
|
391
|
+
return toolsValue.map(t => TOOL_NAME_MAP[t.toLowerCase()] || t.toUpperCase()).filter(Boolean);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Load all agents from .sapper/agents/*.md (with frontmatter support)
|
|
395
|
+
function loadAgents() {
|
|
396
|
+
ensureAgentsDirs();
|
|
397
|
+
const agents = {};
|
|
398
|
+
try {
|
|
399
|
+
const files = fs.readdirSync(AGENTS_DIR);
|
|
400
|
+
for (const file of files) {
|
|
401
|
+
if (file.endsWith('.md')) {
|
|
402
|
+
const name = file.replace('.md', '').toLowerCase();
|
|
403
|
+
const rawContent = fs.readFileSync(join(AGENTS_DIR, file), 'utf8');
|
|
404
|
+
const { meta, body } = parseFrontmatter(rawContent);
|
|
405
|
+
agents[name] = {
|
|
406
|
+
name: meta.name || name,
|
|
407
|
+
file,
|
|
408
|
+
content: body, // body without frontmatter → injected into system prompt
|
|
409
|
+
description: meta.description || meta.name || name,
|
|
410
|
+
tools: normalizeToolList(meta.tools), // null = all, or ['READ','WRITE',...]
|
|
411
|
+
argumentHint: meta['argument-hint'] || null,
|
|
412
|
+
meta, // full parsed metadata
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (e) {}
|
|
417
|
+
return agents;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Load all skills from .sapper/skills/*.md (with frontmatter support)
|
|
421
|
+
function loadSkills() {
|
|
422
|
+
ensureAgentsDirs();
|
|
423
|
+
const skills = {};
|
|
424
|
+
try {
|
|
425
|
+
const files = fs.readdirSync(SKILLS_DIR);
|
|
426
|
+
for (const file of files) {
|
|
427
|
+
if (file.endsWith('.md')) {
|
|
428
|
+
const name = file.replace('.md', '').toLowerCase();
|
|
429
|
+
const rawContent = fs.readFileSync(join(SKILLS_DIR, file), 'utf8');
|
|
430
|
+
const { meta, body } = parseFrontmatter(rawContent);
|
|
431
|
+
skills[name] = {
|
|
432
|
+
name: meta.name || name,
|
|
433
|
+
file,
|
|
434
|
+
content: body,
|
|
435
|
+
description: meta.description || meta.name || name,
|
|
436
|
+
argumentHint: meta['argument-hint'] || null,
|
|
437
|
+
meta,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} catch (e) {}
|
|
442
|
+
return skills;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Create default example agent on first run
|
|
446
|
+
function createDefaultAgentsAndSkills() {
|
|
447
|
+
ensureAgentsDirs();
|
|
448
|
+
|
|
449
|
+
const defaultAgents = {
|
|
450
|
+
'sapper-it': `---
|
|
451
|
+
name: "Sapper IT"
|
|
452
|
+
description: "Expert full-stack coding agent — handles web dev, architecture, debugging, DevOps, databases, APIs, and performance. Use for any coding task."
|
|
453
|
+
tools: [read, edit, write, list, search, shell]
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
# Sapper IT - Coding Agent
|
|
457
|
+
|
|
458
|
+
You are Sapper IT, an expert full-stack coding agent working within Sapper.
|
|
459
|
+
|
|
460
|
+
## Your Expertise
|
|
461
|
+
- Full-stack web development (frontend + backend)
|
|
462
|
+
- System architecture and design patterns
|
|
463
|
+
- Debugging, refactoring, and code review
|
|
464
|
+
- DevOps, CI/CD, and deployment
|
|
465
|
+
- Database design and optimization
|
|
466
|
+
- API development (REST, GraphQL)
|
|
467
|
+
- Performance optimization and security best practices
|
|
468
|
+
|
|
469
|
+
## Behavior
|
|
470
|
+
When the user asks for help, dive into the codebase using Sapper's tools. Read files, understand the structure, then make precise changes.
|
|
471
|
+
|
|
472
|
+
Be technical, thorough, and code-first. Always verify your changes work by running tests or builds.`,
|
|
473
|
+
|
|
474
|
+
'writer': `---
|
|
475
|
+
name: "Technical Writer"
|
|
476
|
+
description: "Documentation and writing agent — READMEs, API docs, tutorials, guides, and code comments. Use for any writing or documentation task."
|
|
477
|
+
tools: [read, edit, write, list, search]
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
# Technical Writer
|
|
481
|
+
|
|
482
|
+
You are an expert technical writer within Sapper.
|
|
483
|
+
|
|
484
|
+
## Your Expertise
|
|
485
|
+
- API documentation and developer guides
|
|
486
|
+
- README files and onboarding docs
|
|
487
|
+
- Architecture decision records (ADRs)
|
|
488
|
+
- Code comments, JSDoc/TSDoc annotations
|
|
489
|
+
- Tutorials, how-to guides, and changelogs
|
|
490
|
+
- Clear, structured, audience-aware writing
|
|
491
|
+
|
|
492
|
+
## Behavior
|
|
493
|
+
- Always READ the code first to understand what it does before writing docs
|
|
494
|
+
- Use examples and code snippets in documentation
|
|
495
|
+
- Keep language simple and scannable
|
|
496
|
+
- Match the project's existing documentation style
|
|
497
|
+
- Prefer concise bullet points over long paragraphs
|
|
498
|
+
|
|
499
|
+
## Workflow
|
|
500
|
+
1. LIST the project to understand structure
|
|
501
|
+
2. READ key files (README, package.json, main entry points)
|
|
502
|
+
3. Identify what needs documenting
|
|
503
|
+
4. WRITE or PATCH documentation files
|
|
504
|
+
5. Cross-reference with existing docs for consistency`,
|
|
505
|
+
|
|
506
|
+
'reviewer': `---
|
|
507
|
+
name: "Code Reviewer"
|
|
508
|
+
description: "Code review agent — analyzes code for bugs, security issues, performance, and best practices. Read-only: won't modify files."
|
|
509
|
+
tools: [read, list, search]
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
# Code Reviewer
|
|
513
|
+
|
|
514
|
+
You are a senior code reviewer within Sapper.
|
|
515
|
+
|
|
516
|
+
## Your Expertise
|
|
517
|
+
- Bug detection and logic errors
|
|
518
|
+
- Security vulnerability scanning (OWASP Top 10)
|
|
519
|
+
- Performance bottleneck identification
|
|
520
|
+
- Code style and best practices
|
|
521
|
+
- Architecture and design pattern review
|
|
522
|
+
- Dependency and import analysis
|
|
523
|
+
|
|
524
|
+
## Behavior
|
|
525
|
+
- You are READ-ONLY — analyze and report, never modify files
|
|
526
|
+
- Be specific: reference exact file paths and line numbers
|
|
527
|
+
- Categorize issues by severity: 🔴 Critical, 🟡 Warning, 🟢 Suggestion
|
|
528
|
+
- Provide the fix alongside the problem
|
|
529
|
+
- Check for: unused variables, error handling gaps, race conditions, SQL injection, XSS, hardcoded secrets
|
|
530
|
+
|
|
531
|
+
## Review Format
|
|
532
|
+
For each issue found:
|
|
533
|
+
\`\`\`
|
|
534
|
+
🔴/🟡/🟢 [Category] — file:line
|
|
535
|
+
Problem: What's wrong
|
|
536
|
+
Fix: How to fix it
|
|
537
|
+
\`\`\``
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const defaultSkills = {
|
|
541
|
+
'git-workflow': `---
|
|
542
|
+
name: git-workflow
|
|
543
|
+
description: "Git best practices — branching, commits, PRs, rebasing, conflict resolution. Use when working with version control."
|
|
544
|
+
argument-hint: "Describe the git operation (e.g., 'create feature branch', 'squash commits')"
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
# Git Workflow
|
|
548
|
+
|
|
549
|
+
Best practices for Git version control.
|
|
550
|
+
|
|
551
|
+
## Commit Messages
|
|
552
|
+
- Format: \`type(scope): description\`
|
|
553
|
+
- Types: feat, fix, docs, style, refactor, test, chore, perf
|
|
554
|
+
- Keep subject line under 72 characters
|
|
555
|
+
- Use imperative mood: "add feature" not "added feature"
|
|
556
|
+
- Examples:
|
|
557
|
+
- \`feat(auth): add JWT token refresh\`
|
|
558
|
+
- \`fix(api): handle null response from payment service\`
|
|
559
|
+
- \`docs(readme): add deployment instructions\`
|
|
560
|
+
|
|
561
|
+
## Branching Strategy
|
|
562
|
+
- \`main\` — production-ready code
|
|
563
|
+
- \`develop\` — integration branch
|
|
564
|
+
- \`feature/name\` — new features
|
|
565
|
+
- \`fix/name\` — bug fixes
|
|
566
|
+
- \`hotfix/name\` — urgent production fixes
|
|
567
|
+
|
|
568
|
+
## Common Operations
|
|
569
|
+
| Task | Command |
|
|
570
|
+
|------|---------|
|
|
571
|
+
| New feature branch | \`git checkout -b feature/name develop\` |
|
|
572
|
+
| Stage specific files | \`git add file1 file2\` |
|
|
573
|
+
| Interactive rebase | \`git rebase -i HEAD~N\` |
|
|
574
|
+
| Squash last N commits | \`git rebase -i HEAD~N\` then change pick to squash |
|
|
575
|
+
| Undo last commit (keep changes) | \`git reset --soft HEAD~1\` |
|
|
576
|
+
| Stash with message | \`git stash push -m "description"\` |
|
|
577
|
+
| Cherry-pick a commit | \`git cherry-pick <hash>\` |
|
|
578
|
+
|
|
579
|
+
## PR Checklist
|
|
580
|
+
- [ ] Branch is up to date with target branch
|
|
581
|
+
- [ ] Tests pass
|
|
582
|
+
- [ ] No console.log / debug statements
|
|
583
|
+
- [ ] Commit messages follow convention
|
|
584
|
+
- [ ] Documentation updated if needed`,
|
|
585
|
+
|
|
586
|
+
'node-project': `---
|
|
587
|
+
name: node-project
|
|
588
|
+
description: "Node.js project conventions — package.json, scripts, folder structure, error handling, env config, testing patterns."
|
|
589
|
+
argument-hint: "Describe what you need (e.g., 'setup express project', 'add testing')"
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
# Node.js Project Conventions
|
|
593
|
+
|
|
594
|
+
## Project Structure
|
|
595
|
+
\`\`\`
|
|
596
|
+
project/
|
|
597
|
+
├── src/
|
|
598
|
+
│ ├── index.js # Entry point
|
|
599
|
+
│ ├── routes/ # Route handlers
|
|
600
|
+
│ ├── controllers/ # Business logic
|
|
601
|
+
│ ├── models/ # Data models
|
|
602
|
+
│ ├── middleware/ # Express middleware
|
|
603
|
+
│ ├── services/ # External service integrations
|
|
604
|
+
│ └── utils/ # Helper functions
|
|
605
|
+
├── tests/
|
|
606
|
+
│ ├── unit/
|
|
607
|
+
│ └── integration/
|
|
608
|
+
├── config/
|
|
609
|
+
│ └── index.js # Environment-based config
|
|
610
|
+
├── .env.example
|
|
611
|
+
├── .gitignore
|
|
612
|
+
├── package.json
|
|
613
|
+
└── README.md
|
|
614
|
+
\`\`\`
|
|
615
|
+
|
|
616
|
+
## Package.json Scripts
|
|
617
|
+
\`\`\`json
|
|
618
|
+
{
|
|
619
|
+
"scripts": {
|
|
620
|
+
"start": "node src/index.js",
|
|
621
|
+
"dev": "nodemon src/index.js",
|
|
622
|
+
"test": "jest --coverage",
|
|
623
|
+
"test:watch": "jest --watch",
|
|
624
|
+
"lint": "eslint src/",
|
|
625
|
+
"lint:fix": "eslint src/ --fix"
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
\`\`\`
|
|
629
|
+
|
|
630
|
+
## Best Practices
|
|
631
|
+
- Use \`const\` by default, \`let\` when needed, never \`var\`
|
|
632
|
+
- Always handle async errors with try/catch or .catch()
|
|
633
|
+
- Use environment variables via dotenv, never hardcode secrets
|
|
634
|
+
- Validate input at API boundaries (use zod, joi, or express-validator)
|
|
635
|
+
- Use structured logging (pino or winston), not console.log in production
|
|
636
|
+
- Prefer async/await over callbacks and .then() chains
|
|
637
|
+
- Exit gracefully: handle SIGTERM and SIGINT`
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
let created = 0;
|
|
641
|
+
for (const [name, content] of Object.entries(defaultAgents)) {
|
|
642
|
+
const filePath = join(AGENTS_DIR, `${name}.md`);
|
|
643
|
+
if (!fs.existsSync(filePath)) {
|
|
644
|
+
fs.writeFileSync(filePath, content);
|
|
645
|
+
created++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
for (const [name, content] of Object.entries(defaultSkills)) {
|
|
649
|
+
const filePath = join(SKILLS_DIR, `${name}.md`);
|
|
650
|
+
if (!fs.existsSync(filePath)) {
|
|
651
|
+
fs.writeFileSync(filePath, content);
|
|
652
|
+
created++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return created;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Build the system prompt with optional agent and skills
|
|
659
|
+
// Global flag — set after model selection, read in buildSystemPrompt
|
|
660
|
+
let _useNativeToolsFlag = false;
|
|
661
|
+
|
|
662
|
+
function buildSystemPrompt(agentContent = null, skillContents = []) {
|
|
663
|
+
const now = new Date();
|
|
664
|
+
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
665
|
+
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
666
|
+
let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
|
|
667
|
+
You can help with ANY task - coding, writing, research, planning, analysis, and more.
|
|
668
|
+
Adapt your personality and expertise based on the active agent role and loaded skills.
|
|
669
|
+
|
|
670
|
+
CURRENT DATE AND TIME: ${dateStr}, ${timeStr}
|
|
671
|
+
|
|
672
|
+
RULES:
|
|
673
|
+
1. EXPLORE FIRST: Use list and read to understand files before making changes.
|
|
674
|
+
2. THINK IN STEPS: Explain what you found and what you plan to do before acting.
|
|
675
|
+
3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
|
|
676
|
+
4. VERIFY: After making changes, verify they work (run tests, check output, etc).
|
|
677
|
+
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`;
|
|
678
|
+
|
|
679
|
+
if (_useNativeToolsFlag) {
|
|
680
|
+
prompt += `
|
|
681
|
+
|
|
682
|
+
TOOLS:
|
|
683
|
+
You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
|
|
684
|
+
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, run_shell.
|
|
685
|
+
|
|
686
|
+
PATCH TIPS:
|
|
687
|
+
- For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
|
|
688
|
+
- Always read_file first to see exact content before using patch_file.
|
|
689
|
+
- If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.`;
|
|
690
|
+
} else {
|
|
691
|
+
prompt += `
|
|
692
|
+
|
|
693
|
+
TOOL SYNTAX (use these to interact with files and system):
|
|
694
|
+
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
695
|
+
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
696
|
+
- [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
|
|
697
|
+
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
698
|
+
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
|
|
699
|
+
- [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace a specific line by number (PREFERRED — more reliable)
|
|
700
|
+
- [TOOL:SHELL]command[/TOOL] - Run shell command
|
|
701
|
+
|
|
702
|
+
PATCH TIPS:
|
|
703
|
+
- PREFER the LINE:number mode when you know which line to change. It is much more reliable than text matching.
|
|
704
|
+
- Always READ the file first to see exact content before using PATCH.
|
|
705
|
+
- If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
|
|
706
|
+
|
|
707
|
+
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.
|
|
708
|
+
Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
prompt += `
|
|
712
|
+
|
|
713
|
+
IMPORTANT CONTEXT:
|
|
714
|
+
- The current working directory is the user's project folder.
|
|
715
|
+
- Sapper has a built-in agent/skill system. Agents are managed via /agents, /agent create, /newagent commands - NOT by you creating files manually.
|
|
716
|
+
- Do NOT try to build agent frameworks, projects, or directory structures when the user mentions agents. The agent system is already built into Sapper.
|
|
717
|
+
- When the user asks you to do something, work within their current project directory.
|
|
718
|
+
- Use "." for the current directory when listing, not "/" or "agent/".
|
|
719
|
+
|
|
720
|
+
When no agent is active, you are a general-purpose assistant. When an agent role is loaded, fully adopt that role.`;
|
|
721
|
+
|
|
722
|
+
if (agentContent) {
|
|
723
|
+
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.`;
|
|
724
|
+
|
|
725
|
+
// If the active agent has tool restrictions, inform the AI
|
|
726
|
+
if (currentAgentTools && currentAgentTools.length > 0) {
|
|
727
|
+
const allTools = ['READ', 'WRITE', 'PATCH', 'LIST', 'SEARCH', 'SHELL', 'MKDIR'];
|
|
728
|
+
const forbidden = allTools.filter(t => !currentAgentTools.includes(t));
|
|
729
|
+
prompt += `\n\nTOOL RESTRICTION: This agent can ONLY use these tools: ${currentAgentTools.join(', ')}.
|
|
730
|
+
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.`;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (skillContents.length > 0) {
|
|
735
|
+
prompt += `\n\n═══ LOADED SKILLS ═══`;
|
|
736
|
+
for (const skill of skillContents) {
|
|
737
|
+
prompt += `\n${skill}\n---`;
|
|
738
|
+
}
|
|
739
|
+
prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return prompt;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Track active agent
|
|
746
|
+
let currentAgent = null; // null = default Sapper, or agent name string
|
|
747
|
+
let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
|
|
748
|
+
let loadedSkills = []; // array of skill names currently loaded
|
|
749
|
+
|
|
83
750
|
// Load config (settings like autoAttach)
|
|
84
751
|
function loadConfig() {
|
|
85
752
|
try {
|
|
@@ -232,6 +899,7 @@ async function buildWorkspaceGraph(showProgress = true) {
|
|
|
232
899
|
modified: stats.mtime.toISOString(),
|
|
233
900
|
imports: deps,
|
|
234
901
|
exports: exports,
|
|
902
|
+
symbols: parseFileSymbols(content, fullPath), // AST-extracted symbols
|
|
235
903
|
summary: summary || '(no summary)'
|
|
236
904
|
};
|
|
237
905
|
|
|
@@ -310,6 +978,248 @@ function formatWorkspaceSummary(workspace) {
|
|
|
310
978
|
return output;
|
|
311
979
|
}
|
|
312
980
|
|
|
981
|
+
// ═══════════════════════════════════════════════════════════════
|
|
982
|
+
// AST PARSING - Extract symbols (functions, classes, variables)
|
|
983
|
+
// ═══════════════════════════════════════════════════════════════
|
|
984
|
+
|
|
985
|
+
// Parse JavaScript/TypeScript file and extract symbols
|
|
986
|
+
function parseFileSymbols(content, filePath) {
|
|
987
|
+
const symbols = [];
|
|
988
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
989
|
+
|
|
990
|
+
// Only parse JS/TS files with acorn
|
|
991
|
+
if (!['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
992
|
+
// For other languages, use regex-based extraction
|
|
993
|
+
return extractSymbolsWithRegex(content, filePath);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
try {
|
|
997
|
+
// Parse with acorn (use loose parsing to handle more syntax)
|
|
998
|
+
const ast = acorn.parse(content, {
|
|
999
|
+
ecmaVersion: 'latest',
|
|
1000
|
+
sourceType: 'module',
|
|
1001
|
+
locations: true,
|
|
1002
|
+
allowHashBang: true,
|
|
1003
|
+
allowAwaitOutsideFunction: true,
|
|
1004
|
+
allowImportExportEverywhere: true,
|
|
1005
|
+
// Be lenient with errors
|
|
1006
|
+
onComment: () => {},
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Walk the AST to extract symbols
|
|
1010
|
+
function walk(node, parentName = null) {
|
|
1011
|
+
if (!node || typeof node !== 'object') return;
|
|
1012
|
+
|
|
1013
|
+
switch (node.type) {
|
|
1014
|
+
case 'FunctionDeclaration':
|
|
1015
|
+
if (node.id?.name) {
|
|
1016
|
+
symbols.push({
|
|
1017
|
+
type: 'function',
|
|
1018
|
+
name: node.id.name,
|
|
1019
|
+
line: node.loc?.start?.line || 0,
|
|
1020
|
+
params: node.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
|
|
1021
|
+
async: node.async || false,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
break;
|
|
1025
|
+
|
|
1026
|
+
case 'ClassDeclaration':
|
|
1027
|
+
if (node.id?.name) {
|
|
1028
|
+
symbols.push({
|
|
1029
|
+
type: 'class',
|
|
1030
|
+
name: node.id.name,
|
|
1031
|
+
line: node.loc?.start?.line || 0,
|
|
1032
|
+
extends: node.superClass?.name || null,
|
|
1033
|
+
});
|
|
1034
|
+
// Extract methods
|
|
1035
|
+
if (node.body?.body) {
|
|
1036
|
+
for (const member of node.body.body) {
|
|
1037
|
+
if (member.type === 'MethodDefinition' && member.key?.name) {
|
|
1038
|
+
symbols.push({
|
|
1039
|
+
type: 'method',
|
|
1040
|
+
name: `${node.id.name}.${member.key.name}`,
|
|
1041
|
+
line: member.loc?.start?.line || 0,
|
|
1042
|
+
kind: member.kind, // 'constructor', 'method', 'get', 'set'
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
break;
|
|
1049
|
+
|
|
1050
|
+
case 'VariableDeclaration':
|
|
1051
|
+
for (const decl of node.declarations || []) {
|
|
1052
|
+
if (decl.id?.name) {
|
|
1053
|
+
// Check if it's a function expression or arrow function
|
|
1054
|
+
const init = decl.init;
|
|
1055
|
+
if (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') {
|
|
1056
|
+
symbols.push({
|
|
1057
|
+
type: 'function',
|
|
1058
|
+
name: decl.id.name,
|
|
1059
|
+
line: node.loc?.start?.line || 0,
|
|
1060
|
+
params: init.params?.map(p => p.name || p.left?.name || '?').join(', ') || '',
|
|
1061
|
+
async: init.async || false,
|
|
1062
|
+
arrow: init.type === 'ArrowFunctionExpression',
|
|
1063
|
+
});
|
|
1064
|
+
} else {
|
|
1065
|
+
symbols.push({
|
|
1066
|
+
type: 'variable',
|
|
1067
|
+
name: decl.id.name,
|
|
1068
|
+
line: node.loc?.start?.line || 0,
|
|
1069
|
+
kind: node.kind, // 'const', 'let', 'var'
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
break;
|
|
1075
|
+
|
|
1076
|
+
case 'ExportNamedDeclaration':
|
|
1077
|
+
if (node.declaration) {
|
|
1078
|
+
walk(node.declaration, parentName);
|
|
1079
|
+
}
|
|
1080
|
+
break;
|
|
1081
|
+
|
|
1082
|
+
case 'ExportDefaultDeclaration':
|
|
1083
|
+
if (node.declaration) {
|
|
1084
|
+
if (node.declaration.id?.name) {
|
|
1085
|
+
symbols.push({
|
|
1086
|
+
type: node.declaration.type === 'ClassDeclaration' ? 'class' : 'function',
|
|
1087
|
+
name: node.declaration.id.name,
|
|
1088
|
+
line: node.loc?.start?.line || 0,
|
|
1089
|
+
exported: 'default',
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Recursively walk children
|
|
1097
|
+
for (const key in node) {
|
|
1098
|
+
if (key === 'loc' || key === 'range') continue;
|
|
1099
|
+
const child = node[key];
|
|
1100
|
+
if (Array.isArray(child)) {
|
|
1101
|
+
child.forEach(c => walk(c, parentName));
|
|
1102
|
+
} else if (child && typeof child === 'object') {
|
|
1103
|
+
walk(child, parentName);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
walk(ast);
|
|
1109
|
+
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
// If AST parsing fails, fall back to regex
|
|
1112
|
+
return extractSymbolsWithRegex(content, filePath);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
return symbols;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Fallback: extract symbols using regex (for non-JS or when AST fails)
|
|
1119
|
+
function extractSymbolsWithRegex(content, filePath) {
|
|
1120
|
+
const symbols = [];
|
|
1121
|
+
const lines = content.split('\n');
|
|
1122
|
+
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
1123
|
+
|
|
1124
|
+
// JavaScript/TypeScript patterns
|
|
1125
|
+
if (['js', 'jsx', 'ts', 'tsx', 'mjs'].includes(ext)) {
|
|
1126
|
+
const funcPattern = /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/g;
|
|
1127
|
+
const classPattern = /(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/g;
|
|
1128
|
+
const arrowPattern = /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>/g;
|
|
1129
|
+
const methodPattern = /^\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/gm;
|
|
1130
|
+
|
|
1131
|
+
let match;
|
|
1132
|
+
while ((match = funcPattern.exec(content))) {
|
|
1133
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1134
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
1135
|
+
}
|
|
1136
|
+
while ((match = classPattern.exec(content))) {
|
|
1137
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1138
|
+
symbols.push({ type: 'class', name: match[1], line, extends: match[2] });
|
|
1139
|
+
}
|
|
1140
|
+
while ((match = arrowPattern.exec(content))) {
|
|
1141
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1142
|
+
symbols.push({ type: 'function', name: match[1], line, arrow: true });
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Python patterns
|
|
1147
|
+
if (ext === 'py') {
|
|
1148
|
+
const funcPattern = /^(?:async\s+)?def\s+(\w+)\s*\(/gm;
|
|
1149
|
+
const classPattern = /^class\s+(\w+)(?:\s*\([^)]*\))?:/gm;
|
|
1150
|
+
|
|
1151
|
+
let match;
|
|
1152
|
+
while ((match = funcPattern.exec(content))) {
|
|
1153
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1154
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
1155
|
+
}
|
|
1156
|
+
while ((match = classPattern.exec(content))) {
|
|
1157
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1158
|
+
symbols.push({ type: 'class', name: match[1], line });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Java/C#/Go patterns
|
|
1163
|
+
if (['java', 'cs', 'go'].includes(ext)) {
|
|
1164
|
+
const funcPattern = /(?:public|private|protected|static|func)?\s*(?:\w+\s+)?(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{/g;
|
|
1165
|
+
const classPattern = /(?:public\s+)?(?:class|struct|interface)\s+(\w+)/g;
|
|
1166
|
+
|
|
1167
|
+
let match;
|
|
1168
|
+
while ((match = funcPattern.exec(content))) {
|
|
1169
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1170
|
+
if (!['if', 'for', 'while', 'switch', 'catch'].includes(match[1])) {
|
|
1171
|
+
symbols.push({ type: 'function', name: match[1], line });
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
while ((match = classPattern.exec(content))) {
|
|
1175
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
1176
|
+
symbols.push({ type: 'class', name: match[1], line });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return symbols;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Search for symbol across workspace
|
|
1184
|
+
function searchSymbol(query, workspace) {
|
|
1185
|
+
const results = [];
|
|
1186
|
+
const queryLower = query.toLowerCase();
|
|
1187
|
+
|
|
1188
|
+
for (const [filePath, fileInfo] of Object.entries(workspace.files)) {
|
|
1189
|
+
if (!fileInfo.symbols) continue;
|
|
1190
|
+
|
|
1191
|
+
for (const symbol of fileInfo.symbols) {
|
|
1192
|
+
if (symbol.name.toLowerCase().includes(queryLower)) {
|
|
1193
|
+
results.push({
|
|
1194
|
+
...symbol,
|
|
1195
|
+
file: filePath,
|
|
1196
|
+
score: symbol.name.toLowerCase() === queryLower ? 100 :
|
|
1197
|
+
symbol.name.toLowerCase().startsWith(queryLower) ? 80 : 50
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Sort by score (exact match first) then by name
|
|
1204
|
+
results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
1205
|
+
return results;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Format symbol for display
|
|
1209
|
+
function formatSymbol(symbol) {
|
|
1210
|
+
const icon = symbol.type === 'function' ? '𝑓' :
|
|
1211
|
+
symbol.type === 'class' ? '◆' :
|
|
1212
|
+
symbol.type === 'method' ? '○' :
|
|
1213
|
+
symbol.type === 'variable' ? '◇' : '•';
|
|
1214
|
+
|
|
1215
|
+
let desc = `${icon} ${symbol.name}`;
|
|
1216
|
+
if (symbol.params !== undefined) desc += `(${symbol.params})`;
|
|
1217
|
+
if (symbol.async) desc = 'async ' + desc;
|
|
1218
|
+
if (symbol.extends) desc += ` extends ${symbol.extends}`;
|
|
1219
|
+
|
|
1220
|
+
return desc;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
313
1223
|
// ═══════════════════════════════════════════════════════════════
|
|
314
1224
|
// EMBEDDINGS & SEMANTIC SEARCH
|
|
315
1225
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -384,6 +1294,176 @@ async function addToEmbeddings(text, embeddings) {
|
|
|
384
1294
|
}
|
|
385
1295
|
}
|
|
386
1296
|
|
|
1297
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1298
|
+
// SMART CONTEXT SUMMARIZATION
|
|
1299
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1300
|
+
|
|
1301
|
+
async function autoSummarizeContext(messages, model) {
|
|
1302
|
+
const contextSize = JSON.stringify(messages).length;
|
|
1303
|
+
if (contextSize <= 32000 || messages.length <= 5) return messages;
|
|
1304
|
+
|
|
1305
|
+
console.log();
|
|
1306
|
+
console.log(box(
|
|
1307
|
+
`Context is ${chalk.red.bold(Math.round(contextSize / 1024) + 'KB')} (${messages.length} messages)\n` +
|
|
1308
|
+
`${chalk.cyan('Auto-summarizing via AI to keep things fast...')}`,
|
|
1309
|
+
'🧠 Smart Summary', 'cyan'
|
|
1310
|
+
));
|
|
1311
|
+
|
|
1312
|
+
const summarySpinner = ora('Summarizing conversation...').start();
|
|
1313
|
+
|
|
1314
|
+
// Separate: system prompt, messages to summarize, recent messages to keep
|
|
1315
|
+
const systemPrompt = messages[0];
|
|
1316
|
+
const recentCount = 4;
|
|
1317
|
+
let recentMessages = messages.slice(-recentCount);
|
|
1318
|
+
let oldMessages = messages.slice(1, -recentCount);
|
|
1319
|
+
|
|
1320
|
+
// Smart selection: ensure we keep at least one tool-usage example in recent messages
|
|
1321
|
+
// This prevents the AI from "forgetting" how to use tools after summarization
|
|
1322
|
+
const hasToolExample = recentMessages.some(m =>
|
|
1323
|
+
m.role === 'assistant' && m.content.includes('[TOOL:') && m.content.includes('[/TOOL]')
|
|
1324
|
+
);
|
|
1325
|
+
if (!hasToolExample) {
|
|
1326
|
+
// Search backwards for the most recent assistant message that used tools
|
|
1327
|
+
for (let i = messages.length - recentCount - 1; i >= 1; i--) {
|
|
1328
|
+
if (messages[i].role === 'assistant' && messages[i].content.includes('[TOOL:') && messages[i].content.includes('[/TOOL]')) {
|
|
1329
|
+
// Include this tool-usage message and the user message before it + tool result after it
|
|
1330
|
+
const toolExampleMessages = [];
|
|
1331
|
+
if (i > 0 && messages[i - 1].role === 'user') toolExampleMessages.push(messages[i - 1]);
|
|
1332
|
+
toolExampleMessages.push(messages[i]);
|
|
1333
|
+
if (i + 1 < messages.length - recentCount && messages[i + 1].role === 'user' && messages[i + 1].content.startsWith('RESULT')) {
|
|
1334
|
+
toolExampleMessages.push(messages[i + 1]);
|
|
1335
|
+
}
|
|
1336
|
+
// Remove these from oldMessages and prepend to recentMessages
|
|
1337
|
+
const toolExampleIndices = new Set();
|
|
1338
|
+
for (let j = Math.max(1, i - 1); j <= Math.min(i + 1, messages.length - recentCount - 1); j++) {
|
|
1339
|
+
if (toolExampleMessages.includes(messages[j])) toolExampleIndices.add(j);
|
|
1340
|
+
}
|
|
1341
|
+
oldMessages = messages.slice(1, -recentCount).filter((_, idx) => !toolExampleIndices.has(idx + 1));
|
|
1342
|
+
recentMessages = [...toolExampleMessages, ...recentMessages];
|
|
1343
|
+
break;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (oldMessages.length < 2) {
|
|
1349
|
+
summarySpinner.stop();
|
|
1350
|
+
return messages; // Nothing meaningful to summarize
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Build a condensed version of old messages for the summary request
|
|
1354
|
+
const conversationText = oldMessages
|
|
1355
|
+
.filter(m => m.role !== 'system')
|
|
1356
|
+
.map(m => {
|
|
1357
|
+
const role = m.role === 'user' ? 'User' : 'Assistant';
|
|
1358
|
+
// Truncate very long messages (file contents, scan results, etc.)
|
|
1359
|
+
const text = m.content.length > 1500
|
|
1360
|
+
? m.content.substring(0, 1500) + '\n... [truncated]'
|
|
1361
|
+
: m.content;
|
|
1362
|
+
return `${role}: ${text}`;
|
|
1363
|
+
})
|
|
1364
|
+
.join('\n\n');
|
|
1365
|
+
|
|
1366
|
+
try {
|
|
1367
|
+
const summaryResponse = await ollama.chat({
|
|
1368
|
+
model,
|
|
1369
|
+
messages: [
|
|
1370
|
+
{
|
|
1371
|
+
role: 'system',
|
|
1372
|
+
content: `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
|
|
1373
|
+
- Key topics discussed and decisions made
|
|
1374
|
+
- Files that were read, created, or modified (with paths)
|
|
1375
|
+
- Important code changes or bugs found
|
|
1376
|
+
- Any pending tasks or open questions
|
|
1377
|
+
- Technical details that would be needed to continue the conversation
|
|
1378
|
+
- Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH) and on what files
|
|
1379
|
+
- The active agent role (if any) and loaded skills
|
|
1380
|
+
- Any tool usage patterns or workflows that were established
|
|
1381
|
+
|
|
1382
|
+
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.
|
|
1383
|
+
|
|
1384
|
+
Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
role: 'user',
|
|
1388
|
+
content: `Summarize this conversation:\n\n${conversationText}`
|
|
1389
|
+
}
|
|
1390
|
+
],
|
|
1391
|
+
stream: false
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
const summary = summaryResponse.message.content;
|
|
1395
|
+
|
|
1396
|
+
// Save old messages to embeddings before discarding
|
|
1397
|
+
const embeddings = loadEmbeddings();
|
|
1398
|
+
const textToEmbed = oldMessages
|
|
1399
|
+
.filter(m => m.role !== 'system')
|
|
1400
|
+
.map(m => m.content.substring(0, 500))
|
|
1401
|
+
.join('\n---\n');
|
|
1402
|
+
|
|
1403
|
+
if (textToEmbed.length > 50) {
|
|
1404
|
+
try {
|
|
1405
|
+
const embedding = await getEmbedding(textToEmbed);
|
|
1406
|
+
if (embedding) {
|
|
1407
|
+
embeddings.chunks.push({
|
|
1408
|
+
text: textToEmbed.substring(0, 2000),
|
|
1409
|
+
embedding,
|
|
1410
|
+
timestamp: Date.now()
|
|
1411
|
+
});
|
|
1412
|
+
if (embeddings.chunks.length > 100) {
|
|
1413
|
+
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
1414
|
+
}
|
|
1415
|
+
saveEmbeddings(embeddings);
|
|
1416
|
+
}
|
|
1417
|
+
} catch (e) {
|
|
1418
|
+
// Silently skip embedding if model not available
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Build agent role reminder if an agent is active
|
|
1423
|
+
const agentReminder = currentAgent ? `\nNote: You are currently operating as the "${currentAgent}" agent. Stay in character.` : '';
|
|
1424
|
+
const skillReminder = loadedSkills.length > 0 ? `\nLoaded skills: ${loadedSkills.join(', ')}. Apply this knowledge when relevant.` : '';
|
|
1425
|
+
|
|
1426
|
+
// Rebuild messages: system prompt + summary + tool reinforcement + recent messages
|
|
1427
|
+
const newMessages = [
|
|
1428
|
+
systemPrompt,
|
|
1429
|
+
{
|
|
1430
|
+
role: 'user',
|
|
1431
|
+
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}`
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
role: 'assistant',
|
|
1435
|
+
content: _useNativeToolsFlag
|
|
1436
|
+
? `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?`
|
|
1437
|
+
: `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?`
|
|
1438
|
+
},
|
|
1439
|
+
...recentMessages
|
|
1440
|
+
];
|
|
1441
|
+
|
|
1442
|
+
// Save immediately
|
|
1443
|
+
ensureSapperDir();
|
|
1444
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(newMessages, null, 2));
|
|
1445
|
+
|
|
1446
|
+
const newSize = JSON.stringify(newMessages).length;
|
|
1447
|
+
summarySpinner.stop();
|
|
1448
|
+
console.log(chalk.green(`✅ Summarized! ${chalk.gray(`${Math.round(contextSize / 1024)}KB → ${Math.round(newSize / 1024)}KB`)} (${messages.length} → ${newMessages.length} messages)`));
|
|
1449
|
+
if (embeddings.chunks.length > 0) {
|
|
1450
|
+
console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
|
|
1451
|
+
}
|
|
1452
|
+
logEntry('summary', {
|
|
1453
|
+
before: `${Math.round(contextSize / 1024)}KB / ${messages.length} msgs`,
|
|
1454
|
+
after: `${Math.round(newSize / 1024)}KB / ${newMessages.length} msgs`
|
|
1455
|
+
});
|
|
1456
|
+
console.log();
|
|
1457
|
+
|
|
1458
|
+
return newMessages;
|
|
1459
|
+
} catch (e) {
|
|
1460
|
+
summarySpinner.stop();
|
|
1461
|
+
console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
|
|
1462
|
+
console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
|
|
1463
|
+
return messages; // Return unchanged on failure
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
387
1467
|
// ═══════════════════════════════════════════════════════════════
|
|
388
1468
|
// FANCY UI HELPERS
|
|
389
1469
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -757,18 +1837,96 @@ const tools = {
|
|
|
757
1837
|
const trimmedPath = path.trim();
|
|
758
1838
|
try {
|
|
759
1839
|
const content = fs.readFileSync(trimmedPath, 'utf8');
|
|
760
|
-
|
|
761
|
-
|
|
1840
|
+
|
|
1841
|
+
// --- Line-number mode: LINE:15|||new text ---
|
|
1842
|
+
const lineMatch = oldText.match(/^LINE:(\d+)$/);
|
|
1843
|
+
if (lineMatch) {
|
|
1844
|
+
const lineNum = parseInt(lineMatch[1], 10);
|
|
1845
|
+
const lines = content.split('\n');
|
|
1846
|
+
if (lineNum < 1 || lineNum > lines.length) {
|
|
1847
|
+
return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
|
|
1848
|
+
}
|
|
1849
|
+
const oldLine = lines[lineNum - 1];
|
|
1850
|
+
lines[lineNum - 1] = newText;
|
|
1851
|
+
const newContent = lines.join('\n');
|
|
1852
|
+
console.log();
|
|
1853
|
+
const diffContent =
|
|
1854
|
+
`${chalk.white('File:')} ${chalk.cyan(trimmedPath)} ${chalk.gray(`(line ${lineNum})`)}\n` +
|
|
1855
|
+
chalk.gray('─'.repeat(40)) + '\n' +
|
|
1856
|
+
chalk.red('- ' + oldLine) + '\n' +
|
|
1857
|
+
chalk.green('+ ' + newText);
|
|
1858
|
+
console.log(box(diffContent, '🔧 Patch (line mode)', 'yellow'));
|
|
1859
|
+
const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
|
|
1860
|
+
if (confirm.toLowerCase() === 'y') {
|
|
1861
|
+
fs.writeFileSync(trimmedPath, newContent);
|
|
1862
|
+
return `Successfully patched line ${lineNum} of ${trimmedPath}`;
|
|
1863
|
+
}
|
|
1864
|
+
return 'Patch rejected by user.';
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// --- Exact match (try as-is first, then trimmed) ---
|
|
1868
|
+
let matchedOld = oldText;
|
|
1869
|
+
let newContent;
|
|
1870
|
+
if (content.includes(oldText)) {
|
|
1871
|
+
newContent = content.replace(oldText, newText);
|
|
1872
|
+
} else if (content.includes(oldText.trim())) {
|
|
1873
|
+
// Trimmed fallback — match what's actually in the file
|
|
1874
|
+
matchedOld = oldText.trim();
|
|
1875
|
+
newContent = content.replace(matchedOld, newText.trim());
|
|
1876
|
+
console.log(chalk.gray(' ℹ️ Matched after trimming whitespace'));
|
|
1877
|
+
} else {
|
|
1878
|
+
// --- Fuzzy fallback: normalize whitespace + strip emoji ---
|
|
1879
|
+
const normalize = (s) => s.replace(/[\u{1F000}-\u{1FFFF}]/gu, '').replace(/\s+/g, ' ').trim();
|
|
1880
|
+
const normalizedOld = normalize(oldText);
|
|
1881
|
+
const lines = content.split('\n');
|
|
1882
|
+
let bestMatch = null;
|
|
1883
|
+
let bestScore = 0;
|
|
1884
|
+
// Sliding window search over lines
|
|
1885
|
+
const oldLines = oldText.trim().split('\n');
|
|
1886
|
+
for (let i = 0; i <= lines.length - oldLines.length; i++) {
|
|
1887
|
+
const window = lines.slice(i, i + oldLines.length).join('\n');
|
|
1888
|
+
const normalizedWindow = normalize(window);
|
|
1889
|
+
if (normalizedWindow === normalizedOld) {
|
|
1890
|
+
bestMatch = { start: i, count: oldLines.length, text: window };
|
|
1891
|
+
bestScore = 1;
|
|
1892
|
+
break;
|
|
1893
|
+
}
|
|
1894
|
+
// Simple similarity: shared words ratio
|
|
1895
|
+
const oldWords = new Set(normalizedOld.split(' '));
|
|
1896
|
+
const winWords = new Set(normalizedWindow.split(' '));
|
|
1897
|
+
const shared = [...oldWords].filter(w => winWords.has(w)).length;
|
|
1898
|
+
const score = shared / Math.max(oldWords.size, winWords.size);
|
|
1899
|
+
if (score > bestScore && score >= 0.7) {
|
|
1900
|
+
bestScore = score;
|
|
1901
|
+
bestMatch = { start: i, count: oldLines.length, text: window };
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (bestMatch && bestScore >= 0.7) {
|
|
1906
|
+
matchedOld = bestMatch.text;
|
|
1907
|
+
newContent = content.replace(matchedOld, newText.trim());
|
|
1908
|
+
console.log(chalk.gray(` ℹ️ Fuzzy match (${(bestScore * 100).toFixed(0)}% similarity) at line ${bestMatch.start + 1}`));
|
|
1909
|
+
} else {
|
|
1910
|
+
// Show nearby lines to help AI on next attempt
|
|
1911
|
+
const keyword = oldText.split('\n')[0].trim().substring(0, 40);
|
|
1912
|
+
const nearby = lines.map((l, i) => ({ line: i + 1, text: l }))
|
|
1913
|
+
.filter(l => l.text.includes(keyword.substring(0, 15)))
|
|
1914
|
+
.slice(0, 3)
|
|
1915
|
+
.map(l => ` Line ${l.line}: ${l.text.substring(0, 80)}`)
|
|
1916
|
+
.join('\n');
|
|
1917
|
+
return `Error: Could not find the text to replace in ${trimmedPath}.\n` +
|
|
1918
|
+
(nearby ? `Nearby matches:\n${nearby}\n` : '') +
|
|
1919
|
+
`Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
|
|
1920
|
+
}
|
|
762
1921
|
}
|
|
763
|
-
const newContent = content.replace(oldText, newText);
|
|
764
1922
|
|
|
765
1923
|
// Show diff preview
|
|
766
1924
|
console.log();
|
|
767
1925
|
const diffContent =
|
|
768
1926
|
`${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
|
|
769
1927
|
chalk.gray('─'.repeat(40)) + '\n' +
|
|
770
|
-
chalk.red('- ' +
|
|
771
|
-
chalk.green('+ ' + newText.split('\n').join('\n+ '));
|
|
1928
|
+
chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
|
|
1929
|
+
chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
|
|
772
1930
|
console.log(box(diffContent, '🔧 Patch', 'yellow'));
|
|
773
1931
|
|
|
774
1932
|
const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
|
|
@@ -813,10 +1971,21 @@ const tools = {
|
|
|
813
1971
|
const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
|
|
814
1972
|
if (confirm.toLowerCase() === 'y') {
|
|
815
1973
|
return new Promise((resolve) => {
|
|
816
|
-
const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
|
|
1974
|
+
const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
|
|
817
1975
|
console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
|
|
818
|
-
const proc = spawn(
|
|
819
|
-
|
|
1976
|
+
const proc = spawn('sh', ['-c', cmd], {
|
|
1977
|
+
cwd: process.cwd()
|
|
1978
|
+
});
|
|
1979
|
+
let output = '';
|
|
1980
|
+
proc.stdout.on('data', (data) => {
|
|
1981
|
+
const text = data.toString();
|
|
1982
|
+
output += text;
|
|
1983
|
+
process.stdout.write(text); // Still show to user in real-time
|
|
1984
|
+
});
|
|
1985
|
+
proc.stderr.on('data', (data) => {
|
|
1986
|
+
const text = data.toString();
|
|
1987
|
+
output += text;
|
|
1988
|
+
process.stderr.write(text); // Still show errors to user
|
|
820
1989
|
});
|
|
821
1990
|
proc.on('close', (code) => {
|
|
822
1991
|
// Crucial: give control back to Node
|
|
@@ -826,7 +1995,13 @@ const tools = {
|
|
|
826
1995
|
// Delay slightly to let terminal settle
|
|
827
1996
|
setTimeout(() => {
|
|
828
1997
|
recreateReadline();
|
|
829
|
-
|
|
1998
|
+
// Return actual output to AI, truncated if too long
|
|
1999
|
+
const maxOutput = 10000;
|
|
2000
|
+
let result = output.trim();
|
|
2001
|
+
if (result.length > maxOutput) {
|
|
2002
|
+
result = result.substring(0, maxOutput) + '\n... (output truncated)';
|
|
2003
|
+
}
|
|
2004
|
+
resolve(result || `Command completed with exit code ${code}`);
|
|
830
2005
|
}, 200);
|
|
831
2006
|
});
|
|
832
2007
|
});
|
|
@@ -900,6 +2075,7 @@ async function runSapper() {
|
|
|
900
2075
|
console.log(box(
|
|
901
2076
|
`${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
|
|
902
2077
|
`${chalk.yellow('💡')} Type ${chalk.cyan('/scan')} to load entire codebase\n` +
|
|
2078
|
+
`${chalk.yellow('💡')} Type ${chalk.cyan('/agents')} to see agents, ${chalk.cyan('/agentname')} to switch\n` +
|
|
903
2079
|
`${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
|
|
904
2080
|
'Quick Tips', 'gray'
|
|
905
2081
|
));
|
|
@@ -911,13 +2087,15 @@ async function runSapper() {
|
|
|
911
2087
|
// Auto-load or build workspace graph
|
|
912
2088
|
let workspace = loadWorkspaceGraph();
|
|
913
2089
|
if (!workspace.indexed) {
|
|
914
|
-
console.log(chalk.cyan('📊 Building workspace index...'));
|
|
2090
|
+
console.log(chalk.cyan('📊 Building workspace index with AST parsing...'));
|
|
915
2091
|
workspace = await buildWorkspaceGraph();
|
|
916
|
-
|
|
2092
|
+
const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
2093
|
+
console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files, ${totalSymbols} symbols\n`));
|
|
917
2094
|
} else {
|
|
918
2095
|
const fileCount = Object.keys(workspace.files).length;
|
|
2096
|
+
const symbolCount = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
919
2097
|
const indexAge = Math.round((Date.now() - new Date(workspace.indexed).getTime()) / 1000 / 60);
|
|
920
|
-
console.log(chalk.gray(`📊 Workspace: ${fileCount} files
|
|
2098
|
+
console.log(chalk.gray(`📊 Workspace: ${fileCount} files, ${symbolCount} symbols (${indexAge}m ago)`));
|
|
921
2099
|
if (indexAge > 60) {
|
|
922
2100
|
console.log(chalk.yellow(` Tip: Run /index to refresh`));
|
|
923
2101
|
}
|
|
@@ -925,7 +2103,22 @@ async function runSapper() {
|
|
|
925
2103
|
|
|
926
2104
|
// Show memory status
|
|
927
2105
|
console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
|
|
928
|
-
console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)
|
|
2106
|
+
console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)`));
|
|
2107
|
+
|
|
2108
|
+
// Initialize agents and skills
|
|
2109
|
+
const newlyCreated = createDefaultAgentsAndSkills();
|
|
2110
|
+
const agents = loadAgents();
|
|
2111
|
+
const skills = loadSkills();
|
|
2112
|
+
const agentCount = Object.keys(agents).length;
|
|
2113
|
+
const skillCount = Object.keys(skills).length;
|
|
2114
|
+
console.log(chalk.gray(`🤖 Agents: ${agentCount} available`) + chalk.gray(` │ `) + chalk.gray(`📘 Skills: ${skillCount} available`));
|
|
2115
|
+
if (newlyCreated > 0) {
|
|
2116
|
+
console.log(chalk.green(` ✨ Created ${newlyCreated} default agents/skills in .sapper/`));
|
|
2117
|
+
}
|
|
2118
|
+
if (agentCount > 0) {
|
|
2119
|
+
console.log(chalk.gray(` Agents: ${Object.keys(agents).map(a => '/' + a).join(', ')}`));
|
|
2120
|
+
}
|
|
2121
|
+
console.log();
|
|
929
2122
|
|
|
930
2123
|
let messages = [];
|
|
931
2124
|
if (fs.existsSync(CONTEXT_FILE)) {
|
|
@@ -983,132 +2176,200 @@ async function runSapper() {
|
|
|
983
2176
|
const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
|
|
984
2177
|
const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
|
|
985
2178
|
|
|
2179
|
+
// ─── Detect native tool-calling support ───────────────────────────
|
|
2180
|
+
let useNativeTools = false;
|
|
2181
|
+
try {
|
|
2182
|
+
const modelInfo = await ollama.show({ model: selectedModel });
|
|
2183
|
+
if (modelInfo.capabilities && modelInfo.capabilities.includes('tools')) {
|
|
2184
|
+
useNativeTools = true;
|
|
2185
|
+
console.log(chalk.green(' ✓ ') + chalk.gray('Native tool calling: ') + chalk.green('enabled'));
|
|
2186
|
+
} else {
|
|
2187
|
+
console.log(chalk.yellow(' ℹ ') + chalk.gray('Native tool calling: ') + chalk.yellow('unavailable — using text markers'));
|
|
2188
|
+
}
|
|
2189
|
+
} catch (e) {
|
|
2190
|
+
console.log(chalk.gray(' ℹ Tool detection skipped — using text markers'));
|
|
2191
|
+
}
|
|
2192
|
+
_useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
|
|
2193
|
+
|
|
2194
|
+
// Native Ollama tool definitions (used when useNativeTools=true)
|
|
2195
|
+
const nativeToolDefs = [
|
|
2196
|
+
{
|
|
2197
|
+
type: 'function',
|
|
2198
|
+
function: {
|
|
2199
|
+
name: 'list_directory',
|
|
2200
|
+
description: 'List the contents of a directory. Use "." for current directory.',
|
|
2201
|
+
parameters: {
|
|
2202
|
+
type: 'object',
|
|
2203
|
+
properties: {
|
|
2204
|
+
path: { type: 'string', description: 'Directory path to list' }
|
|
2205
|
+
},
|
|
2206
|
+
required: ['path']
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
type: 'function',
|
|
2212
|
+
function: {
|
|
2213
|
+
name: 'read_file',
|
|
2214
|
+
description: 'Read the full contents of a file',
|
|
2215
|
+
parameters: {
|
|
2216
|
+
type: 'object',
|
|
2217
|
+
properties: {
|
|
2218
|
+
path: { type: 'string', description: 'File path to read' }
|
|
2219
|
+
},
|
|
2220
|
+
required: ['path']
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
},
|
|
2224
|
+
{
|
|
2225
|
+
type: 'function',
|
|
2226
|
+
function: {
|
|
2227
|
+
name: 'search_files',
|
|
2228
|
+
description: 'Search for a pattern across project files',
|
|
2229
|
+
parameters: {
|
|
2230
|
+
type: 'object',
|
|
2231
|
+
properties: {
|
|
2232
|
+
pattern: { type: 'string', description: 'Search pattern (text or regex)' }
|
|
2233
|
+
},
|
|
2234
|
+
required: ['pattern']
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
},
|
|
2238
|
+
{
|
|
2239
|
+
type: 'function',
|
|
2240
|
+
function: {
|
|
2241
|
+
name: 'write_file',
|
|
2242
|
+
description: 'Create or overwrite a file with new content',
|
|
2243
|
+
parameters: {
|
|
2244
|
+
type: 'object',
|
|
2245
|
+
properties: {
|
|
2246
|
+
path: { type: 'string', description: 'File path to write' },
|
|
2247
|
+
content: { type: 'string', description: 'Content to write to the file' }
|
|
2248
|
+
},
|
|
2249
|
+
required: ['path', 'content']
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
},
|
|
2253
|
+
{
|
|
2254
|
+
type: 'function',
|
|
2255
|
+
function: {
|
|
2256
|
+
name: 'patch_file',
|
|
2257
|
+
description: 'Edit an existing file by replacing old text with new text. Prefer line_number mode.',
|
|
2258
|
+
parameters: {
|
|
2259
|
+
type: 'object',
|
|
2260
|
+
properties: {
|
|
2261
|
+
path: { type: 'string', description: 'File path to patch' },
|
|
2262
|
+
old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
|
|
2263
|
+
new_text: { type: 'string', description: 'Replacement text' }
|
|
2264
|
+
},
|
|
2265
|
+
required: ['path', 'old_text', 'new_text']
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
},
|
|
2269
|
+
{
|
|
2270
|
+
type: 'function',
|
|
2271
|
+
function: {
|
|
2272
|
+
name: 'create_directory',
|
|
2273
|
+
description: 'Create a directory (recursive)',
|
|
2274
|
+
parameters: {
|
|
2275
|
+
type: 'object',
|
|
2276
|
+
properties: {
|
|
2277
|
+
path: { type: 'string', description: 'Directory path to create' }
|
|
2278
|
+
},
|
|
2279
|
+
required: ['path']
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
type: 'function',
|
|
2285
|
+
function: {
|
|
2286
|
+
name: 'run_shell',
|
|
2287
|
+
description: 'Execute a shell command in the project directory',
|
|
2288
|
+
parameters: {
|
|
2289
|
+
type: 'object',
|
|
2290
|
+
properties: {
|
|
2291
|
+
command: { type: 'string', description: 'Shell command to execute' }
|
|
2292
|
+
},
|
|
2293
|
+
required: ['command']
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
];
|
|
2298
|
+
|
|
986
2299
|
if (messages.length === 0) {
|
|
987
2300
|
messages = [{
|
|
988
2301
|
role: 'system',
|
|
989
|
-
content:
|
|
990
|
-
Your goal is to solve the user's request by interacting with the filesystem and shell.
|
|
991
|
-
|
|
992
|
-
RULES:
|
|
993
|
-
1. EXPLORE FIRST: Use LIST and READ to understand the codebase before making changes.
|
|
994
|
-
2. THINK IN STEPS: Explain what you found and what you plan to do before executing tools.
|
|
995
|
-
3. BE PRECISE: When using PATCH, ensure the 'oldText' matches exactly.
|
|
996
|
-
4. VERIFY: After writing code, use the SHELL tool to run tests or linting.
|
|
997
|
-
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.
|
|
998
|
-
|
|
999
|
-
TOOL SYNTAX:
|
|
1000
|
-
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
1001
|
-
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
1002
|
-
- [TOOL:SEARCH]pattern[/TOOL] - Search codebase for pattern
|
|
1003
|
-
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
1004
|
-
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file
|
|
1005
|
-
- [TOOL:SHELL]command[/TOOL] - Run shell command`
|
|
2302
|
+
content: buildSystemPrompt()
|
|
1006
2303
|
}];
|
|
1007
2304
|
}
|
|
1008
2305
|
|
|
2306
|
+
// Log session start
|
|
2307
|
+
logEntry('session_start', {
|
|
2308
|
+
model: selectedModel,
|
|
2309
|
+
resumed: messages.length > 1,
|
|
2310
|
+
contextSize: messages.length
|
|
2311
|
+
});
|
|
2312
|
+
|
|
1009
2313
|
// Main conversation loop - never exits unless user types 'exit'
|
|
1010
2314
|
while (true) {
|
|
1011
2315
|
try {
|
|
1012
|
-
// Context size
|
|
2316
|
+
// Context size check - auto-summarize when too large
|
|
1013
2317
|
const contextSize = JSON.stringify(messages).length;
|
|
1014
2318
|
if (contextSize > 32000) {
|
|
2319
|
+
messages = await autoSummarizeContext(messages, selectedModel);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// Build prompt label with active agent/skills
|
|
2323
|
+
let promptLabel = chalk.white.bold('You');
|
|
2324
|
+
if (currentAgent) {
|
|
2325
|
+
promptLabel += chalk.gray(' → ') + chalk.magenta.bold(currentAgent);
|
|
2326
|
+
}
|
|
2327
|
+
if (loadedSkills.length > 0) {
|
|
2328
|
+
promptLabel += chalk.gray(' [') + chalk.blue(loadedSkills.join(', ')) + chalk.gray(']');
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
const input = await safeQuestion(chalk.cyan('\n┌─[') + promptLabel + chalk.cyan(']\n└─➤ '));
|
|
2332
|
+
|
|
2333
|
+
if (input.toLowerCase() === 'exit') {
|
|
2334
|
+
const stats = getSessionStats();
|
|
2335
|
+
logEntry('system', { event: 'Session End', detail: `Duration: ${formatElapsed(stats.totalDuration)}, ${stats.userMessages} messages, ${stats.toolCalls} tools` });
|
|
1015
2336
|
console.log();
|
|
1016
2337
|
console.log(box(
|
|
1017
|
-
|
|
1018
|
-
`${chalk.
|
|
1019
|
-
'
|
|
2338
|
+
`${chalk.white('Duration:')} ${chalk.cyan(formatElapsed(stats.totalDuration))}\n` +
|
|
2339
|
+
`${chalk.white('Messages:')} ${chalk.blue(stats.userMessages + '↑')} ${chalk.magenta(stats.aiMessages + '↓')}\n` +
|
|
2340
|
+
`${chalk.white('Tools:')} ${chalk.yellow(stats.toolCalls)} | ${chalk.white('Shells:')} ${chalk.red(stats.shellCalls)}\n` +
|
|
2341
|
+
`${chalk.white('Log saved:')} ${chalk.gray(sessionLogFile())}`,
|
|
2342
|
+
'👋 Session Summary', 'cyan'
|
|
1020
2343
|
));
|
|
2344
|
+
console.log();
|
|
2345
|
+
process.exit();
|
|
1021
2346
|
}
|
|
1022
2347
|
|
|
1023
|
-
const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
|
|
1024
|
-
|
|
1025
|
-
if (input.toLowerCase() === 'exit') process.exit();
|
|
1026
|
-
|
|
1027
2348
|
// Handle reset command
|
|
1028
2349
|
if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
|
|
1029
2350
|
if (fs.existsSync(CONTEXT_FILE)) {
|
|
1030
2351
|
fs.unlinkSync(CONTEXT_FILE);
|
|
1031
2352
|
console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
|
|
1032
2353
|
}
|
|
2354
|
+
currentAgent = null;
|
|
2355
|
+
currentAgentTools = null;
|
|
2356
|
+
loadedSkills = [];
|
|
1033
2357
|
messages = [{
|
|
1034
2358
|
role: 'system',
|
|
1035
|
-
content:
|
|
2359
|
+
content: buildSystemPrompt() // Reset to default prompt
|
|
1036
2360
|
}];
|
|
2361
|
+
logEntry('system', { event: 'Context Reset', detail: 'All context cleared, starting fresh' });
|
|
1037
2362
|
continue;
|
|
1038
2363
|
}
|
|
1039
2364
|
|
|
1040
|
-
// Handle prune command -
|
|
2365
|
+
// Handle prune command - smart AI summary then clear old context
|
|
1041
2366
|
if (input.toLowerCase() === '/prune') {
|
|
1042
2367
|
if (messages.length <= 5) {
|
|
1043
2368
|
console.log(chalk.yellow('Context is already small, nothing to prune.'));
|
|
1044
2369
|
continue;
|
|
1045
2370
|
}
|
|
1046
2371
|
|
|
1047
|
-
|
|
1048
|
-
const embeddings = loadEmbeddings();
|
|
1049
|
-
|
|
1050
|
-
// Get messages that will be pruned (all except system and last 4)
|
|
1051
|
-
const messagesToEmbed = messages.slice(1, -4)
|
|
1052
|
-
.filter(m => m.role !== 'system')
|
|
1053
|
-
.map(m => m.content.substring(0, 500))
|
|
1054
|
-
.join('\n---\n');
|
|
1055
|
-
|
|
1056
|
-
if (messagesToEmbed.length > 50) {
|
|
1057
|
-
try {
|
|
1058
|
-
const embedding = await getEmbedding(messagesToEmbed);
|
|
1059
|
-
if (embedding) {
|
|
1060
|
-
embeddings.chunks.push({
|
|
1061
|
-
text: messagesToEmbed.substring(0, 2000),
|
|
1062
|
-
embedding,
|
|
1063
|
-
timestamp: Date.now()
|
|
1064
|
-
});
|
|
1065
|
-
if (embeddings.chunks.length > 100) {
|
|
1066
|
-
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
1067
|
-
}
|
|
1068
|
-
saveEmbeddings(embeddings);
|
|
1069
|
-
console.log(chalk.green(`🧠 Saved to memory! (${embeddings.chunks.length} memories)`));
|
|
1070
|
-
}
|
|
1071
|
-
} catch (e) {
|
|
1072
|
-
// Silently skip embedding if model not available - prune still works
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
// 2. Capture the ORIGINAL detailed system prompt from the very first message
|
|
1077
|
-
const originalSystemPrompt = messages[0];
|
|
1078
|
-
|
|
1079
|
-
// 3. Capture the last 4 messages (the most recent conversation)
|
|
1080
|
-
const recentMessages = messages.slice(-4);
|
|
1081
|
-
|
|
1082
|
-
// 4. Rebuild the messages array starting with the ORIGINAL prompt
|
|
1083
|
-
messages = [originalSystemPrompt, ...recentMessages];
|
|
1084
|
-
|
|
1085
|
-
// 4. Add reminder to stay in Agent Mode (not chatbot mode)
|
|
1086
|
-
messages.push({
|
|
1087
|
-
role: 'system',
|
|
1088
|
-
content: `CONTEXT PRUNED. REMINDER: You are Sapper, an Autonomous Software Engineer.
|
|
1089
|
-
|
|
1090
|
-
RULES:
|
|
1091
|
-
1. EXPLORE FIRST: Use LIST and READ before making changes.
|
|
1092
|
-
2. THINK IN STEPS: Explain your plan before executing tools.
|
|
1093
|
-
3. BE PRECISE: When using PATCH, ensure 'oldText' matches exactly.
|
|
1094
|
-
4. VERIFY: Run tests or linting after writing code.
|
|
1095
|
-
5. NO HALLUCINATIONS: Don't guess file contents.
|
|
1096
|
-
|
|
1097
|
-
TOOL SYNTAX:
|
|
1098
|
-
- [TOOL:LIST]dir[/TOOL]
|
|
1099
|
-
- [TOOL:READ]file_path[/TOOL]
|
|
1100
|
-
- [TOOL:SEARCH]pattern[/TOOL]
|
|
1101
|
-
- [TOOL:WRITE]path:::content[/TOOL]
|
|
1102
|
-
- [TOOL:PATCH]path:::old|||new[/TOOL]
|
|
1103
|
-
- [TOOL:SHELL]command[/TOOL]`
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
// 5. Save to context file so it persists
|
|
1107
|
-
ensureSapperDir();
|
|
1108
|
-
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
1109
|
-
|
|
1110
|
-
console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
|
|
1111
|
-
console.log(chalk.gray(`Context size: ${messages.length} messages\n`));
|
|
2372
|
+
messages = await autoSummarizeContext(messages, selectedModel);
|
|
1112
2373
|
continue;
|
|
1113
2374
|
}
|
|
1114
2375
|
|
|
@@ -1121,14 +2382,28 @@ TOOL SYNTAX:
|
|
|
1121
2382
|
`${chalk.cyan('/scan')} ${chalk.gray('│')} Scan codebase into context\n` +
|
|
1122
2383
|
`${chalk.cyan('/index')} ${chalk.gray('│')} Rebuild workspace graph\n` +
|
|
1123
2384
|
`${chalk.cyan('/graph file')} ${chalk.gray('│')} Show related files\n` +
|
|
2385
|
+
`${chalk.cyan('/symbol name')} ${chalk.gray('│')} Search functions/classes\n` +
|
|
1124
2386
|
`${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
|
|
1125
2387
|
`${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
|
|
1126
2388
|
`${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
|
|
1127
|
-
`${chalk.cyan('/prune')} ${chalk.gray('│')}
|
|
2389
|
+
`${chalk.cyan('/prune')} ${chalk.gray('│')} AI-summarize context + save to memory\n` +
|
|
1128
2390
|
`${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
|
|
1129
2391
|
`${chalk.cyan('/debug')} ${chalk.gray('│')} Toggle debug mode\n` +
|
|
2392
|
+
`${chalk.cyan('/log')} ${chalk.gray('│')} Show activity timeline\n` +
|
|
2393
|
+
`${chalk.cyan('/log stats')} ${chalk.gray('│')} Show session statistics\n` +
|
|
2394
|
+
`${chalk.cyan('/log file')} ${chalk.gray('│')} Show log file path & history\n` +
|
|
1130
2395
|
`${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
|
|
1131
|
-
`${chalk.cyan('exit')} ${chalk.gray('│')} Quit Sapper
|
|
2396
|
+
`${chalk.cyan('exit')} ${chalk.gray('│')} Quit Sapper\n` +
|
|
2397
|
+
`\n` +
|
|
2398
|
+
chalk.bold.white('🤖 Agents & Skills:\n') +
|
|
2399
|
+
`${chalk.cyan('/agents')} ${chalk.gray('│')} List available agents\n` +
|
|
2400
|
+
`${chalk.cyan('/skills')} ${chalk.gray('│')} List available skills\n` +
|
|
2401
|
+
`${chalk.cyan('/agentname')} ${chalk.gray('│')} Switch to agent (e.g., /salesmanager)\n` +
|
|
2402
|
+
`${chalk.cyan('/default')} ${chalk.gray('│')} Switch back to default Sapper\n` +
|
|
2403
|
+
`${chalk.cyan('/use skill')} ${chalk.gray('│')} Load a skill (e.g., /use react)\n` +
|
|
2404
|
+
`${chalk.cyan('/unload skill')} ${chalk.gray('│')} Unload a skill\n` +
|
|
2405
|
+
`${chalk.cyan('/newagent')} ${chalk.gray('│')} Create a new agent\n` +
|
|
2406
|
+
`${chalk.cyan('/newskill')} ${chalk.gray('│')} Create a new skill`;
|
|
1132
2407
|
console.log(box(helpContent, '📚 Commands', 'cyan'));
|
|
1133
2408
|
console.log();
|
|
1134
2409
|
continue;
|
|
@@ -1136,10 +2411,88 @@ TOOL SYNTAX:
|
|
|
1136
2411
|
|
|
1137
2412
|
// Handle index command - rebuild workspace graph
|
|
1138
2413
|
if (input.toLowerCase() === '/index') {
|
|
1139
|
-
console.log(chalk.cyan('\n📊 Rebuilding workspace index...'));
|
|
2414
|
+
console.log(chalk.cyan('\n📊 Rebuilding workspace index with AST parsing...'));
|
|
1140
2415
|
workspace = await buildWorkspaceGraph();
|
|
2416
|
+
const totalSymbols = Object.values(workspace.files).reduce((sum, f) => sum + (f.symbols?.length || 0), 0);
|
|
1141
2417
|
console.log(chalk.green(`✅ Indexed ${Object.keys(workspace.files).length} files`));
|
|
1142
|
-
console.log(chalk.gray(`
|
|
2418
|
+
console.log(chalk.gray(` 📦 ${totalSymbols} symbols (functions, classes, variables)`));
|
|
2419
|
+
console.log(chalk.gray(` 🔗 ${Object.values(workspace.graph).flat().length} dependencies tracked\n`));
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
// Handle symbol search command
|
|
2424
|
+
if (input.toLowerCase().startsWith('/symbol')) {
|
|
2425
|
+
const query = input.slice(7).trim();
|
|
2426
|
+
if (!query) {
|
|
2427
|
+
// Show all symbols summary
|
|
2428
|
+
const allSymbols = [];
|
|
2429
|
+
for (const [file, info] of Object.entries(workspace.files)) {
|
|
2430
|
+
for (const sym of info.symbols || []) {
|
|
2431
|
+
allSymbols.push({ ...sym, file });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// Group by type
|
|
2436
|
+
const functions = allSymbols.filter(s => s.type === 'function');
|
|
2437
|
+
const classes = allSymbols.filter(s => s.type === 'class');
|
|
2438
|
+
const methods = allSymbols.filter(s => s.type === 'method');
|
|
2439
|
+
|
|
2440
|
+
console.log();
|
|
2441
|
+
console.log(box(
|
|
2442
|
+
`${chalk.cyan('Functions:')} ${functions.length}\n` +
|
|
2443
|
+
`${chalk.cyan('Classes:')} ${classes.length}\n` +
|
|
2444
|
+
`${chalk.cyan('Methods:')} ${methods.length}\n` +
|
|
2445
|
+
chalk.gray('─'.repeat(30)) + '\n' +
|
|
2446
|
+
chalk.gray('Usage: /symbol <name> to search'),
|
|
2447
|
+
'📦 Symbol Index', 'cyan'
|
|
2448
|
+
));
|
|
2449
|
+
continue;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
console.log(chalk.cyan(`\n🔍 Searching for: "${query}"...\n`));
|
|
2453
|
+
const results = searchSymbol(query, workspace);
|
|
2454
|
+
|
|
2455
|
+
if (results.length === 0) {
|
|
2456
|
+
console.log(chalk.yellow(`No symbols found matching "${query}"`));
|
|
2457
|
+
console.log(chalk.gray('Tip: Run /index to refresh symbol index'));
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
console.log(chalk.green(`Found ${results.length} symbol${results.length !== 1 ? 's' : ''}:\n`));
|
|
2462
|
+
|
|
2463
|
+
for (const sym of results.slice(0, 15)) {
|
|
2464
|
+
const typeIcon = sym.type === 'function' ? chalk.yellow('𝑓') :
|
|
2465
|
+
sym.type === 'class' ? chalk.blue('◆') :
|
|
2466
|
+
sym.type === 'method' ? chalk.cyan('○') : chalk.gray('◇');
|
|
2467
|
+
const asyncTag = sym.async ? chalk.magenta('async ') : '';
|
|
2468
|
+
const params = sym.params !== undefined ? chalk.gray(`(${sym.params})`) : '';
|
|
2469
|
+
|
|
2470
|
+
console.log(` ${typeIcon} ${asyncTag}${chalk.white.bold(sym.name)}${params}`);
|
|
2471
|
+
console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
if (results.length > 15) {
|
|
2475
|
+
console.log(chalk.gray(`\n ... and ${results.length - 15} more`));
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// Offer to add file to context
|
|
2479
|
+
if (results.length > 0) {
|
|
2480
|
+
console.log();
|
|
2481
|
+
const addToCtx = await safeQuestion(chalk.yellow('Add first match file to context? ') + chalk.gray('(y/n): '));
|
|
2482
|
+
if (addToCtx.toLowerCase() === 'y') {
|
|
2483
|
+
const targetFile = results[0].file;
|
|
2484
|
+
try {
|
|
2485
|
+
const content = fs.readFileSync(targetFile, 'utf8');
|
|
2486
|
+
messages.push({
|
|
2487
|
+
role: 'user',
|
|
2488
|
+
content: `Here is ${targetFile} (contains ${results[0].type} "${results[0].name}" at line ${results[0].line}):\n\n${content}`
|
|
2489
|
+
});
|
|
2490
|
+
console.log(chalk.green(`✅ Added ${targetFile} to context`));
|
|
2491
|
+
} catch (e) {
|
|
2492
|
+
console.log(chalk.red(`Could not read ${targetFile}`));
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
1143
2496
|
continue;
|
|
1144
2497
|
}
|
|
1145
2498
|
|
|
@@ -1223,7 +2576,7 @@ TOOL SYNTAX:
|
|
|
1223
2576
|
const contextSize = JSON.stringify(messages).length;
|
|
1224
2577
|
console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
|
|
1225
2578
|
if (contextSize > 50000) {
|
|
1226
|
-
console.log(chalk.yellow('⚠️ Context is large!
|
|
2579
|
+
console.log(chalk.yellow('⚠️ Context is large! Will auto-summarize on next message, or use /prune now.'));
|
|
1227
2580
|
}
|
|
1228
2581
|
continue;
|
|
1229
2582
|
}
|
|
@@ -1238,6 +2591,349 @@ TOOL SYNTAX:
|
|
|
1238
2591
|
continue;
|
|
1239
2592
|
}
|
|
1240
2593
|
|
|
2594
|
+
// Handle /log command - show activity log
|
|
2595
|
+
if (input.toLowerCase().startsWith('/log')) {
|
|
2596
|
+
const parts = input.split(' ');
|
|
2597
|
+
const count = parseInt(parts[1]) || 30;
|
|
2598
|
+
|
|
2599
|
+
if (parts[1] === 'file') {
|
|
2600
|
+
// Show log file path
|
|
2601
|
+
console.log(chalk.cyan(`\n📁 Log file: ${chalk.white(sessionLogFile())}`));
|
|
2602
|
+
if (fs.existsSync(sessionLogFile())) {
|
|
2603
|
+
const size = fs.statSync(sessionLogFile()).size;
|
|
2604
|
+
console.log(chalk.gray(` Size: ${Math.round(size / 1024)}KB`));
|
|
2605
|
+
}
|
|
2606
|
+
// List all log files
|
|
2607
|
+
try {
|
|
2608
|
+
ensureLogsDir();
|
|
2609
|
+
const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.endsWith('.md')).sort().reverse();
|
|
2610
|
+
if (logFiles.length > 0) {
|
|
2611
|
+
console.log(chalk.cyan(`\n📋 All session logs:`));
|
|
2612
|
+
logFiles.slice(0, 10).forEach((f, i) => {
|
|
2613
|
+
const stats = fs.statSync(`${LOGS_DIR}/${f}`);
|
|
2614
|
+
const isCurrent = f === `session-${sessionId}.md`;
|
|
2615
|
+
const label = isCurrent ? chalk.green(' ← current') : '';
|
|
2616
|
+
console.log(chalk.gray(` ${i + 1}. `) + chalk.white(f) + chalk.gray(` (${Math.round(stats.size / 1024)}KB)`) + label);
|
|
2617
|
+
});
|
|
2618
|
+
if (logFiles.length > 10) {
|
|
2619
|
+
console.log(chalk.gray(` ... and ${logFiles.length - 10} more`));
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
} catch (e) {}
|
|
2623
|
+
continue;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
if (parts[1] === 'stats') {
|
|
2627
|
+
// Show session statistics
|
|
2628
|
+
const stats = getSessionStats();
|
|
2629
|
+
console.log();
|
|
2630
|
+
console.log(box(
|
|
2631
|
+
`${chalk.white('Session Duration:')} ${chalk.cyan(formatElapsed(stats.totalDuration))}\n` +
|
|
2632
|
+
`${chalk.white('User Messages:')} ${chalk.blue.bold(stats.userMessages)}\n` +
|
|
2633
|
+
`${chalk.white('AI Responses:')} ${chalk.magenta.bold(stats.aiMessages)}\n` +
|
|
2634
|
+
`${chalk.white('Tool Calls:')} ${chalk.yellow.bold(stats.toolCalls)}\n` +
|
|
2635
|
+
`${chalk.white('Shell Commands:')} ${chalk.red.bold(stats.shellCalls)}\n` +
|
|
2636
|
+
`${chalk.white('Errors:')} ${stats.errors > 0 ? chalk.red.bold(stats.errors) : chalk.green.bold(stats.errors)}\n` +
|
|
2637
|
+
`${chalk.white('Log Events:')} ${chalk.gray(activityLog.length + ' total')}`,
|
|
2638
|
+
'📊 Session Stats', 'cyan'
|
|
2639
|
+
));
|
|
2640
|
+
console.log();
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
if (parts[1] === 'view' && parts[2]) {
|
|
2645
|
+
// View a specific log file
|
|
2646
|
+
try {
|
|
2647
|
+
const logPath = `${LOGS_DIR}/${parts[2]}`;
|
|
2648
|
+
if (fs.existsSync(logPath)) {
|
|
2649
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
2650
|
+
console.log(renderMarkdown(content));
|
|
2651
|
+
} else {
|
|
2652
|
+
console.log(chalk.yellow(`Log file not found: ${parts[2]}`));
|
|
2653
|
+
}
|
|
2654
|
+
} catch (e) {
|
|
2655
|
+
console.log(chalk.red(`Error reading log: ${e.message}`));
|
|
2656
|
+
}
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// Default: show activity timeline
|
|
2661
|
+
console.log(renderActivityLog(count));
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
// ═══════════════════════════════════════════════════════════
|
|
2666
|
+
// AGENT & SKILL COMMANDS
|
|
2667
|
+
// ═══════════════════════════════════════════════════════════
|
|
2668
|
+
|
|
2669
|
+
// Handle /agents command - list available agents or create one
|
|
2670
|
+
if (input.toLowerCase() === '/agents' || input.toLowerCase() === '/agent') {
|
|
2671
|
+
const currentAgents = loadAgents();
|
|
2672
|
+
const agentNames = Object.keys(currentAgents);
|
|
2673
|
+
if (agentNames.length === 0) {
|
|
2674
|
+
console.log(chalk.yellow('\nNo agents found. Create one with /newagent or /agents create <name> <description>'));
|
|
2675
|
+
} else {
|
|
2676
|
+
console.log();
|
|
2677
|
+
let agentList = '';
|
|
2678
|
+
for (const [name, agent] of Object.entries(currentAgents)) {
|
|
2679
|
+
const active = currentAgent === name ? chalk.green(' ◀ ACTIVE') : '';
|
|
2680
|
+
const toolsBadge = agent.tools ? chalk.gray(` [${agent.tools.join(', ')}]`) : chalk.gray(' [all tools]');
|
|
2681
|
+
agentList += `${chalk.cyan('/' + name)} ${chalk.gray('─')} ${chalk.white(agent.description)}${toolsBadge}${active}\n`;
|
|
2682
|
+
if (agent.argumentHint) {
|
|
2683
|
+
agentList += ` ${chalk.gray('💡 ' + agent.argumentHint)}\n`;
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
agentList += `\n${chalk.gray('Usage:')} ${chalk.cyan('/agentname prompt')} to switch & chat`;
|
|
2687
|
+
agentList += `\n${chalk.gray('Create:')} ${chalk.cyan('/agents create <name> <description>')}`;
|
|
2688
|
+
agentList += `\n${chalk.gray('Format:')} Supports YAML frontmatter (name, description, tools, argument-hint)`;
|
|
2689
|
+
console.log(box(agentList.trim(), '🤖 Available Agents', 'cyan'));
|
|
2690
|
+
}
|
|
2691
|
+
console.log();
|
|
2692
|
+
continue;
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
// Handle /agents create <name> <description> - quick agent creation
|
|
2696
|
+
if (input.toLowerCase().startsWith('/agents create ')) {
|
|
2697
|
+
const rest = input.slice('/agents create '.length).trim();
|
|
2698
|
+
const parts = rest.split(/\s+/);
|
|
2699
|
+
const agentName = (parts[0] || '').toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
2700
|
+
const description = parts.slice(1).join(' ').trim();
|
|
2701
|
+
|
|
2702
|
+
if (!agentName) {
|
|
2703
|
+
console.log(chalk.yellow('\nUsage: /agents create <name> <description>'));
|
|
2704
|
+
console.log(chalk.gray('Example: /agents create salesmanager handles sales strategies and customer relations'));
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
ensureAgentsDirs();
|
|
2709
|
+
const agentFile = join(AGENTS_DIR, `${agentName}.md`);
|
|
2710
|
+
if (fs.existsSync(agentFile)) {
|
|
2711
|
+
console.log(chalk.yellow(`\nAgent "${agentName}" already exists. Edit it at: ${agentFile}`));
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
const agentTitle = agentName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
2716
|
+
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`;
|
|
2717
|
+
|
|
2718
|
+
fs.writeFileSync(agentFile, agentMd);
|
|
2719
|
+
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
2720
|
+
console.log(chalk.gray(` File: ${agentFile}`));
|
|
2721
|
+
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
2722
|
+
continue;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// Handle /skills command - list available skills
|
|
2726
|
+
if (input.toLowerCase() === '/skills') {
|
|
2727
|
+
const currentSkills = loadSkills();
|
|
2728
|
+
const skillNames = Object.keys(currentSkills);
|
|
2729
|
+
if (skillNames.length === 0) {
|
|
2730
|
+
console.log(chalk.yellow('\nNo skills found. Create one with /newskill'));
|
|
2731
|
+
} else {
|
|
2732
|
+
console.log();
|
|
2733
|
+
let skillList = '';
|
|
2734
|
+
for (const [name, skill] of Object.entries(currentSkills)) {
|
|
2735
|
+
const loaded = loadedSkills.includes(name) ? chalk.green(' ◀ LOADED') : '';
|
|
2736
|
+
skillList += `${chalk.cyan(name)} ${chalk.gray('─')} ${chalk.white(skill.description)}${loaded}\n`;
|
|
2737
|
+
if (skill.argumentHint) {
|
|
2738
|
+
skillList += ` ${chalk.gray('💡 ' + skill.argumentHint)}\n`;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
skillList += `\n${chalk.gray('Usage:')} ${chalk.cyan('/use skillname')} to load a skill`;
|
|
2742
|
+
skillList += `\n${chalk.gray('Format:')} Supports YAML frontmatter (name, description, argument-hint)`;
|
|
2743
|
+
console.log(box(skillList.trim(), '📘 Available Skills', 'cyan'));
|
|
2744
|
+
}
|
|
2745
|
+
console.log();
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// Handle /default command - switch back to default Sapper
|
|
2750
|
+
if (input.toLowerCase() === '/default') {
|
|
2751
|
+
currentAgent = null;
|
|
2752
|
+
currentAgentTools = null;
|
|
2753
|
+
// Rebuild system prompt without agent
|
|
2754
|
+
const skillContents = loadedSkills.map(s => {
|
|
2755
|
+
const allSkills = loadSkills();
|
|
2756
|
+
return allSkills[s]?.content || '';
|
|
2757
|
+
}).filter(Boolean);
|
|
2758
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(null, skillContents) };
|
|
2759
|
+
console.log(chalk.green('\n✅ Switched back to default Sapper mode (all tools enabled)'));
|
|
2760
|
+
continue;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// Handle /use command - load a skill
|
|
2764
|
+
if (input.toLowerCase().startsWith('/use ')) {
|
|
2765
|
+
const skillName = input.slice(5).trim().toLowerCase();
|
|
2766
|
+
const currentSkills = loadSkills();
|
|
2767
|
+
|
|
2768
|
+
if (!currentSkills[skillName]) {
|
|
2769
|
+
console.log(chalk.yellow(`\n❌ Skill "${skillName}" not found.`));
|
|
2770
|
+
console.log(chalk.gray(`Available: ${Object.keys(currentSkills).join(', ') || 'none (create with /newskill)'}`));
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
if (loadedSkills.includes(skillName)) {
|
|
2775
|
+
console.log(chalk.yellow(`\nSkill "${skillName}" is already loaded.`));
|
|
2776
|
+
continue;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
loadedSkills.push(skillName);
|
|
2780
|
+
|
|
2781
|
+
// Rebuild system prompt with current agent + all loaded skills
|
|
2782
|
+
const agentContent = currentAgent ? currentSkills[currentAgent]?.content || loadAgents()[currentAgent]?.content : null;
|
|
2783
|
+
const skillContents = loadedSkills.map(s => currentSkills[s]?.content || '').filter(Boolean);
|
|
2784
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agentContent, skillContents) };
|
|
2785
|
+
|
|
2786
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" loaded!`));
|
|
2787
|
+
console.log(chalk.gray(` Active skills: ${loadedSkills.join(', ')}`));
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// Handle /unload command - unload a skill
|
|
2792
|
+
if (input.toLowerCase().startsWith('/unload ')) {
|
|
2793
|
+
const skillName = input.slice(8).trim().toLowerCase();
|
|
2794
|
+
|
|
2795
|
+
if (!loadedSkills.includes(skillName)) {
|
|
2796
|
+
console.log(chalk.yellow(`\nSkill "${skillName}" is not loaded.`));
|
|
2797
|
+
console.log(chalk.gray(`Loaded skills: ${loadedSkills.join(', ') || 'none'}`));
|
|
2798
|
+
continue;
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
loadedSkills = loadedSkills.filter(s => s !== skillName);
|
|
2802
|
+
|
|
2803
|
+
// Rebuild system prompt
|
|
2804
|
+
const allSkills = loadSkills();
|
|
2805
|
+
const agentContent = currentAgent ? loadAgents()[currentAgent]?.content : null;
|
|
2806
|
+
const skillContents = loadedSkills.map(s => allSkills[s]?.content || '').filter(Boolean);
|
|
2807
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agentContent, skillContents) };
|
|
2808
|
+
|
|
2809
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" unloaded.`));
|
|
2810
|
+
if (loadedSkills.length > 0) {
|
|
2811
|
+
console.log(chalk.gray(` Remaining skills: ${loadedSkills.join(', ')}`));
|
|
2812
|
+
}
|
|
2813
|
+
continue;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// Handle /newagent command - create a new agent
|
|
2817
|
+
if (input.toLowerCase() === '/newagent') {
|
|
2818
|
+
console.log();
|
|
2819
|
+
console.log(box(
|
|
2820
|
+
`Create a custom agent with its own persona and expertise.\n` +
|
|
2821
|
+
`The agent file will be saved in ${chalk.cyan('.sapper/agents/')}`,
|
|
2822
|
+
'🤖 New Agent', 'cyan'
|
|
2823
|
+
));
|
|
2824
|
+
|
|
2825
|
+
const agentName = await safeQuestion(chalk.cyan('\nAgent name (lowercase, no spaces): '));
|
|
2826
|
+
if (!agentName.trim() || !/^[a-z0-9_-]+$/.test(agentName.trim())) {
|
|
2827
|
+
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
2828
|
+
continue;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
const agentFile = join(AGENTS_DIR, `${agentName.trim()}.md`);
|
|
2832
|
+
if (fs.existsSync(agentFile)) {
|
|
2833
|
+
console.log(chalk.yellow(`Agent "${agentName}" already exists. Edit it at: ${agentFile}`));
|
|
2834
|
+
continue;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
const agentTitle = await safeQuestion(chalk.cyan('Agent title/role: '));
|
|
2838
|
+
const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
|
|
2839
|
+
const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
|
|
2840
|
+
const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,search,shell: '));
|
|
2841
|
+
|
|
2842
|
+
const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
|
|
2843
|
+
const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
|
|
2844
|
+
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`;
|
|
2845
|
+
|
|
2846
|
+
fs.writeFileSync(agentFile, agentMd);
|
|
2847
|
+
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
2848
|
+
console.log(chalk.gray(` File: ${agentFile}`));
|
|
2849
|
+
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// Handle /newskill command - create a new skill
|
|
2854
|
+
if (input.toLowerCase() === '/newskill') {
|
|
2855
|
+
console.log();
|
|
2856
|
+
console.log(box(
|
|
2857
|
+
`Create a custom skill with domain knowledge.\n` +
|
|
2858
|
+
`The skill file will be saved in ${chalk.cyan('.sapper/skills/')}`,
|
|
2859
|
+
'📘 New Skill', 'cyan'
|
|
2860
|
+
));
|
|
2861
|
+
|
|
2862
|
+
const skillName = await safeQuestion(chalk.cyan('\nSkill name (lowercase, no spaces): '));
|
|
2863
|
+
if (!skillName.trim() || !/^[a-z0-9_-]+$/.test(skillName.trim())) {
|
|
2864
|
+
console.log(chalk.yellow('Invalid name. Use lowercase letters, numbers, hyphens, underscores only.'));
|
|
2865
|
+
continue;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
const skillFile = join(SKILLS_DIR, `${skillName.trim()}.md`);
|
|
2869
|
+
if (fs.existsSync(skillFile)) {
|
|
2870
|
+
console.log(chalk.yellow(`Skill "${skillName}" already exists. Edit it at: ${skillFile}`));
|
|
2871
|
+
continue;
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
const skillTitle = await safeQuestion(chalk.cyan('Skill title: '));
|
|
2875
|
+
const skillDesc = await safeQuestion(chalk.cyan('Brief description (for /skills listing): '));
|
|
2876
|
+
const skillArgHint = await safeQuestion(chalk.cyan('Argument hint (optional, e.g. "Describe what to do"): '));
|
|
2877
|
+
const skillBody = await safeQuestion(chalk.cyan('Skill knowledge (or Enter for template): '));
|
|
2878
|
+
|
|
2879
|
+
const descLine = skillDesc.trim() || skillTitle.trim() || skillName;
|
|
2880
|
+
const argHintLine = skillArgHint.trim() ? `\nargument-hint: "${skillArgHint.trim()}"` : '';
|
|
2881
|
+
|
|
2882
|
+
const skillMd = skillBody.trim()
|
|
2883
|
+
? `---\nname: ${skillTitle.trim() || skillName}\ndescription: "${descLine}"${argHintLine}\n---\n\n# ${skillTitle.trim() || skillName}\n\n${skillBody.trim()}\n`
|
|
2884
|
+
: `---\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`;
|
|
2885
|
+
|
|
2886
|
+
fs.writeFileSync(skillFile, skillMd);
|
|
2887
|
+
console.log(chalk.green(`\n✅ Skill "${skillName}" created!`));
|
|
2888
|
+
console.log(chalk.gray(` File: ${skillFile}`));
|
|
2889
|
+
console.log(chalk.cyan(` Load it: /use ${skillName}`));
|
|
2890
|
+
continue;
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
// Handle /agentname - detect if input matches an agent name
|
|
2894
|
+
let agentHandled = false;
|
|
2895
|
+
{
|
|
2896
|
+
const currentAgents = loadAgents();
|
|
2897
|
+
const inputLower = input.toLowerCase();
|
|
2898
|
+
|
|
2899
|
+
// Check if input starts with /agentname (e.g., /salesmanager how do I sell?)
|
|
2900
|
+
if (inputLower.startsWith('/') && !inputLower.startsWith('//')) {
|
|
2901
|
+
const firstSpace = input.indexOf(' ');
|
|
2902
|
+
const cmdPart = firstSpace > 0 ? inputLower.slice(1, firstSpace) : inputLower.slice(1);
|
|
2903
|
+
|
|
2904
|
+
if (currentAgents[cmdPart]) {
|
|
2905
|
+
const agent = currentAgents[cmdPart];
|
|
2906
|
+
const prompt = firstSpace > 0 ? input.slice(firstSpace + 1).trim() : '';
|
|
2907
|
+
|
|
2908
|
+
// Switch to this agent
|
|
2909
|
+
currentAgent = cmdPart;
|
|
2910
|
+
currentAgentTools = agent.tools; // null = all tools, or ['READ','WRITE',...]
|
|
2911
|
+
|
|
2912
|
+
// Rebuild system prompt with agent + any loaded skills
|
|
2913
|
+
const skillContents = loadedSkills.map(s => {
|
|
2914
|
+
const allSkills = loadSkills();
|
|
2915
|
+
return allSkills[s]?.content || '';
|
|
2916
|
+
}).filter(Boolean);
|
|
2917
|
+
messages[0] = { role: 'system', content: buildSystemPrompt(agent.content, skillContents) };
|
|
2918
|
+
|
|
2919
|
+
console.log();
|
|
2920
|
+
console.log(statusBadge(`AGENT: ${agent.description}`, 'action'));
|
|
2921
|
+
const toolsInfo = agent.tools ? chalk.gray(` [tools: ${agent.tools.join(', ')}]`) : chalk.gray(' [all tools]');
|
|
2922
|
+
console.log(chalk.green(`Switched to /${cmdPart} agent`) + toolsInfo);
|
|
2923
|
+
|
|
2924
|
+
if (!prompt) {
|
|
2925
|
+
console.log(chalk.gray(`Type your prompt to chat with this agent.`));
|
|
2926
|
+
continue; // Just switched, no prompt to send
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// Has a prompt - inject it as user message and let AI respond
|
|
2930
|
+
messages.push({ role: 'user', content: prompt });
|
|
2931
|
+
agentHandled = true;
|
|
2932
|
+
// Don't continue - fall through to the AI response loop below
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
|
|
1241
2937
|
// Handle recall command - search embeddings
|
|
1242
2938
|
if (input.toLowerCase().startsWith('/recall')) {
|
|
1243
2939
|
const query = input.slice(7).trim();
|
|
@@ -1314,6 +3010,8 @@ TOOL SYNTAX:
|
|
|
1314
3010
|
continue;
|
|
1315
3011
|
}
|
|
1316
3012
|
|
|
3013
|
+
// Skip input processing if agent already handled it
|
|
3014
|
+
if (!agentHandled) {
|
|
1317
3015
|
// Handle @ alone or /attach command - interactive file picker
|
|
1318
3016
|
if (input.trim() === '@' || input.toLowerCase() === '/attach') {
|
|
1319
3017
|
const selectedFiles = await pickFiles();
|
|
@@ -1425,22 +3123,50 @@ TOOL SYNTAX:
|
|
|
1425
3123
|
}
|
|
1426
3124
|
|
|
1427
3125
|
messages.push({ role: 'user', content: processedInput });
|
|
3126
|
+
|
|
3127
|
+
// Log user input
|
|
3128
|
+
logEntry('user', {
|
|
3129
|
+
message: processedInput,
|
|
3130
|
+
attachments: fileAttachments.map(f => f.path)
|
|
3131
|
+
});
|
|
3132
|
+
|
|
1428
3133
|
} // End of else block for non-@ input
|
|
3134
|
+
} // End of if (!agentHandled)
|
|
1429
3135
|
|
|
1430
3136
|
let toolRounds = 0; // Prevent infinite loops
|
|
1431
3137
|
const MAX_TOOL_ROUNDS = 20;
|
|
3138
|
+
const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
|
|
3139
|
+
const MAX_PATCH_RETRIES = 3;
|
|
1432
3140
|
|
|
1433
3141
|
let active = true;
|
|
1434
3142
|
while (active) {
|
|
1435
3143
|
if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
|
|
1436
3144
|
|
|
1437
3145
|
spinner.start('Thinking...');
|
|
3146
|
+
const aiStartTime = Date.now();
|
|
1438
3147
|
let response;
|
|
1439
3148
|
try {
|
|
1440
|
-
|
|
3149
|
+
// Build chat options — pass native tools when supported
|
|
3150
|
+
const chatOpts = { model: selectedModel, messages, stream: true };
|
|
3151
|
+
if (useNativeTools) {
|
|
3152
|
+
// Filter tool defs by agent restrictions if any
|
|
3153
|
+
if (currentAgentTools) {
|
|
3154
|
+
const toolNameMap = {
|
|
3155
|
+
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
3156
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
|
|
3157
|
+
};
|
|
3158
|
+
chatOpts.tools = nativeToolDefs.filter(t =>
|
|
3159
|
+
currentAgentTools.includes(toolNameMap[t.function.name])
|
|
3160
|
+
);
|
|
3161
|
+
} else {
|
|
3162
|
+
chatOpts.tools = nativeToolDefs;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
response = await ollama.chat(chatOpts);
|
|
1441
3166
|
} catch (ollamaError) {
|
|
1442
3167
|
spinner.stop();
|
|
1443
3168
|
console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
|
|
3169
|
+
logEntry('error', { message: `Ollama error: ${ollamaError.message}` });
|
|
1444
3170
|
active = false;
|
|
1445
3171
|
continue;
|
|
1446
3172
|
}
|
|
@@ -1451,6 +3177,9 @@ TOOL SYNTAX:
|
|
|
1451
3177
|
let lastChunkTime = Date.now();
|
|
1452
3178
|
let repetitionCount = 0;
|
|
1453
3179
|
let lastContent = '';
|
|
3180
|
+
let wasInterrupted = false;
|
|
3181
|
+
let wasRepetitionStopped = false;
|
|
3182
|
+
let nativeToolCalls = []; // Collect native tool_calls from streaming chunks
|
|
1454
3183
|
abortStream = false; // Reset abort flag before streaming
|
|
1455
3184
|
|
|
1456
3185
|
console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
|
|
@@ -1459,12 +3188,20 @@ TOOL SYNTAX:
|
|
|
1459
3188
|
// Check if user pressed Ctrl+C
|
|
1460
3189
|
if (abortStream) {
|
|
1461
3190
|
console.log(chalk.yellow('\n│ [Response interrupted]'));
|
|
3191
|
+
wasInterrupted = true;
|
|
1462
3192
|
break;
|
|
1463
3193
|
}
|
|
1464
3194
|
|
|
1465
3195
|
const content = chunk.message.content;
|
|
1466
|
-
|
|
1467
|
-
|
|
3196
|
+
if (content) {
|
|
3197
|
+
process.stdout.write(content);
|
|
3198
|
+
msg += content;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
// Collect native tool_calls (arrive in chunks, usually the final one)
|
|
3202
|
+
if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) {
|
|
3203
|
+
nativeToolCalls.push(...chunk.message.tool_calls);
|
|
3204
|
+
}
|
|
1468
3205
|
|
|
1469
3206
|
// Smart loop detection: check for repetitive content patterns
|
|
1470
3207
|
if (msg.length > 10000) {
|
|
@@ -1476,6 +3213,7 @@ TOOL SYNTAX:
|
|
|
1476
3213
|
repetitionCount++;
|
|
1477
3214
|
if (repetitionCount > 3) {
|
|
1478
3215
|
console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
|
|
3216
|
+
wasRepetitionStopped = true;
|
|
1479
3217
|
break;
|
|
1480
3218
|
}
|
|
1481
3219
|
} else {
|
|
@@ -1490,24 +3228,157 @@ TOOL SYNTAX:
|
|
|
1490
3228
|
}
|
|
1491
3229
|
}
|
|
1492
3230
|
console.log(chalk.magenta('└─────────────────────────────────────'));
|
|
1493
|
-
|
|
1494
|
-
messages.push({ role: 'assistant', content: msg });
|
|
1495
3231
|
|
|
1496
|
-
//
|
|
1497
|
-
const
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
3232
|
+
// Render AI response with markdown (only for non-tool responses displayed to user)
|
|
3233
|
+
const hasTextToolCalls = msg.includes('[TOOL:') && msg.includes('[/TOOL]');
|
|
3234
|
+
const hasNativeToolCalls = nativeToolCalls.length > 0;
|
|
3235
|
+
if (!hasTextToolCalls && !hasNativeToolCalls && msg.trim().length > 0) {
|
|
3236
|
+
try {
|
|
3237
|
+
const rendered = renderMarkdown(msg);
|
|
3238
|
+
// Clear raw output and re-render with markdown
|
|
3239
|
+
process.stdout.write('\x1B[2K'); // clear current line
|
|
3240
|
+
console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta('] ') + chalk.gray('(rendered)'));
|
|
3241
|
+
console.log(rendered);
|
|
3242
|
+
console.log(chalk.magenta('└─────────────────────────────────────'));
|
|
3243
|
+
} catch (e) {
|
|
3244
|
+
// Markdown rendering failed, raw output already shown
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
const aiDuration = Date.now() - aiStartTime;
|
|
3249
|
+
// Build assistant message — include tool_calls if native tools were invoked
|
|
3250
|
+
const assistantMsg = { role: 'assistant', content: msg };
|
|
3251
|
+
if (nativeToolCalls.length > 0) {
|
|
3252
|
+
assistantMsg.tool_calls = nativeToolCalls;
|
|
3253
|
+
}
|
|
3254
|
+
messages.push(assistantMsg);
|
|
3255
|
+
|
|
3256
|
+
// Log AI response
|
|
3257
|
+
logEntry('ai', {
|
|
3258
|
+
charCount: msg.length,
|
|
3259
|
+
duration: aiDuration,
|
|
3260
|
+
toolCount: nativeToolCalls.length || 0, // Updated below if text-marker tools found
|
|
3261
|
+
interrupted: wasInterrupted,
|
|
3262
|
+
repetitionStopped: wasRepetitionStopped,
|
|
3263
|
+
preview: msg.replace(/\[TOOL:[^\]]*\][\s\S]*?\[\/TOOL\]/g, '[tool call]')
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
// ═══ NATIVE TOOL CALLS HANDLER ═══════════════════════════════════
|
|
3267
|
+
if (nativeToolCalls.length > 0) {
|
|
3268
|
+
toolRounds++;
|
|
3269
|
+
let hitToolLimit = false;
|
|
3270
|
+
if (toolRounds >= MAX_TOOL_ROUNDS) {
|
|
3271
|
+
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Processing remaining tools then stopping.`));
|
|
3272
|
+
hitToolLimit = true;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
// Map native function names to tool executors
|
|
3276
|
+
const nativeToolNameMap = {
|
|
3277
|
+
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
3278
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
|
|
3279
|
+
};
|
|
3280
|
+
|
|
3281
|
+
for (const tc of nativeToolCalls) {
|
|
3282
|
+
const fn = tc.function;
|
|
3283
|
+
const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
|
|
3284
|
+
const args = fn.arguments || {};
|
|
3285
|
+
|
|
3286
|
+
// Enforce agent tool restrictions
|
|
3287
|
+
if (currentAgentTools && !currentAgentTools.includes(toolType)) {
|
|
3288
|
+
console.log(chalk.yellow(`\n⚠️ Tool ${toolType} blocked — not in agent's allowed tools`));
|
|
3289
|
+
messages.push({ role: 'tool', content: `Error: Tool ${toolType} is not allowed for the current agent.`, tool_name: fn.name });
|
|
3290
|
+
continue;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
const displayPath = args.path || args.pattern || args.command || '';
|
|
3294
|
+
console.log();
|
|
3295
|
+
console.log(statusBadge(toolType, 'action') + chalk.gray(' → ') + chalk.white(displayPath));
|
|
3296
|
+
|
|
3297
|
+
const toolStart = Date.now();
|
|
3298
|
+
let result;
|
|
3299
|
+
let toolSuccess = true;
|
|
3300
|
+
|
|
3301
|
+
try {
|
|
3302
|
+
switch (fn.name) {
|
|
3303
|
+
case 'list_directory':
|
|
3304
|
+
result = tools.list(args.path);
|
|
3305
|
+
logEntry('file', { action: 'list', path: args.path });
|
|
3306
|
+
break;
|
|
3307
|
+
case 'read_file':
|
|
3308
|
+
result = tools.read(args.path);
|
|
3309
|
+
logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
|
|
3310
|
+
break;
|
|
3311
|
+
case 'search_files':
|
|
3312
|
+
result = await tools.search(args.pattern);
|
|
3313
|
+
logEntry('tool', { toolType: 'SEARCH', path: args.pattern, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
3314
|
+
break;
|
|
3315
|
+
case 'write_file':
|
|
3316
|
+
result = await tools.write(args.path, args.content);
|
|
3317
|
+
logEntry('file', { action: 'write', path: args.path, size: args.content?.length || 0, userApproved: result.includes('Successfully') });
|
|
3318
|
+
break;
|
|
3319
|
+
case 'patch_file': {
|
|
3320
|
+
const patchKey = args.path?.trim();
|
|
3321
|
+
if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
|
|
3322
|
+
result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. Use read_file to see exact content, then try write_file instead.`;
|
|
3323
|
+
toolSuccess = false;
|
|
3324
|
+
} else {
|
|
3325
|
+
result = await tools.patch(args.path, args.old_text, args.new_text);
|
|
3326
|
+
if (result.includes('Successfully')) {
|
|
3327
|
+
patchFailures[patchKey] = 0;
|
|
3328
|
+
} else if (result.startsWith('Error:')) {
|
|
3329
|
+
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
3330
|
+
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES})`;
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
logEntry('file', { action: 'patch', path: args.path, userApproved: result.includes('Successfully') });
|
|
3334
|
+
break;
|
|
3335
|
+
}
|
|
3336
|
+
case 'create_directory':
|
|
3337
|
+
result = tools.mkdir(args.path);
|
|
3338
|
+
logEntry('file', { action: 'mkdir', path: args.path });
|
|
3339
|
+
break;
|
|
3340
|
+
case 'run_shell':
|
|
3341
|
+
result = await tools.shell(args.command);
|
|
3342
|
+
logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
|
|
3343
|
+
break;
|
|
3344
|
+
default:
|
|
3345
|
+
result = `Unknown tool: ${fn.name}`;
|
|
3346
|
+
toolSuccess = false;
|
|
3347
|
+
}
|
|
3348
|
+
} catch (toolError) {
|
|
3349
|
+
result = `Error executing ${fn.name}: ${toolError.message}`;
|
|
3350
|
+
toolSuccess = false;
|
|
3351
|
+
logEntry('error', { message: result });
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// Feed result back as tool role message (Ollama native format)
|
|
3355
|
+
messages.push({ role: 'tool', content: String(result), tool_name: fn.name });
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
// Save context
|
|
3359
|
+
ensureSapperDir();
|
|
3360
|
+
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
3361
|
+
|
|
3362
|
+
if (hitToolLimit) {
|
|
3363
|
+
resetTerminal();
|
|
3364
|
+
messages.push({ role: 'user', content: 'STOP using tools now. Provide your analysis based on what you have.' });
|
|
3365
|
+
}
|
|
3366
|
+
continue; // Loop back for AI to process tool results
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
// ═══ TEXT-MARKER TOOL PARSING (fallback for models without native tool support) ═══
|
|
3370
|
+
// Strip markdown code blocks before tool parsing to avoid executing tool examples
|
|
3371
|
+
let msgForToolParsing = msg.replace(/```[\s\S]*?```/g, '');
|
|
3372
|
+
|
|
3373
|
+
// Check for unclosed tool calls and auto-close them instead of burning AI rounds
|
|
3374
|
+
const hasUnclosedTool = msgForToolParsing.includes('[TOOL:') && !msgForToolParsing.includes('[/TOOL]');
|
|
1501
3375
|
if (hasUnclosedTool) {
|
|
1502
|
-
console.log(chalk.yellow('\n⚠️ Unclosed tool detected
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
messages.push({
|
|
1506
|
-
role: 'user',
|
|
1507
|
-
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.'
|
|
1508
|
-
});
|
|
1509
|
-
continue; // Let AI respond with the closing tag
|
|
3376
|
+
console.log(chalk.yellow('\n⚠️ Unclosed tool detected — auto-closing with [/TOOL]'));
|
|
3377
|
+
msgForToolParsing += '[/TOOL]';
|
|
1510
3378
|
}
|
|
3379
|
+
|
|
3380
|
+
// Regex: supports both old format (path]content) and new format (path:::content)
|
|
3381
|
+
const toolMatches = [...msgForToolParsing.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
|
|
1511
3382
|
|
|
1512
3383
|
// Debug mode: show what regex sees
|
|
1513
3384
|
if (debugMode) {
|
|
@@ -1545,45 +3416,105 @@ TOOL SYNTAX:
|
|
|
1545
3416
|
if (toolMatches.length > 0) {
|
|
1546
3417
|
toolRounds++;
|
|
1547
3418
|
|
|
1548
|
-
//
|
|
3419
|
+
// Track if we hit the tool limit — still process this round's tools, then stop
|
|
3420
|
+
let hitToolLimit = false;
|
|
1549
3421
|
if (toolRounds >= MAX_TOOL_ROUNDS) {
|
|
1550
|
-
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds).
|
|
3422
|
+
console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Processing remaining tools then stopping.`));
|
|
1551
3423
|
console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
|
|
1552
|
-
|
|
1553
|
-
messages.push({
|
|
1554
|
-
role: 'user',
|
|
1555
|
-
content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
|
|
1556
|
-
});
|
|
1557
|
-
continue; // Let AI respond without tools
|
|
3424
|
+
hitToolLimit = true;
|
|
1558
3425
|
}
|
|
1559
3426
|
|
|
3427
|
+
// Update the AI log entry with tool count
|
|
3428
|
+
if (activityLog.length > 0) {
|
|
3429
|
+
const lastAiLog = [...activityLog].reverse().find(e => e.type === 'ai');
|
|
3430
|
+
if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
|
|
3431
|
+
}
|
|
3432
|
+
|
|
1560
3433
|
for (const match of toolMatches) {
|
|
1561
3434
|
const [_, type, path, content] = match;
|
|
3435
|
+
|
|
3436
|
+
// Enforce tool restrictions from active agent
|
|
3437
|
+
if (currentAgentTools && !currentAgentTools.includes(type.toUpperCase())) {
|
|
3438
|
+
console.log();
|
|
3439
|
+
console.log(chalk.yellow(`⚠️ Tool ${type.toUpperCase()} blocked — not in agent's allowed tools: [${currentAgentTools.join(', ')}]`));
|
|
3440
|
+
const result = `Error: Tool ${type.toUpperCase()} is not allowed for the current agent. Allowed tools: ${currentAgentTools.join(', ')}. Use only the allowed tools.`;
|
|
3441
|
+
messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
|
|
3442
|
+
logEntry('tool', { toolType: type.toUpperCase(), path, duration: 0, success: false, error: 'blocked by agent tool restriction' });
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3445
|
+
|
|
1562
3446
|
console.log();
|
|
1563
3447
|
console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
|
|
1564
3448
|
|
|
3449
|
+
const toolStart = Date.now();
|
|
1565
3450
|
let result;
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
3451
|
+
let toolSuccess = true;
|
|
3452
|
+
if (type.toLowerCase() === 'list') {
|
|
3453
|
+
result = tools.list(path);
|
|
3454
|
+
logEntry('file', { action: 'list', path });
|
|
3455
|
+
}
|
|
3456
|
+
else if (type.toLowerCase() === 'read') {
|
|
3457
|
+
result = tools.read(path);
|
|
3458
|
+
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
3459
|
+
}
|
|
3460
|
+
else if (type.toLowerCase() === 'mkdir') {
|
|
3461
|
+
result = tools.mkdir(path);
|
|
3462
|
+
logEntry('file', { action: 'mkdir', path });
|
|
3463
|
+
}
|
|
1569
3464
|
else if (type.toLowerCase() === 'write') {
|
|
1570
3465
|
if (!content || content.trim() === '') {
|
|
1571
3466
|
result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
|
|
3467
|
+
toolSuccess = false;
|
|
1572
3468
|
} else {
|
|
1573
3469
|
result = await tools.write(path, content);
|
|
3470
|
+
const approved = result.includes('Successfully');
|
|
3471
|
+
logEntry('file', { action: 'write', path, size: content.length, userApproved: approved });
|
|
1574
3472
|
}
|
|
1575
3473
|
}
|
|
1576
3474
|
else if (type.toLowerCase() === 'patch') {
|
|
1577
|
-
// PATCH format: [TOOL:PATCH]path
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
3475
|
+
// PATCH format: [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL]
|
|
3476
|
+
// Also supports line mode: [TOOL:PATCH]path:::LINE:15|||new text[/TOOL]
|
|
3477
|
+
const patchKey = path.trim();
|
|
3478
|
+
if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
|
|
3479
|
+
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.`;
|
|
3480
|
+
toolSuccess = false;
|
|
3481
|
+
logEntry('file', { action: 'patch', path, userApproved: false });
|
|
1581
3482
|
} else {
|
|
1582
|
-
|
|
3483
|
+
// Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
|
|
3484
|
+
let parts = content?.split('|||');
|
|
3485
|
+
if (!parts || parts.length !== 2) {
|
|
3486
|
+
parts = content?.split('||:');
|
|
3487
|
+
}
|
|
3488
|
+
if (parts && parts.length === 2) {
|
|
3489
|
+
result = await tools.patch(path, parts[0], parts[1]);
|
|
3490
|
+
const approved = result.includes('Successfully');
|
|
3491
|
+
if (!approved && result.startsWith('Error:')) {
|
|
3492
|
+
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
3493
|
+
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES} — after ${MAX_PATCH_RETRIES} failures, PATCH will be blocked on this file)`;
|
|
3494
|
+
} else if (approved) {
|
|
3495
|
+
patchFailures[patchKey] = 0; // Reset on success
|
|
3496
|
+
}
|
|
3497
|
+
logEntry('file', { action: 'patch', path, userApproved: approved });
|
|
3498
|
+
} else {
|
|
3499
|
+
result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
|
|
3500
|
+
toolSuccess = false;
|
|
3501
|
+
}
|
|
1583
3502
|
}
|
|
1584
3503
|
}
|
|
1585
|
-
else if (type.toLowerCase() === 'search')
|
|
1586
|
-
|
|
3504
|
+
else if (type.toLowerCase() === 'search') {
|
|
3505
|
+
result = await tools.search(path);
|
|
3506
|
+
logEntry('tool', { toolType: 'SEARCH', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
3507
|
+
}
|
|
3508
|
+
else if (type.toLowerCase() === 'shell') {
|
|
3509
|
+
result = await tools.shell(path);
|
|
3510
|
+
const approved = !result.includes('blocked');
|
|
3511
|
+
logEntry('shell', { command: path, duration: Date.now() - toolStart, userApproved: approved, exitCode: result.match(/code (\d+)/)?.[1] ?? null });
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
// Log tool execution (for non-shell, non-file specific ones)
|
|
3515
|
+
if (!['list', 'read', 'mkdir', 'write', 'patch', 'search', 'shell'].includes(type.toLowerCase())) {
|
|
3516
|
+
logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
|
|
3517
|
+
}
|
|
1587
3518
|
|
|
1588
3519
|
messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
|
|
1589
3520
|
}
|
|
@@ -1593,6 +3524,15 @@ TOOL SYNTAX:
|
|
|
1593
3524
|
if (toolMatches.length > 30) {
|
|
1594
3525
|
console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
|
|
1595
3526
|
}
|
|
3527
|
+
|
|
3528
|
+
// If tool limit was reached, stop after processing this round
|
|
3529
|
+
if (hitToolLimit) {
|
|
3530
|
+
resetTerminal();
|
|
3531
|
+
messages.push({
|
|
3532
|
+
role: 'user',
|
|
3533
|
+
content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
1596
3536
|
} else {
|
|
1597
3537
|
// No tools found - check if malformed command
|
|
1598
3538
|
if (msg.includes('[TOOL:') && msg.includes('[/]')) {
|
|
@@ -1614,6 +3554,7 @@ TOOL SYNTAX:
|
|
|
1614
3554
|
}
|
|
1615
3555
|
} catch (error) {
|
|
1616
3556
|
console.error(chalk.red('\n❌ Error:'), error.message);
|
|
3557
|
+
logEntry('error', { message: error.message });
|
|
1617
3558
|
// Loop continues automatically
|
|
1618
3559
|
}
|
|
1619
3560
|
}
|