sapper-iq 1.1.37 → 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.
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.
626
760
 
627
- Be technical, thorough, and code-first. Always verify your changes work by running tests or builds.`,
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
783
+
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"
@@ -818,7 +978,10 @@ function buildSystemPrompt(agentContent = null, skillContents = []) {
818
978
  const now = new Date();
819
979
  const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
820
980
  const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
821
- let prompt = `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
981
+ const promptConfig = getPromptConfig();
982
+ const promptPrepend = promptConfig.prepend.trim();
983
+ const promptAppend = promptConfig.append.trim();
984
+ const corePrompt = promptConfig.coreOverride.trim() || `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
822
985
  You can help with ANY task - coding, writing, research, planning, analysis, and more.
823
986
  Adapt your personality and expertise based on the active agent role and loaded skills.
824
987
 
@@ -830,28 +993,60 @@ RULES:
830
993
  3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
831
994
  4. VERIFY: After making changes, verify they work (run tests, check output, etc).
832
995
  5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`;
996
+ let prompt = promptPrepend
997
+ ? `${wrapPromptCustomizationBlock('CUSTOM PROMPT PREPEND', promptPrepend, false)}\n\n${corePrompt}`
998
+ : corePrompt;
833
999
 
834
1000
  if (_useNativeToolsFlag) {
835
1001
  prompt += `
836
1002
 
837
1003
  TOOLS:
838
1004
  You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
839
- Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, run_shell.
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.
840
1006
 
841
1007
  PATCH TIPS:
842
1008
  - For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
843
1009
  - Always read_file first to see exact content before using patch_file.
844
- - If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.`;
1010
+ - If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.
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
+
1023
+ SHELL TIPS:
1024
+ - run_shell may keep long-running commands in a background session depending on config.
1025
+ - If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
1026
+ - Use run_shell command "__shell_list__" to list sessions and "__shell_stop__ <session_id>" to stop one.`;
845
1027
  } else {
846
1028
  prompt += `
847
1029
 
848
1030
  TOOL SYNTAX (use these to interact with files and system):
849
1031
  - [TOOL:LIST]dir[/TOOL] - List directory contents
1032
+ - [TOOL:LS]dir[/TOOL] - Alias for LIST
850
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)
851
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
852
1040
  - [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
853
1041
  - [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
854
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)
855
1050
  - [TOOL:SHELL]command[/TOOL] - Run shell command
856
1051
 
857
1052
  PATCH TIPS:
@@ -859,6 +1054,11 @@ PATCH TIPS:
859
1054
  - Always READ the file first to see exact content before using PATCH.
860
1055
  - If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
861
1056
 
1057
+ SHELL TIPS:
1058
+ - Long-running commands may be moved to a background shell session depending on config.
1059
+ - If shell output mentions a session id, inspect more output with [TOOL:SHELL]__shell_read__ <session_id>[/TOOL].
1060
+ - Use [TOOL:SHELL]__shell_list__[/TOOL] to list sessions and [TOOL:SHELL]__shell_stop__ <session_id>[/TOOL] to stop one.
1061
+
862
1062
  You MUST use the [TOOL:...][/TOOL] syntax above to perform actions. This is how you interact with the filesystem and shell - there is no other way. When you want to read a file, output [TOOL:READ]path[/TOOL] in your response. When you want to list a directory, output [TOOL:LIST].[/TOOL]. Always actually use the tools - do not just describe what you would do.
863
1063
  Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
864
1064
  }
@@ -894,6 +1094,10 @@ FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to u
894
1094
  prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
895
1095
  }
896
1096
 
1097
+ if (promptAppend) {
1098
+ prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
1099
+ }
1100
+
897
1101
  return prompt;
898
1102
  }
899
1103
 
@@ -902,20 +1106,218 @@ let currentAgent = null; // null = default Sapper, or agent name string
902
1106
  let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
903
1107
  let loadedSkills = []; // array of skill names currently loaded
904
1108
 
905
- // Load config (settings like autoAttach)
1109
+ const DEFAULT_CONFIG = Object.freeze({
1110
+ defaultModel: null,
1111
+ defaultAgent: null,
1112
+ autoAttach: true,
1113
+ debug: false,
1114
+ contextLimit: null,
1115
+ toolRoundLimit: 40,
1116
+ patchRetries: 3,
1117
+ maxFileSize: 100000,
1118
+ maxScanSize: 1000000,
1119
+ maxUrlSize: 200000,
1120
+ summaryPhases: true,
1121
+ summarizeTriggerPercent: 65,
1122
+ shell: Object.freeze({
1123
+ streamToModel: true,
1124
+ backgroundMode: 'auto',
1125
+ backgroundAfterSeconds: 8,
1126
+ outputChunkChars: 4000,
1127
+ }),
1128
+ streaming: Object.freeze({
1129
+ showPhaseStatus: true,
1130
+ showHeartbeat: true,
1131
+ idleNoticeSeconds: 4,
1132
+ }),
1133
+ thinking: Object.freeze({
1134
+ mode: 'auto',
1135
+ }),
1136
+ prompt: Object.freeze({
1137
+ prepend: '',
1138
+ append: '',
1139
+ coreOverride: '',
1140
+ }),
1141
+ });
1142
+
1143
+ function normalizeBoolean(value, fallback) {
1144
+ if (typeof value === 'boolean') return value;
1145
+ if (typeof value === 'string') {
1146
+ const normalized = value.trim().toLowerCase();
1147
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
1148
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false;
1149
+ }
1150
+ return fallback;
1151
+ }
1152
+
1153
+ function normalizeContextLimit(value) {
1154
+ const parsed = Number(value);
1155
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : null;
1156
+ }
1157
+
1158
+ function normalizeSummarizeTriggerPercent(value) {
1159
+ let parsed = Number(value);
1160
+ if (!Number.isFinite(parsed)) return DEFAULT_CONFIG.summarizeTriggerPercent;
1161
+ if (parsed > 0 && parsed <= 1) parsed *= 100;
1162
+ return Math.max(40, Math.min(90, Math.round(parsed)));
1163
+ }
1164
+
1165
+ function normalizeToolRoundLimit(value) {
1166
+ return normalizeIntegerInRange(value, DEFAULT_CONFIG.toolRoundLimit, 1, 200);
1167
+ }
1168
+
1169
+ function normalizeThinkingMode(value) {
1170
+ if (typeof value === 'boolean') return value ? 'on' : 'off';
1171
+ const normalized = String(value ?? '').trim().toLowerCase();
1172
+ if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
1173
+ if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
1174
+ return 'auto';
1175
+ }
1176
+
1177
+ function normalizeShellBackgroundMode(value) {
1178
+ if (typeof value === 'boolean') return value ? 'on' : 'off';
1179
+ const normalized = String(value ?? '').trim().toLowerCase();
1180
+ if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
1181
+ if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
1182
+ return 'auto';
1183
+ }
1184
+
1185
+ function normalizeThinkingConfig(thinkingConfig = {}) {
1186
+ if (typeof thinkingConfig === 'boolean' || typeof thinkingConfig === 'string') {
1187
+ return { mode: normalizeThinkingMode(thinkingConfig) };
1188
+ }
1189
+
1190
+ if (!thinkingConfig || typeof thinkingConfig !== 'object' || Array.isArray(thinkingConfig)) {
1191
+ return { ...DEFAULT_CONFIG.thinking };
1192
+ }
1193
+
1194
+ return {
1195
+ mode: normalizeThinkingMode(thinkingConfig.mode),
1196
+ };
1197
+ }
1198
+
1199
+ function normalizeShellConfig(shellConfig = {}) {
1200
+ if (typeof shellConfig === 'boolean' || typeof shellConfig === 'string') {
1201
+ return {
1202
+ ...DEFAULT_CONFIG.shell,
1203
+ backgroundMode: normalizeShellBackgroundMode(shellConfig),
1204
+ };
1205
+ }
1206
+
1207
+ if (!shellConfig || typeof shellConfig !== 'object' || Array.isArray(shellConfig)) {
1208
+ return { ...DEFAULT_CONFIG.shell };
1209
+ }
1210
+
1211
+ return {
1212
+ streamToModel: normalizeBoolean(shellConfig.streamToModel, DEFAULT_CONFIG.shell.streamToModel),
1213
+ backgroundMode: normalizeShellBackgroundMode(shellConfig.backgroundMode),
1214
+ backgroundAfterSeconds: normalizeIntegerInRange(shellConfig.backgroundAfterSeconds, DEFAULT_CONFIG.shell.backgroundAfterSeconds, 2, 120),
1215
+ outputChunkChars: normalizeIntegerInRange(shellConfig.outputChunkChars, DEFAULT_CONFIG.shell.outputChunkChars, 400, 12000),
1216
+ };
1217
+ }
1218
+
1219
+ function normalizeIntegerInRange(value, fallback, min, max) {
1220
+ const parsed = Number(value);
1221
+ if (!Number.isFinite(parsed)) return fallback;
1222
+ return Math.max(min, Math.min(max, Math.round(parsed)));
1223
+ }
1224
+
1225
+ function normalizeStreamingConfig(streamingConfig = {}) {
1226
+ if (typeof streamingConfig === 'boolean') {
1227
+ return {
1228
+ ...DEFAULT_CONFIG.streaming,
1229
+ showPhaseStatus: streamingConfig,
1230
+ showHeartbeat: streamingConfig,
1231
+ };
1232
+ }
1233
+
1234
+ if (!streamingConfig || typeof streamingConfig !== 'object' || Array.isArray(streamingConfig)) {
1235
+ return { ...DEFAULT_CONFIG.streaming };
1236
+ }
1237
+
1238
+ return {
1239
+ showPhaseStatus: normalizeBoolean(streamingConfig.showPhaseStatus, DEFAULT_CONFIG.streaming.showPhaseStatus),
1240
+ showHeartbeat: normalizeBoolean(streamingConfig.showHeartbeat, DEFAULT_CONFIG.streaming.showHeartbeat),
1241
+ idleNoticeSeconds: normalizeIntegerInRange(streamingConfig.idleNoticeSeconds, DEFAULT_CONFIG.streaming.idleNoticeSeconds, 2, 60),
1242
+ };
1243
+ }
1244
+
1245
+ function normalizePromptText(value) {
1246
+ if (typeof value === 'string') return value;
1247
+ if (value === null || value === undefined) return '';
1248
+ return String(value);
1249
+ }
1250
+
1251
+ function normalizePromptConfig(promptConfig = {}) {
1252
+ if (!promptConfig || typeof promptConfig !== 'object' || Array.isArray(promptConfig)) {
1253
+ return {
1254
+ ...DEFAULT_CONFIG.prompt,
1255
+ append: normalizePromptText(promptConfig),
1256
+ };
1257
+ }
1258
+
1259
+ const coreOverride = promptConfig.coreOverride !== undefined
1260
+ ? promptConfig.coreOverride
1261
+ : promptConfig.override;
1262
+
1263
+ return {
1264
+ prepend: normalizePromptText(promptConfig.prepend),
1265
+ append: normalizePromptText(promptConfig.append),
1266
+ coreOverride: normalizePromptText(coreOverride),
1267
+ };
1268
+ }
1269
+
1270
+ function normalizeConfig(config = {}) {
1271
+ return {
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,
1275
+ autoAttach: normalizeBoolean(config.autoAttach, DEFAULT_CONFIG.autoAttach),
1276
+ debug: normalizeBoolean(config.debug, DEFAULT_CONFIG.debug),
1277
+ contextLimit: normalizeContextLimit(config.contextLimit),
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),
1283
+ summaryPhases: normalizeBoolean(config.summaryPhases, DEFAULT_CONFIG.summaryPhases),
1284
+ summarizeTriggerPercent: normalizeSummarizeTriggerPercent(config.summarizeTriggerPercent),
1285
+ shell: normalizeShellConfig(config.shell),
1286
+ streaming: normalizeStreamingConfig(config.streaming),
1287
+ thinking: normalizeThinkingConfig(config.thinking),
1288
+ prompt: normalizePromptConfig(config.prompt),
1289
+ };
1290
+ }
1291
+
1292
+ // Load config (settings like autoAttach and context summarization)
906
1293
  function loadConfig() {
907
1294
  try {
908
1295
  ensureSapperDir();
909
1296
  if (fs.existsSync(CONFIG_FILE)) {
910
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
1297
+ const rawConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
1298
+ const normalizedConfig = normalizeConfig(rawConfig);
1299
+ if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) {
1300
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
1301
+ }
1302
+ return normalizedConfig;
1303
+ }
1304
+ } catch (e) {}
1305
+
1306
+ const defaultConfig = normalizeConfig();
1307
+ try {
1308
+ ensureSapperDir();
1309
+ if (!fs.existsSync(CONFIG_FILE)) {
1310
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2));
911
1311
  }
912
1312
  } catch (e) {}
913
- return { autoAttach: true, contextLimit: null }; // Default: auto-attach ON, no custom context limit
1313
+ return defaultConfig;
914
1314
  }
915
1315
 
916
1316
  function saveConfig(config) {
917
1317
  ensureSapperDir();
918
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
1318
+ const normalizedConfig = normalizeConfig(config);
1319
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
1320
+ sapperConfig = normalizedConfig;
919
1321
  }
920
1322
 
921
1323
  // Global config
@@ -929,6 +1331,264 @@ function effectiveContextLength() {
929
1331
  return modelContextLength;
930
1332
  }
931
1333
 
1334
+ const SUMMARY_PHASES = [
1335
+ 'Prepare summary request',
1336
+ 'Summarize older messages',
1337
+ 'Save compressed context',
1338
+ 'Resume your prompt',
1339
+ ];
1340
+
1341
+ function summaryPhasesEnabled() {
1342
+ return sapperConfig.summaryPhases !== false;
1343
+ }
1344
+
1345
+ function toolRoundLimit() {
1346
+ return normalizeToolRoundLimit(sapperConfig.toolRoundLimit);
1347
+ }
1348
+
1349
+ function getShellConfig() {
1350
+ return normalizeShellConfig(sapperConfig.shell);
1351
+ }
1352
+
1353
+ function shellStreamToModelEnabled() {
1354
+ return getShellConfig().streamToModel;
1355
+ }
1356
+
1357
+ function shellBackgroundMode() {
1358
+ return getShellConfig().backgroundMode;
1359
+ }
1360
+
1361
+ function shellBackgroundAfterSeconds() {
1362
+ return getShellConfig().backgroundAfterSeconds;
1363
+ }
1364
+
1365
+ function shellOutputChunkChars() {
1366
+ return getShellConfig().outputChunkChars;
1367
+ }
1368
+
1369
+ function summaryTriggerPercent() {
1370
+ return normalizeSummarizeTriggerPercent(sapperConfig.summarizeTriggerPercent);
1371
+ }
1372
+
1373
+ function summaryTokenThreshold(ctxLen) {
1374
+ return ctxLen ? Math.floor(ctxLen * (summaryTriggerPercent() / 100)) : 8000;
1375
+ }
1376
+
1377
+ function parseSummaryTriggerInput(value) {
1378
+ if (value === undefined || value === null) return null;
1379
+ const normalized = String(value).trim().replace(/%$/, '');
1380
+ if (!normalized) return null;
1381
+
1382
+ let parsed = Number(normalized);
1383
+ if (!Number.isFinite(parsed)) return null;
1384
+ if (parsed > 0 && parsed <= 1) parsed *= 100;
1385
+
1386
+ return Math.max(40, Math.min(90, Math.round(parsed)));
1387
+ }
1388
+
1389
+ function summaryPhaseText(stepNumber, detail = '') {
1390
+ const fallback = SUMMARY_PHASES[stepNumber - 1] || 'Context summarization';
1391
+ if (!summaryPhasesEnabled()) {
1392
+ return detail || fallback;
1393
+ }
1394
+ return detail
1395
+ ? `Step ${stepNumber}/${SUMMARY_PHASES.length} ${detail}`
1396
+ : `Step ${stepNumber}/${SUMMARY_PHASES.length} ${fallback}`;
1397
+ }
1398
+
1399
+ function renderSummaryPhaseList(activeStep = null) {
1400
+ return SUMMARY_PHASES
1401
+ .map((label, index) => {
1402
+ const stepNumber = index + 1;
1403
+ const line = `Step ${stepNumber}/${SUMMARY_PHASES.length} ${label}`;
1404
+ return activeStep === stepNumber ? chalk.cyan(line) : UI.slate(line);
1405
+ })
1406
+ .join('\n');
1407
+ }
1408
+
1409
+ function getPromptConfig() {
1410
+ return normalizePromptConfig(sapperConfig.prompt);
1411
+ }
1412
+
1413
+ function getThinkingConfig() {
1414
+ return normalizeThinkingConfig(sapperConfig.thinking);
1415
+ }
1416
+
1417
+ function getStreamingConfig() {
1418
+ return normalizeStreamingConfig(sapperConfig.streaming);
1419
+ }
1420
+
1421
+ function streamPhaseStatusEnabled() {
1422
+ return getStreamingConfig().showPhaseStatus;
1423
+ }
1424
+
1425
+ function streamHeartbeatEnabled() {
1426
+ return getStreamingConfig().showHeartbeat;
1427
+ }
1428
+
1429
+ function streamIdleNoticeSeconds() {
1430
+ return getStreamingConfig().idleNoticeSeconds;
1431
+ }
1432
+
1433
+ function thinkingMode() {
1434
+ return getThinkingConfig().mode;
1435
+ }
1436
+
1437
+ function normalizeThinkingInput(input = '') {
1438
+ let normalized = String(input ?? '').trim();
1439
+ if (normalized.startsWith('/') && normalized.includes(' ')) {
1440
+ normalized = normalized.substring(normalized.indexOf(' ') + 1).trim();
1441
+ }
1442
+ return normalized;
1443
+ }
1444
+
1445
+ function isSimplePrompt(input = '') {
1446
+ const normalized = normalizeThinkingInput(input).toLowerCase();
1447
+ if (!normalized) return true;
1448
+ if (normalized.includes('\n')) return false;
1449
+ if (/@|https?:\/\//.test(normalized)) return false;
1450
+ if (/[`{}[\]();<>]/.test(normalized)) return false;
1451
+ if (/^(hi|hello|hey|thanks|thank you|ok|okay|continue|go on|proceed|yes|no|y|n|cool|nice|bye|good morning|good evening)$/.test(normalized)) {
1452
+ return true;
1453
+ }
1454
+ if (/\b(analyze|debug|fix|implement|refactor|design|plan|optimi[sz]e|architect|investigate|review|build|create|generate|search|find|error|bug|test|compare|explain deeply)\b/.test(normalized)) {
1455
+ return false;
1456
+ }
1457
+ if (normalized.length <= 32) return true;
1458
+ return normalized.length <= 60 && normalized.split(/\s+/).length <= 8;
1459
+ }
1460
+
1461
+ function shouldUseThinkingForInput(input = '') {
1462
+ const mode = thinkingMode();
1463
+ if (mode === 'on') return true;
1464
+ if (mode === 'off') return false;
1465
+ return !isSimplePrompt(input);
1466
+ }
1467
+
1468
+ function isLikelyLongRunningCommand(command = '') {
1469
+ const normalized = String(command ?? '').trim().toLowerCase();
1470
+ if (!normalized) return false;
1471
+
1472
+ const patterns = [
1473
+ /\buvicorn\b/,
1474
+ /\bnpm\s+run\s+(dev|start|watch)\b/,
1475
+ /\bpnpm\s+(dev|start|watch)\b/,
1476
+ /\byarn\s+(dev|start|watch)\b/,
1477
+ /\bnext\s+dev\b/,
1478
+ /\bvite\b/,
1479
+ /\bnodemon\b/,
1480
+ /\bdocker\s+compose\s+up\b/,
1481
+ /\bwebpack(?:\s+serve|\s+--watch)?\b/,
1482
+ /\bpython\s+-m\s+http\.server\b/,
1483
+ /\btail\s+-f\b/,
1484
+ /\bserve\b/,
1485
+ /--reload\b/,
1486
+ /--watch\b/
1487
+ ];
1488
+
1489
+ return patterns.some(pattern => pattern.test(normalized));
1490
+ }
1491
+
1492
+ function shouldBackgroundShellCommand(command = '') {
1493
+ const mode = shellBackgroundMode();
1494
+ if (mode === 'off') return false;
1495
+ if (mode === 'on') return true;
1496
+ return isLikelyLongRunningCommand(command);
1497
+ }
1498
+
1499
+ function hasCustomPromptConfig() {
1500
+ const promptConfig = getPromptConfig();
1501
+ return Boolean(promptConfig.prepend.trim() || promptConfig.append.trim() || promptConfig.coreOverride.trim());
1502
+ }
1503
+
1504
+ function wrapPromptCustomizationBlock(title, content, leadingNewline = true) {
1505
+ const normalized = String(content ?? '').trim();
1506
+ if (!normalized) return '';
1507
+ const prefix = leadingNewline ? '\n\n' : '';
1508
+ return `${prefix}═══ ${title} ═══\n${normalized}\n═══ END ${title} ═══`;
1509
+ }
1510
+
1511
+ function resolveLoadedSkillContents() {
1512
+ const allSkills = loadSkills();
1513
+ return loadedSkills.map(skillName => allSkills[skillName]?.content || '').filter(Boolean);
1514
+ }
1515
+
1516
+ function resolveActiveAgentContent() {
1517
+ if (!currentAgent) return null;
1518
+ const allAgents = loadAgents();
1519
+ return allAgents[currentAgent]?.content || null;
1520
+ }
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
+
1584
+ function refreshSystemPrompt(messages) {
1585
+ if (!Array.isArray(messages) || messages.length === 0) return;
1586
+ messages[0] = {
1587
+ role: 'system',
1588
+ content: buildSystemPrompt(resolveActiveAgentContent(), resolveLoadedSkillContents())
1589
+ };
1590
+ }
1591
+
932
1592
  // ═══════════════════════════════════════════════════════════════
933
1593
  // WORKSPACE GRAPH - Track file relationships and summaries
934
1594
  // ═══════════════════════════════════════════════════════════════
@@ -1048,7 +1708,7 @@ async function buildWorkspaceGraph(showProgress = true) {
1048
1708
 
1049
1709
  try {
1050
1710
  const stats = fs.statSync(fullPath);
1051
- if (stats.size > MAX_FILE_SIZE) continue;
1711
+ if (stats.size > getMaxFileSize()) continue;
1052
1712
 
1053
1713
  const content = fs.readFileSync(fullPath, 'utf8');
1054
1714
  const deps = extractDependencies(content, fullPath);
@@ -1130,12 +1790,12 @@ function formatWorkspaceSummary(workspace) {
1130
1790
 
1131
1791
  for (const [dir, files] of Object.entries(byDir)) {
1132
1792
  output += `📁 ${dir}/\n`;
1133
- 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
1134
1794
  const name = f.path.split('/').pop();
1135
1795
  const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
1136
1796
  output += ` 📄 ${name}${exportList}\n`;
1137
1797
  }
1138
- 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`;
1139
1799
  output += '\n';
1140
1800
  }
1141
1801
 
@@ -1446,13 +2106,13 @@ async function addToEmbeddings(text, embeddings) {
1446
2106
  const embedding = await getEmbedding(text);
1447
2107
  if (embedding) {
1448
2108
  embeddings.chunks.push({
1449
- text: text.substring(0, 2000), // Limit stored text
2109
+ text: text.substring(0, LIMITS.EMBEDDINGS_MAX_TEXT), // Limit stored text
1450
2110
  embedding,
1451
2111
  timestamp: Date.now()
1452
2112
  });
1453
- // Keep only last 100 chunks
1454
- if (embeddings.chunks.length > 100) {
1455
- 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);
1456
2116
  }
1457
2117
  saveEmbeddings(embeddings);
1458
2118
  }
@@ -1462,37 +2122,76 @@ async function addToEmbeddings(text, embeddings) {
1462
2122
  // SMART CONTEXT SUMMARIZATION
1463
2123
  // ═══════════════════════════════════════════════════════════════
1464
2124
 
1465
- async function autoSummarizeContext(messages, model, force = false) {
1466
- // 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) {
1467
2127
  const estimatedTokens = estimateMessagesTokens(messages);
1468
2128
  const contextSize = JSON.stringify(messages).length;
1469
-
1470
- // Summarize when we hit 75% of effective context window (leave room for response)
1471
2129
  const ctxLen = effectiveContextLength();
1472
- const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
1473
- // Also keep the old byte-based check as a fallback
2130
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
1474
2131
  const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
1475
- (!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);
1476
2167
 
1477
2168
  if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
1478
2169
 
1479
2170
  const usagePercent = ctxLen
1480
2171
  ? Math.round((estimatedTokens / ctxLen) * 100)
1481
- : Math.round((contextSize / 32000) * 100);
2172
+ : Math.round((contextSize / LIMITS.CONTEXT_BYTE_FALLBACK) * 100);
1482
2173
 
1483
2174
  console.log();
1484
- console.log(box(
1485
- `Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})\n` +
1486
- `${chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`)}\n` +
1487
- `${chalk.cyan('Auto-summarizing to stay within context window...')}`,
1488
- '🧠 Context Window Management', 'cyan'
1489
- ));
2175
+ const summaryIntroLines = [
2176
+ `Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})`,
2177
+ chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`),
2178
+ chalk.cyan('Auto-summarizing to stay within context window before answering your prompt...'),
2179
+ chalk.gray(`Trigger: ${summaryTriggerPercent()}% of the active context window (${tokenThreshold.toLocaleString()} tokens)`),
2180
+ chalk.gray('This is an extra model call, so large contexts can pause here for a while.'),
2181
+ ];
2182
+ if (summaryPhasesEnabled()) {
2183
+ summaryIntroLines.push('');
2184
+ summaryIntroLines.push(renderSummaryPhaseList(1));
2185
+ }
2186
+ console.log(box(summaryIntroLines.join('\n'), '🧠 Context Window Management', 'cyan'));
1490
2187
 
1491
- const summarySpinner = ora('Summarizing conversation...').start();
2188
+ const summaryStart = Date.now();
2189
+ const elapsedSummaryTime = () => `${Math.max(0, Math.round((Date.now() - summaryStart) / 1000))}s`;
2190
+ const summarySpinner = ora(summaryPhaseText(1, 'Preparing summary request...')).start();
1492
2191
 
1493
2192
  // Separate: system prompt, messages to summarize, recent messages to keep
1494
2193
  const systemPrompt = messages[0];
1495
- const recentCount = 4;
2194
+ const recentCount = LIMITS.SUMMARY_RECENT_MSGS;
1496
2195
  let recentMessages = messages.slice(-recentCount);
1497
2196
  let oldMessages = messages.slice(1, -recentCount);
1498
2197
 
@@ -1535,33 +2234,45 @@ async function autoSummarizeContext(messages, model, force = false) {
1535
2234
  .map(m => {
1536
2235
  const role = m.role === 'user' ? 'User' : 'Assistant';
1537
2236
  // Truncate very long messages (file contents, scan results, etc.)
1538
- const text = m.content.length > 1500
1539
- ? 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]'
1540
2239
  : m.content;
1541
2240
  return `${role}: ${text}`;
1542
2241
  })
1543
2242
  .join('\n\n');
1544
2243
 
2244
+ const conversationTokens = estimateTokens(conversationText);
2245
+ const conversationBytes = Buffer.byteLength(conversationText, 'utf8');
2246
+ summarySpinner.text = summaryPhaseText(1, `Preparing summary request from ${oldMessages.length} older messages (~${conversationTokens.toLocaleString()} tokens, ${formatBytes(conversationBytes)})`);
2247
+ let spinnerInterval = null;
2248
+
1545
2249
  try {
1546
- const summaryResponse = await ollama.chat({
1547
- model,
1548
- ...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
1549
- messages: [
1550
- {
1551
- role: 'system',
1552
- content: `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
2250
+ const summaryInstruction = `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
1553
2251
  - Key topics discussed and decisions made
1554
2252
  - Files that were read, created, or modified (with paths)
1555
2253
  - Important code changes or bugs found
1556
2254
  - Any pending tasks or open questions
1557
2255
  - Technical details that would be needed to continue the conversation
1558
- - Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH) and on what files
2256
+ - Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH, CHANGES, FETCH, MEMORY, OPEN) and on what files or URLs
1559
2257
  - The active agent role (if any) and loaded skills
1560
2258
  - Any tool usage patterns or workflows that were established
1561
2259
 
1562
2260
  CRITICAL: The AI assistant uses tools with syntax like [TOOL:READ]path[/TOOL]. Make sure to note which tools were used so the assistant remembers to keep using them after this summary.
1563
2261
 
1564
- Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`
2262
+ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`;
2263
+ const summaryInputTokens = estimateTokens(summaryInstruction) + estimateTokens(`Summarize this conversation:\n\n${conversationText}`);
2264
+ summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
2265
+ spinnerInterval = setInterval(() => {
2266
+ summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
2267
+ }, 1000);
2268
+
2269
+ const summaryResponse = await ollama.chat({
2270
+ model,
2271
+ ...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
2272
+ messages: [
2273
+ {
2274
+ role: 'system',
2275
+ content: summaryInstruction
1565
2276
  },
1566
2277
  {
1567
2278
  role: 'user',
@@ -1570,34 +2281,14 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1570
2281
  ],
1571
2282
  stream: false
1572
2283
  });
2284
+ clearInterval(spinnerInterval);
2285
+ spinnerInterval = null;
1573
2286
 
1574
2287
  const summary = summaryResponse.message.content;
1575
2288
 
1576
2289
  // Save old messages to embeddings before discarding
1577
- const embeddings = loadEmbeddings();
1578
- const textToEmbed = oldMessages
1579
- .filter(m => m.role !== 'system')
1580
- .map(m => m.content.substring(0, 500))
1581
- .join('\n---\n');
1582
-
1583
- if (textToEmbed.length > 50) {
1584
- try {
1585
- const embedding = await getEmbedding(textToEmbed);
1586
- if (embedding) {
1587
- embeddings.chunks.push({
1588
- text: textToEmbed.substring(0, 2000),
1589
- embedding,
1590
- timestamp: Date.now()
1591
- });
1592
- if (embeddings.chunks.length > 100) {
1593
- embeddings.chunks = embeddings.chunks.slice(-100);
1594
- }
1595
- saveEmbeddings(embeddings);
1596
- }
1597
- } catch (e) {
1598
- // Silently skip embedding if model not available
1599
- }
1600
- }
2290
+ summarySpinner.text = summaryPhaseText(3, `Saving compressed context and memory (${elapsedSummaryTime()} elapsed)`);
2291
+ const embeddings = await saveOldMessagesToEmbeddings(oldMessages);
1601
2292
 
1602
2293
  // Build agent role reminder if an agent is active
1603
2294
  const agentReminder = currentAgent ? `\nNote: You are currently operating as the "${currentAgent}" agent. Stay in character.` : '';
@@ -1608,13 +2299,13 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1608
2299
  systemPrompt,
1609
2300
  {
1610
2301
  role: 'user',
1611
- content: `[CONVERSATION SUMMARY - auto-generated]\n${summary}\n[END SUMMARY]\n\nUse this summary as context for our ongoing conversation. Continue using your tools (LIST, READ, WRITE, PATCH, SHELL, SEARCH) as needed.${agentReminder}${skillReminder}`
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}`
1612
2303
  },
1613
2304
  {
1614
2305
  role: 'assistant',
1615
2306
  content: _useNativeToolsFlag
1616
- ? `Understood. I have the conversation summary and will continue helping you. I'll use my tools (list_directory, read_file, write_file, patch_file, search_files, run_shell) as needed.\n\nWhat would you like me to do next?`
1617
- : `Understood. I have the conversation summary and will continue helping you. I'll keep using my tools to explore files, make changes, and run commands as needed:\n- [TOOL:LIST] to browse directories\n- [TOOL:READ] to read files\n- [TOOL:WRITE] to create/overwrite files\n- [TOOL:PATCH] to edit existing files\n- [TOOL:SEARCH] to find patterns\n- [TOOL:SHELL] to run commands\n\nWhat would you like me to do next?`
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?`
1618
2309
  },
1619
2310
  ...recentMessages
1620
2311
  ];
@@ -1626,10 +2317,16 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1626
2317
  const newSize = JSON.stringify(newMessages).length;
1627
2318
  const newTokens = estimateMessagesTokens(newMessages);
1628
2319
  summarySpinner.stop();
2320
+ if (summaryPhasesEnabled()) {
2321
+ console.log(chalk.gray(` ${summaryPhaseText(4, 'Context ready. Returning to chat...')}`));
2322
+ }
1629
2323
  console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
1630
2324
  if (ctxLen) {
1631
2325
  const newPercent = Math.round((newTokens / ctxLen) * 100);
1632
2326
  console.log(chalk.gray(` 📊 Context window usage: ${newPercent}% of ${ctxLen.toLocaleString()} tokens`));
2327
+ if (newPercent >= 80) {
2328
+ console.log(chalk.yellow(' ⚠️ Context is still dense, so the next reply may still be slower than usual.'));
2329
+ }
1633
2330
  }
1634
2331
  if (embeddings.chunks.length > 0) {
1635
2332
  console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
@@ -1642,6 +2339,7 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
1642
2339
 
1643
2340
  return newMessages;
1644
2341
  } catch (e) {
2342
+ if (spinnerInterval) clearInterval(spinnerInterval);
1645
2343
  summarySpinner.stop();
1646
2344
  console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
1647
2345
  console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
@@ -1769,6 +2467,22 @@ function commandRow(command, description, width = 18) {
1769
2467
  return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
1770
2468
  }
1771
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
+
1772
2486
  function meter(current = 0, total = 0, width = 20) {
1773
2487
  if (!total || total <= 0) return UI.slate('░'.repeat(width));
1774
2488
 
@@ -1788,7 +2502,54 @@ function promptShell(label, detail = '') {
1788
2502
  return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
1789
2503
  }
1790
2504
 
1791
- function confirmPrompt(label, type = 'warning') {
2505
+ function renderedTerminalLineCount(text = '', width = process.stdout.columns || 80) {
2506
+ const terminalColumns = Math.max(1, width || 80);
2507
+ return String(text ?? '')
2508
+ .split('\n')
2509
+ .reduce((count, line) => count + Math.max(1, Math.ceil(Math.max(1, visibleLength(line)) / terminalColumns)), 0);
2510
+ }
2511
+
2512
+ function clearPromptEcho(promptText, inputText = '') {
2513
+ const totalLines = renderedTerminalLineCount(`${promptText}${inputText}`);
2514
+ for (let index = 0; index < totalLines; index++) {
2515
+ process.stdout.write('\x1B[1A\x1B[2K');
2516
+ }
2517
+ process.stdout.write('\r');
2518
+ }
2519
+
2520
+ function streamPhaseMessage(message, type = 'neutral') {
2521
+ const colorFn = BADGE_STYLES[type] || UI.slate;
2522
+ return `${colorFn('[status]')} ${UI.slate(message)}`;
2523
+ }
2524
+
2525
+ function showStreamPhase(message, type = 'neutral') {
2526
+ if (!streamPhaseStatusEnabled()) return;
2527
+ console.log(streamPhaseMessage(message, type));
2528
+ }
2529
+
2530
+ function renderStreamingHeartbeat({
2531
+ genTokenCount = 0,
2532
+ genStartTime,
2533
+ lastVisibleActivityAt,
2534
+ stage = 'generating',
2535
+ }) {
2536
+ const elapsedSeconds = Math.max((Date.now() - genStartTime) / 1000, 0.1);
2537
+ const elapsed = elapsedSeconds.toFixed(1);
2538
+ const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
2539
+ const idleThreshold = streamIdleNoticeSeconds();
2540
+
2541
+ if (stage === 'waiting-first') {
2542
+ const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s` : '';
2543
+ process.stdout.write(`\r ${UI.slate(`Waiting for first model chunk... ${elapsed}s elapsed${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
2544
+ return;
2545
+ }
2546
+
2547
+ const tps = genTokenCount / elapsedSeconds;
2548
+ const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s for next chunk` : '';
2549
+ process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
2550
+ }
2551
+
2552
+ function confirmPrompt(label, type = 'warning', optionsLabel = '[y/N] ') {
1792
2553
  const colors = {
1793
2554
  info: UI.accent,
1794
2555
  success: UI.mint,
@@ -1798,16 +2559,273 @@ function confirmPrompt(label, type = 'warning') {
1798
2559
  neutral: UI.slate,
1799
2560
  };
1800
2561
  const colorFn = colors[type] || UI.gold;
1801
- return colorFn(`\n${label}? `) + UI.slate('[y/N] ');
2562
+ return colorFn(`\n${label}? `) + UI.slate(optionsLabel);
1802
2563
  }
1803
2564
 
1804
- // Configure marked with terminal renderer
1805
- marked.use(markedTerminal({
1806
- code: chalk.cyan,
1807
- blockquote: chalk.gray.italic,
1808
- html: chalk.gray,
1809
- heading: chalk.bold.cyan,
1810
- firstHeading: chalk.bold.cyan,
2565
+ function parseApprovalShortcut(input = '') {
2566
+ const trimmed = String(input ?? '').trim();
2567
+ if (!trimmed) return null;
2568
+
2569
+ const match = trimmed.match(/^(f|feedback|e|edit)\b(?:\s*[:=-]?\s*(.*))?$/i);
2570
+ if (!match) return null;
2571
+
2572
+ const command = match[1].toLowerCase();
2573
+ return {
2574
+ type: command.startsWith('e') ? 'edit' : 'feedback',
2575
+ detail: String(match[2] ?? '').trim(),
2576
+ };
2577
+ }
2578
+
2579
+ async function resolveApprovalInstruction(input, {
2580
+ feedbackPrompt = 'Feedback for Sapper: ',
2581
+ editPrompt = 'Edit instruction for Sapper: ',
2582
+ } = {}) {
2583
+ const shortcut = parseApprovalShortcut(input);
2584
+ if (!shortcut) return null;
2585
+
2586
+ let detail = shortcut.detail;
2587
+ if (!detail) {
2588
+ const promptLabel = shortcut.type === 'edit' ? editPrompt : feedbackPrompt;
2589
+ detail = String(await safeQuestion(chalk.cyan(promptLabel))).trim();
2590
+ }
2591
+
2592
+ return {
2593
+ type: shortcut.type,
2594
+ detail,
2595
+ };
2596
+ }
2597
+
2598
+ const shellSessions = new Map();
2599
+ let shellSessionCounter = 0;
2600
+ const SHELL_OUTPUT_BUFFER_MAX_CHARS = 50000;
2601
+
2602
+ function createShellSession(command, cwd, proc) {
2603
+ const id = `shell-${++shellSessionCounter}`;
2604
+ const session = {
2605
+ id,
2606
+ command,
2607
+ cwd,
2608
+ proc,
2609
+ startedAt: Date.now(),
2610
+ output: '',
2611
+ reportedOffset: 0,
2612
+ completed: false,
2613
+ backgrounded: false,
2614
+ exitCode: null,
2615
+ signal: null,
2616
+ error: null,
2617
+ liveEchoEnabled: true,
2618
+ };
2619
+ shellSessions.set(id, session);
2620
+ return session;
2621
+ }
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
+
2635
+ function activeShellSessionCount() {
2636
+ return Array.from(shellSessions.values()).filter(session => !session.completed).length;
2637
+ }
2638
+
2639
+ function appendShellSessionOutput(session, text) {
2640
+ if (!session || !text) return;
2641
+ session.output += text;
2642
+ if (session.output.length > SHELL_OUTPUT_BUFFER_MAX_CHARS) {
2643
+ const overflow = session.output.length - SHELL_OUTPUT_BUFFER_MAX_CHARS;
2644
+ session.output = session.output.slice(overflow);
2645
+ session.reportedOffset = Math.max(0, session.reportedOffset - overflow);
2646
+ }
2647
+ }
2648
+
2649
+ function formatShellOutputChunk(text = '', emptyLabel = '(no output yet)') {
2650
+ const normalized = String(text ?? '').trim();
2651
+ if (!normalized) return emptyLabel;
2652
+ const maxChars = shellOutputChunkChars();
2653
+ if (normalized.length <= maxChars) return normalized;
2654
+ return `... (showing last ${maxChars.toLocaleString()} chars)\n${normalized.slice(-maxChars)}`;
2655
+ }
2656
+
2657
+ function shellSessionUsageHint(sessionId) {
2658
+ return `Use run_shell with command \"__shell_read__ ${sessionId}\" to inspect more output, \"__shell_list__\" to list sessions, or \"__shell_stop__ ${sessionId}\" to stop it.`;
2659
+ }
2660
+
2661
+ function buildShellSessionResult(session, {
2662
+ includeOutput = true,
2663
+ onlyNewOutput = false,
2664
+ markReported = false,
2665
+ backgroundHandoff = false,
2666
+ } = {}) {
2667
+ const relevantOutput = onlyNewOutput
2668
+ ? session.output.slice(session.reportedOffset)
2669
+ : session.output;
2670
+
2671
+ if (markReported) {
2672
+ session.reportedOffset = session.output.length;
2673
+ }
2674
+
2675
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - session.startedAt) / 1000));
2676
+ const statusLine = session.completed
2677
+ ? `Shell session ${session.id} completed in ${elapsedSeconds}s with exit code ${session.exitCode ?? 'unknown'}.`
2678
+ : `Shell session ${session.id} is still running in background after ${elapsedSeconds}s.`;
2679
+
2680
+ const lines = [
2681
+ statusLine,
2682
+ `Command: ${session.command}`,
2683
+ `Directory: ${session.cwd}`,
2684
+ ];
2685
+
2686
+ if (session.error) {
2687
+ lines.push(`Error: ${session.error}`);
2688
+ }
2689
+
2690
+ if (!session.completed || backgroundHandoff) {
2691
+ lines.push(shellSessionUsageHint(session.id));
2692
+ }
2693
+
2694
+ if (includeOutput) {
2695
+ lines.push('');
2696
+ lines.push(onlyNewOutput ? 'Output since last check:' : backgroundHandoff ? 'Initial streamed output:' : 'Captured output:');
2697
+ lines.push(formatShellOutputChunk(relevantOutput, onlyNewOutput ? '(no new output since last check)' : '(no output yet)'));
2698
+ }
2699
+
2700
+ return lines.join('\n');
2701
+ }
2702
+
2703
+ function parseShellSessionCommand(command = '') {
2704
+ const trimmed = String(command ?? '').trim();
2705
+ if (!trimmed.startsWith('__shell_')) return null;
2706
+
2707
+ const [directive, ...rest] = trimmed.split(/\s+/);
2708
+ const sessionId = rest.join(' ').trim();
2709
+
2710
+ if (directive === '__shell_list__') return { action: 'list' };
2711
+ if (directive === '__shell_read__') return { action: 'read', sessionId };
2712
+ if (directive === '__shell_stop__') return { action: 'stop', sessionId };
2713
+ return { action: 'unknown', directive };
2714
+ }
2715
+
2716
+ async function handleShellSessionCommand(command = '') {
2717
+ const parsed = parseShellSessionCommand(command);
2718
+ if (!parsed) return null;
2719
+
2720
+ if (parsed.action === 'unknown') {
2721
+ return `Unknown shell session command: ${parsed.directive}. Use __shell_list__, __shell_read__ <session_id>, or __shell_stop__ <session_id>.`;
2722
+ }
2723
+
2724
+ if (parsed.action === 'list') {
2725
+ const sessions = Array.from(shellSessions.values());
2726
+ if (sessions.length === 0) return 'No shell sessions are currently tracked.';
2727
+ return sessions.map(session => {
2728
+ const state = session.completed ? `done (exit ${session.exitCode ?? 'unknown'})` : 'running';
2729
+ return `${session.id} · ${state} · ${session.command}`;
2730
+ }).join('\n');
2731
+ }
2732
+
2733
+ if (!parsed.sessionId) {
2734
+ return 'Missing shell session id. Use __shell_read__ <session_id> or __shell_stop__ <session_id>.';
2735
+ }
2736
+
2737
+ const session = shellSessions.get(parsed.sessionId);
2738
+ if (!session) {
2739
+ return `Shell session not found: ${parsed.sessionId}. Use __shell_list__ to see available sessions.`;
2740
+ }
2741
+
2742
+ if (parsed.action === 'read') {
2743
+ return buildShellSessionResult(session, {
2744
+ includeOutput: true,
2745
+ onlyNewOutput: true,
2746
+ markReported: true,
2747
+ backgroundHandoff: !session.completed,
2748
+ });
2749
+ }
2750
+
2751
+ if (parsed.action === 'stop') {
2752
+ if (session.completed) {
2753
+ return buildShellSessionResult(session, {
2754
+ includeOutput: true,
2755
+ onlyNewOutput: false,
2756
+ markReported: true,
2757
+ });
2758
+ }
2759
+
2760
+ console.log();
2761
+ const confirmation = await safeQuestion(confirmPrompt(`Stop background shell session ${session.id}`, 'error', '[y/N] '));
2762
+ if (!['y', 'yes'].includes(String(confirmation ?? '').trim().toLowerCase())) {
2763
+ return `Stop request cancelled for shell session ${session.id}.`;
2764
+ }
2765
+
2766
+ try {
2767
+ session.proc.kill('SIGTERM');
2768
+ return `Sent SIGTERM to shell session ${session.id}. ${shellSessionUsageHint(session.id)}`;
2769
+ } catch (error) {
2770
+ return `Could not stop shell session ${session.id}: ${error.message}`;
2771
+ }
2772
+ }
2773
+
2774
+ return null;
2775
+ }
2776
+
2777
+ function getTrackedShellSessions() {
2778
+ return Array.from(shellSessions.values()).sort((left, right) => right.startedAt - left.startedAt);
2779
+ }
2780
+
2781
+ function shellSessionStatusLabel(session) {
2782
+ if (!session) return 'unknown';
2783
+ if (!session.completed) return 'running';
2784
+ if (session.signal) return `stopped (${session.signal})`;
2785
+ return `done (${session.exitCode ?? 'unknown'})`;
2786
+ }
2787
+
2788
+ function renderShellSessionsPanel() {
2789
+ const sessions = getTrackedShellSessions();
2790
+ const activeCount = sessions.filter(session => !session.completed).length;
2791
+ const completedCount = sessions.length - activeCount;
2792
+ const lines = [
2793
+ `config ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`chunk ${shellOutputChunkChars()}`)}`,
2794
+ UI.slate(`visibility bg off keeps long shell commands fully attached and visible in the terminal`),
2795
+ `sessions ${chalk.white(`${activeCount} active`)} ${UI.slate('·')} ${chalk.white(`${completedCount} completed`)}`,
2796
+ ];
2797
+
2798
+ if (sessions.length === 0) {
2799
+ lines.push(UI.slate('No background shell sessions are currently tracked.'));
2800
+ } else {
2801
+ for (const session of sessions.slice(0, 8)) {
2802
+ const elapsed = formatElapsed(Date.now() - session.startedAt);
2803
+ const lastOutputLine = String(session.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || '(no output yet)';
2804
+ lines.push(`${chalk.white(session.id)} ${UI.slate('·')} ${chalk.white(shellSessionStatusLabel(session))} ${UI.slate('·')} ${UI.slate(elapsed)}`);
2805
+ lines.push(` ${UI.ink(ellipsis(session.command, 90))}`);
2806
+ lines.push(` ${UI.slate(ellipsis(lastOutputLine, 90))}`);
2807
+ }
2808
+ if (sessions.length > 8) {
2809
+ lines.push(UI.slate(`Showing 8 of ${sessions.length} tracked sessions.`));
2810
+ }
2811
+ }
2812
+
2813
+ return box(lines.join('\n'), 'Shell Sessions', 'cyan');
2814
+ }
2815
+
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)
2823
+ marked.use(markedTerminal({
2824
+ code: chalk.cyan, // fallback when highlight fails
2825
+ blockquote: chalk.gray.italic,
2826
+ html: chalk.gray,
2827
+ heading: chalk.bold.cyan,
2828
+ firstHeading: chalk.bold.cyan,
1811
2829
  table: chalk.white,
1812
2830
  tableOptions: {
1813
2831
  chars: {
@@ -1825,11 +2843,152 @@ marked.use(markedTerminal({
1825
2843
  del: chalk.strikethrough,
1826
2844
  link: chalk.underline.blue,
1827
2845
  href: chalk.gray,
1828
- showSectionPrefix: true,
2846
+ showSectionPrefix: false,
1829
2847
  reflowText: true,
1830
- width: Math.min(process.stdout.columns || 80, 120)
2848
+ emoji: true,
2849
+ tab: 2,
2850
+ width: 120, // overridden dynamically below
1831
2851
  }));
1832
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
+
1833
2992
  // Render markdown to terminal
1834
2993
  function renderMarkdown(text) {
1835
2994
  try {
@@ -1840,7 +2999,7 @@ function renderMarkdown(text) {
1840
2999
  }
1841
3000
 
1842
3001
  let stepMode = false;
1843
- let debugMode = false; // Toggle with /debug command
3002
+ let debugMode = sapperConfig.debug || false; // Toggle with /debug command, or set in config
1844
3003
  let abortStream = false; // Flag to interrupt AI response
1845
3004
 
1846
3005
  // ═══════════════════════════════════════════════════════════════
@@ -1901,6 +3060,181 @@ async function safeQuestion(query) {
1901
3060
  });
1902
3061
  }
1903
3062
 
3063
+ function countLines(text = '') {
3064
+ if (!text) return 0;
3065
+ return String(text).split('\n').length;
3066
+ }
3067
+
3068
+ function formatPreviewLine(line = '', maxWidth = Math.max(32, terminalWidth(82) - 12)) {
3069
+ return ellipsis(String(line).replace(/\t/g, ' '), maxWidth);
3070
+ }
3071
+
3072
+ function buildPreviewBlock(lines, startIdx, endIdx, changeStart, changeEnd, marker, colorFn, maxLines = 14) {
3073
+ if (lines.length === 0) {
3074
+ return colorFn(`${marker} | (empty)`);
3075
+ }
3076
+
3077
+ const indexes = [];
3078
+ for (let index = startIdx; index <= endIdx; index++) {
3079
+ indexes.push(index);
3080
+ }
3081
+
3082
+ const clipped = indexes.length > maxLines;
3083
+ const visibleIndexes = clipped
3084
+ ? [
3085
+ ...indexes.slice(0, Math.ceil(maxLines / 2)),
3086
+ -1,
3087
+ ...indexes.slice(-(Math.floor(maxLines / 2)))
3088
+ ]
3089
+ : indexes;
3090
+ const numberWidth = String(Math.max(endIdx + 1, 1)).length;
3091
+ const rows = [];
3092
+
3093
+ if (startIdx > 0) {
3094
+ rows.push(UI.slate(' ...'));
3095
+ }
3096
+
3097
+ for (const index of visibleIndexes) {
3098
+ if (index === -1) {
3099
+ rows.push(UI.slate(' ...'));
3100
+ continue;
3101
+ }
3102
+
3103
+ const prefix = index >= changeStart && index <= changeEnd ? marker : ' ';
3104
+ const row = `${prefix} ${String(index + 1).padStart(numberWidth)} | ${formatPreviewLine(lines[index])}`;
3105
+ rows.push(prefix === marker ? colorFn(row) : UI.slate(row));
3106
+ }
3107
+
3108
+ if (clipped || endIdx < lines.length - 1) {
3109
+ rows.push(UI.slate(' ...'));
3110
+ }
3111
+
3112
+ return rows.join('\n');
3113
+ }
3114
+
3115
+ function buildFileChangePreview(oldContent = '', newContent = '') {
3116
+ const before = String(oldContent ?? '');
3117
+ const after = String(newContent ?? '');
3118
+
3119
+ if (before === after) {
3120
+ return UI.slate('No visible text changes.');
3121
+ }
3122
+
3123
+ const oldLines = before ? before.split('\n') : [];
3124
+ const newLines = after ? after.split('\n') : [];
3125
+
3126
+ if (oldLines.length === 0) {
3127
+ return [
3128
+ chalk.green('New file content'),
3129
+ buildPreviewBlock(newLines, 0, Math.max(0, Math.min(newLines.length - 1, 13)), 0, Math.max(0, Math.min(newLines.length - 1, 13)), '+', chalk.green)
3130
+ ].join('\n');
3131
+ }
3132
+
3133
+ let start = 0;
3134
+ while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {
3135
+ start++;
3136
+ }
3137
+
3138
+ let oldEnd = oldLines.length - 1;
3139
+ let newEnd = newLines.length - 1;
3140
+ while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
3141
+ oldEnd--;
3142
+ newEnd--;
3143
+ }
3144
+
3145
+ const contextLines = 3;
3146
+ const oldStart = Math.max(0, start - contextLines);
3147
+ const newStart = Math.max(0, start - contextLines);
3148
+ const oldPreviewEnd = Math.min(oldLines.length - 1, Math.max(oldEnd, start - 1) + contextLines);
3149
+ const newPreviewEnd = Math.min(newLines.length - 1, Math.max(newEnd, start - 1) + contextLines);
3150
+
3151
+ return [
3152
+ chalk.red('Before'),
3153
+ buildPreviewBlock(oldLines, oldStart, oldPreviewEnd, start, oldEnd, '-', chalk.red),
3154
+ '',
3155
+ chalk.green('After'),
3156
+ buildPreviewBlock(newLines, newStart, newPreviewEnd, start, newEnd, '+', chalk.green),
3157
+ ].join('\n');
3158
+ }
3159
+
3160
+ function ensureParentDirectory(filePath) {
3161
+ const parentDir = dirname(filePath);
3162
+ if (parentDir && parentDir !== '.' && !fs.existsSync(parentDir)) {
3163
+ fs.mkdirSync(parentDir, { recursive: true });
3164
+ }
3165
+ }
3166
+
3167
+ function restoreFileSnapshot(filePath, originalContent, existedBefore) {
3168
+ if (existedBefore) {
3169
+ fs.writeFileSync(filePath, originalContent);
3170
+ } else if (fs.existsSync(filePath)) {
3171
+ fs.unlinkSync(filePath);
3172
+ }
3173
+ }
3174
+
3175
+ async function reviewCandidateFile({ filePath, originalContent = '', newContent = '', title = 'File Review', successMessage }) {
3176
+ const existedBefore = fs.existsSync(filePath);
3177
+
3178
+ ensureParentDirectory(filePath);
3179
+ fs.writeFileSync(filePath, newContent);
3180
+
3181
+ while (true) {
3182
+ console.log();
3183
+ console.log(box(
3184
+ `${keyValue('File', chalk.white(filePath), 8)}\n` +
3185
+ `${keyValue('Status', chalk.white(existedBefore ? 'modified' : 'new file'), 8)}\n` +
3186
+ `${keyValue('Lines', chalk.white(`${countLines(originalContent)} -> ${countLines(newContent)}`), 8)}\n` +
3187
+ `${UI.slate('Candidate change written to disk. Review it in your editor now.')}\n` +
3188
+ `${UI.slate('Choose keep to accept it, ignore to revert it, diff to inspect, f for feedback, or e for edit instructions.')}`,
3189
+ title, 'yellow'
3190
+ ));
3191
+
3192
+ const decisionInput = await safeQuestion(chalk.yellow('Review change ') + chalk.gray('[k]eep/[i]gnore/[d]iff/[f]eedback/[e]dit: '));
3193
+ const decisionRaw = String(decisionInput ?? '').trim();
3194
+ const decision = decisionRaw.toLowerCase();
3195
+
3196
+ if (['k', 'keep', 'y', 'yes'].includes(decision)) {
3197
+ return successMessage || `Successfully saved changes to ${filePath}`;
3198
+ }
3199
+
3200
+ if (['i', 'ignore', 'n', 'no'].includes(decision)) {
3201
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
3202
+ return existedBefore
3203
+ ? `Ignored change and restored ${filePath}`
3204
+ : `Ignored change and removed ${filePath}`;
3205
+ }
3206
+
3207
+ if (decision === '' || decision === 'd' || decision === 'diff') {
3208
+ console.log();
3209
+ console.log(box(buildFileChangePreview(originalContent, newContent), 'Change Diff', 'yellow'));
3210
+ continue;
3211
+ }
3212
+
3213
+ const approvalInstruction = await resolveApprovalInstruction(decisionRaw, {
3214
+ feedbackPrompt: 'Feedback for this change: ',
3215
+ editPrompt: 'Edit instruction for this change: ',
3216
+ });
3217
+
3218
+ if (approvalInstruction) {
3219
+ if (!approvalInstruction.detail) {
3220
+ console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose keep/ignore/diff.'));
3221
+ continue;
3222
+ }
3223
+
3224
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
3225
+ const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
3226
+ return `Change rejected by user for ${filePath}.\n${label}: ${approvalInstruction.detail}\nThe original file was restored. Revise the change and try again.`;
3227
+ }
3228
+
3229
+ if (decisionRaw) {
3230
+ restoreFileSnapshot(filePath, originalContent, existedBefore);
3231
+ return `Change rejected by user for ${filePath}.\nUser feedback: ${decisionRaw}\nThe original file was restored. Revise the change and try again.`;
3232
+ }
3233
+
3234
+ console.log(UI.slate('Type k to keep, i to ignore, d to view the diff, f for feedback, e for edit instructions, or write feedback directly.'));
3235
+ }
3236
+ }
3237
+
1904
3238
  // Directories to ignore when listing files
1905
3239
  const IGNORE_DIRS = new Set([
1906
3240
  'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
@@ -1919,9 +3253,11 @@ const CODE_EXTENSIONS = new Set([
1919
3253
  ]);
1920
3254
 
1921
3255
  // Max file size to include (skip large files like bundled/minified)
1922
- const MAX_FILE_SIZE = 100000; // 100KB per file
1923
- const MAX_TOTAL_SCAN_SIZE = 1000000; // 1000KB total scan limit
1924
- 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; }
1925
3261
 
1926
3262
  // ═══════════════════════════════════════════════════════════════
1927
3263
  // URL FETCHING — Read web pages and learn from them
@@ -1930,7 +3266,7 @@ import https from 'https';
1930
3266
  import http from 'http';
1931
3267
 
1932
3268
  // Fetch a URL and return extracted text content
1933
- function fetchUrl(url, timeout = 15000) {
3269
+ function fetchUrl(url, timeout = LIMITS.FETCH_URL_TIMEOUT_MS) {
1934
3270
  return new Promise((resolve, reject) => {
1935
3271
  const lib = url.startsWith('https') ? https : http;
1936
3272
  const req = lib.get(url, {
@@ -1955,9 +3291,9 @@ function fetchUrl(url, timeout = 15000) {
1955
3291
  let size = 0;
1956
3292
  res.on('data', (chunk) => {
1957
3293
  size += chunk.length;
1958
- if (size > MAX_URL_SIZE) {
3294
+ if (size > getMaxUrlSize()) {
1959
3295
  res.destroy();
1960
- 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)`));
1961
3297
  return;
1962
3298
  }
1963
3299
  data += chunk;
@@ -1992,8 +3328,8 @@ function htmlToText(html) {
1992
3328
  text = text.replace(/\n\s*\n/g, '\n\n');
1993
3329
  text = text.trim();
1994
3330
  // Limit to reasonable size
1995
- if (text.length > 50000) {
1996
- 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 ...]';
1997
3333
  }
1998
3334
  return text;
1999
3335
  }
@@ -2073,6 +3409,20 @@ function shouldIgnore(nameOrPath) {
2073
3409
  return ignored;
2074
3410
  }
2075
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
+
2076
3426
  // Scan entire codebase and return summary
2077
3427
  function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
2078
3428
  if (depth > maxDepth) return { files: [], totalSize: 0 };
@@ -2102,11 +3452,11 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
2102
3452
 
2103
3453
  try {
2104
3454
  const stats = fs.statSync(fullPath);
2105
- if (stats.size > MAX_FILE_SIZE) {
3455
+ if (stats.size > getMaxFileSize()) {
2106
3456
  files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
2107
3457
  continue;
2108
3458
  }
2109
- if (totalSize + stats.size > MAX_TOTAL_SCAN_SIZE) {
3459
+ if (totalSize + stats.size > getMaxScanSize()) {
2110
3460
  files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
2111
3461
  continue;
2112
3462
  }
@@ -2327,18 +3677,19 @@ async function pickModel(models) {
2327
3677
 
2328
3678
  const render = () => {
2329
3679
  const current = models[cursor];
2330
- console.clear();
2331
- console.log(BANNER);
2332
- console.log(`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`);
2333
- console.log(divider());
2334
- console.log(sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'));
2335
- console.log();
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
+ ];
2336
3687
 
2337
3688
  const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
2338
3689
  const endIdx = Math.min(startIdx + pageSize, models.length);
2339
3690
 
2340
3691
  if (startIdx > 0) {
2341
- console.log(UI.slate(' ↑ more models'));
3692
+ lines.push(UI.slate(' ↑ more models'));
2342
3693
  }
2343
3694
 
2344
3695
  for (let i = startIdx; i < endIdx; i++) {
@@ -2353,20 +3704,20 @@ async function pickModel(models) {
2353
3704
  model.details?.parameter_size || null,
2354
3705
  ].filter(Boolean).join(' · ');
2355
3706
 
2356
- console.log(`${marker} ${index} ${name}`);
3707
+ lines.push(`${marker} ${index} ${name}`);
2357
3708
  if (meta) {
2358
- console.log(` ${UI.slate(meta)}`);
3709
+ lines.push(` ${UI.slate(meta)}`);
2359
3710
  }
2360
3711
  }
2361
3712
 
2362
3713
  if (endIdx < models.length) {
2363
- console.log(UI.slate(' ↓ more models'));
3714
+ lines.push(UI.slate(' ↓ more models'));
2364
3715
  }
2365
3716
 
2366
3717
  const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
2367
3718
  const quant = current.details?.quantization_level || current.details?.quantization || 'default';
2368
- console.log();
2369
- console.log(box(
3719
+ lines.push('');
3720
+ lines.push(box(
2370
3721
  `${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
2371
3722
  `${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
2372
3723
  `${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
@@ -2374,6 +3725,8 @@ async function pickModel(models) {
2374
3725
  `${keyValue('Quant', UI.ink(quant), 10)}`,
2375
3726
  'Preview', 'gray'
2376
3727
  ));
3728
+
3729
+ renderViewport(lines.join('\n'), { verticalAlign: 'center' });
2377
3730
  };
2378
3731
 
2379
3732
  return new Promise((resolve) => {
@@ -2408,11 +3761,11 @@ async function pickModel(models) {
2408
3761
  render();
2409
3762
  } else if (key.name === 'return') {
2410
3763
  cleanup();
2411
- console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3764
+ console.clear();
2412
3765
  resolve(models[cursor].name);
2413
3766
  } else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
2414
3767
  cleanup();
2415
- console.log(UI.slate(`\nUsing ${models[cursor].name}`));
3768
+ console.clear();
2416
3769
  resolve(models[cursor].name);
2417
3770
  }
2418
3771
  };
@@ -2421,48 +3774,289 @@ async function pickModel(models) {
2421
3774
  });
2422
3775
  }
2423
3776
 
2424
- const tools = {
2425
- read: (path) => {
2426
- try { return fs.readFileSync(path.trim(), 'utf8'); }
2427
- catch (error) { return `Error reading file: ${error.message}`; }
2428
- },
2429
- patch: async (path, oldText, newText) => {
2430
- const trimmedPath = path.trim();
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 = [];
2431
3864
  try {
2432
- const content = fs.readFileSync(trimmedPath, 'utf8');
3865
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
3866
+ } catch {
3867
+ return;
3868
+ }
2433
3869
 
2434
- // --- Line-number mode: LINE:15|||new text ---
2435
- const lineMatch = oldText.match(/^LINE:(\d+)$/);
2436
- if (lineMatch) {
2437
- const lineNum = parseInt(lineMatch[1], 10);
2438
- const lines = content.split('\n');
2439
- if (lineNum < 1 || lineNum > lines.length) {
2440
- return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
2441
- }
2442
- const oldLine = lines[lineNum - 1];
2443
- lines[lineNum - 1] = newText;
2444
- const newContent = lines.join('\n');
2445
- console.log();
2446
- const diffContent =
2447
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2448
- `${keyValue('Line', chalk.white(String(lineNum)), 8)}\n` +
2449
- `${UI.slate('Preview')}\n` +
2450
- chalk.red('- ' + oldLine) + '\n' +
2451
- chalk.green('+ ' + newText);
2452
- console.log(box(diffContent, 'Patch Review', 'yellow'));
2453
- const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
2454
- if (confirm.toLowerCase() === 'y') {
2455
- fs.writeFileSync(trimmedPath, newContent);
2456
- return `Successfully patched line ${lineNum} of ${trimmedPath}`;
2457
- }
2458
- return 'Patch rejected by user.';
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);
2459
3882
  }
2460
3883
 
2461
- // --- Exact match (try as-is first, then trimmed) ---
2462
- let matchedOld = oldText;
2463
- let newContent;
2464
- if (content.includes(oldText)) {
2465
- newContent = content.replace(oldText, newText);
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
+
4015
+ const tools = {
4016
+ read: (path) => {
4017
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4018
+ if (!trimmedPath) return 'Error reading file: missing file path';
4019
+ try { return fs.readFileSync(resolveToolPath(trimmedPath), 'utf8'); }
4020
+ catch (error) { return `Error reading file: ${error.message}`; }
4021
+ },
4022
+ patch: async (path, oldText, newText) => {
4023
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4024
+ if (!trimmedPath) return 'Error patching file: missing file path';
4025
+ if (typeof oldText !== 'string' || typeof newText !== 'string') {
4026
+ return 'Error patching file: missing old_text or new_text';
4027
+ }
4028
+ try {
4029
+ const resolvedPath = resolveToolPath(trimmedPath);
4030
+ const content = fs.readFileSync(resolvedPath, 'utf8');
4031
+
4032
+ // --- Line-number mode: LINE:15|||new text ---
4033
+ const lineMatch = oldText.match(/^LINE:(\d+)$/);
4034
+ if (lineMatch) {
4035
+ const lineNum = parseInt(lineMatch[1], 10);
4036
+ const lines = content.split('\n');
4037
+ if (lineNum < 1 || lineNum > lines.length) {
4038
+ return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
4039
+ }
4040
+ lines[lineNum - 1] = newText;
4041
+ const newContent = lines.join('\n');
4042
+ if (newContent === content) {
4043
+ return `No changes needed in ${trimmedPath}`;
4044
+ }
4045
+
4046
+ return reviewCandidateFile({
4047
+ filePath: resolvedPath,
4048
+ originalContent: content,
4049
+ newContent,
4050
+ title: 'Patch Review',
4051
+ successMessage: `Successfully patched line ${lineNum} of ${trimmedPath}`,
4052
+ });
4053
+ }
4054
+
4055
+ // --- Exact match (try as-is first, then trimmed) ---
4056
+ let matchedOld = oldText;
4057
+ let newContent;
4058
+ if (content.includes(oldText)) {
4059
+ newContent = content.replace(oldText, newText);
2466
4060
  } else if (content.includes(oldText.trim())) {
2467
4061
  // Trimmed fallback — match what's actually in the file
2468
4062
  matchedOld = oldText.trim();
@@ -2513,102 +4107,372 @@ const tools = {
2513
4107
  `Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
2514
4108
  }
2515
4109
  }
2516
-
2517
- // Show diff preview
2518
- console.log();
2519
- const diffContent =
2520
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2521
- `${UI.slate('Preview')}\n` +
2522
- chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
2523
- chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
2524
- console.log(box(diffContent, 'Patch Review', 'yellow'));
2525
-
2526
- const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
2527
- if (confirm.toLowerCase() === 'y') {
2528
- fs.writeFileSync(trimmedPath, newContent);
2529
- return `Successfully patched ${trimmedPath}`;
4110
+
4111
+ if (newContent === content) {
4112
+ return `No changes needed in ${trimmedPath}`;
2530
4113
  }
2531
- return 'Patch rejected by user.';
4114
+
4115
+ return reviewCandidateFile({
4116
+ filePath: resolvedPath,
4117
+ originalContent: content,
4118
+ newContent,
4119
+ title: 'Patch Review',
4120
+ successMessage: `Successfully patched ${trimmedPath}`,
4121
+ });
2532
4122
  } catch (error) { return `Error patching file: ${error.message}`; }
2533
4123
  },
2534
4124
  write: async (path, content) => {
2535
- const trimmedPath = path.trim();
4125
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4126
+ if (!trimmedPath) return 'Error writing file: missing file path';
4127
+ try {
4128
+ const resolvedPath = resolveToolPath(trimmedPath);
4129
+ const fileExists = fs.existsSync(resolvedPath);
4130
+ const existingContent = fileExists ? fs.readFileSync(resolvedPath, 'utf8') : '';
4131
+ const nextContent = String(content ?? '');
4132
+
4133
+ if (fileExists && existingContent === nextContent) {
4134
+ return `No changes needed in ${trimmedPath}`;
4135
+ }
4136
+
4137
+ return reviewCandidateFile({
4138
+ filePath: resolvedPath,
4139
+ originalContent: existingContent,
4140
+ newContent: nextContent,
4141
+ title: 'Write Review',
4142
+ successMessage: `Successfully saved changes to ${trimmedPath}`,
4143
+ });
4144
+ } catch (error) { return `Error writing file: ${error.message}`; }
4145
+ },
4146
+ mkdir: (path) => {
4147
+ const trimmedPath = typeof path === 'string' ? path.trim() : '';
4148
+ if (!trimmedPath) return 'Error creating directory: missing directory path';
4149
+ try {
4150
+ fs.mkdirSync(resolveToolPath(trimmedPath), { recursive: true });
4151
+ return `Directory created: ${trimmedPath}`;
4152
+ } catch (error) { return `Error creating directory: ${error.message}`; }
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
+
2536
4309
  console.log();
2537
4310
  console.log(box(
2538
- `${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
2539
- `${keyValue('Size', chalk.white((content?.length || 0) + ' chars'), 8)}\n` +
2540
- `${UI.slate('Preview')}\n` +
2541
- chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
2542
- 'Write Review', 'yellow'
4311
+ `${keyValue('URL', chalk.white(trimmedUrl), 11)}\n` +
4312
+ `${UI.slate('This will open the URL in your default browser.')}`,
4313
+ 'Open URL', 'red'
2543
4314
  ));
2544
- const confirm = await safeQuestion(confirmPrompt('Allow file write', 'warning'));
2545
- if (confirm.toLowerCase() === 'y') {
2546
- try {
2547
- fs.writeFileSync(trimmedPath, content);
2548
- return `Successfully saved changes to ${trimmedPath}`;
2549
- } catch (error) { return `Error writing file: ${error.message}`; }
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.';
2550
4318
  }
2551
- return "Write blocked by user.";
2552
- },
2553
- mkdir: (path) => {
4319
+
2554
4320
  try {
2555
- fs.mkdirSync(path.trim(), { recursive: true });
2556
- return `Directory created: ${path}`;
2557
- } catch (error) { return `Error creating directory: ${error.message}`; }
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
+ }
2558
4340
  },
2559
4341
  shell: async (cmd) => {
4342
+ const trimmedCmd = String(cmd ?? '').trim();
4343
+ if (!trimmedCmd) return 'Error executing shell: missing command';
4344
+
4345
+ const sessionCommandResult = await handleShellSessionCommand(trimmedCmd);
4346
+ if (sessionCommandResult !== null) {
4347
+ return sessionCommandResult;
4348
+ }
4349
+
4350
+ const backgroundEligible = shouldBackgroundShellCommand(trimmedCmd);
2560
4351
  console.log();
2561
4352
  console.log(box(
2562
- `${keyValue('Directory', chalk.white(process.cwd()), 11)}\n` +
2563
- `${UI.slate('Command')}\n${chalk.white.bold(cmd)}`,
4353
+ `${keyValue('Directory', chalk.white(getToolWorkingDirectory()), 11)}\n` +
4354
+ `${UI.slate('Command')}\n${chalk.white.bold(trimmedCmd)}\n` +
4355
+ `${UI.slate('Type y to run, n to block, f for feedback, e for edit instructions, or write feedback directly.')}\n` +
4356
+ `${UI.slate(backgroundEligible ? `Background handoff ${shellBackgroundMode()} after ${shellBackgroundAfterSeconds()}s if still running.` : 'This command will stay attached unless it exits quickly.')}`,
2564
4357
  'Shell Approval', 'red'
2565
4358
  ));
2566
- const confirm = await safeQuestion(confirmPrompt('Run shell command', 'error'));
2567
- if (confirm.toLowerCase() === 'y') {
2568
- return new Promise((resolve) => {
2569
- const useShell = cmd.includes('&&') || cmd.includes('|') || cmd.includes('cd ') || cmd.includes('>') || cmd.includes('<');
2570
- console.log(chalk.cyan(`\n[RUNNING] ${cmd}\n`));
2571
- const proc = spawn('sh', ['-c', cmd], {
2572
- cwd: process.cwd()
2573
- });
2574
- let output = '';
2575
- proc.stdout.on('data', (data) => {
2576
- const text = data.toString();
2577
- output += text;
2578
- process.stdout.write(text); // Still show to user in real-time
2579
- });
2580
- proc.stderr.on('data', (data) => {
2581
- const text = data.toString();
2582
- output += text;
2583
- process.stderr.write(text); // Still show errors to user
2584
- });
2585
- proc.on('close', (code) => {
2586
- // Crucial: give control back to Node
2587
- if (process.stdin.isTTY) {
2588
- try { process.stdin.setRawMode(false); } catch (e) {}
4359
+ while (true) {
4360
+ const confirmInput = await safeQuestion(confirmPrompt('Run shell command', 'error', '[y/N/f/e or text] '));
4361
+ const confirmRaw = String(confirmInput ?? '').trim();
4362
+ const confirm = confirmRaw.toLowerCase();
4363
+
4364
+ if (['y', 'yes'].includes(confirm)) {
4365
+ return new Promise((resolve) => {
4366
+ console.log(chalk.cyan(`\n[RUNNING] ${trimmedCmd}\n`));
4367
+ const proc = spawn('sh', ['-c', trimmedCmd], {
4368
+ cwd: getToolWorkingDirectory()
4369
+ });
4370
+ const session = createShellSession(trimmedCmd, getToolWorkingDirectory(), proc);
4371
+ let resolved = false;
4372
+ let backgroundTimer = null;
4373
+
4374
+ const finish = (result) => {
4375
+ if (resolved) return;
4376
+ resolved = true;
4377
+ if (backgroundTimer) {
4378
+ clearTimeout(backgroundTimer);
4379
+ backgroundTimer = null;
4380
+ }
4381
+ resolve(result);
4382
+ };
4383
+
4384
+ if (backgroundEligible) {
4385
+ backgroundTimer = setTimeout(() => {
4386
+ if (resolved || session.completed) return;
4387
+ session.backgrounded = true;
4388
+ session.liveEchoEnabled = false;
4389
+ showStreamPhase(`Shell command still running. Background session ${session.id} is active...`, 'warning');
4390
+ finish(buildShellSessionResult(session, {
4391
+ includeOutput: shellStreamToModelEnabled(),
4392
+ onlyNewOutput: false,
4393
+ markReported: shellStreamToModelEnabled(),
4394
+ backgroundHandoff: true,
4395
+ }));
4396
+ }, shellBackgroundAfterSeconds() * 1000);
2589
4397
  }
2590
- // Delay slightly to let terminal settle
2591
- setTimeout(() => {
2592
- recreateReadline();
2593
- // Return actual output to AI, truncated if too long
2594
- const maxOutput = 10000;
2595
- let result = output.trim();
2596
- if (result.length > maxOutput) {
2597
- result = result.substring(0, maxOutput) + '\n... (output truncated)';
4398
+
4399
+ proc.stdout.on('data', (data) => {
4400
+ const text = data.toString();
4401
+ appendShellSessionOutput(session, text);
4402
+ if (session.liveEchoEnabled) {
4403
+ process.stdout.write(text);
4404
+ }
4405
+ });
4406
+ proc.stderr.on('data', (data) => {
4407
+ const text = data.toString();
4408
+ appendShellSessionOutput(session, text);
4409
+ if (session.liveEchoEnabled) {
4410
+ process.stderr.write(text);
4411
+ }
4412
+ });
4413
+ proc.on('error', (error) => {
4414
+ session.completed = true;
4415
+ session.error = error.message;
4416
+ session.exitCode = 1;
4417
+ pruneCompletedShellSessions();
4418
+ finish(`Shell command failed to start: ${error.message}`);
4419
+ });
4420
+ proc.on('close', (code, signal) => {
4421
+ session.completed = true;
4422
+ session.exitCode = code;
4423
+ session.signal = signal;
4424
+ pruneCompletedShellSessions();
4425
+
4426
+ if (resolved) {
4427
+ return;
4428
+ }
4429
+
4430
+ if (process.stdin.isTTY) {
4431
+ try { process.stdin.setRawMode(false); } catch (e) {}
2598
4432
  }
2599
- resolve(result || `Command completed with exit code ${code}`);
2600
- }, 200);
4433
+
4434
+ setTimeout(() => {
4435
+ recreateReadline();
4436
+ const maxOutput = 10000;
4437
+ let result = session.output.trim();
4438
+ if (result.length > maxOutput) {
4439
+ result = result.substring(0, maxOutput) + '\n... (output truncated)';
4440
+ }
4441
+ finish(result || `Command completed with exit code ${code}`);
4442
+ }, 200);
4443
+ });
2601
4444
  });
4445
+ }
4446
+
4447
+ if (['', 'n', 'no'].includes(confirm)) {
4448
+ return "Command blocked by user.";
4449
+ }
4450
+
4451
+ const approvalInstruction = await resolveApprovalInstruction(confirmRaw, {
4452
+ feedbackPrompt: 'Feedback for this command: ',
4453
+ editPrompt: 'Edit instruction for this command: ',
2602
4454
  });
4455
+
4456
+ if (approvalInstruction) {
4457
+ if (!approvalInstruction.detail) {
4458
+ console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose y/n.'));
4459
+ continue;
4460
+ }
4461
+
4462
+ const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
4463
+ return `Command blocked by user.\n${label}: ${approvalInstruction.detail}\nNo command was executed. Revise the command and ask again if needed.`;
4464
+ }
4465
+
4466
+ return `Command blocked by user.\nUser feedback: ${confirmRaw}\nNo command was executed. Revise the command and ask again if needed.`;
2603
4467
  }
2604
- return "Command blocked by user.";
2605
4468
  },
2606
4469
  list: (path) => {
2607
4470
  try {
2608
- let dir = path.trim() || '.';
4471
+ let dir = typeof path === 'string' ? path.trim() : '';
4472
+ if (!dir) dir = '.';
2609
4473
  // If AI sends "/" (root), treat as current directory "."
2610
4474
  if (dir === '/') dir = '.';
2611
- const entries = fs.readdirSync(dir);
4475
+ const entries = fs.readdirSync(resolveToolPath(dir));
2612
4476
  // Filter out ignored files/directories (respects .sapperignore)
2613
4477
  const filtered = entries.filter(entry => {
2614
4478
  if (shouldIgnore(entry)) return false;
@@ -2626,16 +4490,35 @@ const tools = {
2626
4490
  for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
2627
4491
  if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
2628
4492
  }
2629
- const excludeDirs = Array.from(allIgnoreDirs).join(',');
2630
- // Use grep to search for pattern, excluding ignored directories
2631
- const cmd = `grep -rEin "${pattern.replace(/"/g, '\\"')}" . --exclude-dir={${excludeDirs}} --include="*.{js,ts,jsx,tsx,py,java,go,rs,rb,php,c,cpp,h,css,scss,html,json,md,txt,yml,yaml,toml,sh}" 2>/dev/null | head -50`;
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');
2632
4504
 
2633
- const proc = spawn('sh', ['-c', cmd], { cwd: process.cwd() });
4505
+ const proc = spawn('grep', args, { cwd: getToolWorkingDirectory() });
2634
4506
  let output = '';
4507
+ let lineCount = 0;
2635
4508
 
2636
- proc.stdout.on('data', (data) => { output += data.toString(); });
2637
- 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
2638
4518
 
4519
+ proc.on('error', (err) => {
4520
+ resolve(`Error searching: ${err.message}`);
4521
+ });
2639
4522
  proc.on('close', () => {
2640
4523
  if (output.trim()) {
2641
4524
  resolve(`Found matches:\n${output.trim()}`);
@@ -2721,6 +4604,12 @@ async function runSapper() {
2721
4604
  const startupLines = [
2722
4605
  `${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
2723
4606
  `${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
4607
+ `${statusBadge('prompt', hasCustomPromptConfig() ? 'warning' : 'neutral')} ${UI.slate(hasCustomPromptConfig() ? 'custom prompt on' : 'default prompt')}`,
4608
+ `${statusBadge('thinking', 'neutral')} ${UI.slate(`mode ${thinkingMode()}`)}`,
4609
+ `${statusBadge('tools', 'action')} ${UI.slate(`limit ${toolRoundLimit()} rounds`)}`,
4610
+ `${statusBadge('shell', 'info')} ${UI.slate(`stream ${shellStreamToModelEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${UI.slate(`${activeShellSessionCount()} active`)}`,
4611
+ `${statusBadge('stream', 'neutral')} ${UI.slate(`heartbeat ${streamHeartbeatEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`phases ${streamPhaseStatusEnabled() ? 'on' : 'off'}`)}`,
4612
+ `${statusBadge('summary', 'info')} ${UI.slate(`phases ${summaryPhasesEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`trigger ${summaryTriggerPercent()}%`)}`,
2724
4613
  `${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
2725
4614
  ];
2726
4615
  if (newlyCreated > 0) {
@@ -2774,7 +4663,15 @@ async function runSapper() {
2774
4663
  process.exit(1);
2775
4664
  }
2776
4665
 
2777
- 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
+ }
2778
4675
 
2779
4676
  // ─── Detect model capabilities & context window ───────────────────
2780
4677
  let useNativeTools = false;
@@ -2797,48 +4694,147 @@ async function runSapper() {
2797
4694
  break;
2798
4695
  }
2799
4696
  }
2800
- }
2801
- // Fallback: parse from parameters string (e.g. "num_ctx 4096")
2802
- if (!modelContextLength && modelInfo.parameters) {
2803
- const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
2804
- if (match) modelContextLength = parseInt(match[1]);
2805
- }
2806
- if (modelContextLength) {
2807
- contextLabel = `${modelContextLength.toLocaleString()} tokens`;
2808
- } else {
2809
- modelContextLength = 4096; // Conservative default
2810
- contextLabel = '4,096 tokens (default)';
2811
- }
2812
- } catch (e) {
2813
- modelContextLength = 4096;
2814
- toolModeLabel = 'default mode';
2815
- contextLabel = '4,096 tokens (fallback)';
2816
- }
2817
- // Show custom limit if set
2818
- const effectiveCtx = effectiveContextLength();
2819
- if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
2820
- contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
2821
- }
2822
- console.log(box(
2823
- `${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
2824
- `${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
2825
- `${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
2826
- 'Session', 'cyan'
2827
- ));
2828
- console.log();
2829
- _useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
2830
-
2831
- // Native Ollama tool definitions (used when useNativeTools=true)
2832
- const nativeToolDefs = [
4697
+ }
4698
+ // Fallback: parse from parameters string (e.g. "num_ctx 4096")
4699
+ if (!modelContextLength && modelInfo.parameters) {
4700
+ const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
4701
+ if (match) modelContextLength = parseInt(match[1]);
4702
+ }
4703
+ if (modelContextLength) {
4704
+ contextLabel = `${modelContextLength.toLocaleString()} tokens`;
4705
+ } else {
4706
+ modelContextLength = 4096; // Conservative default
4707
+ contextLabel = '4,096 tokens (default)';
4708
+ }
4709
+ } catch (e) {
4710
+ modelContextLength = 4096;
4711
+ toolModeLabel = 'default mode';
4712
+ contextLabel = '4,096 tokens (fallback)';
4713
+ }
4714
+ // Show custom limit if set
4715
+ const effectiveCtx = effectiveContextLength();
4716
+ if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
4717
+ contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
4718
+ }
4719
+ console.log(box(
4720
+ `${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
4721
+ `${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
4722
+ `${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
4723
+ 'Session', 'cyan'
4724
+ ));
4725
+ console.log();
4726
+ _useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
4727
+
4728
+ // Native Ollama tool definitions (used when useNativeTools=true)
4729
+ const nativeToolDefs = [
4730
+ {
4731
+ type: 'function',
4732
+ function: {
4733
+ name: 'list_directory',
4734
+ description: 'List the contents of a directory. If path is omitted, use the current directory ".".',
4735
+ parameters: {
4736
+ type: 'object',
4737
+ properties: {
4738
+ path: { type: 'string', description: 'Directory path to list' }
4739
+ }
4740
+ }
4741
+ }
4742
+ },
4743
+ {
4744
+ type: 'function',
4745
+ function: {
4746
+ name: 'read_file',
4747
+ description: 'Read the full contents of a file',
4748
+ parameters: {
4749
+ type: 'object',
4750
+ properties: {
4751
+ path: { type: 'string', description: 'File path to read' }
4752
+ },
4753
+ required: ['path']
4754
+ }
4755
+ }
4756
+ },
4757
+ {
4758
+ type: 'function',
4759
+ function: {
4760
+ name: 'search_files',
4761
+ description: 'Search for a pattern across project files',
4762
+ parameters: {
4763
+ type: 'object',
4764
+ properties: {
4765
+ pattern: { type: 'string', description: 'Search pattern (text or regex)' }
4766
+ },
4767
+ required: ['pattern']
4768
+ }
4769
+ }
4770
+ },
4771
+ {
4772
+ type: 'function',
4773
+ function: {
4774
+ name: 'write_file',
4775
+ description: 'Create or overwrite a file with new content',
4776
+ parameters: {
4777
+ type: 'object',
4778
+ properties: {
4779
+ path: { type: 'string', description: 'File path to write' },
4780
+ content: { type: 'string', description: 'Content to write to the file' }
4781
+ },
4782
+ required: ['path', 'content']
4783
+ }
4784
+ }
4785
+ },
4786
+ {
4787
+ type: 'function',
4788
+ function: {
4789
+ name: 'patch_file',
4790
+ description: 'Edit an existing file by replacing old text with new text. Prefer line_number mode.',
4791
+ parameters: {
4792
+ type: 'object',
4793
+ properties: {
4794
+ path: { type: 'string', description: 'File path to patch' },
4795
+ old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
4796
+ new_text: { type: 'string', description: 'Replacement text' }
4797
+ },
4798
+ required: ['path', 'old_text', 'new_text']
4799
+ }
4800
+ }
4801
+ },
4802
+ {
4803
+ type: 'function',
4804
+ function: {
4805
+ name: 'create_directory',
4806
+ description: 'Create a directory (recursive)',
4807
+ parameters: {
4808
+ type: 'object',
4809
+ properties: {
4810
+ path: { type: 'string', description: 'Directory path to create' }
4811
+ },
4812
+ required: ['path']
4813
+ }
4814
+ }
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
+ },
2833
4829
  {
2834
4830
  type: 'function',
2835
4831
  function: {
2836
- name: 'list_directory',
2837
- description: 'List the contents of a directory. Use "." for current directory.',
4832
+ name: 'cat',
4833
+ description: 'Read the full contents of a file.',
2838
4834
  parameters: {
2839
4835
  type: 'object',
2840
4836
  properties: {
2841
- path: { type: 'string', description: 'Directory path to list' }
4837
+ path: { type: 'string', description: 'File path to read' }
2842
4838
  },
2843
4839
  required: ['path']
2844
4840
  }
@@ -2847,12 +4843,13 @@ async function runSapper() {
2847
4843
  {
2848
4844
  type: 'function',
2849
4845
  function: {
2850
- name: 'read_file',
2851
- description: 'Read the full contents of a file',
4846
+ name: 'head',
4847
+ description: 'Read the first lines of a file.',
2852
4848
  parameters: {
2853
4849
  type: 'object',
2854
4850
  properties: {
2855
- path: { type: 'string', description: 'File path to read' }
4851
+ path: { type: 'string', description: 'File path to read' },
4852
+ lines: { type: 'number', description: 'How many lines to show (default 20)' }
2856
4853
  },
2857
4854
  required: ['path']
2858
4855
  }
@@ -2861,8 +4858,23 @@ async function runSapper() {
2861
4858
  {
2862
4859
  type: 'function',
2863
4860
  function: {
2864
- name: 'search_files',
2865
- description: 'Search for a pattern across project files',
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.',
2866
4878
  parameters: {
2867
4879
  type: 'object',
2868
4880
  properties: {
@@ -2875,53 +4887,117 @@ async function runSapper() {
2875
4887
  {
2876
4888
  type: 'function',
2877
4889
  function: {
2878
- name: 'write_file',
2879
- description: 'Create or overwrite a file with new content',
4890
+ name: 'find',
4891
+ description: 'Find files or directories by name.',
2880
4892
  parameters: {
2881
4893
  type: 'object',
2882
4894
  properties: {
2883
- path: { type: 'string', description: 'File path to write' },
2884
- content: { type: 'string', description: 'Content to write to the file' }
4895
+ pattern: { type: 'string', description: 'Name fragment to search for' },
4896
+ path: { type: 'string', description: 'Directory to search from (default current tool working directory)' }
2885
4897
  },
2886
- required: ['path', 'content']
4898
+ required: ['pattern']
2887
4899
  }
2888
4900
  }
2889
4901
  },
2890
4902
  {
2891
4903
  type: 'function',
2892
4904
  function: {
2893
- name: 'patch_file',
2894
- description: 'Edit an existing file by replacing old text with new text. Prefer line_number mode.',
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.',
2895
4918
  parameters: {
2896
4919
  type: 'object',
2897
4920
  properties: {
2898
- path: { type: 'string', description: 'File path to patch' },
2899
- old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
2900
- new_text: { type: 'string', description: 'Replacement text' }
4921
+ path: { type: 'string', description: 'Directory path to switch to' }
2901
4922
  },
2902
- required: ['path', 'old_text', 'new_text']
4923
+ required: ['path']
2903
4924
  }
2904
4925
  }
2905
4926
  },
2906
4927
  {
2907
4928
  type: 'function',
2908
4929
  function: {
2909
- name: 'create_directory',
2910
- description: 'Create a directory (recursive)',
4930
+ name: 'rmdir',
4931
+ description: 'Remove a directory recursively after approval.',
2911
4932
  parameters: {
2912
4933
  type: 'object',
2913
4934
  properties: {
2914
- path: { type: 'string', description: 'Directory path to create' }
4935
+ path: { type: 'string', description: 'Directory path to remove' }
2915
4936
  },
2916
4937
  required: ['path']
2917
4938
  }
2918
4939
  }
2919
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
+ },
2920
4996
  {
2921
4997
  type: 'function',
2922
4998
  function: {
2923
4999
  name: 'run_shell',
2924
- description: 'Execute a shell command in the project directory',
5000
+ description: 'Execute a shell command in the project directory. Special commands: __shell_list__, __shell_read__ <session_id>, __shell_stop__ <session_id>.',
2925
5001
  parameters: {
2926
5002
  type: 'object',
2927
5003
  properties: {
@@ -2940,6 +5016,20 @@ async function runSapper() {
2940
5016
  }];
2941
5017
  }
2942
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
+
2943
5033
  // Log session start
2944
5034
  logEntry('session_start', {
2945
5035
  model: selectedModel,
@@ -2950,51 +5040,62 @@ async function runSapper() {
2950
5040
  // Main conversation loop - never exits unless user types 'exit'
2951
5041
  while (true) {
2952
5042
  try {
5043
+ const previousConfig = JSON.stringify(sapperConfig);
5044
+ const reloadedConfig = loadConfig();
5045
+ if (JSON.stringify(reloadedConfig) !== previousConfig) {
5046
+ sapperConfig = reloadedConfig;
5047
+ if (messages.length > 0 && messages[0]?.role === 'system') {
5048
+ refreshSystemPrompt(messages);
5049
+ }
5050
+ console.log(chalk.gray(`↻ Reloaded ${CONFIG_FILE}`));
5051
+ console.log(chalk.gray(' System prompt and runtime settings refreshed from config.'));
5052
+ }
5053
+
2953
5054
  // Context size check - auto-summarize when approaching effective context limit
2954
- const estimatedTokens = estimateMessagesTokens(messages);
5055
+ let estimatedTokens = estimateMessagesTokens(messages);
2955
5056
  const ctxLen = effectiveContextLength();
2956
- const tokenThreshold = ctxLen ? Math.floor(ctxLen * 0.75) : 8000;
5057
+ const tokenThreshold = summaryTokenThreshold(ctxLen);
2957
5058
  if (estimatedTokens > tokenThreshold) {
2958
5059
  messages = await autoSummarizeContext(messages, selectedModel);
5060
+ estimatedTokens = estimateMessagesTokens(messages);
2959
5061
  }
2960
5062
 
2961
5063
  // Build prompt label with active agent/skills
2962
5064
  const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
2963
5065
  const promptParts = [
2964
5066
  statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
2965
- currentAgent ? statusBadge(`/${currentAgent}`, 'info') : statusBadge('default', 'neutral'),
5067
+ activeAgentPromptBadge(),
2966
5068
  ];
2967
- if (loadedSkills.length > 0) {
2968
- promptParts.push(statusBadge(`${loadedSkills.length} skill${loadedSkills.length !== 1 ? 's' : ''}`, 'success'));
5069
+ const skillsBadge = activeSkillsPromptBadge();
5070
+ if (skillsBadge) {
5071
+ promptParts.push(skillsBadge);
2969
5072
  }
2970
5073
  if (contextPercent !== null) {
2971
5074
  const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
2972
5075
  promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
2973
5076
  }
2974
5077
 
2975
- const promptDetail = ctxLen
5078
+ const promptDetailLines = [ctxLen
2976
5079
  ? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
2977
- : 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');
2978
5087
 
2979
- const input = await safeQuestion(`\n${promptShell(promptParts.join(' '), promptDetail)}`);
5088
+ const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
5089
+ const input = await safeQuestion(promptText);
5090
+ clearPromptEcho(promptText, input);
2980
5091
 
2981
5092
  // Block empty prompts
2982
5093
  if (!input.trim()) {
2983
5094
  continue;
2984
5095
  }
2985
5096
 
2986
- // Clear readline echo to prevent duplicate display
2987
- {
2988
- const promptWidth = visibleLength(promptParts.join(' ')) + 4; // account for prompt chars
2989
- const totalLen = promptWidth + input.length;
2990
- const lines = Math.ceil(totalLen / (process.stdout.columns || 80));
2991
- for (let i = 0; i < lines; i++) {
2992
- process.stdout.write('\x1B[1A\x1B[2K');
2993
- }
2994
- // Reprint clean version
2995
- const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
2996
- console.log(UI.accent('› ') + chalk.white(preview));
2997
- }
5097
+ const preview = input.length > LIMITS.INPUT_PREVIEW_CHARS ? input.substring(0, LIMITS.INPUT_PREVIEW_CHARS) + chalk.gray('...') : input;
5098
+ console.log(UI.accent('› ') + chalk.white(preview));
2998
5099
 
2999
5100
  if (input.toLowerCase() === 'exit') {
3000
5101
  const stats = getSessionStats();
@@ -3056,7 +5157,11 @@ async function runSapper() {
3056
5157
  console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
3057
5158
  console.log(commandRow('/reset /clear', 'Clear all current context'));
3058
5159
  console.log(commandRow('/prune', 'Summarize long context and store memory'));
3059
- console.log(commandRow('/context', 'Inspect token usage and model window'));
5160
+ console.log(commandRow('/summary', 'Show or change auto-summary settings'));
5161
+ console.log(commandRow('/shell', 'Inspect shell config and background sessions'));
5162
+ console.log(commandRow('/shell read <id>', 'Read output from a tracked shell session'));
5163
+ console.log(commandRow('/shell stop <id>', 'Stop a tracked shell session'));
5164
+ console.log(commandRow('/context', 'Inspect token usage, summary trigger, and model window'));
3060
5165
  console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
3061
5166
  console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
3062
5167
  console.log(commandRow('/log', 'Show the session activity timeline'));
@@ -3064,6 +5169,13 @@ async function runSapper() {
3064
5169
  console.log(commandRow('/log file', 'Show log file path and history'));
3065
5170
  console.log(commandRow('/help', 'Open this command view again'));
3066
5171
  console.log(commandRow('exit', 'Quit Sapper'));
5172
+ console.log(UI.slate(' Summary settings: /summary | /summary phases off | /summary trigger 60'));
5173
+ console.log(UI.slate(' Tool config: .sapper/config.json -> toolRoundLimit (default 40)'));
5174
+ console.log(UI.slate(' Shell config: .sapper/config.json -> shell.streamToModel, shell.backgroundMode [off|auto|on], shell.backgroundAfterSeconds, shell.outputChunkChars'));
5175
+ console.log(UI.slate(' Want to see all live shell output? Set shell.backgroundMode to off. thinking.mode only controls model reasoning.'));
5176
+ console.log(UI.slate(' Streaming config: .sapper/config.json -> streaming.showPhaseStatus, streaming.showHeartbeat, streaming.idleNoticeSeconds'));
5177
+ console.log(UI.slate(' Thinking config: .sapper/config.json -> thinking.mode [auto|on|off]'));
5178
+ console.log(UI.slate(' Prompt config: .sapper/config.json -> prompt.prepend, prompt.append, prompt.coreOverride'));
3067
5179
  console.log();
3068
5180
  console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
3069
5181
  console.log(commandRow('/agents', 'List available agents'));
@@ -3141,8 +5253,8 @@ async function runSapper() {
3141
5253
  console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
3142
5254
  }
3143
5255
 
3144
- if (results.length > 15) {
3145
- 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`));
3146
5258
  }
3147
5259
 
3148
5260
  // Offer to add file to context
@@ -3211,7 +5323,7 @@ async function runSapper() {
3211
5323
  let contextContent = `\n📄 ${matchingFile}:\n`;
3212
5324
  contextContent += fs.readFileSync(matchingFile, 'utf8');
3213
5325
 
3214
- 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
3215
5327
  try {
3216
5328
  contextContent += `\n\n📄 ${relFile} (related):\n`;
3217
5329
  contextContent += fs.readFileSync(relFile, 'utf8');
@@ -3288,20 +5400,112 @@ async function runSapper() {
3288
5400
  continue;
3289
5401
  }
3290
5402
 
5403
+ if (input.toLowerCase().startsWith('/summary')) {
5404
+ const arg = input.substring(8).trim();
5405
+
5406
+ if (!arg) {
5407
+ const effective = effectiveContextLength();
5408
+ const threshold = summaryTokenThreshold(effective);
5409
+ const lines = [
5410
+ `phases ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`,
5411
+ `trigger ${chalk.white.bold(summaryTriggerPercent() + '%')} ${UI.slate(`(~${threshold.toLocaleString()} tokens)`)}`,
5412
+ `config file ${chalk.white(CONFIG_FILE)}`,
5413
+ ];
5414
+ console.log();
5415
+ console.log(box(lines.join('\n'), 'Summary Settings', 'cyan'));
5416
+ console.log(UI.slate(' Usage: /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
5417
+ continue;
5418
+ }
5419
+
5420
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
5421
+ const subcommand = subcommandRaw.toLowerCase();
5422
+ const value = rest.join(' ').trim();
5423
+
5424
+ if (subcommand === 'reset' || subcommand === 'default') {
5425
+ sapperConfig.summaryPhases = DEFAULT_CONFIG.summaryPhases;
5426
+ sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
5427
+ saveConfig(sapperConfig);
5428
+ console.log(chalk.green(`✅ Summary settings reset: phases ${summaryPhasesEnabled() ? 'ON' : 'OFF'}, trigger ${summaryTriggerPercent()}%`));
5429
+ continue;
5430
+ }
5431
+
5432
+ if (subcommand === 'phases' || subcommand === 'phase') {
5433
+ let nextValue = null;
5434
+
5435
+ if (!value) {
5436
+ nextValue = !summaryPhasesEnabled();
5437
+ } else {
5438
+ const normalized = value.toLowerCase();
5439
+ if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(normalized)) {
5440
+ nextValue = true;
5441
+ } else if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(normalized)) {
5442
+ nextValue = false;
5443
+ } else if (['toggle', 'flip'].includes(normalized)) {
5444
+ nextValue = !summaryPhasesEnabled();
5445
+ }
5446
+ }
5447
+
5448
+ if (nextValue === null) {
5449
+ console.log(chalk.yellow('Usage: /summary phases [on|off]'));
5450
+ continue;
5451
+ }
5452
+
5453
+ sapperConfig.summaryPhases = nextValue;
5454
+ saveConfig(sapperConfig);
5455
+ console.log(chalk.green(`✅ Summary phases: ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
5456
+ continue;
5457
+ }
5458
+
5459
+ if (subcommand === 'trigger' || subcommand === 'percent' || subcommand === 'threshold') {
5460
+ if (!value) {
5461
+ console.log(chalk.yellow('Usage: /summary trigger <percent>'));
5462
+ console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
5463
+ continue;
5464
+ }
5465
+
5466
+ const parsedTrigger = parseSummaryTriggerInput(value);
5467
+ if (parsedTrigger === null) {
5468
+ console.log(chalk.yellow(`Invalid summary trigger: ${value}`));
5469
+ console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
5470
+ continue;
5471
+ }
5472
+
5473
+ sapperConfig.summarizeTriggerPercent = parsedTrigger;
5474
+ saveConfig(sapperConfig);
5475
+ const effective = effectiveContextLength();
5476
+ const threshold = summaryTokenThreshold(effective);
5477
+ console.log(chalk.green(`✅ Summary trigger set to ${chalk.white.bold(summaryTriggerPercent() + '%')}`));
5478
+ console.log(chalk.gray(` Auto-summary will start near ${threshold.toLocaleString()} tokens.`));
5479
+ continue;
5480
+ }
5481
+
5482
+ console.log(chalk.yellow(`Unknown summary option: ${subcommand}`));
5483
+ console.log(chalk.gray(' Usage: /summary | /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
5484
+ continue;
5485
+ }
5486
+
3291
5487
  if (input.toLowerCase() === '/context') {
3292
5488
  const contextSize = JSON.stringify(messages).length;
3293
5489
  const estTokens = estimateMessagesTokens(messages);
3294
5490
  const ctxLen = effectiveContextLength();
5491
+ const triggerPercent = summaryTriggerPercent();
5492
+ const promptConfig = getPromptConfig();
3295
5493
  const contextLines = [
3296
5494
  `messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
3297
5495
  ];
5496
+ contextLines.push(`prompt ${chalk.white(hasCustomPromptConfig() ? 'customized' : 'default')} ${UI.slate('·')} ${chalk.white(`prepend ${promptConfig.prepend.trim() ? 'yes' : 'no'}`)} ${UI.slate('·')} ${chalk.white(`append ${promptConfig.append.trim() ? 'yes' : 'no'}`)}`);
5497
+ contextLines.push(`thinking ${chalk.white(thinkingMode())} ${UI.slate('·')} ${UI.slate(thinkingMode() === 'auto' ? 'simple prompts skip reasoning' : thinkingMode() === 'off' ? 'reasoning hidden for all prompts' : 'reasoning enabled for all prompts')}`);
5498
+ contextLines.push(`tools ${chalk.white(`limit ${toolRoundLimit()} rounds`)} ${UI.slate('·')} ${UI.slate('per prompt turn')}`);
5499
+ contextLines.push(`shell ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`${activeShellSessionCount()} active`)}`);
5500
+ contextLines.push(`stream ${chalk.white(streamHeartbeatEnabled() ? 'heartbeat on' : 'heartbeat off')} ${UI.slate('·')} ${chalk.white(streamPhaseStatusEnabled() ? 'phase status on' : 'phase status off')} ${UI.slate('·')} ${chalk.white(`idle ${streamIdleNoticeSeconds()}s`)}`);
3298
5501
  if (ctxLen) {
3299
5502
  const usagePercent = Math.round((estTokens / ctxLen) * 100);
3300
- const threshold = Math.floor(ctxLen * 0.75);
5503
+ const threshold = summaryTokenThreshold(ctxLen);
3301
5504
  const limitLabel = sapperConfig.contextLimit
3302
5505
  ? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
3303
5506
  : `${ctxLen.toLocaleString()} tokens`;
3304
5507
  contextLines.push(`limit ${chalk.white(limitLabel)} ${UI.slate('·')} usage ${chalk.white(usagePercent + '%')}`);
5508
+ contextLines.push(`summary ${chalk.white(`trigger ${triggerPercent}%`)} ${UI.slate('·')} ${chalk.white(summaryPhasesEnabled() ? 'phases on' : 'phases off')}`);
3305
5509
  contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
3306
5510
  }
3307
5511
  if (lastPromptTokens > 0) {
@@ -3311,6 +5515,38 @@ async function runSapper() {
3311
5515
  console.log(box(contextLines.join('\n'), 'Context', 'gray'));
3312
5516
  continue;
3313
5517
  }
5518
+
5519
+ if (input.toLowerCase().startsWith('/shell')) {
5520
+ const arg = input.substring(6).trim();
5521
+
5522
+ if (!arg || ['sessions', 'session', 'list', 'ls', 'status'].includes(arg.toLowerCase())) {
5523
+ console.log();
5524
+ console.log(renderShellSessionsPanel());
5525
+ console.log(UI.slate(' Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
5526
+ continue;
5527
+ }
5528
+
5529
+ const [subcommandRaw, ...rest] = arg.split(/\s+/);
5530
+ const subcommand = subcommandRaw.toLowerCase();
5531
+ const sessionId = rest.join(' ').trim();
5532
+
5533
+ if (['read', 'show', 'tail'].includes(subcommand)) {
5534
+ const result = await handleShellSessionCommand(`__shell_read__ ${sessionId}`);
5535
+ console.log();
5536
+ console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Read', 'cyan'));
5537
+ continue;
5538
+ }
5539
+
5540
+ if (['stop', 'kill', 'end'].includes(subcommand)) {
5541
+ const result = await handleShellSessionCommand(`__shell_stop__ ${sessionId}`);
5542
+ console.log();
5543
+ console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Stop', 'red'));
5544
+ continue;
5545
+ }
5546
+
5547
+ console.log(chalk.yellow('Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
5548
+ continue;
5549
+ }
3314
5550
 
3315
5551
  // Handle debug mode toggle
3316
5552
  if (input.toLowerCase() === '/debug') {
@@ -3340,14 +5576,14 @@ async function runSapper() {
3340
5576
  const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.endsWith('.md')).sort().reverse();
3341
5577
  if (logFiles.length > 0) {
3342
5578
  console.log(chalk.cyan(`\n📋 All session logs:`));
3343
- logFiles.slice(0, 10).forEach((f, i) => {
5579
+ logFiles.slice(0, LIMITS.LOG_FILES_DISPLAY_MAX).forEach((f, i) => {
3344
5580
  const stats = fs.statSync(`${LOGS_DIR}/${f}`);
3345
5581
  const isCurrent = f === `session-${sessionId}.md`;
3346
5582
  const label = isCurrent ? chalk.green(' ← current') : '';
3347
5583
  console.log(chalk.gray(` ${i + 1}. `) + chalk.white(f) + chalk.gray(` (${Math.round(stats.size / 1024)}KB)`) + label);
3348
5584
  });
3349
- if (logFiles.length > 10) {
3350
- 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`));
3351
5587
  }
3352
5588
  }
3353
5589
  } catch (e) {}
@@ -3447,6 +5683,7 @@ async function runSapper() {
3447
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`;
3448
5684
 
3449
5685
  fs.writeFileSync(agentFile, agentMd);
5686
+ invalidateLoaderCache('agents');
3450
5687
  console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
3451
5688
  console.log(chalk.gray(` File: ${agentFile}`));
3452
5689
  console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
@@ -3568,13 +5805,14 @@ async function runSapper() {
3568
5805
  const agentTitle = await safeQuestion(chalk.cyan('Agent title/role: '));
3569
5806
  const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
3570
5807
  const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
3571
- const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,search,shell: '));
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: '));
3572
5809
 
3573
5810
  const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
3574
5811
  const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
3575
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`;
3576
5813
 
3577
5814
  fs.writeFileSync(agentFile, agentMd);
5815
+ invalidateLoaderCache('agents');
3578
5816
  console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
3579
5817
  console.log(chalk.gray(` File: ${agentFile}`));
3580
5818
  console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
@@ -3615,6 +5853,7 @@ async function runSapper() {
3615
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`;
3616
5854
 
3617
5855
  fs.writeFileSync(skillFile, skillMd);
5856
+ invalidateLoaderCache('skills');
3618
5857
  console.log(chalk.green(`\n✅ Skill "${skillName}" created!`));
3619
5858
  console.log(chalk.gray(` File: ${skillFile}`));
3620
5859
  console.log(chalk.cyan(` Load it: /use ${skillName}`));
@@ -3732,7 +5971,7 @@ async function runSapper() {
3732
5971
  console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
3733
5972
  relevant.forEach((chunk, i) => {
3734
5973
  console.log(box(
3735
- chalk.gray(chunk.text.substring(0, 300) + '...') + '\n' +
5974
+ chalk.gray(chunk.text.substring(0, LIMITS.MEMORY_PREVIEW_CHARS) + '...') + '\n' +
3736
5975
  chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
3737
5976
  `Memory ${i + 1}`, 'magenta'
3738
5977
  ));
@@ -3802,12 +6041,12 @@ async function runSapper() {
3802
6041
  continue;
3803
6042
  }
3804
6043
  const stats = fs.statSync(filePath);
3805
- if (stats.size > MAX_FILE_SIZE) {
6044
+ if (stats.size > getMaxFileSize()) {
3806
6045
  console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
3807
6046
  console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
3808
6047
  console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
3809
6048
  console.log(chalk.yellow(` File: ${filePath}`));
3810
- console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(MAX_FILE_SIZE/1024)}KB)`));
6049
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(getMaxFileSize()/1024)}KB)`));
3811
6050
  console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3812
6051
  continue;
3813
6052
  }
@@ -3831,16 +6070,7 @@ async function runSapper() {
3831
6070
  }
3832
6071
 
3833
6072
  // Build message with attachments
3834
- let attachedContent = '\n\n══════════════════════════════════════\n';
3835
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
3836
- attachedContent += '══════════════════════════════════════\n\n';
3837
-
3838
- for (const file of fileAttachments) {
3839
- attachedContent += `┌─── ${file.path} ───\n`;
3840
- attachedContent += file.content;
3841
- if (!file.content.endsWith('\n')) attachedContent += '\n';
3842
- attachedContent += `└─── END ${file.path} ───\n\n`;
3843
- }
6073
+ const attachedContent = formatFileAttachments(fileAttachments);
3844
6074
 
3845
6075
  messages.push({ role: 'user', content: prompt + attachedContent });
3846
6076
  // Continue to AI response (don't use 'continue' here)
@@ -3862,11 +6092,11 @@ async function runSapper() {
3862
6092
  }
3863
6093
  const stats = fs.statSync(filePath);
3864
6094
  if (stats.isFile()) {
3865
- if (stats.size > MAX_FILE_SIZE) {
6095
+ if (stats.size > getMaxFileSize()) {
3866
6096
  console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
3867
6097
  console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
3868
6098
  console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
3869
- console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(MAX_FILE_SIZE/1024)}KB limit`));
6099
+ console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(getMaxFileSize()/1024)}KB limit`));
3870
6100
  console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
3871
6101
  } else {
3872
6102
  const content = fs.readFileSync(filePath, 'utf8');
@@ -3880,7 +6110,7 @@ async function runSapper() {
3880
6110
  try {
3881
6111
  if (!fileAttachments.some(f => f.path === relFile)) {
3882
6112
  const relStats = fs.statSync(relFile);
3883
- if (relStats.size <= MAX_FILE_SIZE) {
6113
+ if (relStats.size <= getMaxFileSize()) {
3884
6114
  const relContent = fs.readFileSync(relFile, 'utf8');
3885
6115
  fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
3886
6116
  console.log(chalk.gray(` ↳ +${relFile} (related)`));
@@ -3901,16 +6131,7 @@ async function runSapper() {
3901
6131
 
3902
6132
  // Build the final message with attachments
3903
6133
  if (fileAttachments.length > 0) {
3904
- let attachedContent = '\n\n══════════════════════════════════════\n';
3905
- attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
3906
- attachedContent += '══════════════════════════════════════\n\n';
3907
-
3908
- for (const file of fileAttachments) {
3909
- attachedContent += `┌─── ${file.path} ───\n`;
3910
- attachedContent += file.content;
3911
- if (!file.content.endsWith('\n')) attachedContent += '\n';
3912
- attachedContent += `└─── END ${file.path} ───\n\n`;
3913
- }
6134
+ const attachedContent = formatFileAttachments(fileAttachments);
3914
6135
 
3915
6136
  processedInput = input + attachedContent;
3916
6137
  }
@@ -3981,9 +6202,28 @@ async function runSapper() {
3981
6202
  } // End of if (!agentHandled)
3982
6203
 
3983
6204
  let toolRounds = 0; // Prevent infinite loops
3984
- const MAX_TOOL_ROUNDS = 20;
6205
+ const MAX_TOOL_ROUNDS = toolRoundLimit();
3985
6206
  const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
3986
- 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
+ }
6226
+ const turnThinkingEnabled = shouldUseThinkingForInput(input);
3987
6227
 
3988
6228
  let active = true;
3989
6229
  while (active) {
@@ -3998,17 +6238,20 @@ async function runSapper() {
3998
6238
  if (effectiveContextLength()) {
3999
6239
  chatOpts.options = { num_ctx: effectiveContextLength() };
4000
6240
  }
4001
- // Enable thinking for reasoning models (deepseek-r1, qwq, etc.)
4002
- chatOpts.think = true;
6241
+ // Thinking can be forced on, forced off, or auto-disabled for simple prompts.
6242
+ chatOpts.think = turnThinkingEnabled;
4003
6243
  if (useNativeTools) {
4004
6244
  // Filter tool defs by agent restrictions if any
4005
6245
  if (currentAgentTools) {
4006
6246
  const toolNameMap = {
4007
6247
  list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
4008
- 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'
4009
6252
  };
4010
6253
  chatOpts.tools = nativeToolDefs.filter(t =>
4011
- currentAgentTools.includes(toolNameMap[t.function.name])
6254
+ isToolAllowedForAgent(currentAgentTools, toolNameMap[t.function.name])
4012
6255
  );
4013
6256
  } else {
4014
6257
  chatOpts.tools = nativeToolDefs;
@@ -4026,7 +6269,7 @@ async function runSapper() {
4026
6269
 
4027
6270
  let msg = '';
4028
6271
  let thinkMsg = ''; // Thinking/reasoning content from thinking models
4029
- const MAX_RESPONSE_LENGTH = 100000; // 100KB - allow long code generation
6272
+ const MAX_RESPONSE_LENGTH = LIMITS.MAX_RESPONSE_LENGTH;
4030
6273
  let lastChunkTime = Date.now();
4031
6274
  let repetitionCount = 0;
4032
6275
  let lastContent = '';
@@ -4037,10 +6280,55 @@ async function runSapper() {
4037
6280
  let chunkPromptTokens = 0; // Track actual tokens from Ollama
4038
6281
  let chunkEvalTokens = 0;
4039
6282
  let isThinking = false; // Track if we're currently in thinking mode
6283
+ let thinkingContinuationNeedsPrefix = false;
6284
+ let lastThinkingIdleNoticeAt = 0;
4040
6285
  const genStartTime = Date.now(); // Track generation elapsed time
4041
6286
  let genTokenCount = 0; // Count response tokens as they stream
6287
+ let lastVisibleActivityAt = Date.now();
6288
+ let heartbeatInterval = null;
4042
6289
 
4043
- 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
6302
+ if (streamHeartbeatEnabled()) {
6303
+ heartbeatInterval = setInterval(() => {
6304
+ if (abortStream) return;
6305
+
6306
+ if (isThinking) {
6307
+ const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
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
+ }
6314
+ if (idleSeconds >= idleThreshold && Date.now() - lastThinkingIdleNoticeAt >= 5000) {
6315
+ process.stdout.write(`\n${UI.slate(' │ ')}${UI.slate.italic(`... waiting ${idleSeconds}s for more reasoning`)}\n`);
6316
+ thinkingContinuationNeedsPrefix = true;
6317
+ lastThinkingIdleNoticeAt = Date.now();
6318
+ }
6319
+ return;
6320
+ }
6321
+
6322
+ renderStreamingHeartbeat({
6323
+ genTokenCount,
6324
+ genStartTime,
6325
+ lastVisibleActivityAt,
6326
+ stage: genTokenCount > 0 ? 'generating' : 'waiting-first',
6327
+ });
6328
+ }, 1000);
6329
+ }
6330
+ let streamErrored = null;
6331
+ try {
4044
6332
  for await (const chunk of response) {
4045
6333
  // Check if user pressed Ctrl+C
4046
6334
  if (abortStream) {
@@ -4059,10 +6347,13 @@ async function runSapper() {
4059
6347
  // Live-stream thinking — dim italic, wrap at line breaks
4060
6348
  const lines = thinking.split('\n');
4061
6349
  for (let li = 0; li < lines.length; li++) {
4062
- if (li > 0) process.stdout.write(`\n${UI.slate(' │ ')}`);
6350
+ if (li > 0 || thinkingContinuationNeedsPrefix) process.stdout.write(`\n${UI.slate(' │ ')}`);
6351
+ thinkingContinuationNeedsPrefix = false;
4063
6352
  process.stdout.write(UI.slate.italic(lines[li]));
4064
6353
  }
4065
6354
  thinkMsg += thinking;
6355
+ lastVisibleActivityAt = Date.now();
6356
+ lastThinkingIdleNoticeAt = 0;
4066
6357
  }
4067
6358
 
4068
6359
  const content = chunk.message.content;
@@ -4073,10 +6364,13 @@ async function runSapper() {
4073
6364
  }
4074
6365
  msg += content;
4075
6366
  genTokenCount++;
4076
- // Show live progress with timer, tokens, and interrupt hint
4077
- const elapsed = ((Date.now() - genStartTime) / 1000).toFixed(1);
4078
- const tps = genTokenCount / Math.max((Date.now() - genStartTime) / 1000, 0.1);
4079
- process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s`)} ${UI.slate.italic('Ctrl+C to stop')}`);
6367
+ lastVisibleActivityAt = Date.now();
6368
+ renderStreamingHeartbeat({
6369
+ genTokenCount,
6370
+ genStartTime,
6371
+ lastVisibleActivityAt,
6372
+ stage: 'generating',
6373
+ });
4080
6374
  }
4081
6375
 
4082
6376
  // Capture token stats from the final chunk (done: true)
@@ -4089,14 +6383,14 @@ async function runSapper() {
4089
6383
  }
4090
6384
 
4091
6385
  // Smart loop detection: check for repetitive content patterns
4092
- if (msg.length > 10000) {
4093
- const recentContent = msg.slice(-500);
4094
- 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);
4095
6389
 
4096
6390
  // If last 500 chars are very similar to previous 500, might be looping
4097
6391
  if (recentContent === previousContent) {
4098
6392
  repetitionCount++;
4099
- if (repetitionCount > 3) {
6393
+ if (repetitionCount > LIMITS.REPETITION_COUNT) {
4100
6394
  console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
4101
6395
  wasRepetitionStopped = true;
4102
6396
  break;
@@ -4112,9 +6406,34 @@ async function runSapper() {
4112
6406
  // Don't break - just warn. User can Ctrl+C if needed
4113
6407
  }
4114
6408
  }
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;
6427
+ }
6428
+ if (isThinking) {
6429
+ isThinking = false;
6430
+ process.stdout.write(`\n${UI.slate(' └─')}\n`);
6431
+ }
4115
6432
  // Clear progress line and render formatted markdown
4116
6433
  process.stdout.write('\r\x1b[K');
6434
+ showStreamPhase('Finalizing streamed response...');
4117
6435
  if (msg.trim()) {
6436
+ showStreamPhase('Rendering markdown output...');
4118
6437
  console.log(renderMarkdown(msg));
4119
6438
  } else {
4120
6439
  console.log();
@@ -4176,22 +6495,27 @@ async function runSapper() {
4176
6495
  // Map native function names to tool executors
4177
6496
  const nativeToolNameMap = {
4178
6497
  list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
4179
- 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'
4180
6502
  };
4181
6503
 
6504
+ showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
6505
+
4182
6506
  for (const tc of nativeToolCalls) {
4183
6507
  const fn = tc.function;
4184
6508
  const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
4185
6509
  const args = fn.arguments || {};
4186
6510
 
4187
6511
  // Enforce agent tool restrictions
4188
- if (currentAgentTools && !currentAgentTools.includes(toolType)) {
6512
+ if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, toolType)) {
4189
6513
  console.log(chalk.yellow(`\n⚠️ Tool ${toolType} blocked — not in agent's allowed tools`));
4190
6514
  messages.push({ role: 'tool', content: `Error: Tool ${toolType} is not allowed for the current agent.`, tool_name: fn.name });
4191
6515
  continue;
4192
6516
  }
4193
6517
 
4194
- const displayPath = args.path || args.pattern || args.command || '';
6518
+ const displayPath = args.path || args.pattern || args.url || args.query || args.command || '';
4195
6519
  console.log();
4196
6520
  console.log(statusBadge(toolType, 'action') + chalk.gray(' → ') + chalk.white(displayPath));
4197
6521
 
@@ -4202,8 +6526,8 @@ async function runSapper() {
4202
6526
  try {
4203
6527
  switch (fn.name) {
4204
6528
  case 'list_directory':
4205
- result = tools.list(args.path);
4206
- logEntry('file', { action: 'list', path: args.path });
6529
+ result = tools.list(args.path ?? '.');
6530
+ logEntry('file', { action: 'list', path: args.path ?? '.' });
4207
6531
  break;
4208
6532
  case 'read_file':
4209
6533
  result = tools.read(args.path);
@@ -4218,26 +6542,66 @@ async function runSapper() {
4218
6542
  logEntry('file', { action: 'write', path: args.path, size: args.content?.length || 0, userApproved: result.includes('Successfully') });
4219
6543
  break;
4220
6544
  case 'patch_file': {
4221
- const patchKey = args.path?.trim();
4222
- if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
4223
- result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. Use read_file to see exact content, then try write_file instead.`;
4224
- toolSuccess = false;
4225
- } else {
4226
- result = await tools.patch(args.path, args.old_text, args.new_text);
4227
- if (result.includes('Successfully')) {
4228
- patchFailures[patchKey] = 0;
4229
- } else if (result.startsWith('Error:')) {
4230
- patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
4231
- result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES})`;
4232
- }
4233
- }
4234
- logEntry('file', { action: 'patch', path: args.path, userApproved: result.includes('Successfully') });
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 });
4235
6549
  break;
4236
6550
  }
4237
6551
  case 'create_directory':
4238
6552
  result = tools.mkdir(args.path);
4239
6553
  logEntry('file', { action: 'mkdir', path: args.path });
4240
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;
4241
6605
  case 'run_shell':
4242
6606
  result = await tools.shell(args.command);
4243
6607
  logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
@@ -4261,8 +6625,11 @@ async function runSapper() {
4261
6625
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
4262
6626
 
4263
6627
  if (hitToolLimit) {
6628
+ showStreamPhase('Tool limit reached. Requesting final answer...');
4264
6629
  resetTerminal();
4265
6630
  messages.push({ role: 'user', content: 'STOP using tools now. Provide your analysis based on what you have.' });
6631
+ } else {
6632
+ showStreamPhase('Tool results ready. Continuing response generation...');
4266
6633
  }
4267
6634
  continue; // Loop back for AI to process tool results
4268
6635
  }
@@ -4308,7 +6675,7 @@ async function runSapper() {
4308
6675
  const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
4309
6676
  if (toolAttempt) {
4310
6677
  console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
4311
- console.log(chalk.gray(` "${toolAttempt[0].substring(0, 150)}..."`));
6678
+ console.log(chalk.gray(` "${toolAttempt[0].substring(0, LIMITS.DEBUG_TOOL_PREVIEW)}..."`));
4312
6679
  }
4313
6680
  }
4314
6681
  console.log(chalk.magenta('═══════════════════════════════\n'));
@@ -4331,11 +6698,13 @@ async function runSapper() {
4331
6698
  if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
4332
6699
  }
4333
6700
 
6701
+ showStreamPhase(`Running ${toolMatches.length} parsed tool call${toolMatches.length === 1 ? '' : 's'}...`);
6702
+
4334
6703
  for (const match of toolMatches) {
4335
6704
  const [_, type, path, content] = match;
4336
6705
 
4337
6706
  // Enforce tool restrictions from active agent
4338
- if (currentAgentTools && !currentAgentTools.includes(type.toUpperCase())) {
6707
+ if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, type)) {
4339
6708
  console.log();
4340
6709
  console.log(chalk.yellow(`⚠️ Tool ${type.toUpperCase()} blocked — not in agent's allowed tools: [${currentAgentTools.join(', ')}]`));
4341
6710
  const result = `Error: Tool ${type.toUpperCase()} is not allowed for the current agent. Allowed tools: ${currentAgentTools.join(', ')}. Use only the allowed tools.`;
@@ -4354,14 +6723,40 @@ async function runSapper() {
4354
6723
  result = tools.list(path);
4355
6724
  logEntry('file', { action: 'list', path });
4356
6725
  }
6726
+ else if (type.toLowerCase() === 'ls') {
6727
+ result = tools.ls(path);
6728
+ logEntry('file', { action: 'list', path: path || '.' });
6729
+ }
4357
6730
  else if (type.toLowerCase() === 'read') {
4358
6731
  result = tools.read(path);
4359
6732
  logEntry('file', { action: 'read', path, size: result?.length || 0 });
4360
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
+ }
4361
6746
  else if (type.toLowerCase() === 'mkdir') {
4362
6747
  result = tools.mkdir(path);
4363
6748
  logEntry('file', { action: 'mkdir', path });
4364
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
+ }
4365
6760
  else if (type.toLowerCase() === 'write') {
4366
6761
  if (!content || content.trim() === '') {
4367
6762
  result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
@@ -4375,37 +6770,55 @@ async function runSapper() {
4375
6770
  else if (type.toLowerCase() === 'patch') {
4376
6771
  // PATCH format: [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL]
4377
6772
  // Also supports line mode: [TOOL:PATCH]path:::LINE:15|||new text[/TOOL]
4378
- const patchKey = path.trim();
4379
- if (patchFailures[patchKey] >= MAX_PATCH_RETRIES) {
4380
- result = `Error: PATCH failed ${MAX_PATCH_RETRIES} times on ${patchKey}. STOP retrying PATCH on this file. Instead, use [TOOL:READ]${patchKey}[/TOOL] to see exact content, then either use LINE:number mode (e.g. [TOOL:PATCH]${patchKey}:::LINE:42|||new text[/TOOL]) or use [TOOL:WRITE] to rewrite the file.`;
4381
- toolSuccess = false;
4382
- logEntry('file', { action: 'patch', path, userApproved: false });
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)];
4383
6778
  } else {
4384
- // Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
4385
- let parts = content?.split('|||');
4386
- if (!parts || parts.length !== 2) {
4387
- parts = content?.split('||:');
4388
- }
4389
- if (parts && parts.length === 2) {
4390
- result = await tools.patch(path, parts[0], parts[1]);
4391
- const approved = result.includes('Successfully');
4392
- if (!approved && result.startsWith('Error:')) {
4393
- patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
4394
- result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES} — after ${MAX_PATCH_RETRIES} failures, PATCH will be blocked on this file)`;
4395
- } else if (approved) {
4396
- patchFailures[patchKey] = 0; // Reset on success
4397
- }
4398
- logEntry('file', { action: 'patch', path, userApproved: approved });
4399
- } else {
4400
- result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
4401
- toolSuccess = false;
6779
+ const sepIdx2 = content?.indexOf('||:');
6780
+ if (sepIdx2 > -1) {
6781
+ parts = [content.substring(0, sepIdx2), content.substring(sepIdx2 + 3)];
4402
6782
  }
4403
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
+ }
4404
6793
  }
4405
6794
  else if (type.toLowerCase() === 'search') {
4406
6795
  result = await tools.search(path);
4407
6796
  logEntry('tool', { toolType: 'SEARCH', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
4408
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
+ }
4409
6822
  else if (type.toLowerCase() === 'shell') {
4410
6823
  result = await tools.shell(path);
4411
6824
  const approved = !result.includes('blocked');
@@ -4413,7 +6826,7 @@ async function runSapper() {
4413
6826
  }
4414
6827
 
4415
6828
  // Log tool execution (for non-shell, non-file specific ones)
4416
- 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())) {
4417
6830
  logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
4418
6831
  }
4419
6832
 
@@ -4422,17 +6835,20 @@ async function runSapper() {
4422
6835
  ensureSapperDir();
4423
6836
  fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
4424
6837
 
4425
- if (toolMatches.length > 30) {
6838
+ if (toolMatches.length > LIMITS.TOOL_WARN_THRESHOLD) {
4426
6839
  console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
4427
6840
  }
4428
6841
 
4429
6842
  // If tool limit was reached, stop after processing this round
4430
6843
  if (hitToolLimit) {
6844
+ showStreamPhase('Tool limit reached. Requesting final answer...');
4431
6845
  resetTerminal();
4432
6846
  messages.push({
4433
6847
  role: 'user',
4434
6848
  content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
4435
6849
  });
6850
+ } else {
6851
+ showStreamPhase('Tool results ready. Continuing response generation...');
4436
6852
  }
4437
6853
  } else {
4438
6854
  // No tools found - check if malformed command