sapper-iq 1.1.38 → 1.1.39

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.
Files changed (4) hide show
  1. package/README.md +401 -88
  2. package/package.json +2 -1
  3. package/sapper-ui.mjs +66 -19
  4. package/sapper.mjs +1456 -211
package/sapper.mjs CHANGED
@@ -6,9 +6,10 @@ import chalk from 'chalk';
6
6
  import ora from 'ora';
7
7
  import readline from 'readline';
8
8
  import { fileURLToPath } from 'url';
9
- import { dirname, join } from 'path';
9
+ import { dirname, join, isAbsolute, resolve as pathResolve } from 'path';
10
10
  import { marked } from 'marked';
11
11
  import { markedTerminal } from 'marked-terminal';
12
+ import { highlight as highlightCode } from 'cli-highlight';
12
13
  import * as acorn from 'acorn';
13
14
 
14
15
  const __filename = fileURLToPath(import.meta.url);
@@ -44,7 +45,7 @@ process.on('SIGINT', () => {
44
45
 
45
46
  // Reset terminal immediately
46
47
  resetTerminal();
47
- setTimeout(() => { ctrlCCount = 0; }, 2000); // Reset after 2 seconds
48
+ setTimeout(() => { ctrlCCount = 0; }, LIMITS.CTRL_C_RESET_MS);
48
49
  });
49
50
 
50
51
  // Reset terminal state - fixes "ghost input" after shell commands or AI streaming
@@ -82,6 +83,60 @@ const SKILLS_DIR = `${SAPPER_DIR}/skills`;
82
83
  const LOGS_DIR = `${SAPPER_DIR}/logs`;
83
84
  const SAPPERIGNORE_FILE = '.sapperignore';
84
85
 
86
+ // ═══════════════════════════════════════════════════════════════
87
+ // CENTRALIZED LIMITS & THRESHOLDS
88
+ // ═══════════════════════════════════════════════════════════════
89
+ const LIMITS = Object.freeze({
90
+ // Timeouts (milliseconds)
91
+ CTRL_C_RESET_MS: 2000,
92
+ FETCH_URL_TIMEOUT_MS: 15000,
93
+
94
+ // Context & summarization
95
+ CONTEXT_BYTE_FALLBACK: 32000, // Byte threshold when token count unavailable
96
+ SUMMARY_RECENT_MSGS: 4, // Recent messages preserved during summarization
97
+ MSG_TRUNCATION_CHARS: 1500, // Max chars per message when building summary text
98
+ SUMMARY_MAX_WORDS: 800, // Target word count for summary output
99
+
100
+ // Embeddings
101
+ EMBEDDINGS_MAX_TEXT: 2000, // Max chars stored per embedding chunk
102
+ EMBEDDINGS_MAX_CHUNKS: 100, // Max chunks kept in embeddings file
103
+ EMBEDDING_SIMILARITY: 0.5, // Cosine similarity threshold for recall
104
+ EMBEDDING_TOP_K: 3, // Default top-K results from memory search
105
+ EMBEDDING_MIN_TEXT: 50, // Minimum text length to bother embedding
106
+
107
+ // Streaming & response
108
+ MAX_RESPONSE_LENGTH: 100000, // 100KB hard cap on AI response size
109
+ REPETITION_WINDOW: 500, // Chars to compare for loop detection
110
+ REPETITION_THRESHOLD: 10000, // Min response length before checking for loops
111
+ REPETITION_COUNT: 3, // Repeats before stopping
112
+
113
+ // Display
114
+ LOG_PREVIEW_CHARS: 500, // Max chars in activity log preview
115
+ LOG_AI_PREVIEW_CHARS: 800, // Max chars in AI response log preview
116
+ INPUT_PREVIEW_CHARS: 120, // Max chars shown for user input preview
117
+ TERMINAL_WIDTH_MAX: 90, // Max width for activity log box
118
+ SYMBOL_RESULTS_MAX: 15, // Max results before "and N more" in /symbol
119
+ LOG_FILES_DISPLAY_MAX: 10, // Max log files shown in /log list
120
+ MEMORY_PREVIEW_CHARS: 300, // Chars shown in /recall results
121
+ DEBUG_TOOL_PREVIEW: 150, // Chars shown in debug tool attempt
122
+
123
+ // Content limits
124
+ WEB_CONTENT_MAX_CHARS: 50000, // Max chars from fetched web content
125
+ TOOL_WARN_THRESHOLD: 30, // Tool calls per round before warning
126
+
127
+ // Shell
128
+ SHELL_MIN_BG_SECONDS: 2, // Min seconds for background shell config
129
+ SHELL_MAX_BG_SECONDS: 120, // Max seconds for background shell config
130
+ SHELL_MIN_CHUNK_CHARS: 400, // Min chars for shell output chunk config
131
+ SHELL_MAX_CHUNK_CHARS: 12000, // Max chars for shell output chunk config
132
+ SHELL_MAX_BUFFER: 50000, // Max buffered shell output chars
133
+
134
+ // Workspace & scanning
135
+ WORKSPACE_FILES_PER_DIR: 10, // Files shown per directory in workspace summary
136
+ WORKSPACE_RELATED_DEPTH: 5, // Max related files from dependency graph
137
+ FILE_SUMMARY_PREVIEW: 150, // Chars for file content summary
138
+ });
139
+
85
140
  // ═══════════════════════════════════════════════════════════════
86
141
  // COMPREHENSIVE ACTIVITY LOGGER
87
142
  // ═══════════════════════════════════════════════════════════════
@@ -145,7 +200,7 @@ function appendLogToFile(entry) {
145
200
  break;
146
201
  case 'user':
147
202
  line += `### 💬 User Input \`${time}\` _(+${elapsed})_\n`;
148
- line += `\`\`\`\n${entry.message?.substring(0, 500)}${entry.message?.length > 500 ? '\n...' : ''}\n\`\`\`\n`;
203
+ line += `\`\`\`\n${entry.message?.substring(0, LIMITS.LOG_PREVIEW_CHARS)}${entry.message?.length > LIMITS.LOG_PREVIEW_CHARS ? '\n...' : ''}\n\`\`\`\n`;
149
204
  if (entry.attachments?.length > 0) {
150
205
  line += `📎 **Attached:** ${entry.attachments.join(', ')}\n`;
151
206
  }
@@ -159,7 +214,7 @@ function appendLogToFile(entry) {
159
214
  if (entry.interrupted) line += `- ⚠️ **Interrupted**\n`;
160
215
  if (entry.repetitionStopped) line += `- ⚠️ **Stopped: repetitive output**\n`;
161
216
  line += `\n<details><summary>Response preview</summary>\n\n`;
162
- line += `${entry.preview?.substring(0, 800)}${entry.preview?.length > 800 ? '\n...' : ''}\n`;
217
+ line += `${entry.preview?.substring(0, LIMITS.LOG_AI_PREVIEW_CHARS)}${entry.preview?.length > LIMITS.LOG_AI_PREVIEW_CHARS ? '\n...' : ''}\n`;
163
218
  line += `\n</details>\n\n`;
164
219
  break;
165
220
  case 'tool':
@@ -531,23 +586,107 @@ const TOOL_NAME_MAP = {
531
586
  'edit': 'PATCH',
532
587
  'patch': 'PATCH',
533
588
  'list': 'LIST',
589
+ 'ls': 'LS',
534
590
  'search': 'SEARCH',
591
+ 'grep': 'GREP',
592
+ 'find': 'FIND',
535
593
  'shell': 'SHELL',
536
594
  'mkdir': 'MKDIR',
595
+ 'rmdir': 'RMDIR',
596
+ 'cd': 'CD',
597
+ 'pwd': 'PWD',
598
+ 'cat': 'CAT',
599
+ 'head': 'HEAD',
600
+ 'tail': 'TAIL',
601
+ 'changes': 'CHANGES',
602
+ 'diff': 'CHANGES',
603
+ 'git_changes': 'CHANGES',
604
+ 'fetch': 'FETCH',
605
+ 'web': 'FETCH',
606
+ 'fetch_web': 'FETCH',
607
+ 'memory': 'MEMORY',
608
+ 'recall': 'MEMORY',
609
+ 'recall_memory': 'MEMORY',
610
+ 'open': 'OPEN',
611
+ 'browser': 'OPEN',
612
+ 'open_url': 'OPEN',
537
613
  'todo': 'LIST', // alias — list tasks
538
614
  };
539
615
 
616
+ const TOOL_ALLOWED_BY = {
617
+ READ: ['READ', 'CAT', 'HEAD', 'TAIL'],
618
+ CAT: ['READ', 'CAT', 'HEAD', 'TAIL'],
619
+ HEAD: ['READ', 'CAT', 'HEAD', 'TAIL'],
620
+ TAIL: ['READ', 'CAT', 'HEAD', 'TAIL'],
621
+ LIST: ['LIST', 'LS'],
622
+ LS: ['LIST', 'LS'],
623
+ SEARCH: ['SEARCH', 'GREP'],
624
+ GREP: ['SEARCH', 'GREP'],
625
+ FIND: ['FIND'],
626
+ WRITE: ['WRITE'],
627
+ PATCH: ['PATCH'],
628
+ MKDIR: ['MKDIR'],
629
+ RMDIR: ['RMDIR', 'SHELL'],
630
+ PWD: ['PWD', 'SHELL'],
631
+ CD: ['CD', 'SHELL'],
632
+ CHANGES: ['CHANGES', 'SHELL'],
633
+ FETCH: ['FETCH', 'SHELL'],
634
+ MEMORY: ['MEMORY'],
635
+ OPEN: ['OPEN', 'SHELL'],
636
+ SHELL: ['SHELL'],
637
+ };
638
+
639
+ function normalizeToolName(toolName = '') {
640
+ const normalized = String(toolName ?? '').trim();
641
+ if (!normalized) return '';
642
+ return TOOL_NAME_MAP[normalized.toLowerCase()] || normalized.toUpperCase();
643
+ }
644
+
540
645
  function normalizeToolList(toolsValue) {
541
646
  if (!toolsValue) return null; // null = all tools allowed
542
647
  if (typeof toolsValue === 'string') {
543
648
  toolsValue = toolsValue.split(',').map(s => s.trim());
544
649
  }
545
650
  if (!Array.isArray(toolsValue)) return null;
546
- return toolsValue.map(t => TOOL_NAME_MAP[t.toLowerCase()] || t.toUpperCase()).filter(Boolean);
651
+ return Array.from(new Set(toolsValue.map(normalizeToolName).filter(Boolean)));
547
652
  }
548
653
 
549
- // Load all agents from .sapper/agents/*.md (with frontmatter support)
654
+ function isToolAllowedForAgent(allowedTools, toolName) {
655
+ if (!allowedTools || allowedTools.length === 0) return true;
656
+ const normalized = normalizeToolName(toolName);
657
+ const allowedBy = TOOL_ALLOWED_BY[normalized] || [normalized];
658
+ return allowedBy.some(candidate => allowedTools.includes(candidate));
659
+ }
660
+
661
+ // ── Memoized loaders (avoid re-scanning filesystem every prompt turn) ──
662
+ const _loaderCache = { agents: null, agentsAt: 0, skills: null, skillsAt: 0 };
663
+ const LOADER_CACHE_TTL = 5000; // 5s TTL — balances freshness vs disk I/O
664
+
550
665
  function loadAgents() {
666
+ const now = Date.now();
667
+ if (_loaderCache.agents && now - _loaderCache.agentsAt < LOADER_CACHE_TTL) return _loaderCache.agents;
668
+ const result = _loadAgentsFromDisk();
669
+ _loaderCache.agents = result;
670
+ _loaderCache.agentsAt = now;
671
+ return result;
672
+ }
673
+
674
+ function loadSkills() {
675
+ const now = Date.now();
676
+ if (_loaderCache.skills && now - _loaderCache.skillsAt < LOADER_CACHE_TTL) return _loaderCache.skills;
677
+ const result = _loadSkillsFromDisk();
678
+ _loaderCache.skills = result;
679
+ _loaderCache.skillsAt = now;
680
+ return result;
681
+ }
682
+
683
+ function invalidateLoaderCache(which = 'both') {
684
+ if (which === 'both' || which === 'agents') { _loaderCache.agents = null; _loaderCache.agentsAt = 0; }
685
+ if (which === 'both' || which === 'skills') { _loaderCache.skills = null; _loaderCache.skillsAt = 0; }
686
+ }
687
+
688
+ // Load all agents from .sapper/agents/*.md (with frontmatter support)
689
+ function _loadAgentsFromDisk() {
551
690
  ensureAgentsDirs();
552
691
  const agents = {};
553
692
  try {
@@ -573,7 +712,7 @@ function loadAgents() {
573
712
  }
574
713
 
575
714
  // Load all skills from .sapper/skills/*.md (with frontmatter support)
576
- function loadSkills() {
715
+ function _loadSkillsFromDisk() {
577
716
  ensureAgentsDirs();
578
717
  const skills = {};
579
718
  try {
@@ -604,27 +743,48 @@ function createDefaultAgentsAndSkills() {
604
743
  const defaultAgents = {
605
744
  'sapper-it': `---
606
745
  name: "Sapper IT"
607
- description: "Expert full-stack coding agent — handles web dev, architecture, debugging, DevOps, databases, APIs, and performance. Use for any coding task."
608
- tools: [read, edit, write, list, search, shell]
746
+ description: "General software development agent — implementation, debugging, refactoring, architecture, testing, tooling, automation, and release workflows across languages and stacks. Use for coding or technical problem-solving."
747
+ argument-hint: "Describe the feature, bug, refactor, architecture, or development task to work on."
609
748
  ---
610
749
 
611
- # Sapper IT - Coding Agent
750
+ # Sapper IT - Development Agent
612
751
 
613
- You are Sapper IT, an expert full-stack coding agent working within Sapper.
752
+ You are Sapper IT, a senior software development agent working within Sapper.
614
753
 
615
- ## Your Expertise
616
- - Full-stack web development (frontend + backend)
617
- - System architecture and design patterns
618
- - Debugging, refactoring, and code review
619
- - DevOps, CI/CD, and deployment
620
- - Database design and optimization
621
- - API development (REST, GraphQL)
622
- - Performance optimization and security best practices
754
+ You have access to all available Sapper tools unless the session applies a separate restriction.
623
755
 
624
- ## Behavior
625
- When the user asks for help, dive into the codebase using Sapper's tools. Read files, understand the structure, then make precise changes.
756
+ ## Mission
757
+
758
+ Help users move software projects forward across different languages, frameworks, and codebases.
759
+ Do not assume a specific stack, architecture, or workflow before inspecting the repository.
760
+
761
+ Adapt to the project that exists, not a hard-coded template of technologies.
762
+
763
+ ## What You Handle
764
+
765
+ - Feature implementation and bug fixing
766
+ - Refactoring and code cleanup
767
+ - Architecture and system design decisions
768
+ - Tooling, build, automation, and developer workflow improvements
769
+ - Tests, validation, and release-readiness checks
770
+ - Performance, reliability, and maintainability problems
771
+ - APIs, data flow, storage, and integration work when the codebase requires it
772
+
773
+ ## Working Style
774
+
775
+ - Understand the request and inspect the relevant code before proposing or making changes.
776
+ - Prefer the smallest change that solves the root problem cleanly.
777
+ - Match the repository's existing conventions, abstractions, and naming.
778
+ - Use tools proactively: read code, search broadly, edit precisely, and run shell commands when they help verify behavior.
779
+ - Verify work proportionally with checks, builds, tests, or direct inspection when feasible.
780
+ - Be concise, technical, and practical. Explain tradeoffs only when they matter.
781
+
782
+ ## Decision Rules
626
783
 
627
- Be technical, thorough, and code-first. Always verify your changes work by running tests or builds.`,
784
+ - If the task is ambiguous, gather context first instead of guessing.
785
+ - If multiple approaches are possible, choose the one with the best balance of correctness, simplicity, and fit for the current codebase.
786
+ - If asked for a review, prioritize bugs, risks, regressions, and missing validation ahead of style commentary.
787
+ - If a request spans unfamiliar technologies, infer behavior from the actual project files rather than generic assumptions.`,
628
788
 
629
789
  'writer': `---
630
790
  name: "Technical Writer"
@@ -842,13 +1002,24 @@ RULES:
842
1002
 
843
1003
  TOOLS:
844
1004
  You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
845
- Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, run_shell.
1005
+ Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, pwd, cd, rmdir, changes, fetch_web, recall_memory, open_url, run_shell.
846
1006
 
847
1007
  PATCH TIPS:
848
1008
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
849
1009
  - Always read_file first to see exact content before using patch_file.
850
1010
  - If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.
851
1011
 
1012
+ EXTRA TOOL TIPS:
1013
+ - ls lists directory contents using the current tool working directory when path is omitted.
1014
+ - cat reads a full file, while head and tail read the first or last N lines.
1015
+ - grep searches file contents, and find searches file or directory names.
1016
+ - pwd shows the current tool working directory, and cd changes it for later tool calls.
1017
+ - rmdir removes a directory recursively and always asks for approval.
1018
+ - changes shows git status and diff output for the current repository or an optional path.
1019
+ - fetch_web fetches a web page and returns readable text content.
1020
+ - recall_memory searches Sapper's saved conversation memory.
1021
+ - open_url opens a URL in the default browser and always asks for approval.
1022
+
852
1023
  SHELL TIPS:
853
1024
  - run_shell may keep long-running commands in a background session depending on config.
854
1025
  - If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
@@ -858,11 +1029,24 @@ SHELL TIPS:
858
1029
 
859
1030
  TOOL SYNTAX (use these to interact with files and system):
860
1031
  - [TOOL:LIST]dir[/TOOL] - List directory contents
1032
+ - [TOOL:LS]dir[/TOOL] - Alias for LIST
861
1033
  - [TOOL:READ]file_path[/TOOL] - Read file contents
1034
+ - [TOOL:CAT]file_path[/TOOL] - Alias for READ
1035
+ - [TOOL:HEAD]file_path:::20[/TOOL] - Read the first N lines of a file (default 20)
1036
+ - [TOOL:TAIL]file_path:::20[/TOOL] - Read the last N lines of a file (default 20)
862
1037
  - [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
1038
+ - [TOOL:GREP]pattern[/TOOL] - Alias for SEARCH
1039
+ - [TOOL:FIND]name_or_fragment[/TOOL] - Find files and directories by name
863
1040
  - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
864
1041
  - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
865
1042
  - [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace a specific line by number (PREFERRED — more reliable)
1043
+ - [TOOL:PWD][/TOOL] - Show the current tool working directory
1044
+ - [TOOL:CD]dir[/TOOL] - Change the tool working directory for later tool calls
1045
+ - [TOOL:RMDIR]dir[/TOOL] - Remove a directory recursively (asks for approval)
1046
+ - [TOOL:CHANGES]path[/TOOL] - Show git status and diffs for the repository or a path
1047
+ - [TOOL:FETCH]https://example.com[/TOOL] - Fetch a web page and return readable content
1048
+ - [TOOL:MEMORY]query[/TOOL] - Search saved conversation memory
1049
+ - [TOOL:OPEN]https://example.com[/TOOL] - Open a URL in the default browser (asks for approval)
866
1050
  - [TOOL:SHELL]command[/TOOL] - Run shell command
867
1051
 
868
1052
  PATCH TIPS:
@@ -923,9 +1107,16 @@ let currentAgentTools = null; // null = all tools allowed, or array of allowed t
923
1107
  let loadedSkills = []; // array of skill names currently loaded
924
1108
 
925
1109
  const DEFAULT_CONFIG = Object.freeze({
1110
+ defaultModel: null,
1111
+ defaultAgent: null,
926
1112
  autoAttach: true,
1113
+ debug: false,
927
1114
  contextLimit: null,
928
1115
  toolRoundLimit: 40,
1116
+ patchRetries: 3,
1117
+ maxFileSize: 100000,
1118
+ maxScanSize: 1000000,
1119
+ maxUrlSize: 200000,
929
1120
  summaryPhases: true,
930
1121
  summarizeTriggerPercent: 65,
931
1122
  shell: Object.freeze({
@@ -1079,9 +1270,16 @@ function normalizePromptConfig(promptConfig = {}) {
1079
1270
  function normalizeConfig(config = {}) {
1080
1271
  return {
1081
1272
  ...config,
1273
+ defaultModel: typeof config.defaultModel === 'string' && config.defaultModel.trim() ? config.defaultModel.trim() : null,
1274
+ defaultAgent: typeof config.defaultAgent === 'string' && config.defaultAgent.trim() ? config.defaultAgent.trim() : null,
1082
1275
  autoAttach: normalizeBoolean(config.autoAttach, DEFAULT_CONFIG.autoAttach),
1276
+ debug: normalizeBoolean(config.debug, DEFAULT_CONFIG.debug),
1083
1277
  contextLimit: normalizeContextLimit(config.contextLimit),
1084
1278
  toolRoundLimit: normalizeToolRoundLimit(config.toolRoundLimit),
1279
+ patchRetries: normalizeIntegerInRange(config.patchRetries, DEFAULT_CONFIG.patchRetries, 1, 20),
1280
+ maxFileSize: normalizeIntegerInRange(config.maxFileSize, DEFAULT_CONFIG.maxFileSize, 10000, 10000000),
1281
+ maxScanSize: normalizeIntegerInRange(config.maxScanSize, DEFAULT_CONFIG.maxScanSize, 100000, 50000000),
1282
+ maxUrlSize: normalizeIntegerInRange(config.maxUrlSize, DEFAULT_CONFIG.maxUrlSize, 10000, 10000000),
1085
1283
  summaryPhases: normalizeBoolean(config.summaryPhases, DEFAULT_CONFIG.summaryPhases),
1086
1284
  summarizeTriggerPercent: normalizeSummarizeTriggerPercent(config.summarizeTriggerPercent),
1087
1285
  shell: normalizeShellConfig(config.shell),
@@ -1321,6 +1519,68 @@ function resolveActiveAgentContent() {
1321
1519
  return allAgents[currentAgent]?.content || null;
1322
1520
  }
1323
1521
 
1522
+ function getActiveAgentMeta() {
1523
+ if (!currentAgent) return null;
1524
+ const allAgents = loadAgents();
1525
+ const agent = allAgents[currentAgent];
1526
+ return {
1527
+ key: currentAgent,
1528
+ name: agent?.name || currentAgent,
1529
+ description: agent?.description || '',
1530
+ tools: agent?.tools || currentAgentTools,
1531
+ };
1532
+ }
1533
+
1534
+ function getLoadedSkillMetaList() {
1535
+ const allSkills = loadSkills();
1536
+ return loadedSkills.map(skillName => {
1537
+ const skill = allSkills[skillName];
1538
+ return {
1539
+ key: skillName,
1540
+ name: skill?.name || skillName,
1541
+ description: skill?.description || '',
1542
+ };
1543
+ });
1544
+ }
1545
+
1546
+ function summarizeModeNames(names = [], maxVisible = 3) {
1547
+ const normalized = names.map(name => String(name ?? '').trim()).filter(Boolean);
1548
+ if (normalized.length === 0) return '';
1549
+ if (normalized.length <= maxVisible) return normalized.join(', ');
1550
+ return `${normalized.slice(0, maxVisible).join(', ')}, +${normalized.length - maxVisible} more`;
1551
+ }
1552
+
1553
+ function activeModeSummary({ includeAgent = true, maxSkills = 3 } = {}) {
1554
+ const parts = [];
1555
+ const activeAgent = getActiveAgentMeta();
1556
+ const activeSkills = getLoadedSkillMetaList();
1557
+
1558
+ if (includeAgent && activeAgent) {
1559
+ const agentName = activeAgent.name || currentAgent;
1560
+ const agentSuffix = currentAgent && agentName !== currentAgent ? ` (/${currentAgent})` : currentAgent ? ` /${currentAgent}` : '';
1561
+ parts.push(`agent ${agentName}${agentSuffix}`);
1562
+ }
1563
+
1564
+ if (activeSkills.length > 0) {
1565
+ parts.push(`skills ${summarizeModeNames(activeSkills.map(skill => skill.name || skill.key), maxSkills)}`);
1566
+ }
1567
+
1568
+ return parts.join(' · ');
1569
+ }
1570
+
1571
+ function activeAgentPromptBadge() {
1572
+ const activeAgent = getActiveAgentMeta();
1573
+ if (!activeAgent) return statusBadge('default', 'neutral');
1574
+ return statusBadge(`agent:${ellipsis(activeAgent.name || currentAgent, 20)}`, 'info');
1575
+ }
1576
+
1577
+ function activeSkillsPromptBadge() {
1578
+ const activeSkills = getLoadedSkillMetaList();
1579
+ if (activeSkills.length === 0) return null;
1580
+ const prefix = activeSkills.length === 1 ? 'skill:' : 'skills:';
1581
+ return statusBadge(`${prefix}${ellipsis(summarizeModeNames(activeSkills.map(skill => skill.name || skill.key), 2), 22)}`, 'success');
1582
+ }
1583
+
1324
1584
  function refreshSystemPrompt(messages) {
1325
1585
  if (!Array.isArray(messages) || messages.length === 0) return;
1326
1586
  messages[0] = {
@@ -1448,7 +1708,7 @@ async function buildWorkspaceGraph(showProgress = true) {
1448
1708
 
1449
1709
  try {
1450
1710
  const stats = fs.statSync(fullPath);
1451
- if (stats.size > MAX_FILE_SIZE) continue;
1711
+ if (stats.size > getMaxFileSize()) continue;
1452
1712
 
1453
1713
  const content = fs.readFileSync(fullPath, 'utf8');
1454
1714
  const deps = extractDependencies(content, fullPath);
@@ -1530,12 +1790,12 @@ function formatWorkspaceSummary(workspace) {
1530
1790
 
1531
1791
  for (const [dir, files] of Object.entries(byDir)) {
1532
1792
  output += `📁 ${dir}/\n`;
1533
- for (const f of files.slice(0, 10)) { // Limit per directory
1793
+ for (const f of files.slice(0, LIMITS.WORKSPACE_FILES_PER_DIR)) { // Limit per directory
1534
1794
  const name = f.path.split('/').pop();
1535
1795
  const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
1536
1796
  output += ` 📄 ${name}${exportList}\n`;
1537
1797
  }
1538
- if (files.length > 10) output += ` ... and ${files.length - 10} more\n`;
1798
+ if (files.length > LIMITS.WORKSPACE_FILES_PER_DIR) output += ` ... and ${files.length - LIMITS.WORKSPACE_FILES_PER_DIR} more\n`;
1539
1799
  output += '\n';
1540
1800
  }
1541
1801
 
@@ -1846,13 +2106,13 @@ async function addToEmbeddings(text, embeddings) {
1846
2106
  const embedding = await getEmbedding(text);
1847
2107
  if (embedding) {
1848
2108
  embeddings.chunks.push({
1849
- text: text.substring(0, 2000), // Limit stored text
2109
+ text: text.substring(0, LIMITS.EMBEDDINGS_MAX_TEXT), // Limit stored text
1850
2110
  embedding,
1851
2111
  timestamp: Date.now()
1852
2112
  });
1853
- // Keep only last 100 chunks
1854
- if (embeddings.chunks.length > 100) {
1855
- embeddings.chunks = embeddings.chunks.slice(-100);
2113
+ // Keep only last N chunks
2114
+ if (embeddings.chunks.length > LIMITS.EMBEDDINGS_MAX_CHUNKS) {
2115
+ embeddings.chunks = embeddings.chunks.slice(-LIMITS.EMBEDDINGS_MAX_CHUNKS);
1856
2116
  }
1857
2117
  saveEmbeddings(embeddings);
1858
2118
  }
@@ -1862,23 +2122,54 @@ async function addToEmbeddings(text, embeddings) {
1862
2122
  // SMART CONTEXT SUMMARIZATION
1863
2123
  // ═══════════════════════════════════════════════════════════════
1864
2124
 
1865
- async function autoSummarizeContext(messages, model, force = false) {
1866
- // Use real token-based threshold if we know the model's context length
2125
+ // Check whether context is large enough to need summarization
2126
+ function needsSummarize(messages) {
1867
2127
  const estimatedTokens = estimateMessagesTokens(messages);
1868
2128
  const contextSize = JSON.stringify(messages).length;
1869
-
1870
- // Summarize when we hit the configured share of the effective context window
1871
2129
  const ctxLen = effectiveContextLength();
1872
2130
  const tokenThreshold = summaryTokenThreshold(ctxLen);
1873
- // Also keep the old byte-based check as a fallback
1874
2131
  const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
1875
- (!ctxLen && contextSize > 32000);
2132
+ (!ctxLen && contextSize > LIMITS.CONTEXT_BYTE_FALLBACK);
2133
+ return { shouldSummarize, estimatedTokens, contextSize, ctxLen, tokenThreshold };
2134
+ }
2135
+
2136
+ // Save old messages into the embedding store for later recall
2137
+ async function saveOldMessagesToEmbeddings(oldMessages) {
2138
+ const embeddings = loadEmbeddings();
2139
+ const textToEmbed = oldMessages
2140
+ .filter(m => m.role !== 'system')
2141
+ .map(m => m.content.substring(0, LIMITS.LOG_PREVIEW_CHARS))
2142
+ .join('\n---\n');
2143
+
2144
+ if (textToEmbed.length > LIMITS.EMBEDDING_MIN_TEXT) {
2145
+ try {
2146
+ const embedding = await getEmbedding(textToEmbed);
2147
+ if (embedding) {
2148
+ embeddings.chunks.push({
2149
+ text: textToEmbed.substring(0, LIMITS.EMBEDDINGS_MAX_TEXT),
2150
+ embedding,
2151
+ timestamp: Date.now()
2152
+ });
2153
+ if (embeddings.chunks.length > LIMITS.EMBEDDINGS_MAX_CHUNKS) {
2154
+ embeddings.chunks = embeddings.chunks.slice(-LIMITS.EMBEDDINGS_MAX_CHUNKS);
2155
+ }
2156
+ saveEmbeddings(embeddings);
2157
+ }
2158
+ } catch (e) {
2159
+ // Silently skip embedding if model not available
2160
+ }
2161
+ }
2162
+ return embeddings;
2163
+ }
2164
+
2165
+ async function autoSummarizeContext(messages, model, force = false) {
2166
+ const { shouldSummarize, estimatedTokens, contextSize, ctxLen, tokenThreshold } = needsSummarize(messages);
1876
2167
 
1877
2168
  if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
1878
2169
 
1879
2170
  const usagePercent = ctxLen
1880
2171
  ? Math.round((estimatedTokens / ctxLen) * 100)
1881
- : Math.round((contextSize / 32000) * 100);
2172
+ : Math.round((contextSize / LIMITS.CONTEXT_BYTE_FALLBACK) * 100);
1882
2173
 
1883
2174
  console.log();
1884
2175
  const summaryIntroLines = [
@@ -1900,7 +2191,7 @@ async function autoSummarizeContext(messages, model, force = false) {
1900
2191
 
1901
2192
  // Separate: system prompt, messages to summarize, recent messages to keep
1902
2193
  const systemPrompt = messages[0];
1903
- const recentCount = 4;
2194
+ const recentCount = LIMITS.SUMMARY_RECENT_MSGS;
1904
2195
  let recentMessages = messages.slice(-recentCount);
1905
2196
  let oldMessages = messages.slice(1, -recentCount);
1906
2197
 
@@ -1943,8 +2234,8 @@ async function autoSummarizeContext(messages, model, force = false) {
1943
2234
  .map(m => {
1944
2235
  const role = m.role === 'user' ? 'User' : 'Assistant';
1945
2236
  // Truncate very long messages (file contents, scan results, etc.)
1946
- const text = m.content.length > 1500
1947
- ? m.content.substring(0, 1500) + '\n... [truncated]'
2237
+ const text = m.content.length > LIMITS.MSG_TRUNCATION_CHARS
2238
+ ? m.content.substring(0, LIMITS.MSG_TRUNCATION_CHARS) + '\n... [truncated]'
1948
2239
  : m.content;
1949
2240
  return `${role}: ${text}`;
1950
2241
  })
@@ -1962,7 +2253,7 @@ async function autoSummarizeContext(messages, model, force = false) {
1962
2253
  - Important code changes or bugs found
1963
2254
  - Any pending tasks or open questions
1964
2255
  - Technical details that would be needed to continue the conversation
1965
- - Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH) and on what files
2256
+ - Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH, CHANGES, FETCH, MEMORY, OPEN) and on what files or URLs
1966
2257
  - The active agent role (if any) and loaded skills
1967
2258
  - Any tool usage patterns or workflows that were established
1968
2259
 
@@ -1997,30 +2288,7 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1997
2288
 
1998
2289
  // Save old messages to embeddings before discarding
1999
2290
  summarySpinner.text = summaryPhaseText(3, `Saving compressed context and memory (${elapsedSummaryTime()} elapsed)`);
2000
- const embeddings = loadEmbeddings();
2001
- const textToEmbed = oldMessages
2002
- .filter(m => m.role !== 'system')
2003
- .map(m => m.content.substring(0, 500))
2004
- .join('\n---\n');
2005
-
2006
- if (textToEmbed.length > 50) {
2007
- try {
2008
- const embedding = await getEmbedding(textToEmbed);
2009
- if (embedding) {
2010
- embeddings.chunks.push({
2011
- text: textToEmbed.substring(0, 2000),
2012
- embedding,
2013
- timestamp: Date.now()
2014
- });
2015
- if (embeddings.chunks.length > 100) {
2016
- embeddings.chunks = embeddings.chunks.slice(-100);
2017
- }
2018
- saveEmbeddings(embeddings);
2019
- }
2020
- } catch (e) {
2021
- // Silently skip embedding if model not available
2022
- }
2023
- }
2291
+ const embeddings = await saveOldMessagesToEmbeddings(oldMessages);
2024
2292
 
2025
2293
  // Build agent role reminder if an agent is active
2026
2294
  const agentReminder = currentAgent ? `\nNote: You are currently operating as the "${currentAgent}" agent. Stay in character.` : '';
@@ -2031,13 +2299,13 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
2031
2299
  systemPrompt,
2032
2300
  {
2033
2301
  role: 'user',
2034
- 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}`
2302
+ 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, CHANGES, FETCH, MEMORY, OPEN) as needed.${agentReminder}${skillReminder}`
2035
2303
  },
2036
2304
  {
2037
2305
  role: 'assistant',
2038
2306
  content: _useNativeToolsFlag
2039
- ? `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?`
2040
- : `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?`
2307
+ ? `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, changes, fetch_web, recall_memory, open_url, run_shell) as needed.\n\nWhat would you like me to do next?`
2308
+ : `Understood. I have the conversation summary and will continue helping you. I'll keep using my tools to explore files, inspect changes, fetch references, recall memory, open URLs when needed, make edits, 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:CHANGES] to inspect git changes\n- [TOOL:FETCH] to read web pages\n- [TOOL:MEMORY] to search saved memory\n- [TOOL:OPEN] to open URLs with approval\n- [TOOL:SHELL] to run commands\n\nWhat would you like me to do next?`
2041
2309
  },
2042
2310
  ...recentMessages
2043
2311
  ];
@@ -2199,6 +2467,22 @@ function commandRow(command, description, width = 18) {
2199
2467
  return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
2200
2468
  }
2201
2469
 
2470
+ function renderViewport(content, { verticalAlign = 'top', minTopPadding = 0 } = {}) {
2471
+ const text = String(content ?? '').replace(/\n+$/, '');
2472
+ const rows = Math.max(12, process.stdout.rows || 24);
2473
+ const lineCount = text ? text.split('\n').length : 0;
2474
+ const centeredPadding = verticalAlign === 'center'
2475
+ ? Math.max(0, Math.floor((rows - lineCount) / 2))
2476
+ : 0;
2477
+ const topPadding = Math.max(minTopPadding, centeredPadding);
2478
+
2479
+ console.clear();
2480
+ if (topPadding > 0) {
2481
+ process.stdout.write('\n'.repeat(topPadding));
2482
+ }
2483
+ process.stdout.write(`${text}\n`);
2484
+ }
2485
+
2202
2486
  function meter(current = 0, total = 0, width = 20) {
2203
2487
  if (!total || total <= 0) return UI.slate('░'.repeat(width));
2204
2488
 
@@ -2336,6 +2620,18 @@ function createShellSession(command, cwd, proc) {
2336
2620
  return session;
2337
2621
  }
2338
2622
 
2623
+ // Prune completed shell sessions to prevent memory leaks (keep last 20)
2624
+ function pruneCompletedShellSessions() {
2625
+ const completed = Array.from(shellSessions.entries()).filter(([, s]) => s.completed);
2626
+ const MAX_COMPLETED = 20;
2627
+ if (completed.length > MAX_COMPLETED) {
2628
+ completed
2629
+ .sort((a, b) => a[1].startedAt - b[1].startedAt)
2630
+ .slice(0, completed.length - MAX_COMPLETED)
2631
+ .forEach(([id]) => shellSessions.delete(id));
2632
+ }
2633
+ }
2634
+
2339
2635
  function activeShellSessionCount() {
2340
2636
  return Array.from(shellSessions.values()).filter(session => !session.completed).length;
2341
2637
  }
@@ -2517,9 +2813,15 @@ function renderShellSessionsPanel() {
2517
2813
  return box(lines.join('\n'), 'Shell Sessions', 'cyan');
2518
2814
  }
2519
2815
 
2520
- // Configure marked with terminal renderer
2816
+ // ─── Markdown terminal rendering ───────────────────────────────────
2817
+ // Dynamic width helper (recalculated on every render)
2818
+ function mdWidth() {
2819
+ return Math.min(process.stdout.columns || 80, 120);
2820
+ }
2821
+
2822
+ // Base marked-terminal config (tables, emoji, reflow, text styles)
2521
2823
  marked.use(markedTerminal({
2522
- code: chalk.cyan,
2824
+ code: chalk.cyan, // fallback when highlight fails
2523
2825
  blockquote: chalk.gray.italic,
2524
2826
  html: chalk.gray,
2525
2827
  heading: chalk.bold.cyan,
@@ -2541,11 +2843,152 @@ marked.use(markedTerminal({
2541
2843
  del: chalk.strikethrough,
2542
2844
  link: chalk.underline.blue,
2543
2845
  href: chalk.gray,
2544
- showSectionPrefix: true,
2846
+ showSectionPrefix: false,
2545
2847
  reflowText: true,
2546
- width: Math.min(process.stdout.columns || 80, 120)
2848
+ emoji: true,
2849
+ tab: 2,
2850
+ width: 120, // overridden dynamically below
2547
2851
  }));
2548
2852
 
2853
+ // ─── Enhanced renderers (override marked-terminal defaults) ────────
2854
+ const HEADING_STYLES = [
2855
+ chalk.hex('#7cc4ff').bold.underline, // h1 – bright cyan underline
2856
+ chalk.hex('#7cc4ff').bold, // h2 – bright cyan bold
2857
+ chalk.hex('#9bbcff').bold, // h3 – soft blue bold
2858
+ chalk.hex('#b8d9ff'), // h4 – light blue
2859
+ chalk.hex('#8a95a6').bold, // h5 – slate bold
2860
+ chalk.hex('#8a95a6'), // h6 – slate
2861
+ ];
2862
+ const HEADING_PREFIX = ['◆', '◇', '▸', '▹', '·', '·'];
2863
+
2864
+ function syntaxHighlight(code, lang) {
2865
+ try {
2866
+ if (!lang) return chalk.hex('#e6ebf2')(code);
2867
+ return highlightCode(code, { language: lang, ignoreIllegals: true });
2868
+ } catch {
2869
+ return chalk.hex('#e6ebf2')(code);
2870
+ }
2871
+ }
2872
+
2873
+ function framedCodeBlock(code, lang) {
2874
+ const width = mdWidth();
2875
+ const innerWidth = Math.max(20, width - 6); // " │ " prefix = 4, " │" suffix = 2
2876
+ const highlighted = syntaxHighlight(code, lang);
2877
+ const lines = highlighted.split('\n');
2878
+
2879
+ // Top rule with optional language label
2880
+ let topRule;
2881
+ if (lang) {
2882
+ const label = ` ${chalk.hex('#8a95a6').italic(lang)} `;
2883
+ const labelLen = lang.length + 2; // visible length of " lang "
2884
+ const preDashes = 2;
2885
+ const postDashes = Math.max(0, innerWidth + 2 - preDashes - labelLen);
2886
+ topRule = chalk.hex('#3d4f5f')(' ┌' + '─'.repeat(preDashes)) + label + chalk.hex('#3d4f5f')('─'.repeat(postDashes) + '┐');
2887
+ } else {
2888
+ topRule = chalk.hex('#3d4f5f')(' ┌' + '─'.repeat(innerWidth + 2) + '┐');
2889
+ }
2890
+
2891
+ // Code lines with left+right border
2892
+ const framedLines = lines.map(line => {
2893
+ const visLen = stripAnsi(line).length;
2894
+ const pad = Math.max(0, innerWidth - visLen);
2895
+ return chalk.hex('#3d4f5f')(' │ ') + line + ' '.repeat(pad) + chalk.hex('#3d4f5f')(' │');
2896
+ });
2897
+
2898
+ // Bottom rule
2899
+ const bottomRule = chalk.hex('#3d4f5f')(' └' + '─'.repeat(innerWidth + 2) + '┘');
2900
+
2901
+ return '\n' + topRule + '\n' + framedLines.join('\n') + '\n' + bottomRule + '\n';
2902
+ }
2903
+
2904
+ const LIST_BULLETS = ['●', '○', '◦', '·'];
2905
+
2906
+ marked.use({
2907
+ renderer: {
2908
+ // ── Fenced code blocks: framed box with syntax highlighting ──
2909
+ code({ text, lang }) {
2910
+ return framedCodeBlock(text, lang || '');
2911
+ },
2912
+
2913
+ // ── Headings: level-aware icons + color gradient ──
2914
+ heading({ tokens, depth }) {
2915
+ const text = this.parser.parseInline(tokens);
2916
+ const level = Math.max(0, Math.min(depth - 1, 5));
2917
+ const style = HEADING_STYLES[level];
2918
+ const prefix = HEADING_PREFIX[level];
2919
+ const plain = stripAnsi(text);
2920
+ const underChar = level === 0 ? '═' : level === 1 ? '─' : '';
2921
+ const underline = underChar ? '\n ' + chalk.hex('#3d4f5f')(underChar.repeat(Math.min(plain.length + 4, mdWidth() - 4))) : '';
2922
+ return `\n ${chalk.hex('#3d4f5f')(prefix)} ${style(plain)}${underline}\n`;
2923
+ },
2924
+
2925
+ // ── Blockquotes: thick left bar with dimmed styling ──
2926
+ blockquote({ tokens }) {
2927
+ const body = this.parser.parse(tokens);
2928
+ const lines = body.split('\n').filter(l => l.trim() !== '');
2929
+ const bar = chalk.hex('#5a7a9a')('▌');
2930
+ const textStyle = chalk.hex('#9aafcc').italic;
2931
+ return '\n' + lines.map(line => {
2932
+ const clean = stripAnsi(line).trim();
2933
+ return ` ${bar} ${textStyle(clean)}`;
2934
+ }).join('\n') + '\n';
2935
+ },
2936
+
2937
+ // ── Lists: modern bullets ──
2938
+ list({ items, ordered }) {
2939
+ const result = items.map((item, i) => {
2940
+ // Parse inline tokens to properly render codespan, strong, em, etc.
2941
+ let body = '';
2942
+ for (const tok of item.tokens) {
2943
+ if (tok.tokens) {
2944
+ body += this.parser.parseInline(tok.tokens);
2945
+ } else if (tok.type === 'space') {
2946
+ body += '';
2947
+ } else {
2948
+ body += tok.text || '';
2949
+ }
2950
+ }
2951
+ body = body.replace(/\n+$/, '');
2952
+ const lines = body.split('\n');
2953
+ if (ordered) {
2954
+ const num = chalk.hex('#7cc4ff')(`${i + 1}.`);
2955
+ const prefix = ` ${num} `;
2956
+ return lines.map((line, li) => {
2957
+ return li === 0 ? `${prefix}${line.trim()}` : ` ${line.trim()}`;
2958
+ }).join('\n');
2959
+ }
2960
+ const bullet = chalk.hex('#5a7a9a')(LIST_BULLETS[Math.min(item.depth || 0, LIST_BULLETS.length - 1)] || '●');
2961
+ const prefix = ` ${bullet} `;
2962
+ return lines.map((line, li) => {
2963
+ return li === 0 ? `${prefix}${line.trim()}` : ` ${line.trim()}`;
2964
+ }).join('\n');
2965
+ }).join('\n');
2966
+ return '\n' + result + '\n';
2967
+ },
2968
+
2969
+ // ── Horizontal rules: themed divider ──
2970
+ hr() {
2971
+ const w = Math.max(20, mdWidth() - 4);
2972
+ return '\n ' + chalk.hex('#3d4f5f')('─'.repeat(w)) + '\n';
2973
+ },
2974
+
2975
+ // ── Inline code: highlighted background effect ──
2976
+ codespan({ text }) {
2977
+ return chalk.bgHex('#1a2733').hex('#7cc4ff')(` ${text} `);
2978
+ },
2979
+
2980
+ // ── Links: visible URL with icon ──
2981
+ link({ href, tokens }) {
2982
+ const text = this.parser.parseInline(tokens);
2983
+ const plain = stripAnsi(text);
2984
+ if (plain === href) {
2985
+ return chalk.hex('#7cc4ff').underline(href);
2986
+ }
2987
+ return `${chalk.hex('#7cc4ff').underline(plain)} ${chalk.hex('#8a95a6')('→')} ${chalk.hex('#5a7a9a').underline(href)}`;
2988
+ },
2989
+ }
2990
+ });
2991
+
2549
2992
  // Render markdown to terminal
2550
2993
  function renderMarkdown(text) {
2551
2994
  try {
@@ -2556,7 +2999,7 @@ function renderMarkdown(text) {
2556
2999
  }
2557
3000
 
2558
3001
  let stepMode = false;
2559
- let debugMode = false; // Toggle with /debug command
3002
+ let debugMode = sapperConfig.debug || false; // Toggle with /debug command, or set in config
2560
3003
  let abortStream = false; // Flag to interrupt AI response
2561
3004
 
2562
3005
  // ═══════════════════════════════════════════════════════════════
@@ -2810,9 +3253,11 @@ const CODE_EXTENSIONS = new Set([
2810
3253
  ]);
2811
3254
 
2812
3255
  // Max file size to include (skip large files like bundled/minified)
2813
- const MAX_FILE_SIZE = 100000; // 100KB per file
2814
- const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
2815
- const MAX_URL_SIZE = 200000; // 200KB max for fetched web pages
3256
+ // File limits configurable via .sapper/config.json
3257
+ function getMaxFileSize() { return sapperConfig.maxFileSize || DEFAULT_CONFIG.maxFileSize; }
3258
+ function getMaxScanSize() { return sapperConfig.maxScanSize || DEFAULT_CONFIG.maxScanSize; }
3259
+ function getMaxUrlSize() { return sapperConfig.maxUrlSize || DEFAULT_CONFIG.maxUrlSize; }
3260
+ function getPatchRetries() { return sapperConfig.patchRetries || DEFAULT_CONFIG.patchRetries; }
2816
3261
 
2817
3262
  // ═══════════════════════════════════════════════════════════════
2818
3263
  // URL FETCHING — Read web pages and learn from them
@@ -2821,7 +3266,7 @@ import https from 'https';
2821
3266
  import http from 'http';
2822
3267
 
2823
3268
  // Fetch a URL and return extracted text content
2824
- function fetchUrl(url, timeout = 15000) {
3269
+ function fetchUrl(url, timeout = LIMITS.FETCH_URL_TIMEOUT_MS) {
2825
3270
  return new Promise((resolve, reject) => {
2826
3271
  const lib = url.startsWith('https') ? https : http;
2827
3272
  const req = lib.get(url, {
@@ -2846,9 +3291,9 @@ function fetchUrl(url, timeout = 15000) {
2846
3291
  let size = 0;
2847
3292
  res.on('data', (chunk) => {
2848
3293
  size += chunk.length;
2849
- if (size > MAX_URL_SIZE) {
3294
+ if (size > getMaxUrlSize()) {
2850
3295
  res.destroy();
2851
- reject(new Error(`Page too large (>${Math.round(MAX_URL_SIZE/1024)}KB)`));
3296
+ reject(new Error(`Page too large (>${Math.round(getMaxUrlSize()/1024)}KB)`));
2852
3297
  return;
2853
3298
  }
2854
3299
  data += chunk;
@@ -2883,8 +3328,8 @@ function htmlToText(html) {
2883
3328
  text = text.replace(/\n\s*\n/g, '\n\n');
2884
3329
  text = text.trim();
2885
3330
  // Limit to reasonable size
2886
- if (text.length > 50000) {
2887
- text = text.substring(0, 50000) + '\n\n[... content truncated at 50KB ...]';
3331
+ if (text.length > LIMITS.WEB_CONTENT_MAX_CHARS) {
3332
+ text = text.substring(0, LIMITS.WEB_CONTENT_MAX_CHARS) + '\n\n[... content truncated at 50KB ...]';
2888
3333
  }
2889
3334
  return text;
2890
3335
  }
@@ -2964,6 +3409,20 @@ function shouldIgnore(nameOrPath) {
2964
3409
  return ignored;
2965
3410
  }
2966
3411
 
3412
+ // Format file attachments into a decorated string block for context messages
3413
+ function formatFileAttachments(fileAttachments) {
3414
+ let s = '\n\n══════════════════════════════════════\n';
3415
+ s += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
3416
+ s += '══════════════════════════════════════\n\n';
3417
+ for (const file of fileAttachments) {
3418
+ s += `┌─── ${file.path} ───\n`;
3419
+ s += file.content;
3420
+ if (!file.content.endsWith('\n')) s += '\n';
3421
+ s += `└─── END ${file.path} ───\n\n`;
3422
+ }
3423
+ return s;
3424
+ }
3425
+
2967
3426
  // Scan entire codebase and return summary
2968
3427
  function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
2969
3428
  if (depth > maxDepth) return { files: [], totalSize: 0 };
@@ -2993,11 +3452,11 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
2993
3452
 
2994
3453
  try {
2995
3454
  const stats = fs.statSync(fullPath);
2996
- if (stats.size > MAX_FILE_SIZE) {
3455
+ if (stats.size > getMaxFileSize()) {
2997
3456
  files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
2998
3457
  continue;
2999
3458
  }
3000
- if (totalSize + stats.size > MAX_TOTAL_SCAN_SIZE) {
3459
+ if (totalSize + stats.size > getMaxScanSize()) {
3001
3460
  files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
3002
3461
  continue;
3003
3462
  }
@@ -3218,18 +3677,19 @@ async function pickModel(models) {
3218
3677
 
3219
3678
  const render = () => {
3220
3679
  const current = models[cursor];
3221
- console.clear();
3222
- console.log(BANNER);
3223
- console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
3224
- console.log(divider());
3225
- console.log(sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'));
3226
- console.log();
3680
+ const lines = [
3681
+ BANNER,
3682
+ `${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`,
3683
+ divider(),
3684
+ sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'),
3685
+ ''
3686
+ ];
3227
3687
 
3228
3688
  const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
3229
3689
  const endIdx = Math.min(startIdx + pageSize, models.length);
3230
3690
 
3231
3691
  if (startIdx > 0) {
3232
- console.log(UI.slate(' ↑ more models'));
3692
+ lines.push(UI.slate(' ↑ more models'));
3233
3693
  }
3234
3694
 
3235
3695
  for (let i = startIdx; i < endIdx; i++) {
@@ -3244,20 +3704,20 @@ async function pickModel(models) {
3244
3704
  model.details?.parameter_size || null,
3245
3705
  ].filter(Boolean).join(' · ');
3246
3706
 
3247
- console.log(`${marker} ${index} ${name}`);
3707
+ lines.push(`${marker} ${index} ${name}`);
3248
3708
  if (meta) {
3249
- console.log(` ${UI.slate(meta)}`);
3709
+ lines.push(` ${UI.slate(meta)}`);
3250
3710
  }
3251
3711
  }
3252
3712
 
3253
3713
  if (endIdx < models.length) {
3254
- console.log(UI.slate(' ↓ more models'));
3714
+ lines.push(UI.slate(' ↓ more models'));
3255
3715
  }
3256
3716
 
3257
3717
  const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
3258
3718
  const quant = current.details?.quantization_level || current.details?.quantization || 'default';
3259
- console.log();
3260
- console.log(box(
3719
+ lines.push('');
3720
+ lines.push(box(
3261
3721
  `${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
3262
3722
  `${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
3263
3723
  `${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
@@ -3265,6 +3725,8 @@ async function pickModel(models) {
3265
3725
  `${keyValue('Quant', UI.ink(quant), 10)}`,
3266
3726
  'Preview', 'gray'
3267
3727
  ));
3728
+
3729
+ renderViewport(lines.join('\n'), { verticalAlign: 'center' });
3268
3730
  };
3269
3731
 
3270
3732
  return new Promise((resolve) => {
@@ -3299,11 +3761,11 @@ async function pickModel(models) {
3299
3761
  render();
3300
3762
  } else if (key.name === 'return') {
3301
3763
  cleanup();
3302
- console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3764
+ console.clear();
3303
3765
  resolve(models[cursor].name);
3304
3766
  } else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
3305
3767
  cleanup();
3306
- console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3768
+ console.clear();
3307
3769
  resolve(models[cursor].name);
3308
3770
  }
3309
3771
  };
@@ -3312,11 +3774,249 @@ async function pickModel(models) {
3312
3774
  });
3313
3775
  }
3314
3776
 
3777
+ let toolWorkingDirectory = process.cwd();
3778
+
3779
+ function getToolWorkingDirectory() {
3780
+ return toolWorkingDirectory || process.cwd();
3781
+ }
3782
+
3783
+ function resolveToolPath(pathValue = '.', { allowEmpty = false } = {}) {
3784
+ let rawPath = typeof pathValue === 'string' ? pathValue.trim() : '';
3785
+ // Strip surrounding quotes that models sometimes add
3786
+ if ((rawPath.startsWith('"') && rawPath.endsWith('"')) || (rawPath.startsWith("'") && rawPath.endsWith("'"))) {
3787
+ rawPath = rawPath.slice(1, -1).trim();
3788
+ }
3789
+ if (!rawPath) {
3790
+ return allowEmpty ? '' : getToolWorkingDirectory();
3791
+ }
3792
+ if (rawPath === '/') {
3793
+ return getToolWorkingDirectory();
3794
+ }
3795
+ const resolved = isAbsolute(rawPath) ? rawPath : pathResolve(getToolWorkingDirectory(), rawPath);
3796
+ // Prevent path traversal outside the project directory
3797
+ const projectRoot = process.cwd();
3798
+ if (!resolved.startsWith(projectRoot + '/') && resolved !== projectRoot) {
3799
+ return projectRoot; // Fall back to project root for paths that escape sandbox
3800
+ }
3801
+ return resolved;
3802
+ }
3803
+
3804
+ function resolveLineWindowCount(value, fallback = 20) {
3805
+ return normalizeIntegerInRange(value, fallback, 1, 400);
3806
+ }
3807
+
3808
+ function readFileLineWindow(pathValue, mode = 'head', countValue = 20) {
3809
+ const trimmedPath = typeof pathValue === 'string' ? pathValue.trim() : '';
3810
+ if (!trimmedPath) return 'Error reading file: missing file path';
3811
+
3812
+ try {
3813
+ const resolvedPath = resolveToolPath(trimmedPath);
3814
+ const rawContent = fs.readFileSync(resolvedPath, 'utf8');
3815
+ const lines = rawContent === '' ? [] : rawContent.split('\n');
3816
+ const requestedCount = resolveLineWindowCount(countValue, 20);
3817
+
3818
+ if (lines.length === 0) {
3819
+ return `${mode === 'tail' ? 'Last' : 'First'} 0 lines of ${trimmedPath}:\n(empty file)`;
3820
+ }
3821
+
3822
+ const slice = mode === 'tail'
3823
+ ? lines.slice(-requestedCount)
3824
+ : lines.slice(0, requestedCount);
3825
+ const shownCount = slice.length;
3826
+ const lineLabel = shownCount === 1 ? 'line' : 'lines';
3827
+ const descriptor = mode === 'tail' ? 'last' : 'first';
3828
+
3829
+ return `Showing the ${descriptor} ${shownCount} ${lineLabel} of ${trimmedPath}:\n${slice.join('\n')}`;
3830
+ } catch (error) {
3831
+ return `Error reading file: ${error.message}`;
3832
+ }
3833
+ }
3834
+
3835
+ function findPathsByName(patternValue, startPathValue = '.') {
3836
+ const pattern = String(patternValue ?? '').trim();
3837
+ const startPath = typeof startPathValue === 'string' ? startPathValue.trim() : '';
3838
+ if (!pattern) return 'Error finding files: missing search pattern';
3839
+
3840
+ const resolvedStartPath = resolveToolPath(startPath || '.');
3841
+ if (!fs.existsSync(resolvedStartPath)) {
3842
+ return `Error finding files: ${startPath || '.'} does not exist`;
3843
+ }
3844
+
3845
+ let startStats;
3846
+ try {
3847
+ startStats = fs.statSync(resolvedStartPath);
3848
+ } catch (error) {
3849
+ return `Error finding files: ${error.message}`;
3850
+ }
3851
+
3852
+ if (!startStats.isDirectory()) {
3853
+ return `Error finding files: ${startPath || '.'} is not a directory`;
3854
+ }
3855
+
3856
+ const matches = [];
3857
+ const maxResults = 100;
3858
+ const patternLower = pattern.toLowerCase();
3859
+
3860
+ const visit = (dirPath, displayPrefix = '') => {
3861
+ if (matches.length >= maxResults) return;
3862
+
3863
+ let entries = [];
3864
+ try {
3865
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
3866
+ } catch {
3867
+ return;
3868
+ }
3869
+
3870
+ for (const entry of entries) {
3871
+ if (matches.length >= maxResults) return;
3872
+ if (entry.name.startsWith('.')) continue;
3873
+ if (shouldIgnore(entry.name)) continue;
3874
+
3875
+ const fullPath = join(dirPath, entry.name);
3876
+ const relativePath = displayPrefix ? `${displayPrefix}/${entry.name}` : entry.name;
3877
+ if (shouldIgnore(relativePath) || shouldIgnore(fullPath)) continue;
3878
+
3879
+ const displayPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
3880
+ if (entry.name.toLowerCase().includes(patternLower) || relativePath.toLowerCase().includes(patternLower)) {
3881
+ matches.push(displayPath);
3882
+ }
3883
+
3884
+ if (entry.isDirectory()) {
3885
+ visit(fullPath, relativePath);
3886
+ }
3887
+ }
3888
+ };
3889
+
3890
+ visit(resolvedStartPath);
3891
+
3892
+ if (matches.length === 0) {
3893
+ return `No files or directories found matching: ${pattern}`;
3894
+ }
3895
+
3896
+ const header = `Found ${matches.length} matching path${matches.length === 1 ? '' : 's'} for: ${pattern}`;
3897
+ const body = matches.join('\n');
3898
+ const truncated = matches.length >= maxResults ? '\n... (results truncated)' : '';
3899
+ return `${header}\n${body}${truncated}`;
3900
+ }
3901
+
3902
+ function truncateToolText(textValue = '', maxChars = 24000) {
3903
+ const text = String(textValue ?? '').trim();
3904
+ if (!text) return '';
3905
+ if (text.length <= maxChars) return text;
3906
+ return `${text.slice(0, maxChars)}\n... (output truncated at ${maxChars.toLocaleString()} chars)`;
3907
+ }
3908
+
3909
+ function shellQuote(value = '') {
3910
+ return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
3911
+ }
3912
+
3913
+ function runCapturedCommand(command, { cwd = getToolWorkingDirectory(), timeoutMs = 12000, maxOutput = 24000 } = {}) {
3914
+ return new Promise((resolve) => {
3915
+ const proc = spawn('sh', ['-c', command], { cwd });
3916
+ let stdout = '';
3917
+ let stderr = '';
3918
+ let stdoutTruncated = false;
3919
+ let stderrTruncated = false;
3920
+ let finished = false;
3921
+
3922
+ const appendLimited = (existing, chunkText, maxChars, setTruncated) => {
3923
+ if (existing.length >= maxChars) {
3924
+ setTruncated(true);
3925
+ return existing;
3926
+ }
3927
+ const remaining = maxChars - existing.length;
3928
+ if (chunkText.length > remaining) {
3929
+ setTruncated(true);
3930
+ return existing + chunkText.slice(0, remaining);
3931
+ }
3932
+ return existing + chunkText;
3933
+ };
3934
+
3935
+ const finish = (result) => {
3936
+ if (finished) return;
3937
+ finished = true;
3938
+ if (timer) clearTimeout(timer);
3939
+ const finalStdout = stdoutTruncated
3940
+ ? `${stdout}\n... (stdout truncated at ${maxOutput.toLocaleString()} chars)`
3941
+ : stdout;
3942
+ const finalStderr = stderrTruncated
3943
+ ? `${stderr}\n... (stderr truncated at ${maxOutput.toLocaleString()} chars)`
3944
+ : stderr;
3945
+ resolve({ ...result, stdout: finalStdout, stderr: finalStderr });
3946
+ };
3947
+
3948
+ const timer = timeoutMs > 0
3949
+ ? setTimeout(() => {
3950
+ try { proc.kill('SIGTERM'); } catch (e) {}
3951
+ finish({ exitCode: 124, stdout, stderr: `${stderr}\nCommand timed out after ${timeoutMs}ms`.trim(), timedOut: true });
3952
+ }, timeoutMs)
3953
+ : null;
3954
+
3955
+ proc.stdout.on('data', (data) => {
3956
+ stdout = appendLimited(stdout, data.toString(), maxOutput, (value) => { stdoutTruncated = value; });
3957
+ });
3958
+ proc.stderr.on('data', (data) => {
3959
+ stderr = appendLimited(stderr, data.toString(), maxOutput, (value) => { stderrTruncated = value; });
3960
+ });
3961
+
3962
+ proc.on('error', (error) => {
3963
+ finish({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`.trim(), timedOut: false });
3964
+ });
3965
+ proc.on('close', (code) => {
3966
+ finish({ exitCode: code ?? 0, stdout, stderr, timedOut: false });
3967
+ });
3968
+ });
3969
+ }
3970
+
3971
+ function normalizeFetchedWebContent(rawContent = '') {
3972
+ const trimmed = String(rawContent ?? '').trim();
3973
+ if (!trimmed) return '';
3974
+
3975
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
3976
+ try {
3977
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
3978
+ } catch (error) {
3979
+ return trimmed;
3980
+ }
3981
+ }
3982
+
3983
+ if (trimmed.startsWith('<') || trimmed.toLowerCase().includes('<html')) {
3984
+ return htmlToText(trimmed);
3985
+ }
3986
+
3987
+ return trimmed;
3988
+ }
3989
+
3990
+ function keywordRecallMemory(query, embeddings, topK = 3) {
3991
+ const queryText = String(query ?? '').trim().toLowerCase();
3992
+ if (!queryText || !embeddings?.chunks?.length) return [];
3993
+
3994
+ const queryWords = Array.from(new Set(
3995
+ queryText.split(/[^a-z0-9_]+/i).map(word => word.trim()).filter(word => word.length >= 2)
3996
+ ));
3997
+ const maxScore = Math.max(1, 4 + queryWords.length);
3998
+
3999
+ return embeddings.chunks
4000
+ .map((chunk) => {
4001
+ const text = String(chunk?.text ?? '');
4002
+ const lowered = text.toLowerCase();
4003
+ let score = 0;
4004
+ if (lowered.includes(queryText)) score += 4;
4005
+ for (const word of queryWords) {
4006
+ if (lowered.includes(word)) score += 1;
4007
+ }
4008
+ return { ...chunk, score: Math.min(1, score / maxScore) };
4009
+ })
4010
+ .filter(chunk => chunk.score > 0)
4011
+ .sort((a, b) => b.score - a.score || (b.timestamp || 0) - (a.timestamp || 0))
4012
+ .slice(0, topK);
4013
+ }
4014
+
3315
4015
  const tools = {
3316
4016
  read: (path) => {
3317
4017
  const trimmedPath = typeof path === 'string' ? path.trim() : '';
3318
4018
  if (!trimmedPath) return 'Error reading file: missing file path';
3319
- try { return fs.readFileSync(trimmedPath, 'utf8'); }
4019
+ try { return fs.readFileSync(resolveToolPath(trimmedPath), 'utf8'); }
3320
4020
  catch (error) { return `Error reading file: ${error.message}`; }
3321
4021
  },
3322
4022
  patch: async (path, oldText, newText) => {
@@ -3326,7 +4026,8 @@ const tools = {
3326
4026
  return 'Error patching file: missing old_text or new_text';
3327
4027
  }
3328
4028
  try {
3329
- const content = fs.readFileSync(trimmedPath, 'utf8');
4029
+ const resolvedPath = resolveToolPath(trimmedPath);
4030
+ const content = fs.readFileSync(resolvedPath, 'utf8');
3330
4031
 
3331
4032
  // --- Line-number mode: LINE:15|||new text ---
3332
4033
  const lineMatch = oldText.match(/^LINE:(\d+)$/);
@@ -3343,7 +4044,7 @@ const tools = {
3343
4044
  }
3344
4045
 
3345
4046
  return reviewCandidateFile({
3346
- filePath: trimmedPath,
4047
+ filePath: resolvedPath,
3347
4048
  originalContent: content,
3348
4049
  newContent,
3349
4050
  title: 'Patch Review',
@@ -3412,7 +4113,7 @@ const tools = {
3412
4113
  }
3413
4114
 
3414
4115
  return reviewCandidateFile({
3415
- filePath: trimmedPath,
4116
+ filePath: resolvedPath,
3416
4117
  originalContent: content,
3417
4118
  newContent,
3418
4119
  title: 'Patch Review',
@@ -3424,8 +4125,9 @@ const tools = {
3424
4125
  const trimmedPath = typeof path === 'string' ? path.trim() : '';
3425
4126
  if (!trimmedPath) return 'Error writing file: missing file path';
3426
4127
  try {
3427
- const fileExists = fs.existsSync(trimmedPath);
3428
- const existingContent = fileExists ? fs.readFileSync(trimmedPath, 'utf8') : '';
4128
+ const resolvedPath = resolveToolPath(trimmedPath);
4129
+ const fileExists = fs.existsSync(resolvedPath);
4130
+ const existingContent = fileExists ? fs.readFileSync(resolvedPath, 'utf8') : '';
3429
4131
  const nextContent = String(content ?? '');
3430
4132
 
3431
4133
  if (fileExists && existingContent === nextContent) {
@@ -3433,7 +4135,7 @@ const tools = {
3433
4135
  }
3434
4136
 
3435
4137
  return reviewCandidateFile({
3436
- filePath: trimmedPath,
4138
+ filePath: resolvedPath,
3437
4139
  originalContent: existingContent,
3438
4140
  newContent: nextContent,
3439
4141
  title: 'Write Review',
@@ -3445,10 +4147,197 @@ const tools = {
3445
4147
  const trimmedPath = typeof path === 'string' ? path.trim() : '';
3446
4148
  if (!trimmedPath) return 'Error creating directory: missing directory path';
3447
4149
  try {
3448
- fs.mkdirSync(trimmedPath, { recursive: true });
4150
+ fs.mkdirSync(resolveToolPath(trimmedPath), { recursive: true });
3449
4151
  return `Directory created: ${trimmedPath}`;
3450
4152
  } catch (error) { return `Error creating directory: ${error.message}`; }
3451
4153
  },
4154
+ pwd: () => getToolWorkingDirectory(),
4155
+ cd: (path) => {
4156
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4157
+ if (!trimmedPath) return 'Error changing directory: missing directory path';
4158
+ try {
4159
+ const resolvedPath = resolveToolPath(trimmedPath);
4160
+ const stats = fs.statSync(resolvedPath);
4161
+ if (!stats.isDirectory()) {
4162
+ return `Error changing directory: ${trimmedPath} is not a directory`;
4163
+ }
4164
+ toolWorkingDirectory = resolvedPath;
4165
+ return `Working directory changed to ${toolWorkingDirectory}`;
4166
+ } catch (error) {
4167
+ return `Error changing directory: ${error.message}`;
4168
+ }
4169
+ },
4170
+ rmdir: async (path) => {
4171
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4172
+ if (!trimmedPath) return 'Error removing directory: missing directory path';
4173
+ if (['.', '..', '/'].includes(trimmedPath)) {
4174
+ return `Error removing directory: refusing to remove ${trimmedPath}`;
4175
+ }
4176
+
4177
+ try {
4178
+ const resolvedPath = resolveToolPath(trimmedPath);
4179
+ if (!fs.existsSync(resolvedPath)) {
4180
+ return `Error removing directory: ${trimmedPath} does not exist`;
4181
+ }
4182
+ const stats = fs.statSync(resolvedPath);
4183
+ if (!stats.isDirectory()) {
4184
+ return `Error removing directory: ${trimmedPath} is not a directory`;
4185
+ }
4186
+
4187
+ console.log();
4188
+ console.log(box(
4189
+ `${keyValue('Directory', chalk.white(trimmedPath), 11)}\n` +
4190
+ `${keyValue('Action', chalk.white('remove recursively'), 11)}\n` +
4191
+ `${UI.slate('This will permanently delete the directory and its contents.')}`,
4192
+ 'Directory Removal', 'red'
4193
+ ));
4194
+ const confirm = await safeQuestion(confirmPrompt('Remove directory', 'error'));
4195
+ if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
4196
+ return 'Directory removal blocked by user.';
4197
+ }
4198
+
4199
+ fs.rmSync(resolvedPath, { recursive: true, force: false });
4200
+ return `Directory removed: ${trimmedPath}`;
4201
+ } catch (error) {
4202
+ return `Error removing directory: ${error.message}`;
4203
+ }
4204
+ },
4205
+ ls: (path) => tools.list(path),
4206
+ cat: (path) => tools.read(path),
4207
+ head: (path, lines) => readFileLineWindow(path, 'head', lines),
4208
+ tail: (path, lines) => readFileLineWindow(path, 'tail', lines),
4209
+ grep: (pattern) => tools.search(pattern),
4210
+ find: (pattern, startPath) => findPathsByName(pattern, startPath),
4211
+ changes: async (path) => {
4212
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4213
+ const cwd = getToolWorkingDirectory();
4214
+ const quotedPath = trimmedPath ? ` -- ${shellQuote(trimmedPath)}` : '';
4215
+
4216
+ const repoCheck = await runCapturedCommand('git rev-parse --show-toplevel', {
4217
+ cwd,
4218
+ timeoutMs: 5000,
4219
+ maxOutput: 4000,
4220
+ });
4221
+ if (repoCheck.exitCode !== 0) {
4222
+ return 'Error: current tool working directory is not inside a git repository';
4223
+ }
4224
+
4225
+ const statusCmd = trimmedPath
4226
+ ? `git --no-pager status --short ${quotedPath}`
4227
+ : 'git --no-pager status --short --branch';
4228
+ const [statusResult, unstagedResult, stagedResult] = await Promise.all([
4229
+ runCapturedCommand(statusCmd, { cwd, timeoutMs: 8000, maxOutput: 12000 }),
4230
+ runCapturedCommand(`git --no-pager diff --no-ext-diff --minimal${quotedPath}`, { cwd, timeoutMs: 8000, maxOutput: 20000 }),
4231
+ runCapturedCommand(`git --no-pager diff --cached --no-ext-diff --minimal${quotedPath}`, { cwd, timeoutMs: 8000, maxOutput: 20000 }),
4232
+ ]);
4233
+
4234
+ if (statusResult.exitCode !== 0) {
4235
+ return `Error reading git changes: ${statusResult.stderr.trim() || 'git status failed'}`;
4236
+ }
4237
+
4238
+ const parts = [];
4239
+ const scopeLabel = trimmedPath ? ` for ${trimmedPath}` : '';
4240
+ parts.push(`Git changes${scopeLabel}:`);
4241
+ parts.push(statusResult.stdout.trim() || 'No changed files in working tree.');
4242
+
4243
+ const unstagedDiff = unstagedResult.stdout.trim();
4244
+ const stagedDiff = stagedResult.stdout.trim();
4245
+
4246
+ if (unstagedDiff) {
4247
+ parts.push(`Unstaged diff:\n${unstagedDiff}`);
4248
+ }
4249
+ if (stagedDiff) {
4250
+ parts.push(`Staged diff:\n${stagedDiff}`);
4251
+ }
4252
+ if (!unstagedDiff && !stagedDiff) {
4253
+ parts.push('No staged or unstaged diff output.');
4254
+ }
4255
+
4256
+ return truncateToolText(parts.join('\n\n'), 28000);
4257
+ },
4258
+ fetch_web: async (url) => {
4259
+ const trimmedUrl = String(url ?? '').trim();
4260
+ if (!trimmedUrl) return 'Error fetching web page: missing URL';
4261
+ if (!/^https?:\/\//i.test(trimmedUrl)) {
4262
+ return 'Error fetching web page: URL must start with http:// or https://';
4263
+ }
4264
+
4265
+ try {
4266
+ const rawContent = await fetchUrl(trimmedUrl);
4267
+ const text = normalizeFetchedWebContent(rawContent);
4268
+ if (!text) return `No readable content found at ${trimmedUrl}`;
4269
+ return `Fetched ${trimmedUrl}:\n\n${truncateToolText(text, 28000)}`;
4270
+ } catch (error) {
4271
+ return `Error fetching web page: ${error.message}`;
4272
+ }
4273
+ },
4274
+ recall_memory: async (query) => {
4275
+ const trimmedQuery = String(query ?? '').trim();
4276
+ if (!trimmedQuery) return 'Error searching memory: missing query';
4277
+
4278
+ const embeddings = loadEmbeddings();
4279
+ if (!embeddings.chunks.length) {
4280
+ return 'No saved memory yet. Use /prune or continue working so Sapper can store conversation memory.';
4281
+ }
4282
+
4283
+ let relevant = await findRelevantContext(trimmedQuery, embeddings, 3);
4284
+ if (!relevant.length) {
4285
+ relevant = keywordRecallMemory(trimmedQuery, embeddings, 3);
4286
+ }
4287
+ if (!relevant.length) {
4288
+ return `No relevant memories found for: ${trimmedQuery}`;
4289
+ }
4290
+
4291
+ const formatted = relevant.map((chunk, index) => {
4292
+ const timestamp = chunk.timestamp ? new Date(chunk.timestamp).toISOString() : 'unknown time';
4293
+ const score = typeof chunk.score === 'number'
4294
+ ? `${Math.round(chunk.score * 100)}%`
4295
+ : 'n/a';
4296
+ const text = truncateToolText(chunk.text || '', 1200);
4297
+ return `[${index + 1}] ${timestamp} · relevance ${score}\n${text}`;
4298
+ }).join('\n\n');
4299
+
4300
+ return `Found ${relevant.length} memory match${relevant.length === 1 ? '' : 'es'} for: ${trimmedQuery}\n\n${formatted}`;
4301
+ },
4302
+ open_url: async (url) => {
4303
+ const trimmedUrl = String(url ?? '').trim();
4304
+ if (!trimmedUrl) return 'Error opening URL: missing URL';
4305
+ if (!/^(https?:|file:)/i.test(trimmedUrl)) {
4306
+ return 'Error opening URL: URL must start with http://, https://, or file:';
4307
+ }
4308
+
4309
+ console.log();
4310
+ console.log(box(
4311
+ `${keyValue('URL', chalk.white(trimmedUrl), 11)}\n` +
4312
+ `${UI.slate('This will open the URL in your default browser.')}`,
4313
+ 'Open URL', 'red'
4314
+ ));
4315
+ const confirm = await safeQuestion(confirmPrompt('Open URL in browser', 'error'));
4316
+ if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
4317
+ return 'Open URL blocked by user.';
4318
+ }
4319
+
4320
+ try {
4321
+ let command = 'open';
4322
+ let args = [trimmedUrl];
4323
+ if (process.platform === 'win32') {
4324
+ command = 'cmd';
4325
+ args = ['/c', 'start', '', trimmedUrl];
4326
+ } else if (process.platform !== 'darwin') {
4327
+ command = 'xdg-open';
4328
+ args = [trimmedUrl];
4329
+ }
4330
+
4331
+ const proc = spawn(command, args, {
4332
+ detached: true,
4333
+ stdio: 'ignore'
4334
+ });
4335
+ proc.unref();
4336
+ return `Opened URL in default browser: ${trimmedUrl}`;
4337
+ } catch (error) {
4338
+ return `Error opening URL: ${error.message}`;
4339
+ }
4340
+ },
3452
4341
  shell: async (cmd) => {
3453
4342
  const trimmedCmd = String(cmd ?? '').trim();
3454
4343
  if (!trimmedCmd) return 'Error executing shell: missing command';
@@ -3461,7 +4350,7 @@ const tools = {
3461
4350
  const backgroundEligible = shouldBackgroundShellCommand(trimmedCmd);
3462
4351
  console.log();
3463
4352
  console.log(box(
3464
- `${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
4353
+ `${keyValue('Directory', chalk.white(getToolWorkingDirectory()), 11)}\n` +
3465
4354
  `${UI.slate('Command')}\n${chalk.white.bold(trimmedCmd)}\n` +
3466
4355
  `${UI.slate('Type y to run, n to block, f for feedback, e for edit instructions, or write feedback directly.')}\n` +
3467
4356
  `${UI.slate(backgroundEligible ? `Background handoff ${shellBackgroundMode()} after ${shellBackgroundAfterSeconds()}s if still running.` : 'This command will stay attached unless it exits quickly.')}`,
@@ -3476,9 +4365,9 @@ const tools = {
3476
4365
  return new Promise((resolve) => {
3477
4366
  console.log(chalk.cyan(`\n[RUNNING] ${trimmedCmd}\n`));
3478
4367
  const proc = spawn('sh', ['-c', trimmedCmd], {
3479
- cwd: process.cwd()
4368
+ cwd: getToolWorkingDirectory()
3480
4369
  });
3481
- const session = createShellSession(trimmedCmd, process.cwd(), proc);
4370
+ const session = createShellSession(trimmedCmd, getToolWorkingDirectory(), proc);
3482
4371
  let resolved = false;
3483
4372
  let backgroundTimer = null;
3484
4373
 
@@ -3525,12 +4414,14 @@ const tools = {
3525
4414
  session.completed = true;
3526
4415
  session.error = error.message;
3527
4416
  session.exitCode = 1;
4417
+ pruneCompletedShellSessions();
3528
4418
  finish(`Shell command failed to start: ${error.message}`);
3529
4419
  });
3530
4420
  proc.on('close', (code, signal) => {
3531
4421
  session.completed = true;
3532
4422
  session.exitCode = code;
3533
4423
  session.signal = signal;
4424
+ pruneCompletedShellSessions();
3534
4425
 
3535
4426
  if (resolved) {
3536
4427
  return;
@@ -3581,7 +4472,7 @@ const tools = {
3581
4472
  if (!dir) dir = '.';
3582
4473
  // If AI sends "/" (root), treat as current directory "."
3583
4474
  if (dir === '/') dir = '.';
3584
- const entries = fs.readdirSync(dir);
4475
+ const entries = fs.readdirSync(resolveToolPath(dir));
3585
4476
  // Filter out ignored files/directories (respects .sapperignore)
3586
4477
  const filtered = entries.filter(entry => {
3587
4478
  if (shouldIgnore(entry)) return false;
@@ -3599,16 +4490,35 @@ const tools = {
3599
4490
  for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
3600
4491
  if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
3601
4492
  }
3602
- const excludeDirs = Array.from(allIgnoreDirs).join(',');
3603
- // Use grep to search for pattern, excluding ignored directories
3604
- const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludeDirs}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
4493
+ // Use grep with args array to avoid command injection
4494
+ const args = ['-rEin', pattern, '.'];
4495
+ for (const dir of allIgnoreDirs) {
4496
+ args.push(`--exclude-dir=${dir}`);
4497
+ }
4498
+ args.push('--include=*.js', '--include=*.ts', '--include=*.jsx', '--include=*.tsx',
4499
+ '--include=*.py', '--include=*.java', '--include=*.go', '--include=*.rs',
4500
+ '--include=*.rb', '--include=*.php', '--include=*.c', '--include=*.cpp',
4501
+ '--include=*.h', '--include=*.css', '--include=*.scss', '--include=*.html',
4502
+ '--include=*.json', '--include=*.md', '--include=*.txt', '--include=*.yml',
4503
+ '--include=*.yaml', '--include=*.toml', '--include=*.sh');
3605
4504
 
3606
- const proc = spawn('sh', ['-c', cmd], { cwd: process.cwd() });
4505
+ const proc = spawn('grep', args, { cwd: getToolWorkingDirectory() });
3607
4506
  let output = '';
4507
+ let lineCount = 0;
3608
4508
 
3609
- proc.stdout.on('data', (data) => { output += data.toString(); });
3610
- proc.stderr.on('data', (data) => { output += data.toString(); });
4509
+ proc.stdout.on('data', (data) => {
4510
+ const text = data.toString();
4511
+ const lines = text.split('\n');
4512
+ for (const line of lines) {
4513
+ if (lineCount >= 50) { proc.kill(); return; }
4514
+ if (line) { output += line + '\n'; lineCount++; }
4515
+ }
4516
+ });
4517
+ proc.stderr.on('data', () => {}); // ignore stderr
3611
4518
 
4519
+ proc.on('error', (err) => {
4520
+ resolve(`Error searching: ${err.message}`);
4521
+ });
3612
4522
  proc.on('close', () => {
3613
4523
  if (output.trim()) {
3614
4524
  resolve(`Found matches:\n${output.trim()}`);
@@ -3753,7 +4663,15 @@ async function runSapper() {
3753
4663
  process.exit(1);
3754
4664
  }
3755
4665
 
3756
- const selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
4666
+ // Use defaultModel from config if available and exists in local models
4667
+ let selectedModel;
4668
+ const configModel = sapperConfig.defaultModel;
4669
+ if (configModel && localModels.models.some(m => m.name === configModel)) {
4670
+ selectedModel = configModel;
4671
+ console.log(UI.slate(` Using configured model: ${configModel}`));
4672
+ } else {
4673
+ selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
4674
+ }
3757
4675
 
3758
4676
  // ─── Detect model capabilities & context window ───────────────────
3759
4677
  let useNativeTools = false;
@@ -3895,6 +4813,186 @@ async function runSapper() {
3895
4813
  }
3896
4814
  }
3897
4815
  },
4816
+ {
4817
+ type: 'function',
4818
+ function: {
4819
+ name: 'ls',
4820
+ description: 'List directory contents. If path is omitted, use the current tool working directory.',
4821
+ parameters: {
4822
+ type: 'object',
4823
+ properties: {
4824
+ path: { type: 'string', description: 'Directory path to list' }
4825
+ }
4826
+ }
4827
+ }
4828
+ },
4829
+ {
4830
+ type: 'function',
4831
+ function: {
4832
+ name: 'cat',
4833
+ description: 'Read the full contents of a file.',
4834
+ parameters: {
4835
+ type: 'object',
4836
+ properties: {
4837
+ path: { type: 'string', description: 'File path to read' }
4838
+ },
4839
+ required: ['path']
4840
+ }
4841
+ }
4842
+ },
4843
+ {
4844
+ type: 'function',
4845
+ function: {
4846
+ name: 'head',
4847
+ description: 'Read the first lines of a file.',
4848
+ parameters: {
4849
+ type: 'object',
4850
+ properties: {
4851
+ path: { type: 'string', description: 'File path to read' },
4852
+ lines: { type: 'number', description: 'How many lines to show (default 20)' }
4853
+ },
4854
+ required: ['path']
4855
+ }
4856
+ }
4857
+ },
4858
+ {
4859
+ type: 'function',
4860
+ function: {
4861
+ name: 'tail',
4862
+ description: 'Read the last lines of a file.',
4863
+ parameters: {
4864
+ type: 'object',
4865
+ properties: {
4866
+ path: { type: 'string', description: 'File path to read' },
4867
+ lines: { type: 'number', description: 'How many lines to show (default 20)' }
4868
+ },
4869
+ required: ['path']
4870
+ }
4871
+ }
4872
+ },
4873
+ {
4874
+ type: 'function',
4875
+ function: {
4876
+ name: 'grep',
4877
+ description: 'Search for matching text across project files.',
4878
+ parameters: {
4879
+ type: 'object',
4880
+ properties: {
4881
+ pattern: { type: 'string', description: 'Search pattern (text or regex)' }
4882
+ },
4883
+ required: ['pattern']
4884
+ }
4885
+ }
4886
+ },
4887
+ {
4888
+ type: 'function',
4889
+ function: {
4890
+ name: 'find',
4891
+ description: 'Find files or directories by name.',
4892
+ parameters: {
4893
+ type: 'object',
4894
+ properties: {
4895
+ pattern: { type: 'string', description: 'Name fragment to search for' },
4896
+ path: { type: 'string', description: 'Directory to search from (default current tool working directory)' }
4897
+ },
4898
+ required: ['pattern']
4899
+ }
4900
+ }
4901
+ },
4902
+ {
4903
+ type: 'function',
4904
+ function: {
4905
+ name: 'pwd',
4906
+ description: 'Show the current tool working directory.',
4907
+ parameters: {
4908
+ type: 'object',
4909
+ properties: {}
4910
+ }
4911
+ }
4912
+ },
4913
+ {
4914
+ type: 'function',
4915
+ function: {
4916
+ name: 'cd',
4917
+ description: 'Change the tool working directory for later tool calls.',
4918
+ parameters: {
4919
+ type: 'object',
4920
+ properties: {
4921
+ path: { type: 'string', description: 'Directory path to switch to' }
4922
+ },
4923
+ required: ['path']
4924
+ }
4925
+ }
4926
+ },
4927
+ {
4928
+ type: 'function',
4929
+ function: {
4930
+ name: 'rmdir',
4931
+ description: 'Remove a directory recursively after approval.',
4932
+ parameters: {
4933
+ type: 'object',
4934
+ properties: {
4935
+ path: { type: 'string', description: 'Directory path to remove' }
4936
+ },
4937
+ required: ['path']
4938
+ }
4939
+ }
4940
+ },
4941
+ {
4942
+ type: 'function',
4943
+ function: {
4944
+ name: 'changes',
4945
+ description: 'Show git status and diffs for the current repository or an optional file/directory path.',
4946
+ parameters: {
4947
+ type: 'object',
4948
+ properties: {
4949
+ path: { type: 'string', description: 'Optional file or directory path to scope the diff output' }
4950
+ }
4951
+ }
4952
+ }
4953
+ },
4954
+ {
4955
+ type: 'function',
4956
+ function: {
4957
+ name: 'fetch_web',
4958
+ description: 'Fetch a web page and return readable text content.',
4959
+ parameters: {
4960
+ type: 'object',
4961
+ properties: {
4962
+ url: { type: 'string', description: 'URL to fetch, starting with http:// or https://' }
4963
+ },
4964
+ required: ['url']
4965
+ }
4966
+ }
4967
+ },
4968
+ {
4969
+ type: 'function',
4970
+ function: {
4971
+ name: 'recall_memory',
4972
+ description: 'Search Sapper\'s saved conversation memory for relevant prior context.',
4973
+ parameters: {
4974
+ type: 'object',
4975
+ properties: {
4976
+ query: { type: 'string', description: 'Memory search query' }
4977
+ },
4978
+ required: ['query']
4979
+ }
4980
+ }
4981
+ },
4982
+ {
4983
+ type: 'function',
4984
+ function: {
4985
+ name: 'open_url',
4986
+ description: 'Open a URL in the default browser after approval.',
4987
+ parameters: {
4988
+ type: 'object',
4989
+ properties: {
4990
+ url: { type: 'string', description: 'URL to open, usually http://, https://, or file:' }
4991
+ },
4992
+ required: ['url']
4993
+ }
4994
+ }
4995
+ },
3898
4996
  {
3899
4997
  type: 'function',
3900
4998
  function: {
@@ -3918,6 +5016,20 @@ async function runSapper() {
3918
5016
  }];
3919
5017
  }
3920
5018
 
5019
+ // Auto-load defaultAgent from config if set
5020
+ if (!currentAgent && sapperConfig.defaultAgent) {
5021
+ const agents = loadAgents();
5022
+ const agentKey = sapperConfig.defaultAgent.toLowerCase();
5023
+ if (agents[agentKey]) {
5024
+ currentAgent = agentKey;
5025
+ currentAgentTools = agents[agentKey].tools || null;
5026
+ if (messages.length > 0 && messages[0]?.role === 'system') {
5027
+ messages[0].content = buildSystemPrompt(agents[agentKey].content);
5028
+ }
5029
+ console.log(UI.slate(` Using configured agent: ${agents[agentKey].name}`));
5030
+ }
5031
+ }
5032
+
3921
5033
  // Log session start
3922
5034
  logEntry('session_start', {
3923
5035
  model: selectedModel,
@@ -3952,19 +5064,26 @@ async function runSapper() {
3952
5064
  const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
3953
5065
  const promptParts = [
3954
5066
  statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
3955
- currentAgent ? statusBadge(`/${currentAgent}`, 'info') : statusBadge('default', 'neutral'),
5067
+ activeAgentPromptBadge(),
3956
5068
  ];
3957
- if (loadedSkills.length > 0) {
3958
- promptParts.push(statusBadge(`${loadedSkills.length} skill${loadedSkills.length !== 1 ? 's' : ''}`, 'success'));
5069
+ const skillsBadge = activeSkillsPromptBadge();
5070
+ if (skillsBadge) {
5071
+ promptParts.push(skillsBadge);
3959
5072
  }
3960
5073
  if (contextPercent !== null) {
3961
5074
  const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
3962
5075
  promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
3963
5076
  }
3964
5077
 
3965
- const promptDetail = ctxLen
5078
+ const promptDetailLines = [ctxLen
3966
5079
  ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
3967
- : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`);
5080
+ : UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`)
5081
+ ];
5082
+ const modeSummary = activeModeSummary({ includeAgent: true, maxSkills: 3 });
5083
+ if (modeSummary) {
5084
+ promptDetailLines.push(UI.slate(modeSummary));
5085
+ }
5086
+ const promptDetail = promptDetailLines.join('\n');
3968
5087
 
3969
5088
  const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
3970
5089
  const input = await safeQuestion(promptText);
@@ -3975,7 +5094,7 @@ async function runSapper() {
3975
5094
  continue;
3976
5095
  }
3977
5096
 
3978
- const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
5097
+ const preview = input.length > LIMITS.INPUT_PREVIEW_CHARS ? input.substring(0, LIMITS.INPUT_PREVIEW_CHARS) + chalk.gray('...') : input;
3979
5098
  console.log(UI.accent('› ') + chalk.white(preview));
3980
5099
 
3981
5100
  if (input.toLowerCase() === 'exit') {
@@ -4134,8 +5253,8 @@ async function runSapper() {
4134
5253
  console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
4135
5254
  }
4136
5255
 
4137
- if (results.length > 15) {
4138
- console.log(chalk.gray(`\n ... and ${results.length - 15} more`));
5256
+ if (results.length > LIMITS.SYMBOL_RESULTS_MAX) {
5257
+ console.log(chalk.gray(`\n ... and ${results.length - LIMITS.SYMBOL_RESULTS_MAX} more`));
4139
5258
  }
4140
5259
 
4141
5260
  // Offer to add file to context
@@ -4204,7 +5323,7 @@ async function runSapper() {
4204
5323
  let contextContent = `\n📄 ${matchingFile}:\n`;
4205
5324
  contextContent += fs.readFileSync(matchingFile, 'utf8');
4206
5325
 
4207
- for (const relFile of related.slice(0, 5)) { // Limit to 5 related
5326
+ for (const relFile of related.slice(0, LIMITS.WORKSPACE_RELATED_DEPTH)) { // Limit to N related
4208
5327
  try {
4209
5328
  contextContent += `\n\n📄 ${relFile} (related):\n`;
4210
5329
  contextContent += fs.readFileSync(relFile, 'utf8');
@@ -4457,14 +5576,14 @@ async function runSapper() {
4457
5576
  const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.endsWith('.md')).sort().reverse();
4458
5577
  if (logFiles.length > 0) {
4459
5578
  console.log(chalk.cyan(`\n📋 All session logs:`));
4460
- logFiles.slice(0, 10).forEach((f, i) => {
5579
+ logFiles.slice(0, LIMITS.LOG_FILES_DISPLAY_MAX).forEach((f, i) => {
4461
5580
  const stats = fs.statSync(`${LOGS_DIR}/${f}`);
4462
5581
  const isCurrent = f === `session-${sessionId}.md`;
4463
5582
  const label = isCurrent ? chalk.green(' ← current') : '';
4464
5583
  console.log(chalk.gray(` ${i + 1}. `) + chalk.white(f) + chalk.gray(` (${Math.round(stats.size / 1024)}KB)`) + label);
4465
5584
  });
4466
- if (logFiles.length > 10) {
4467
- console.log(chalk.gray(` ... and ${logFiles.length - 10} more`));
5585
+ if (logFiles.length > LIMITS.LOG_FILES_DISPLAY_MAX) {
5586
+ console.log(chalk.gray(` ... and ${logFiles.length - LIMITS.LOG_FILES_DISPLAY_MAX} more`));
4468
5587
  }
4469
5588
  }
4470
5589
  } catch (e) {}
@@ -4564,6 +5683,7 @@ async function runSapper() {
4564
5683
  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`;
4565
5684
 
4566
5685
  fs.writeFileSync(agentFile, agentMd);
5686
+ invalidateLoaderCache('agents');
4567
5687
  console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
4568
5688
  console.log(chalk.gray(` File: ${agentFile}`));
4569
5689
  console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
@@ -4685,13 +5805,14 @@ async function runSapper() {
4685
5805
  const agentTitle = await safeQuestion(chalk.cyan('Agent title/role: '));
4686
5806
  const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
4687
5807
  const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
4688
- const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,search,shell: '));
5808
+ const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,ls,search,grep,find,shell,mkdir,rmdir,pwd,cd,cat,head,tail,changes,fetch,memory,open: '));
4689
5809
 
4690
5810
  const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
4691
5811
  const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
4692
5812
  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`;
4693
5813
 
4694
5814
  fs.writeFileSync(agentFile, agentMd);
5815
+ invalidateLoaderCache('agents');
4695
5816
  console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
4696
5817
  console.log(chalk.gray(` File: ${agentFile}`));
4697
5818
  console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
@@ -4732,6 +5853,7 @@ async function runSapper() {
4732
5853
  : `---\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`;
4733
5854
 
4734
5855
  fs.writeFileSync(skillFile, skillMd);
5856
+ invalidateLoaderCache('skills');
4735
5857
  console.log(chalk.green(`\n✅ Skill "${skillName}" created!`));
4736
5858
  console.log(chalk.gray(` File: ${skillFile}`));
4737
5859
  console.log(chalk.cyan(` Load it: /use ${skillName}`));
@@ -4849,7 +5971,7 @@ async function runSapper() {
4849
5971
  console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
4850
5972
  relevant.forEach((chunk, i) => {
4851
5973
  console.log(box(
4852
- chalk.gray(chunk.text.substring(0, 300) + '...') + '\n' +
5974
+ chalk.gray(chunk.text.substring(0, LIMITS.MEMORY_PREVIEW_CHARS) + '...') + '\n' +
4853
5975
  chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
4854
5976
  `Memory ${i + 1}`, 'magenta'
4855
5977
  ));
@@ -4919,12 +6041,12 @@ async function runSapper() {
4919
6041
  continue;
4920
6042
  }
4921
6043
  const stats = fs.statSync(filePath);
4922
- if (stats.size > MAX_FILE_SIZE) {
6044
+ if (stats.size > getMaxFileSize()) {
4923
6045
  console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
4924
6046
  console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
4925
6047
  console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
4926
6048
  console.log(chalk.yellow(` File: ${filePath}`));
4927
- console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(MAX_FILE_SIZE/1024)}KB)`));
6049
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(getMaxFileSize()/1024)}KB)`));
4928
6050
  console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
4929
6051
  continue;
4930
6052
  }
@@ -4948,16 +6070,7 @@ async function runSapper() {
4948
6070
  }
4949
6071
 
4950
6072
  // Build message with attachments
4951
- let attachedContent = '\n\n══════════════════════════════════════\n';
4952
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
4953
- attachedContent += '══════════════════════════════════════\n\n';
4954
-
4955
- for (const file of fileAttachments) {
4956
- attachedContent += `┌─── ${file.path} ───\n`;
4957
- attachedContent += file.content;
4958
- if (!file.content.endsWith('\n')) attachedContent += '\n';
4959
- attachedContent += `└─── END ${file.path} ───\n\n`;
4960
- }
6073
+ const attachedContent = formatFileAttachments(fileAttachments);
4961
6074
 
4962
6075
  messages.push({ role: 'user', content: prompt + attachedContent });
4963
6076
  // Continue to AI response (don't use 'continue' here)
@@ -4979,11 +6092,11 @@ async function runSapper() {
4979
6092
  }
4980
6093
  const stats = fs.statSync(filePath);
4981
6094
  if (stats.isFile()) {
4982
- if (stats.size > MAX_FILE_SIZE) {
6095
+ if (stats.size > getMaxFileSize()) {
4983
6096
  console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
4984
6097
  console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
4985
6098
  console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
4986
- console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(MAX_FILE_SIZE/1024)}KB limit`));
6099
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(getMaxFileSize()/1024)}KB limit`));
4987
6100
  console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
4988
6101
  } else {
4989
6102
  const content = fs.readFileSync(filePath, 'utf8');
@@ -4997,7 +6110,7 @@ async function runSapper() {
4997
6110
  try {
4998
6111
  if (!fileAttachments.some(f => f.path === relFile)) {
4999
6112
  const relStats = fs.statSync(relFile);
5000
- if (relStats.size <= MAX_FILE_SIZE) {
6113
+ if (relStats.size <= getMaxFileSize()) {
5001
6114
  const relContent = fs.readFileSync(relFile, 'utf8');
5002
6115
  fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
5003
6116
  console.log(chalk.gray(` ↳ +${relFile} (related)`));
@@ -5018,16 +6131,7 @@ async function runSapper() {
5018
6131
 
5019
6132
  // Build the final message with attachments
5020
6133
  if (fileAttachments.length > 0) {
5021
- let attachedContent = '\n\n══════════════════════════════════════\n';
5022
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
5023
- attachedContent += '══════════════════════════════════════\n\n';
5024
-
5025
- for (const file of fileAttachments) {
5026
- attachedContent += `┌─── ${file.path} ───\n`;
5027
- attachedContent += file.content;
5028
- if (!file.content.endsWith('\n')) attachedContent += '\n';
5029
- attachedContent += `└─── END ${file.path} ───\n\n`;
5030
- }
6134
+ const attachedContent = formatFileAttachments(fileAttachments);
5031
6135
 
5032
6136
  processedInput = input + attachedContent;
5033
6137
  }
@@ -5100,7 +6204,25 @@ async function runSapper() {
5100
6204
  let toolRounds = 0; // Prevent infinite loops
5101
6205
  const MAX_TOOL_ROUNDS = toolRoundLimit();
5102
6206
  const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
5103
- const MAX_PATCH_RETRIES = 3;
6207
+ const MAX_PATCH_RETRIES = getPatchRetries();
6208
+
6209
+ // Unified patch-with-retry logic used by both native and text-marker tool handlers
6210
+ async function patchWithRetry(filePath, oldText, newText) {
6211
+ const key = filePath.trim();
6212
+ if (patchFailures[key] >= MAX_PATCH_RETRIES) {
6213
+ return { result: `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${key}. STOP retrying PATCH on this file. Instead, READ the file to see exact content, then use LINE:number mode or WRITE to rewrite the file.`, success: false };
6214
+ }
6215
+ const result = await tools.patch(filePath, oldText, newText);
6216
+ if (result.includes('Successfully')) {
6217
+ patchFailures[key] = 0;
6218
+ return { result, success: true };
6219
+ }
6220
+ if (result.startsWith('Error:')) {
6221
+ patchFailures[key] = (patchFailures[key] || 0) + 1;
6222
+ return { result: result + `\n(Attempt ${patchFailures[key]}/${MAX_PATCH_RETRIES})`, success: false };
6223
+ }
6224
+ return { result, success: true };
6225
+ }
5104
6226
  const turnThinkingEnabled = shouldUseThinkingForInput(input);
5105
6227
 
5106
6228
  let active = true;
@@ -5123,10 +6245,13 @@ async function runSapper() {
5123
6245
  if (currentAgentTools) {
5124
6246
  const toolNameMap = {
5125
6247
  list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
5126
- write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
6248
+ write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
6249
+ ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
6250
+ pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
6251
+ fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
5127
6252
  };
5128
6253
  chatOpts.tools = nativeToolDefs.filter(t =>
5129
- currentAgentTools.includes(toolNameMap[t.function.name])
6254
+ isToolAllowedForAgent(currentAgentTools, toolNameMap[t.function.name])
5130
6255
  );
5131
6256
  } else {
5132
6257
  chatOpts.tools = nativeToolDefs;
@@ -5144,7 +6269,7 @@ async function runSapper() {
5144
6269
 
5145
6270
  let msg = '';
5146
6271
  let thinkMsg = ''; // Thinking/reasoning content from thinking models
5147
- const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
6272
+ const MAX_RESPONSE_LENGTH = LIMITS.MAX_RESPONSE_LENGTH;
5148
6273
  let lastChunkTime = Date.now();
5149
6274
  let repetitionCount = 0;
5150
6275
  let lastContent = '';
@@ -5162,7 +6287,18 @@ async function runSapper() {
5162
6287
  let lastVisibleActivityAt = Date.now();
5163
6288
  let heartbeatInterval = null;
5164
6289
 
5165
- console.log(sectionTitle('Sapper', selectedModel, 'cyan'));
6290
+ const activeAgent = getActiveAgentMeta();
6291
+ const responseTitle = activeAgent ? activeAgent.name || currentAgent : 'Sapper';
6292
+ const responseSubtitleParts = [selectedModel];
6293
+ if (activeAgent && currentAgent) {
6294
+ responseSubtitleParts.push(`/${currentAgent}`);
6295
+ }
6296
+ console.log(sectionTitle(responseTitle, responseSubtitleParts.join(' · '), activeAgent ? 'magenta' : 'cyan'));
6297
+ const responseModeSummary = activeModeSummary({ includeAgent: !activeAgent, maxSkills: 4 });
6298
+ if (responseModeSummary) {
6299
+ console.log(UI.slate(responseModeSummary));
6300
+ }
6301
+ const MAX_THINKING_IDLE_SECONDS = 300; // Abort if model stalls in thinking >5min
5166
6302
  if (streamHeartbeatEnabled()) {
5167
6303
  heartbeatInterval = setInterval(() => {
5168
6304
  if (abortStream) return;
@@ -5170,6 +6306,11 @@ async function runSapper() {
5170
6306
  if (isThinking) {
5171
6307
  const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
5172
6308
  const idleThreshold = streamIdleNoticeSeconds();
6309
+ if (idleSeconds >= MAX_THINKING_IDLE_SECONDS) {
6310
+ process.stdout.write(`\n${UI.slate(' │ ')}${chalk.yellow(`⚠ thinking stalled ${idleSeconds}s — aborting stream`)}\n`);
6311
+ abortStream = true;
6312
+ return;
6313
+ }
5173
6314
  if (idleSeconds >= idleThreshold && Date.now() - lastThinkingIdleNoticeAt >= 5000) {
5174
6315
  process.stdout.write(`\n${UI.slate(' │ ')}${UI.slate.italic(`... waiting ${idleSeconds}s for more reasoning`)}\n`);
5175
6316
  thinkingContinuationNeedsPrefix = true;
@@ -5186,6 +6327,8 @@ async function runSapper() {
5186
6327
  });
5187
6328
  }, 1000);
5188
6329
  }
6330
+ let streamErrored = null;
6331
+ try {
5189
6332
  for await (const chunk of response) {
5190
6333
  // Check if user pressed Ctrl+C
5191
6334
  if (abortStream) {
@@ -5240,14 +6383,14 @@ async function runSapper() {
5240
6383
  }
5241
6384
 
5242
6385
  // Smart loop detection: check for repetitive content patterns
5243
- if (msg.length > 10000) {
5244
- const recentContent = msg.slice(-500);
5245
- const previousContent = msg.slice(-1000, -500);
6386
+ if (msg.length > LIMITS.REPETITION_THRESHOLD) {
6387
+ const recentContent = msg.slice(-LIMITS.REPETITION_WINDOW);
6388
+ const previousContent = msg.slice(-LIMITS.REPETITION_WINDOW * 2, -LIMITS.REPETITION_WINDOW);
5246
6389
 
5247
6390
  // If last 500 chars are very similar to previous 500, might be looping
5248
6391
  if (recentContent === previousContent) {
5249
6392
  repetitionCount++;
5250
- if (repetitionCount > 3) {
6393
+ if (repetitionCount > LIMITS.REPETITION_COUNT) {
5251
6394
  console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
5252
6395
  wasRepetitionStopped = true;
5253
6396
  break;
@@ -5263,9 +6406,24 @@ async function runSapper() {
5263
6406
  // Don't break - just warn. User can Ctrl+C if needed
5264
6407
  }
5265
6408
  }
5266
- if (heartbeatInterval) {
5267
- clearInterval(heartbeatInterval);
5268
- heartbeatInterval = null;
6409
+ } catch (streamErr) {
6410
+ streamErrored = streamErr;
6411
+ } finally {
6412
+ if (heartbeatInterval) {
6413
+ clearInterval(heartbeatInterval);
6414
+ heartbeatInterval = null;
6415
+ }
6416
+ }
6417
+ if (streamErrored) {
6418
+ if (isThinking) {
6419
+ isThinking = false;
6420
+ process.stdout.write(`\n${UI.slate(' └─')}\n`);
6421
+ }
6422
+ process.stdout.write('\r\x1b[K');
6423
+ console.error(chalk.red(`\n❌ Stream error: ${streamErrored.message || streamErrored}`));
6424
+ logEntry('error', { message: `Stream error: ${streamErrored.message || streamErrored}` });
6425
+ active = false;
6426
+ continue;
5269
6427
  }
5270
6428
  if (isThinking) {
5271
6429
  isThinking = false;
@@ -5337,7 +6495,10 @@ async function runSapper() {
5337
6495
  // Map native function names to tool executors
5338
6496
  const nativeToolNameMap = {
5339
6497
  list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
5340
- write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR', run_shell: 'SHELL'
6498
+ write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
6499
+ ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
6500
+ pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
6501
+ fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
5341
6502
  };
5342
6503
 
5343
6504
  showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
@@ -5348,13 +6509,13 @@ async function runSapper() {
5348
6509
  const args = fn.arguments || {};
5349
6510
 
5350
6511
  // Enforce agent tool restrictions
5351
- if (currentAgentTools && !currentAgentTools.includes(toolType)) {
6512
+ if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, toolType)) {
5352
6513
  console.log(chalk.yellow(`\n⚠️ Tool ${toolType} blocked — not in agent's allowed tools`));
5353
6514
  messages.push({ role: 'tool', content: `Error: Tool ${toolType} is not allowed for the current agent.`, tool_name: fn.name });
5354
6515
  continue;
5355
6516
  }
5356
6517
 
5357
- const displayPath = args.path || args.pattern || args.command || '';
6518
+ const displayPath = args.path || args.pattern || args.url || args.query || args.command || '';
5358
6519
  console.log();
5359
6520
  console.log(statusBadge(toolType, 'action') + chalk.gray(' → ') + chalk.white(displayPath));
5360
6521
 
@@ -5381,26 +6542,66 @@ async function runSapper() {
5381
6542
  logEntry('file', { action: 'write', path: args.path, size: args.content?.length || 0, userApproved: result.includes('Successfully') });
5382
6543
  break;
5383
6544
  case 'patch_file': {
5384
- const patchKey = args.path?.trim();
5385
- if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
5386
- result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. Use read_file to see exact content, then try write_file instead.`;
5387
- toolSuccess = false;
5388
- } else {
5389
- result = await tools.patch(args.path, args.old_text, args.new_text);
5390
- if (result.includes('Successfully')) {
5391
- patchFailures[patchKey] = 0;
5392
- } else if (result.startsWith('Error:')) {
5393
- patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
5394
- result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES})`;
5395
- }
5396
- }
5397
- logEntry('file', { action: 'patch', path: args.path, userApproved: result.includes('Successfully') });
6545
+ const pr = await patchWithRetry(args.path, args.old_text, args.new_text);
6546
+ result = pr.result;
6547
+ toolSuccess = pr.success;
6548
+ logEntry('file', { action: 'patch', path: args.path, userApproved: pr.success });
5398
6549
  break;
5399
6550
  }
5400
6551
  case 'create_directory':
5401
6552
  result = tools.mkdir(args.path);
5402
6553
  logEntry('file', { action: 'mkdir', path: args.path });
5403
6554
  break;
6555
+ case 'ls':
6556
+ result = tools.ls(args.path ?? '.');
6557
+ logEntry('file', { action: 'list', path: args.path ?? '.' });
6558
+ break;
6559
+ case 'cat':
6560
+ result = tools.cat(args.path);
6561
+ logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
6562
+ break;
6563
+ case 'head':
6564
+ result = tools.head(args.path, args.lines);
6565
+ logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
6566
+ break;
6567
+ case 'tail':
6568
+ result = tools.tail(args.path, args.lines);
6569
+ logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
6570
+ break;
6571
+ case 'grep':
6572
+ result = await tools.grep(args.pattern);
6573
+ logEntry('tool', { toolType: 'GREP', path: args.pattern, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
6574
+ break;
6575
+ case 'find':
6576
+ result = tools.find(args.pattern, args.path ?? '.');
6577
+ logEntry('tool', { toolType: 'FIND', path: args.pattern, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6578
+ break;
6579
+ case 'pwd':
6580
+ result = tools.pwd();
6581
+ break;
6582
+ case 'cd':
6583
+ result = tools.cd(args.path);
6584
+ break;
6585
+ case 'rmdir':
6586
+ result = await tools.rmdir(args.path);
6587
+ logEntry('file', { action: 'rmdir', path: args.path, userApproved: !String(result).includes('blocked') });
6588
+ break;
6589
+ case 'changes':
6590
+ result = await tools.changes(args.path);
6591
+ logEntry('tool', { toolType: 'CHANGES', path: args.path ?? '.', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6592
+ break;
6593
+ case 'fetch_web':
6594
+ result = await tools.fetch_web(args.url);
6595
+ logEntry('tool', { toolType: 'FETCH', path: args.url, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6596
+ break;
6597
+ case 'recall_memory':
6598
+ result = await tools.recall_memory(args.query);
6599
+ logEntry('tool', { toolType: 'MEMORY', path: args.query, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6600
+ break;
6601
+ case 'open_url':
6602
+ result = await tools.open_url(args.url);
6603
+ logEntry('tool', { toolType: 'OPEN', path: args.url, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
6604
+ break;
5404
6605
  case 'run_shell':
5405
6606
  result = await tools.shell(args.command);
5406
6607
  logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
@@ -5474,7 +6675,7 @@ async function runSapper() {
5474
6675
  const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
5475
6676
  if (toolAttempt) {
5476
6677
  console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
5477
- console.log(chalk.gray(` "${toolAttempt[0].substring(0, 150)}..."`));
6678
+ console.log(chalk.gray(` "${toolAttempt[0].substring(0, LIMITS.DEBUG_TOOL_PREVIEW)}..."`));
5478
6679
  }
5479
6680
  }
5480
6681
  console.log(chalk.magenta('═══════════════════════════════\n'));
@@ -5503,7 +6704,7 @@ async function runSapper() {
5503
6704
  const [_, type, path, content] = match;
5504
6705
 
5505
6706
  // Enforce tool restrictions from active agent
5506
- if (currentAgentTools && !currentAgentTools.includes(type.toUpperCase())) {
6707
+ if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, type)) {
5507
6708
  console.log();
5508
6709
  console.log(chalk.yellow(`⚠️ Tool ${type.toUpperCase()} blocked — not in agent's allowed tools: [${currentAgentTools.join(', ')}]`));
5509
6710
  const result = `Error: Tool ${type.toUpperCase()} is not allowed for the current agent. Allowed tools: ${currentAgentTools.join(', ')}. Use only the allowed tools.`;
@@ -5522,14 +6723,40 @@ async function runSapper() {
5522
6723
  result = tools.list(path);
5523
6724
  logEntry('file', { action: 'list', path });
5524
6725
  }
6726
+ else if (type.toLowerCase() === 'ls') {
6727
+ result = tools.ls(path);
6728
+ logEntry('file', { action: 'list', path: path || '.' });
6729
+ }
5525
6730
  else if (type.toLowerCase() === 'read') {
5526
6731
  result = tools.read(path);
5527
6732
  logEntry('file', { action: 'read', path, size: result?.length || 0 });
5528
6733
  }
6734
+ else if (type.toLowerCase() === 'cat') {
6735
+ result = tools.cat(path);
6736
+ logEntry('file', { action: 'read', path, size: result?.length || 0 });
6737
+ }
6738
+ else if (type.toLowerCase() === 'head') {
6739
+ result = tools.head(path, content);
6740
+ logEntry('file', { action: 'read', path, size: result?.length || 0 });
6741
+ }
6742
+ else if (type.toLowerCase() === 'tail') {
6743
+ result = tools.tail(path, content);
6744
+ logEntry('file', { action: 'read', path, size: result?.length || 0 });
6745
+ }
5529
6746
  else if (type.toLowerCase() === 'mkdir') {
5530
6747
  result = tools.mkdir(path);
5531
6748
  logEntry('file', { action: 'mkdir', path });
5532
6749
  }
6750
+ else if (type.toLowerCase() === 'rmdir') {
6751
+ result = await tools.rmdir(path);
6752
+ logEntry('file', { action: 'rmdir', path, userApproved: !String(result).includes('blocked') });
6753
+ }
6754
+ else if (type.toLowerCase() === 'pwd') {
6755
+ result = tools.pwd();
6756
+ }
6757
+ else if (type.toLowerCase() === 'cd') {
6758
+ result = tools.cd(path);
6759
+ }
5533
6760
  else if (type.toLowerCase() === 'write') {
5534
6761
  if (!content || content.trim() === '') {
5535
6762
  result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
@@ -5543,37 +6770,55 @@ async function runSapper() {
5543
6770
  else if (type.toLowerCase() === 'patch') {
5544
6771
  // PATCH format: [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL]
5545
6772
  // Also supports line mode: [TOOL:PATCH]path:::LINE:15|||new text[/TOOL]
5546
- const patchKey = path.trim();
5547
- if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
5548
- 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.`;
5549
- toolSuccess = false;
5550
- logEntry('file', { action: 'patch', path, userApproved: false });
6773
+ // Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
6774
+ let parts = null;
6775
+ const sepIdx = content?.indexOf('|||');
6776
+ if (sepIdx > -1) {
6777
+ parts = [content.substring(0, sepIdx), content.substring(sepIdx + 3)];
5551
6778
  } else {
5552
- // Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
5553
- let parts = content?.split('|||');
5554
- if (!parts || parts.length !== 2) {
5555
- parts = content?.split('||:');
5556
- }
5557
- if (parts && parts.length === 2) {
5558
- result = await tools.patch(path, parts[0], parts[1]);
5559
- const approved = result.includes('Successfully');
5560
- if (!approved && result.startsWith('Error:')) {
5561
- patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
5562
- result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES} — after ${MAX_PATCH_RETRIES} failures, PATCH will be blocked on this file)`;
5563
- } else if (approved) {
5564
- patchFailures[patchKey] = 0; // Reset on success
5565
- }
5566
- logEntry('file', { action: 'patch', path, userApproved: approved });
5567
- } else {
5568
- result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
5569
- toolSuccess = false;
6779
+ const sepIdx2 = content?.indexOf('||:');
6780
+ if (sepIdx2 > -1) {
6781
+ parts = [content.substring(0, sepIdx2), content.substring(sepIdx2 + 3)];
5570
6782
  }
5571
6783
  }
6784
+ if (parts && parts.length === 2) {
6785
+ const pr = await patchWithRetry(path, parts[0], parts[1]);
6786
+ result = pr.result;
6787
+ toolSuccess = pr.success;
6788
+ logEntry('file', { action: 'patch', path, userApproved: pr.success });
6789
+ } else {
6790
+ result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
6791
+ toolSuccess = false;
6792
+ }
5572
6793
  }
5573
6794
  else if (type.toLowerCase() === 'search') {
5574
6795
  result = await tools.search(path);
5575
6796
  logEntry('tool', { toolType: 'SEARCH', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
5576
6797
  }
6798
+ else if (type.toLowerCase() === 'grep') {
6799
+ result = await tools.grep(path);
6800
+ logEntry('tool', { toolType: 'GREP', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
6801
+ }
6802
+ else if (type.toLowerCase() === 'find') {
6803
+ result = tools.find(path, content);
6804
+ logEntry('tool', { toolType: 'FIND', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6805
+ }
6806
+ else if (type.toLowerCase() === 'changes') {
6807
+ result = await tools.changes(path);
6808
+ logEntry('tool', { toolType: 'CHANGES', path: path || '.', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6809
+ }
6810
+ else if (type.toLowerCase() === 'fetch') {
6811
+ result = await tools.fetch_web(path);
6812
+ logEntry('tool', { toolType: 'FETCH', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6813
+ }
6814
+ else if (type.toLowerCase() === 'memory') {
6815
+ result = await tools.recall_memory(path);
6816
+ logEntry('tool', { toolType: 'MEMORY', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
6817
+ }
6818
+ else if (type.toLowerCase() === 'open') {
6819
+ result = await tools.open_url(path);
6820
+ logEntry('tool', { toolType: 'OPEN', path, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
6821
+ }
5577
6822
  else if (type.toLowerCase() === 'shell') {
5578
6823
  result = await tools.shell(path);
5579
6824
  const approved = !result.includes('blocked');
@@ -5581,7 +6826,7 @@ async function runSapper() {
5581
6826
  }
5582
6827
 
5583
6828
  // Log tool execution (for non-shell, non-file specific ones)
5584
- if (!['list', 'read', 'mkdir', 'write', 'patch', 'search', 'shell'].includes(type.toLowerCase())) {
6829
+ if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'changes', 'fetch', 'memory', 'open', 'shell'].includes(type.toLowerCase())) {
5585
6830
  logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
5586
6831
  }
5587
6832
 
@@ -5590,7 +6835,7 @@ async function runSapper() {
5590
6835
  ensureSapperDir();
5591
6836
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
5592
6837
 
5593
- if (toolMatches.length > 30) {
6838
+ if (toolMatches.length > LIMITS.TOOL_WARN_THRESHOLD) {
5594
6839
  console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
5595
6840
  }
5596
6841