sapper-iq 1.1.35 → 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.mjs CHANGED
@@ -73,6 +73,225 @@ const CONTEXT_FILE = `${SAPPER_DIR}/context.json`;
73
73
  const EMBEDDINGS_FILE = `${SAPPER_DIR}/embeddings.json`;
74
74
  const WORKSPACE_FILE = `${SAPPER_DIR}/workspace.json`;
75
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
+ }
76
295
 
77
296
  // Ensure .sapper directory exists
78
297
  function ensureSapperDir() {
@@ -81,6 +300,453 @@ function ensureSapperDir() {
81
300
  }
82
301
  }
83
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
+
84
750
  // Load config (settings like autoAttach)
85
751
  function loadConfig() {
86
752
  try {
@@ -628,6 +1294,176 @@ async function addToEmbeddings(text, embeddings) {
628
1294
  }
629
1295
  }
630
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
+
631
1467
  // ═══════════════════════════════════════════════════════════════
632
1468
  // FANCY UI HELPERS
633
1469
  // ═══════════════════════════════════════════════════════════════
@@ -1001,18 +1837,96 @@ const tools = {
1001
1837
  const trimmedPath = path.trim();
1002
1838
  try {
1003
1839
  const content = fs.readFileSync(trimmedPath, 'utf8');
1004
- if (!content.includes(oldText)) {
1005
- return `Error: Could not find the text to replace in ${trimmedPath}. Make sure oldText matches exactly (including whitespace).`;
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
+ }
1006
1921
  }
1007
- const newContent = content.replace(oldText, newText);
1008
1922
 
1009
1923
  // Show diff preview
1010
1924
  console.log();
1011
1925
  const diffContent =
1012
1926
  `${chalk.white('File:')} ${chalk.cyan(trimmedPath)}\n` +
1013
1927
  chalk.gray('─'.repeat(40)) + '\n' +
1014
- chalk.red('- ' + oldText.split('\n').join('\n- ')) + '\n' +
1015
- 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+ '));
1016
1930
  console.log(box(diffContent, '🔧 Patch', 'yellow'));
1017
1931
 
1018
1932
  const confirm = await safeQuestion(chalk.yellow('\n↪ Apply patch? ') + chalk.gray('(y/n): '));
@@ -1057,10 +1971,21 @@ const tools = {
1057
1971
  const confirm = await safeQuestion(chalk.red('\n↪ Execute? ') + chalk.gray('(y/n): '));
1058
1972
  if (confirm.toLowerCase() === 'y') {
1059
1973
  return new Promise((resolve) => {
1060
- const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ');
1974
+ const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
1061
1975
  console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
1062
- const proc = spawn(useShell ? 'sh' : cmd.split(' ')[0], useShell ? ['-c', cmd] : cmd.split(' ').slice(1), {
1063
- stdio: 'inherit', shell: useShell
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
1064
1989
  });
1065
1990
  proc.on('close', (code) => {
1066
1991
  // Crucial: give control back to Node
@@ -1070,7 +1995,13 @@ const tools = {
1070
1995
  // Delay slightly to let terminal settle
1071
1996
  setTimeout(() => {
1072
1997
  recreateReadline();
1073
- resolve(`Command completed with code ${code}`);
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}`);
1074
2005
  }, 200);
1075
2006
  });
1076
2007
  });
@@ -1144,6 +2075,7 @@ async function runSapper() {
1144
2075
  console.log(box(
1145
2076
  `${chalk.yellow('💡')} Use ${chalk.cyan('@file')} to attach files (e.g., "fix @app.js")\n` +
1146
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` +
1147
2079
  `${chalk.yellow('💡')} Type ${chalk.cyan('/help')} for all commands`,
1148
2080
  'Quick Tips', 'gray'
1149
2081
  ));
@@ -1171,7 +2103,22 @@ async function runSapper() {
1171
2103
 
1172
2104
  // Show memory status
1173
2105
  console.log(chalk.gray(`📁 Memory: .sapper/ folder`));
1174
- console.log(chalk.gray(`🔗 Auto-attach: ${sapperConfig.autoAttach ? 'ON' : 'OFF'} (toggle with /auto)\n`));
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();
1175
2122
 
1176
2123
  let messages = [];
1177
2124
  if (fs.existsSync(CONTEXT_FILE)) {
@@ -1229,132 +2176,200 @@ async function runSapper() {
1229
2176
  const choice = await safeQuestion(chalk.cyan('\n⚡ Select model: '));
1230
2177
  const selectedModel = localModels.models[parseInt(choice) - 1]?.name || localModels.models[0].name;
1231
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
+
1232
2299
  if (messages.length === 0) {
1233
2300
  messages = [{
1234
2301
  role: 'system',
1235
- content: `You are Sapper, a high-level Autonomous Software Engineer.
1236
- Your goal is to solve the user's request by interacting with the filesystem and shell.
1237
-
1238
- RULES:
1239
- 1. EXPLORE FIRST: Use LIST and READ to understand the codebase before making changes.
1240
- 2. THINK IN STEPS: Explain what you found and what you plan to do before executing tools.
1241
- 3. BE PRECISE: When using PATCH, ensure the 'oldText' matches exactly.
1242
- 4. VERIFY: After writing code, use the SHELL tool to run tests or linting.
1243
- 5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.
1244
-
1245
- TOOL SYNTAX:
1246
- - [TOOL:LIST]dir[/TOOL] - List directory contents
1247
- - [TOOL:READ]file_path[/TOOL] - Read file contents
1248
- - [TOOL:SEARCH]pattern[/TOOL] - Search codebase for pattern
1249
- - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
1250
- - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file
1251
- - [TOOL:SHELL]command[/TOOL] - Run shell command`
2302
+ content: buildSystemPrompt()
1252
2303
  }];
1253
2304
  }
1254
2305
 
2306
+ // Log session start
2307
+ logEntry('session_start', {
2308
+ model: selectedModel,
2309
+ resumed: messages.length > 1,
2310
+ contextSize: messages.length
2311
+ });
2312
+
1255
2313
  // Main conversation loop - never exits unless user types 'exit'
1256
2314
  while (true) {
1257
2315
  try {
1258
- // Context size warning - large context causes hangs
2316
+ // Context size check - auto-summarize when too large
1259
2317
  const contextSize = JSON.stringify(messages).length;
1260
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` });
1261
2336
  console.log();
1262
2337
  console.log(box(
1263
- `Context is ${chalk.red.bold(Math.round(contextSize/1024) + 'KB')} - this may cause slowdowns!\n` +
1264
- `${chalk.yellow('Tip:')} Type ${chalk.cyan('/prune')} to reduce context size`,
1265
- '⚠️ Warning', 'yellow'
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'
1266
2343
  ));
2344
+ console.log();
2345
+ process.exit();
1267
2346
  }
1268
2347
 
1269
- const input = await safeQuestion(chalk.cyan('\n┌─[') + chalk.white.bold('You') + chalk.cyan(']\n└─➤ '));
1270
-
1271
- if (input.toLowerCase() === 'exit') process.exit();
1272
-
1273
2348
  // Handle reset command
1274
2349
  if (input.toLowerCase() === '/reset' || input.toLowerCase() === '/clear') {
1275
2350
  if (fs.existsSync(CONTEXT_FILE)) {
1276
2351
  fs.unlinkSync(CONTEXT_FILE);
1277
2352
  console.log(chalk.green('✅ Context cleared! Starting fresh...\n'));
1278
2353
  }
2354
+ currentAgent = null;
2355
+ currentAgentTools = null;
2356
+ loadedSkills = [];
1279
2357
  messages = [{
1280
2358
  role: 'system',
1281
- content: messages[0].content // Keep system prompt
2359
+ content: buildSystemPrompt() // Reset to default prompt
1282
2360
  }];
2361
+ logEntry('system', { event: 'Context Reset', detail: 'All context cleared, starting fresh' });
1283
2362
  continue;
1284
2363
  }
1285
2364
 
1286
- // Handle prune command - AUTO-EMBED then clear old context
2365
+ // Handle prune command - smart AI summary then clear old context
1287
2366
  if (input.toLowerCase() === '/prune') {
1288
2367
  if (messages.length <= 5) {
1289
2368
  console.log(chalk.yellow('Context is already small, nothing to prune.'));
1290
2369
  continue;
1291
2370
  }
1292
2371
 
1293
- // 1. AUTO-EMBED: Save conversation to memory BEFORE pruning (silently skip if no model)
1294
- const embeddings = loadEmbeddings();
1295
-
1296
- // Get messages that will be pruned (all except system and last 4)
1297
- const messagesToEmbed = messages.slice(1, -4)
1298
- .filter(m => m.role !== 'system')
1299
- .map(m => m.content.substring(0, 500))
1300
- .join('\n---\n');
1301
-
1302
- if (messagesToEmbed.length > 50) {
1303
- try {
1304
- const embedding = await getEmbedding(messagesToEmbed);
1305
- if (embedding) {
1306
- embeddings.chunks.push({
1307
- text: messagesToEmbed.substring(0, 2000),
1308
- embedding,
1309
- timestamp: Date.now()
1310
- });
1311
- if (embeddings.chunks.length > 100) {
1312
- embeddings.chunks = embeddings.chunks.slice(-100);
1313
- }
1314
- saveEmbeddings(embeddings);
1315
- console.log(chalk.green(`🧠 Saved to memory! (${embeddings.chunks.length} memories)`));
1316
- }
1317
- } catch (e) {
1318
- // Silently skip embedding if model not available - prune still works
1319
- }
1320
- }
1321
-
1322
- // 2. Capture the ORIGINAL detailed system prompt from the very first message
1323
- const originalSystemPrompt = messages[0];
1324
-
1325
- // 3. Capture the last 4 messages (the most recent conversation)
1326
- const recentMessages = messages.slice(-4);
1327
-
1328
- // 4. Rebuild the messages array starting with the ORIGINAL prompt
1329
- messages = [originalSystemPrompt, ...recentMessages];
1330
-
1331
- // 4. Add reminder to stay in Agent Mode (not chatbot mode)
1332
- messages.push({
1333
- role: 'system',
1334
- content: `CONTEXT PRUNED. REMINDER: You are Sapper, an Autonomous Software Engineer.
1335
-
1336
- RULES:
1337
- 1. EXPLORE FIRST: Use LIST and READ before making changes.
1338
- 2. THINK IN STEPS: Explain your plan before executing tools.
1339
- 3. BE PRECISE: When using PATCH, ensure 'oldText' matches exactly.
1340
- 4. VERIFY: Run tests or linting after writing code.
1341
- 5. NO HALLUCINATIONS: Don't guess file contents.
1342
-
1343
- TOOL SYNTAX:
1344
- - [TOOL:LIST]dir[/TOOL]
1345
- - [TOOL:READ]file_path[/TOOL]
1346
- - [TOOL:SEARCH]pattern[/TOOL]
1347
- - [TOOL:WRITE]path:::content[/TOOL]
1348
- - [TOOL:PATCH]path:::old|||new[/TOOL]
1349
- - [TOOL:SHELL]command[/TOOL]`
1350
- });
1351
-
1352
- // 5. Save to context file so it persists
1353
- ensureSapperDir();
1354
- fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
1355
-
1356
- console.log(chalk.green(`✅ Pruned context. Sapper reminded to stay in Agent Mode.`));
1357
- console.log(chalk.gray(`Context size: ${messages.length} messages\n`));
2372
+ messages = await autoSummarizeContext(messages, selectedModel);
1358
2373
  continue;
1359
2374
  }
1360
2375
 
@@ -1371,11 +2386,24 @@ TOOL SYNTAX:
1371
2386
  `${chalk.cyan('/auto')} ${chalk.gray('│')} Toggle auto-attach related files\n` +
1372
2387
  `${chalk.cyan('/recall')} ${chalk.gray('│')} Search memory for relevant context\n` +
1373
2388
  `${chalk.cyan('/reset /clear')} ${chalk.gray('│')} Clear all context\n` +
1374
- `${chalk.cyan('/prune')} ${chalk.gray('│')} Save to memory + keep last 4 msgs\n` +
2389
+ `${chalk.cyan('/prune')} ${chalk.gray('│')} AI-summarize context + save to memory\n` +
1375
2390
  `${chalk.cyan('/context')} ${chalk.gray('│')} Show context size\n` +
1376
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` +
1377
2395
  `${chalk.cyan('/help')} ${chalk.gray('│')} Show this help\n` +
1378
- `${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`;
1379
2407
  console.log(box(helpContent, '📚 Commands', 'cyan'));
1380
2408
  console.log();
1381
2409
  continue;
@@ -1548,7 +2576,7 @@ TOOL SYNTAX:
1548
2576
  const contextSize = JSON.stringify(messages).length;
1549
2577
  console.log(chalk.cyan(`\n📊 Context: ${messages.length} messages, ~${Math.round(contextSize/1024)}KB`));
1550
2578
  if (contextSize > 50000) {
1551
- console.log(chalk.yellow('⚠️ Context is large! Consider using /prune'));
2579
+ console.log(chalk.yellow('⚠️ Context is large! Will auto-summarize on next message, or use /prune now.'));
1552
2580
  }
1553
2581
  continue;
1554
2582
  }
@@ -1563,6 +2591,349 @@ TOOL SYNTAX:
1563
2591
  continue;
1564
2592
  }
1565
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
+
1566
2937
  // Handle recall command - search embeddings
1567
2938
  if (input.toLowerCase().startsWith('/recall')) {
1568
2939
  const query = input.slice(7).trim();
@@ -1639,6 +3010,8 @@ TOOL SYNTAX:
1639
3010
  continue;
1640
3011
  }
1641
3012
 
3013
+ // Skip input processing if agent already handled it
3014
+ if (!agentHandled) {
1642
3015
  // Handle @ alone or /attach command - interactive file picker
1643
3016
  if (input.trim() === '@' || input.toLowerCase() === '/attach') {
1644
3017
  const selectedFiles = await pickFiles();
@@ -1750,22 +3123,50 @@ TOOL SYNTAX:
1750
3123
  }
1751
3124
 
1752
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
+
1753
3133
  } // End of else block for non-@ input
3134
+ } // End of if (!agentHandled)
1754
3135
 
1755
3136
  let toolRounds = 0; // Prevent infinite loops
1756
3137
  const MAX_TOOL_ROUNDS = 20;
3138
+ const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
3139
+ const MAX_PATCH_RETRIES = 3;
1757
3140
 
1758
3141
  let active = true;
1759
3142
  while (active) {
1760
3143
  if (stepMode) await safeQuestion(chalk.gray('[STEP] Press Enter to let AI think...'));
1761
3144
 
1762
3145
  spinner.start('Thinking...');
3146
+ const aiStartTime = Date.now();
1763
3147
  let response;
1764
3148
  try {
1765
- response = await ollama.chat({ model: selectedModel, messages, stream: true });
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);
1766
3166
  } catch (ollamaError) {
1767
3167
  spinner.stop();
1768
3168
  console.error(chalk.red('\n❌ Ollama error:'), ollamaError.message);
3169
+ logEntry('error', { message: `Ollama error: ${ollamaError.message}` });
1769
3170
  active = false;
1770
3171
  continue;
1771
3172
  }
@@ -1776,6 +3177,9 @@ TOOL SYNTAX:
1776
3177
  let lastChunkTime = Date.now();
1777
3178
  let repetitionCount = 0;
1778
3179
  let lastContent = '';
3180
+ let wasInterrupted = false;
3181
+ let wasRepetitionStopped = false;
3182
+ let nativeToolCalls = []; // Collect native tool_calls from streaming chunks
1779
3183
  abortStream = false; // Reset abort flag before streaming
1780
3184
 
1781
3185
  console.log(chalk.magenta('┌─[') + chalk.white.bold('Sapper') + chalk.magenta(']'));
@@ -1784,12 +3188,20 @@ TOOL SYNTAX:
1784
3188
  // Check if user pressed Ctrl+C
1785
3189
  if (abortStream) {
1786
3190
  console.log(chalk.yellow('\n│ [Response interrupted]'));
3191
+ wasInterrupted = true;
1787
3192
  break;
1788
3193
  }
1789
3194
 
1790
3195
  const content = chunk.message.content;
1791
- process.stdout.write(content);
1792
- msg += content;
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
+ }
1793
3205
 
1794
3206
  // Smart loop detection: check for repetitive content patterns
1795
3207
  if (msg.length > 10000) {
@@ -1801,6 +3213,7 @@ TOOL SYNTAX:
1801
3213
  repetitionCount++;
1802
3214
  if (repetitionCount > 3) {
1803
3215
  console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
3216
+ wasRepetitionStopped = true;
1804
3217
  break;
1805
3218
  }
1806
3219
  } else {
@@ -1815,24 +3228,157 @@ TOOL SYNTAX:
1815
3228
  }
1816
3229
  }
1817
3230
  console.log(chalk.magenta('└─────────────────────────────────────'));
1818
-
1819
- messages.push({ role: 'assistant', content: msg });
1820
3231
 
1821
- // Regex: supports both old format (path]content) and new format (path:::content)
1822
- const toolMatches = [...msg.matchAll(/\[TOOL:(\w+)\]([^:\]]*?)(?:(?:::|\])([\s\S]*?))?\[\/TOOL\]/g)];
1823
-
1824
- // Check for unclosed tool calls (AI started a tool but didn't close it)
1825
- const hasUnclosedTool = msg.includes('[TOOL:') && !msg.includes('[/TOOL]');
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]');
1826
3375
  if (hasUnclosedTool) {
1827
- console.log(chalk.yellow('\n⚠️ Unclosed tool detected! AI forgot [/TOOL] closing tag.'));
1828
- console.log(chalk.gray(' Asking AI to complete the tool call...\n'));
1829
-
1830
- messages.push({
1831
- role: 'user',
1832
- content: 'ERROR: Your tool call is incomplete - you forgot to add [/TOOL] at the end. Please complete the tool call by providing the closing [/TOOL] tag. If you were writing a file, just output [/TOOL] to close it.'
1833
- });
1834
- continue; // Let AI respond with the closing tag
3376
+ console.log(chalk.yellow('\n⚠️ Unclosed tool detected auto-closing with [/TOOL]'));
3377
+ msgForToolParsing += '[/TOOL]';
1835
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)];
1836
3382
 
1837
3383
  // Debug mode: show what regex sees
1838
3384
  if (debugMode) {
@@ -1870,45 +3416,105 @@ TOOL SYNTAX:
1870
3416
  if (toolMatches.length > 0) {
1871
3417
  toolRounds++;
1872
3418
 
1873
- // Prevent infinite tool loops
3419
+ // Track if we hit the tool limit — still process this round's tools, then stop
3420
+ let hitToolLimit = false;
1874
3421
  if (toolRounds >= MAX_TOOL_ROUNDS) {
1875
- console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Stopping auto-execution.`));
3422
+ console.log(chalk.yellow(`\n⚠️ Tool limit reached (${MAX_TOOL_ROUNDS} rounds). Processing remaining tools then stopping.`));
1876
3423
  console.log(chalk.gray('💡 Tip: Type /prune after analysis to reduce context size.'));
1877
- resetTerminal(); // Ensure terminal is responsive
1878
- messages.push({
1879
- role: 'user',
1880
- content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
1881
- });
1882
- continue; // Let AI respond without tools
3424
+ hitToolLimit = true;
1883
3425
  }
1884
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
+
1885
3433
  for (const match of toolMatches) {
1886
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
+
1887
3446
  console.log();
1888
3447
  console.log(statusBadge(type.toUpperCase(), 'action') + chalk.gray(' → ') + chalk.white(path));
1889
3448
 
3449
+ const toolStart = Date.now();
1890
3450
  let result;
1891
- if (type.toLowerCase() === 'list') result = tools.list(path);
1892
- else if (type.toLowerCase() === 'read') result = tools.read(path);
1893
- else if (type.toLowerCase() === 'mkdir') result = tools.mkdir(path);
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
+ }
1894
3464
  else if (type.toLowerCase() === 'write') {
1895
3465
  if (!content || content.trim() === '') {
1896
3466
  result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
3467
+ toolSuccess = false;
1897
3468
  } else {
1898
3469
  result = await tools.write(path, content);
3470
+ const approved = result.includes('Successfully');
3471
+ logEntry('file', { action: 'write', path, size: content.length, userApproved: approved });
1899
3472
  }
1900
3473
  }
1901
3474
  else if (type.toLowerCase() === 'patch') {
1902
- // PATCH format: [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]
1903
- const parts = content?.split('|||');
1904
- if (parts && parts.length === 2) {
1905
- result = await tools.patch(path, parts[0], parts[1]);
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 });
1906
3482
  } else {
1907
- result = 'Error: PATCH requires format [TOOL:PATCH]path]OLD_TEXT|||NEW_TEXT[/TOOL]';
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
+ }
1908
3502
  }
1909
3503
  }
1910
- else if (type.toLowerCase() === 'search') result = await tools.search(path);
1911
- else if (type.toLowerCase() === 'shell') result = await tools.shell(path);
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
+ }
1912
3518
 
1913
3519
  messages.push({ role: 'user', content: `RESULT (${path}): ${result}` });
1914
3520
  }
@@ -1918,6 +3524,15 @@ TOOL SYNTAX:
1918
3524
  if (toolMatches.length > 30) {
1919
3525
  console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
1920
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
+ }
1921
3536
  } else {
1922
3537
  // No tools found - check if malformed command
1923
3538
  if (msg.includes('[TOOL:') && msg.includes('[/]')) {
@@ -1939,6 +3554,7 @@ TOOL SYNTAX:
1939
3554
  }
1940
3555
  } catch (error) {
1941
3556
  console.error(chalk.red('\n❌ Error:'), error.message);
3557
+ logEntry('error', { message: error.message });
1942
3558
  // Loop continues automatically
1943
3559
  }
1944
3560
  }