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