sapper-iq 1.1.37 → 1.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +426 -52
- package/package.json +7 -1
- package/sapper-ui.mjs +66 -19
- package/sapper.mjs +2832 -416
- package/.github/workflows/ci.yml +0 -35
- package/.github/workflows/publish.yml +0 -46
- package/.sapper/agents/reviewer.md +0 -32
- package/.sapper/agents/sapper-it.md +0 -23
- package/.sapper/agents/writer.md +0 -31
- package/.sapper/config.json +0 -4
- package/.sapper/context.json +0 -14
- package/.sapper/logs/session-2026-04-06T06-20-07.md +0 -29
- package/.sapper/skills/git-workflow.md +0 -44
- package/.sapper/skills/node-project.md +0 -52
- package/.sapper/workspace.json +0 -52
- package/.sapperignore +0 -137
- package/PUBLISHING.md +0 -148
- package/old/sapper copy 2.mjs +0 -673
- package/old/sapper copy 3.mjs +0 -1154
- package/old/sapper copy.mjs +0 -483
- package/old/sapper copy4.mjs +0 -1950
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.
|
|
626
760
|
|
|
627
|
-
|
|
761
|
+
Adapt to the project that exists, not a hard-coded template of technologies.
|
|
762
|
+
|
|
763
|
+
## What You Handle
|
|
764
|
+
|
|
765
|
+
- Feature implementation and bug fixing
|
|
766
|
+
- Refactoring and code cleanup
|
|
767
|
+
- Architecture and system design decisions
|
|
768
|
+
- Tooling, build, automation, and developer workflow improvements
|
|
769
|
+
- Tests, validation, and release-readiness checks
|
|
770
|
+
- Performance, reliability, and maintainability problems
|
|
771
|
+
- APIs, data flow, storage, and integration work when the codebase requires it
|
|
772
|
+
|
|
773
|
+
## Working Style
|
|
774
|
+
|
|
775
|
+
- Understand the request and inspect the relevant code before proposing or making changes.
|
|
776
|
+
- Prefer the smallest change that solves the root problem cleanly.
|
|
777
|
+
- Match the repository's existing conventions, abstractions, and naming.
|
|
778
|
+
- Use tools proactively: read code, search broadly, edit precisely, and run shell commands when they help verify behavior.
|
|
779
|
+
- Verify work proportionally with checks, builds, tests, or direct inspection when feasible.
|
|
780
|
+
- Be concise, technical, and practical. Explain tradeoffs only when they matter.
|
|
781
|
+
|
|
782
|
+
## Decision Rules
|
|
783
|
+
|
|
784
|
+
- If the task is ambiguous, gather context first instead of guessing.
|
|
785
|
+
- If multiple approaches are possible, choose the one with the best balance of correctness, simplicity, and fit for the current codebase.
|
|
786
|
+
- If asked for a review, prioritize bugs, risks, regressions, and missing validation ahead of style commentary.
|
|
787
|
+
- If a request spans unfamiliar technologies, infer behavior from the actual project files rather than generic assumptions.`,
|
|
628
788
|
|
|
629
789
|
'writer': `---
|
|
630
790
|
name: "Technical Writer"
|
|
@@ -818,7 +978,10 @@ function buildSystemPrompt(agentContent = null, skillContents = []) {
|
|
|
818
978
|
const now = new Date();
|
|
819
979
|
const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
|
820
980
|
const timeStr = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
|
821
|
-
|
|
981
|
+
const promptConfig = getPromptConfig();
|
|
982
|
+
const promptPrepend = promptConfig.prepend.trim();
|
|
983
|
+
const promptAppend = promptConfig.append.trim();
|
|
984
|
+
const corePrompt = promptConfig.coreOverride.trim() || `You are Sapper, an intelligent AI assistant with access to the local filesystem and shell.
|
|
822
985
|
You can help with ANY task - coding, writing, research, planning, analysis, and more.
|
|
823
986
|
Adapt your personality and expertise based on the active agent role and loaded skills.
|
|
824
987
|
|
|
@@ -830,28 +993,60 @@ RULES:
|
|
|
830
993
|
3. BE PRECISE: When using patch, ensure the 'old_text' matches exactly.
|
|
831
994
|
4. VERIFY: After making changes, verify they work (run tests, check output, etc).
|
|
832
995
|
5. NO HALLUCINATIONS: If a file doesn't exist, don't guess its content. List the directory instead.`;
|
|
996
|
+
let prompt = promptPrepend
|
|
997
|
+
? `${wrapPromptCustomizationBlock('CUSTOM PROMPT PREPEND', promptPrepend, false)}\n\n${corePrompt}`
|
|
998
|
+
: corePrompt;
|
|
833
999
|
|
|
834
1000
|
if (_useNativeToolsFlag) {
|
|
835
1001
|
prompt += `
|
|
836
1002
|
|
|
837
1003
|
TOOLS:
|
|
838
1004
|
You have function-calling tools available. Call them directly — do NOT use [TOOL:...] text markers.
|
|
839
|
-
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, run_shell.
|
|
1005
|
+
Available tools: list_directory, read_file, search_files, write_file, patch_file, create_directory, ls, cat, head, tail, grep, find, pwd, cd, rmdir, changes, fetch_web, recall_memory, open_url, run_shell.
|
|
840
1006
|
|
|
841
1007
|
PATCH TIPS:
|
|
842
1008
|
- For patch_file, set old_text to "LINE:<number>" to replace a specific line by number (most reliable).
|
|
843
1009
|
- Always read_file first to see exact content before using patch_file.
|
|
844
|
-
- If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead
|
|
1010
|
+
- If a patch fails, do NOT retry with slight variations. Switch to LINE:number mode or use write_file instead.
|
|
1011
|
+
|
|
1012
|
+
EXTRA TOOL TIPS:
|
|
1013
|
+
- ls lists directory contents using the current tool working directory when path is omitted.
|
|
1014
|
+
- cat reads a full file, while head and tail read the first or last N lines.
|
|
1015
|
+
- grep searches file contents, and find searches file or directory names.
|
|
1016
|
+
- pwd shows the current tool working directory, and cd changes it for later tool calls.
|
|
1017
|
+
- rmdir removes a directory recursively and always asks for approval.
|
|
1018
|
+
- changes shows git status and diff output for the current repository or an optional path.
|
|
1019
|
+
- fetch_web fetches a web page and returns readable text content.
|
|
1020
|
+
- recall_memory searches Sapper's saved conversation memory.
|
|
1021
|
+
- open_url opens a URL in the default browser and always asks for approval.
|
|
1022
|
+
|
|
1023
|
+
SHELL TIPS:
|
|
1024
|
+
- run_shell may keep long-running commands in a background session depending on config.
|
|
1025
|
+
- If a shell result returns a session id, inspect more output with run_shell command "__shell_read__ <session_id>".
|
|
1026
|
+
- Use run_shell command "__shell_list__" to list sessions and "__shell_stop__ <session_id>" to stop one.`;
|
|
845
1027
|
} else {
|
|
846
1028
|
prompt += `
|
|
847
1029
|
|
|
848
1030
|
TOOL SYNTAX (use these to interact with files and system):
|
|
849
1031
|
- [TOOL:LIST]dir[/TOOL] - List directory contents
|
|
1032
|
+
- [TOOL:LS]dir[/TOOL] - Alias for LIST
|
|
850
1033
|
- [TOOL:READ]file_path[/TOOL] - Read file contents
|
|
1034
|
+
- [TOOL:CAT]file_path[/TOOL] - Alias for READ
|
|
1035
|
+
- [TOOL:HEAD]file_path:::20[/TOOL] - Read the first N lines of a file (default 20)
|
|
1036
|
+
- [TOOL:TAIL]file_path:::20[/TOOL] - Read the last N lines of a file (default 20)
|
|
851
1037
|
- [TOOL:SEARCH]pattern[/TOOL] - Search files for pattern
|
|
1038
|
+
- [TOOL:GREP]pattern[/TOOL] - Alias for SEARCH
|
|
1039
|
+
- [TOOL:FIND]name_or_fragment[/TOOL] - Find files and directories by name
|
|
852
1040
|
- [TOOL:WRITE]path:::content[/TOOL] - Create/overwrite file
|
|
853
1041
|
- [TOOL:PATCH]path:::old|||new[/TOOL] - Edit existing file (exact match, trimmed, or fuzzy)
|
|
854
1042
|
- [TOOL:PATCH]path:::LINE:number|||new text[/TOOL] - Replace a specific line by number (PREFERRED — more reliable)
|
|
1043
|
+
- [TOOL:PWD][/TOOL] - Show the current tool working directory
|
|
1044
|
+
- [TOOL:CD]dir[/TOOL] - Change the tool working directory for later tool calls
|
|
1045
|
+
- [TOOL:RMDIR]dir[/TOOL] - Remove a directory recursively (asks for approval)
|
|
1046
|
+
- [TOOL:CHANGES]path[/TOOL] - Show git status and diffs for the repository or a path
|
|
1047
|
+
- [TOOL:FETCH]https://example.com[/TOOL] - Fetch a web page and return readable content
|
|
1048
|
+
- [TOOL:MEMORY]query[/TOOL] - Search saved conversation memory
|
|
1049
|
+
- [TOOL:OPEN]https://example.com[/TOOL] - Open a URL in the default browser (asks for approval)
|
|
855
1050
|
- [TOOL:SHELL]command[/TOOL] - Run shell command
|
|
856
1051
|
|
|
857
1052
|
PATCH TIPS:
|
|
@@ -859,6 +1054,11 @@ PATCH TIPS:
|
|
|
859
1054
|
- Always READ the file first to see exact content before using PATCH.
|
|
860
1055
|
- If a PATCH fails, do NOT retry with slight variations. Switch to LINE:number mode or use WRITE instead.
|
|
861
1056
|
|
|
1057
|
+
SHELL TIPS:
|
|
1058
|
+
- Long-running commands may be moved to a background shell session depending on config.
|
|
1059
|
+
- If shell output mentions a session id, inspect more output with [TOOL:SHELL]__shell_read__ <session_id>[/TOOL].
|
|
1060
|
+
- Use [TOOL:SHELL]__shell_list__[/TOOL] to list sessions and [TOOL:SHELL]__shell_stop__ <session_id>[/TOOL] to stop one.
|
|
1061
|
+
|
|
862
1062
|
You MUST use the [TOOL:...][/TOOL] syntax above to perform actions. This is how you interact with the filesystem and shell - there is no other way. When you want to read a file, output [TOOL:READ]path[/TOOL] in your response. When you want to list a directory, output [TOOL:LIST].[/TOOL]. Always actually use the tools - do not just describe what you would do.
|
|
863
1063
|
Do NOT show tool syntax as examples or documentation to the user. Only use them to perform real actions.`;
|
|
864
1064
|
}
|
|
@@ -894,6 +1094,10 @@ FORBIDDEN TOOLS (DO NOT USE): ${forbidden.join(', ')}. You MUST NOT attempt to u
|
|
|
894
1094
|
prompt += `\n═══ END SKILLS ═══\n\nUse the knowledge from the loaded skills above when relevant to the user's request.`;
|
|
895
1095
|
}
|
|
896
1096
|
|
|
1097
|
+
if (promptAppend) {
|
|
1098
|
+
prompt += wrapPromptCustomizationBlock('CUSTOM PROMPT APPEND', promptAppend);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
897
1101
|
return prompt;
|
|
898
1102
|
}
|
|
899
1103
|
|
|
@@ -902,20 +1106,218 @@ let currentAgent = null; // null = default Sapper, or agent name string
|
|
|
902
1106
|
let currentAgentTools = null; // null = all tools allowed, or array of allowed tool names
|
|
903
1107
|
let loadedSkills = []; // array of skill names currently loaded
|
|
904
1108
|
|
|
905
|
-
|
|
1109
|
+
const DEFAULT_CONFIG = Object.freeze({
|
|
1110
|
+
defaultModel: null,
|
|
1111
|
+
defaultAgent: null,
|
|
1112
|
+
autoAttach: true,
|
|
1113
|
+
debug: false,
|
|
1114
|
+
contextLimit: null,
|
|
1115
|
+
toolRoundLimit: 40,
|
|
1116
|
+
patchRetries: 3,
|
|
1117
|
+
maxFileSize: 100000,
|
|
1118
|
+
maxScanSize: 1000000,
|
|
1119
|
+
maxUrlSize: 200000,
|
|
1120
|
+
summaryPhases: true,
|
|
1121
|
+
summarizeTriggerPercent: 65,
|
|
1122
|
+
shell: Object.freeze({
|
|
1123
|
+
streamToModel: true,
|
|
1124
|
+
backgroundMode: 'auto',
|
|
1125
|
+
backgroundAfterSeconds: 8,
|
|
1126
|
+
outputChunkChars: 4000,
|
|
1127
|
+
}),
|
|
1128
|
+
streaming: Object.freeze({
|
|
1129
|
+
showPhaseStatus: true,
|
|
1130
|
+
showHeartbeat: true,
|
|
1131
|
+
idleNoticeSeconds: 4,
|
|
1132
|
+
}),
|
|
1133
|
+
thinking: Object.freeze({
|
|
1134
|
+
mode: 'auto',
|
|
1135
|
+
}),
|
|
1136
|
+
prompt: Object.freeze({
|
|
1137
|
+
prepend: '',
|
|
1138
|
+
append: '',
|
|
1139
|
+
coreOverride: '',
|
|
1140
|
+
}),
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
function normalizeBoolean(value, fallback) {
|
|
1144
|
+
if (typeof value === 'boolean') return value;
|
|
1145
|
+
if (typeof value === 'string') {
|
|
1146
|
+
const normalized = value.trim().toLowerCase();
|
|
1147
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
1148
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
1149
|
+
}
|
|
1150
|
+
return fallback;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function normalizeContextLimit(value) {
|
|
1154
|
+
const parsed = Number(value);
|
|
1155
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function normalizeSummarizeTriggerPercent(value) {
|
|
1159
|
+
let parsed = Number(value);
|
|
1160
|
+
if (!Number.isFinite(parsed)) return DEFAULT_CONFIG.summarizeTriggerPercent;
|
|
1161
|
+
if (parsed > 0 && parsed <= 1) parsed *= 100;
|
|
1162
|
+
return Math.max(40, Math.min(90, Math.round(parsed)));
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function normalizeToolRoundLimit(value) {
|
|
1166
|
+
return normalizeIntegerInRange(value, DEFAULT_CONFIG.toolRoundLimit, 1, 200);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function normalizeThinkingMode(value) {
|
|
1170
|
+
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
|
1171
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
1172
|
+
if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
|
|
1173
|
+
if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
|
|
1174
|
+
return 'auto';
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function normalizeShellBackgroundMode(value) {
|
|
1178
|
+
if (typeof value === 'boolean') return value ? 'on' : 'off';
|
|
1179
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
1180
|
+
if (['on', 'true', '1', 'yes', 'enable', 'enabled', 'always'].includes(normalized)) return 'on';
|
|
1181
|
+
if (['off', 'false', '0', 'no', 'disable', 'disabled', 'never'].includes(normalized)) return 'off';
|
|
1182
|
+
return 'auto';
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function normalizeThinkingConfig(thinkingConfig = {}) {
|
|
1186
|
+
if (typeof thinkingConfig === 'boolean' || typeof thinkingConfig === 'string') {
|
|
1187
|
+
return { mode: normalizeThinkingMode(thinkingConfig) };
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (!thinkingConfig || typeof thinkingConfig !== 'object' || Array.isArray(thinkingConfig)) {
|
|
1191
|
+
return { ...DEFAULT_CONFIG.thinking };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
mode: normalizeThinkingMode(thinkingConfig.mode),
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function normalizeShellConfig(shellConfig = {}) {
|
|
1200
|
+
if (typeof shellConfig === 'boolean' || typeof shellConfig === 'string') {
|
|
1201
|
+
return {
|
|
1202
|
+
...DEFAULT_CONFIG.shell,
|
|
1203
|
+
backgroundMode: normalizeShellBackgroundMode(shellConfig),
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (!shellConfig || typeof shellConfig !== 'object' || Array.isArray(shellConfig)) {
|
|
1208
|
+
return { ...DEFAULT_CONFIG.shell };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
streamToModel: normalizeBoolean(shellConfig.streamToModel, DEFAULT_CONFIG.shell.streamToModel),
|
|
1213
|
+
backgroundMode: normalizeShellBackgroundMode(shellConfig.backgroundMode),
|
|
1214
|
+
backgroundAfterSeconds: normalizeIntegerInRange(shellConfig.backgroundAfterSeconds, DEFAULT_CONFIG.shell.backgroundAfterSeconds, 2, 120),
|
|
1215
|
+
outputChunkChars: normalizeIntegerInRange(shellConfig.outputChunkChars, DEFAULT_CONFIG.shell.outputChunkChars, 400, 12000),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function normalizeIntegerInRange(value, fallback, min, max) {
|
|
1220
|
+
const parsed = Number(value);
|
|
1221
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
1222
|
+
return Math.max(min, Math.min(max, Math.round(parsed)));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function normalizeStreamingConfig(streamingConfig = {}) {
|
|
1226
|
+
if (typeof streamingConfig === 'boolean') {
|
|
1227
|
+
return {
|
|
1228
|
+
...DEFAULT_CONFIG.streaming,
|
|
1229
|
+
showPhaseStatus: streamingConfig,
|
|
1230
|
+
showHeartbeat: streamingConfig,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (!streamingConfig || typeof streamingConfig !== 'object' || Array.isArray(streamingConfig)) {
|
|
1235
|
+
return { ...DEFAULT_CONFIG.streaming };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return {
|
|
1239
|
+
showPhaseStatus: normalizeBoolean(streamingConfig.showPhaseStatus, DEFAULT_CONFIG.streaming.showPhaseStatus),
|
|
1240
|
+
showHeartbeat: normalizeBoolean(streamingConfig.showHeartbeat, DEFAULT_CONFIG.streaming.showHeartbeat),
|
|
1241
|
+
idleNoticeSeconds: normalizeIntegerInRange(streamingConfig.idleNoticeSeconds, DEFAULT_CONFIG.streaming.idleNoticeSeconds, 2, 60),
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function normalizePromptText(value) {
|
|
1246
|
+
if (typeof value === 'string') return value;
|
|
1247
|
+
if (value === null || value === undefined) return '';
|
|
1248
|
+
return String(value);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function normalizePromptConfig(promptConfig = {}) {
|
|
1252
|
+
if (!promptConfig || typeof promptConfig !== 'object' || Array.isArray(promptConfig)) {
|
|
1253
|
+
return {
|
|
1254
|
+
...DEFAULT_CONFIG.prompt,
|
|
1255
|
+
append: normalizePromptText(promptConfig),
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const coreOverride = promptConfig.coreOverride !== undefined
|
|
1260
|
+
? promptConfig.coreOverride
|
|
1261
|
+
: promptConfig.override;
|
|
1262
|
+
|
|
1263
|
+
return {
|
|
1264
|
+
prepend: normalizePromptText(promptConfig.prepend),
|
|
1265
|
+
append: normalizePromptText(promptConfig.append),
|
|
1266
|
+
coreOverride: normalizePromptText(coreOverride),
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function normalizeConfig(config = {}) {
|
|
1271
|
+
return {
|
|
1272
|
+
...config,
|
|
1273
|
+
defaultModel: typeof config.defaultModel === 'string' && config.defaultModel.trim() ? config.defaultModel.trim() : null,
|
|
1274
|
+
defaultAgent: typeof config.defaultAgent === 'string' && config.defaultAgent.trim() ? config.defaultAgent.trim() : null,
|
|
1275
|
+
autoAttach: normalizeBoolean(config.autoAttach, DEFAULT_CONFIG.autoAttach),
|
|
1276
|
+
debug: normalizeBoolean(config.debug, DEFAULT_CONFIG.debug),
|
|
1277
|
+
contextLimit: normalizeContextLimit(config.contextLimit),
|
|
1278
|
+
toolRoundLimit: normalizeToolRoundLimit(config.toolRoundLimit),
|
|
1279
|
+
patchRetries: normalizeIntegerInRange(config.patchRetries, DEFAULT_CONFIG.patchRetries, 1, 20),
|
|
1280
|
+
maxFileSize: normalizeIntegerInRange(config.maxFileSize, DEFAULT_CONFIG.maxFileSize, 10000, 10000000),
|
|
1281
|
+
maxScanSize: normalizeIntegerInRange(config.maxScanSize, DEFAULT_CONFIG.maxScanSize, 100000, 50000000),
|
|
1282
|
+
maxUrlSize: normalizeIntegerInRange(config.maxUrlSize, DEFAULT_CONFIG.maxUrlSize, 10000, 10000000),
|
|
1283
|
+
summaryPhases: normalizeBoolean(config.summaryPhases, DEFAULT_CONFIG.summaryPhases),
|
|
1284
|
+
summarizeTriggerPercent: normalizeSummarizeTriggerPercent(config.summarizeTriggerPercent),
|
|
1285
|
+
shell: normalizeShellConfig(config.shell),
|
|
1286
|
+
streaming: normalizeStreamingConfig(config.streaming),
|
|
1287
|
+
thinking: normalizeThinkingConfig(config.thinking),
|
|
1288
|
+
prompt: normalizePromptConfig(config.prompt),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Load config (settings like autoAttach and context summarization)
|
|
906
1293
|
function loadConfig() {
|
|
907
1294
|
try {
|
|
908
1295
|
ensureSapperDir();
|
|
909
1296
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
910
|
-
|
|
1297
|
+
const rawConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
1298
|
+
const normalizedConfig = normalizeConfig(rawConfig);
|
|
1299
|
+
if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) {
|
|
1300
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
|
|
1301
|
+
}
|
|
1302
|
+
return normalizedConfig;
|
|
1303
|
+
}
|
|
1304
|
+
} catch (e) {}
|
|
1305
|
+
|
|
1306
|
+
const defaultConfig = normalizeConfig();
|
|
1307
|
+
try {
|
|
1308
|
+
ensureSapperDir();
|
|
1309
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
1310
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2));
|
|
911
1311
|
}
|
|
912
1312
|
} catch (e) {}
|
|
913
|
-
return
|
|
1313
|
+
return defaultConfig;
|
|
914
1314
|
}
|
|
915
1315
|
|
|
916
1316
|
function saveConfig(config) {
|
|
917
1317
|
ensureSapperDir();
|
|
918
|
-
|
|
1318
|
+
const normalizedConfig = normalizeConfig(config);
|
|
1319
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(normalizedConfig, null, 2));
|
|
1320
|
+
sapperConfig = normalizedConfig;
|
|
919
1321
|
}
|
|
920
1322
|
|
|
921
1323
|
// Global config
|
|
@@ -929,6 +1331,264 @@ function effectiveContextLength() {
|
|
|
929
1331
|
return modelContextLength;
|
|
930
1332
|
}
|
|
931
1333
|
|
|
1334
|
+
const SUMMARY_PHASES = [
|
|
1335
|
+
'Prepare summary request',
|
|
1336
|
+
'Summarize older messages',
|
|
1337
|
+
'Save compressed context',
|
|
1338
|
+
'Resume your prompt',
|
|
1339
|
+
];
|
|
1340
|
+
|
|
1341
|
+
function summaryPhasesEnabled() {
|
|
1342
|
+
return sapperConfig.summaryPhases !== false;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function toolRoundLimit() {
|
|
1346
|
+
return normalizeToolRoundLimit(sapperConfig.toolRoundLimit);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function getShellConfig() {
|
|
1350
|
+
return normalizeShellConfig(sapperConfig.shell);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function shellStreamToModelEnabled() {
|
|
1354
|
+
return getShellConfig().streamToModel;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function shellBackgroundMode() {
|
|
1358
|
+
return getShellConfig().backgroundMode;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function shellBackgroundAfterSeconds() {
|
|
1362
|
+
return getShellConfig().backgroundAfterSeconds;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function shellOutputChunkChars() {
|
|
1366
|
+
return getShellConfig().outputChunkChars;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function summaryTriggerPercent() {
|
|
1370
|
+
return normalizeSummarizeTriggerPercent(sapperConfig.summarizeTriggerPercent);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function summaryTokenThreshold(ctxLen) {
|
|
1374
|
+
return ctxLen ? Math.floor(ctxLen * (summaryTriggerPercent() / 100)) : 8000;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function parseSummaryTriggerInput(value) {
|
|
1378
|
+
if (value === undefined || value === null) return null;
|
|
1379
|
+
const normalized = String(value).trim().replace(/%$/, '');
|
|
1380
|
+
if (!normalized) return null;
|
|
1381
|
+
|
|
1382
|
+
let parsed = Number(normalized);
|
|
1383
|
+
if (!Number.isFinite(parsed)) return null;
|
|
1384
|
+
if (parsed > 0 && parsed <= 1) parsed *= 100;
|
|
1385
|
+
|
|
1386
|
+
return Math.max(40, Math.min(90, Math.round(parsed)));
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function summaryPhaseText(stepNumber, detail = '') {
|
|
1390
|
+
const fallback = SUMMARY_PHASES[stepNumber - 1] || 'Context summarization';
|
|
1391
|
+
if (!summaryPhasesEnabled()) {
|
|
1392
|
+
return detail || fallback;
|
|
1393
|
+
}
|
|
1394
|
+
return detail
|
|
1395
|
+
? `Step ${stepNumber}/${SUMMARY_PHASES.length} ${detail}`
|
|
1396
|
+
: `Step ${stepNumber}/${SUMMARY_PHASES.length} ${fallback}`;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function renderSummaryPhaseList(activeStep = null) {
|
|
1400
|
+
return SUMMARY_PHASES
|
|
1401
|
+
.map((label, index) => {
|
|
1402
|
+
const stepNumber = index + 1;
|
|
1403
|
+
const line = `Step ${stepNumber}/${SUMMARY_PHASES.length} ${label}`;
|
|
1404
|
+
return activeStep === stepNumber ? chalk.cyan(line) : UI.slate(line);
|
|
1405
|
+
})
|
|
1406
|
+
.join('\n');
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function getPromptConfig() {
|
|
1410
|
+
return normalizePromptConfig(sapperConfig.prompt);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function getThinkingConfig() {
|
|
1414
|
+
return normalizeThinkingConfig(sapperConfig.thinking);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function getStreamingConfig() {
|
|
1418
|
+
return normalizeStreamingConfig(sapperConfig.streaming);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function streamPhaseStatusEnabled() {
|
|
1422
|
+
return getStreamingConfig().showPhaseStatus;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function streamHeartbeatEnabled() {
|
|
1426
|
+
return getStreamingConfig().showHeartbeat;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function streamIdleNoticeSeconds() {
|
|
1430
|
+
return getStreamingConfig().idleNoticeSeconds;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function thinkingMode() {
|
|
1434
|
+
return getThinkingConfig().mode;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function normalizeThinkingInput(input = '') {
|
|
1438
|
+
let normalized = String(input ?? '').trim();
|
|
1439
|
+
if (normalized.startsWith('/') && normalized.includes(' ')) {
|
|
1440
|
+
normalized = normalized.substring(normalized.indexOf(' ') + 1).trim();
|
|
1441
|
+
}
|
|
1442
|
+
return normalized;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function isSimplePrompt(input = '') {
|
|
1446
|
+
const normalized = normalizeThinkingInput(input).toLowerCase();
|
|
1447
|
+
if (!normalized) return true;
|
|
1448
|
+
if (normalized.includes('\n')) return false;
|
|
1449
|
+
if (/@|https?:\/\//.test(normalized)) return false;
|
|
1450
|
+
if (/[`{}[\]();<>]/.test(normalized)) return false;
|
|
1451
|
+
if (/^(hi|hello|hey|thanks|thank you|ok|okay|continue|go on|proceed|yes|no|y|n|cool|nice|bye|good morning|good evening)$/.test(normalized)) {
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
if (/\b(analyze|debug|fix|implement|refactor|design|plan|optimi[sz]e|architect|investigate|review|build|create|generate|search|find|error|bug|test|compare|explain deeply)\b/.test(normalized)) {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
if (normalized.length <= 32) return true;
|
|
1458
|
+
return normalized.length <= 60 && normalized.split(/\s+/).length <= 8;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function shouldUseThinkingForInput(input = '') {
|
|
1462
|
+
const mode = thinkingMode();
|
|
1463
|
+
if (mode === 'on') return true;
|
|
1464
|
+
if (mode === 'off') return false;
|
|
1465
|
+
return !isSimplePrompt(input);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function isLikelyLongRunningCommand(command = '') {
|
|
1469
|
+
const normalized = String(command ?? '').trim().toLowerCase();
|
|
1470
|
+
if (!normalized) return false;
|
|
1471
|
+
|
|
1472
|
+
const patterns = [
|
|
1473
|
+
/\buvicorn\b/,
|
|
1474
|
+
/\bnpm\s+run\s+(dev|start|watch)\b/,
|
|
1475
|
+
/\bpnpm\s+(dev|start|watch)\b/,
|
|
1476
|
+
/\byarn\s+(dev|start|watch)\b/,
|
|
1477
|
+
/\bnext\s+dev\b/,
|
|
1478
|
+
/\bvite\b/,
|
|
1479
|
+
/\bnodemon\b/,
|
|
1480
|
+
/\bdocker\s+compose\s+up\b/,
|
|
1481
|
+
/\bwebpack(?:\s+serve|\s+--watch)?\b/,
|
|
1482
|
+
/\bpython\s+-m\s+http\.server\b/,
|
|
1483
|
+
/\btail\s+-f\b/,
|
|
1484
|
+
/\bserve\b/,
|
|
1485
|
+
/--reload\b/,
|
|
1486
|
+
/--watch\b/
|
|
1487
|
+
];
|
|
1488
|
+
|
|
1489
|
+
return patterns.some(pattern => pattern.test(normalized));
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function shouldBackgroundShellCommand(command = '') {
|
|
1493
|
+
const mode = shellBackgroundMode();
|
|
1494
|
+
if (mode === 'off') return false;
|
|
1495
|
+
if (mode === 'on') return true;
|
|
1496
|
+
return isLikelyLongRunningCommand(command);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
function hasCustomPromptConfig() {
|
|
1500
|
+
const promptConfig = getPromptConfig();
|
|
1501
|
+
return Boolean(promptConfig.prepend.trim() || promptConfig.append.trim() || promptConfig.coreOverride.trim());
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function wrapPromptCustomizationBlock(title, content, leadingNewline = true) {
|
|
1505
|
+
const normalized = String(content ?? '').trim();
|
|
1506
|
+
if (!normalized) return '';
|
|
1507
|
+
const prefix = leadingNewline ? '\n\n' : '';
|
|
1508
|
+
return `${prefix}═══ ${title} ═══\n${normalized}\n═══ END ${title} ═══`;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function resolveLoadedSkillContents() {
|
|
1512
|
+
const allSkills = loadSkills();
|
|
1513
|
+
return loadedSkills.map(skillName => allSkills[skillName]?.content || '').filter(Boolean);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function resolveActiveAgentContent() {
|
|
1517
|
+
if (!currentAgent) return null;
|
|
1518
|
+
const allAgents = loadAgents();
|
|
1519
|
+
return allAgents[currentAgent]?.content || null;
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function getActiveAgentMeta() {
|
|
1523
|
+
if (!currentAgent) return null;
|
|
1524
|
+
const allAgents = loadAgents();
|
|
1525
|
+
const agent = allAgents[currentAgent];
|
|
1526
|
+
return {
|
|
1527
|
+
key: currentAgent,
|
|
1528
|
+
name: agent?.name || currentAgent,
|
|
1529
|
+
description: agent?.description || '',
|
|
1530
|
+
tools: agent?.tools || currentAgentTools,
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function getLoadedSkillMetaList() {
|
|
1535
|
+
const allSkills = loadSkills();
|
|
1536
|
+
return loadedSkills.map(skillName => {
|
|
1537
|
+
const skill = allSkills[skillName];
|
|
1538
|
+
return {
|
|
1539
|
+
key: skillName,
|
|
1540
|
+
name: skill?.name || skillName,
|
|
1541
|
+
description: skill?.description || '',
|
|
1542
|
+
};
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function summarizeModeNames(names = [], maxVisible = 3) {
|
|
1547
|
+
const normalized = names.map(name => String(name ?? '').trim()).filter(Boolean);
|
|
1548
|
+
if (normalized.length === 0) return '';
|
|
1549
|
+
if (normalized.length <= maxVisible) return normalized.join(', ');
|
|
1550
|
+
return `${normalized.slice(0, maxVisible).join(', ')}, +${normalized.length - maxVisible} more`;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function activeModeSummary({ includeAgent = true, maxSkills = 3 } = {}) {
|
|
1554
|
+
const parts = [];
|
|
1555
|
+
const activeAgent = getActiveAgentMeta();
|
|
1556
|
+
const activeSkills = getLoadedSkillMetaList();
|
|
1557
|
+
|
|
1558
|
+
if (includeAgent && activeAgent) {
|
|
1559
|
+
const agentName = activeAgent.name || currentAgent;
|
|
1560
|
+
const agentSuffix = currentAgent && agentName !== currentAgent ? ` (/${currentAgent})` : currentAgent ? ` /${currentAgent}` : '';
|
|
1561
|
+
parts.push(`agent ${agentName}${agentSuffix}`);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (activeSkills.length > 0) {
|
|
1565
|
+
parts.push(`skills ${summarizeModeNames(activeSkills.map(skill => skill.name || skill.key), maxSkills)}`);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
return parts.join(' · ');
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function activeAgentPromptBadge() {
|
|
1572
|
+
const activeAgent = getActiveAgentMeta();
|
|
1573
|
+
if (!activeAgent) return statusBadge('default', 'neutral');
|
|
1574
|
+
return statusBadge(`agent:${ellipsis(activeAgent.name || currentAgent, 20)}`, 'info');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function activeSkillsPromptBadge() {
|
|
1578
|
+
const activeSkills = getLoadedSkillMetaList();
|
|
1579
|
+
if (activeSkills.length === 0) return null;
|
|
1580
|
+
const prefix = activeSkills.length === 1 ? 'skill:' : 'skills:';
|
|
1581
|
+
return statusBadge(`${prefix}${ellipsis(summarizeModeNames(activeSkills.map(skill => skill.name || skill.key), 2), 22)}`, 'success');
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
function refreshSystemPrompt(messages) {
|
|
1585
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
1586
|
+
messages[0] = {
|
|
1587
|
+
role: 'system',
|
|
1588
|
+
content: buildSystemPrompt(resolveActiveAgentContent(), resolveLoadedSkillContents())
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
932
1592
|
// ═══════════════════════════════════════════════════════════════
|
|
933
1593
|
// WORKSPACE GRAPH - Track file relationships and summaries
|
|
934
1594
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1048,7 +1708,7 @@ async function buildWorkspaceGraph(showProgress = true) {
|
|
|
1048
1708
|
|
|
1049
1709
|
try {
|
|
1050
1710
|
const stats = fs.statSync(fullPath);
|
|
1051
|
-
if (stats.size >
|
|
1711
|
+
if (stats.size > getMaxFileSize()) continue;
|
|
1052
1712
|
|
|
1053
1713
|
const content = fs.readFileSync(fullPath, 'utf8');
|
|
1054
1714
|
const deps = extractDependencies(content, fullPath);
|
|
@@ -1130,12 +1790,12 @@ function formatWorkspaceSummary(workspace) {
|
|
|
1130
1790
|
|
|
1131
1791
|
for (const [dir, files] of Object.entries(byDir)) {
|
|
1132
1792
|
output += `📁 ${dir}/\n`;
|
|
1133
|
-
for (const f of files.slice(0,
|
|
1793
|
+
for (const f of files.slice(0, LIMITS.WORKSPACE_FILES_PER_DIR)) { // Limit per directory
|
|
1134
1794
|
const name = f.path.split('/').pop();
|
|
1135
1795
|
const exportList = f.exports?.length ? ` [${f.exports.slice(0, 3).join(', ')}${f.exports.length > 3 ? '...' : ''}]` : '';
|
|
1136
1796
|
output += ` 📄 ${name}${exportList}\n`;
|
|
1137
1797
|
}
|
|
1138
|
-
if (files.length >
|
|
1798
|
+
if (files.length > LIMITS.WORKSPACE_FILES_PER_DIR) output += ` ... and ${files.length - LIMITS.WORKSPACE_FILES_PER_DIR} more\n`;
|
|
1139
1799
|
output += '\n';
|
|
1140
1800
|
}
|
|
1141
1801
|
|
|
@@ -1446,13 +2106,13 @@ async function addToEmbeddings(text, embeddings) {
|
|
|
1446
2106
|
const embedding = await getEmbedding(text);
|
|
1447
2107
|
if (embedding) {
|
|
1448
2108
|
embeddings.chunks.push({
|
|
1449
|
-
text: text.substring(0,
|
|
2109
|
+
text: text.substring(0, LIMITS.EMBEDDINGS_MAX_TEXT), // Limit stored text
|
|
1450
2110
|
embedding,
|
|
1451
2111
|
timestamp: Date.now()
|
|
1452
2112
|
});
|
|
1453
|
-
// Keep only last
|
|
1454
|
-
if (embeddings.chunks.length >
|
|
1455
|
-
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);
|
|
1456
2116
|
}
|
|
1457
2117
|
saveEmbeddings(embeddings);
|
|
1458
2118
|
}
|
|
@@ -1462,37 +2122,76 @@ async function addToEmbeddings(text, embeddings) {
|
|
|
1462
2122
|
// SMART CONTEXT SUMMARIZATION
|
|
1463
2123
|
// ═══════════════════════════════════════════════════════════════
|
|
1464
2124
|
|
|
1465
|
-
|
|
1466
|
-
|
|
2125
|
+
// Check whether context is large enough to need summarization
|
|
2126
|
+
function needsSummarize(messages) {
|
|
1467
2127
|
const estimatedTokens = estimateMessagesTokens(messages);
|
|
1468
2128
|
const contextSize = JSON.stringify(messages).length;
|
|
1469
|
-
|
|
1470
|
-
// Summarize when we hit 75% of effective context window (leave room for response)
|
|
1471
2129
|
const ctxLen = effectiveContextLength();
|
|
1472
|
-
const tokenThreshold =
|
|
1473
|
-
// Also keep the old byte-based check as a fallback
|
|
2130
|
+
const tokenThreshold = summaryTokenThreshold(ctxLen);
|
|
1474
2131
|
const shouldSummarize = (ctxLen && estimatedTokens > tokenThreshold) ||
|
|
1475
|
-
(!ctxLen && contextSize >
|
|
2132
|
+
(!ctxLen && contextSize > LIMITS.CONTEXT_BYTE_FALLBACK);
|
|
2133
|
+
return { shouldSummarize, estimatedTokens, contextSize, ctxLen, tokenThreshold };
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// Save old messages into the embedding store for later recall
|
|
2137
|
+
async function saveOldMessagesToEmbeddings(oldMessages) {
|
|
2138
|
+
const embeddings = loadEmbeddings();
|
|
2139
|
+
const textToEmbed = oldMessages
|
|
2140
|
+
.filter(m => m.role !== 'system')
|
|
2141
|
+
.map(m => m.content.substring(0, LIMITS.LOG_PREVIEW_CHARS))
|
|
2142
|
+
.join('\n---\n');
|
|
2143
|
+
|
|
2144
|
+
if (textToEmbed.length > LIMITS.EMBEDDING_MIN_TEXT) {
|
|
2145
|
+
try {
|
|
2146
|
+
const embedding = await getEmbedding(textToEmbed);
|
|
2147
|
+
if (embedding) {
|
|
2148
|
+
embeddings.chunks.push({
|
|
2149
|
+
text: textToEmbed.substring(0, LIMITS.EMBEDDINGS_MAX_TEXT),
|
|
2150
|
+
embedding,
|
|
2151
|
+
timestamp: Date.now()
|
|
2152
|
+
});
|
|
2153
|
+
if (embeddings.chunks.length > LIMITS.EMBEDDINGS_MAX_CHUNKS) {
|
|
2154
|
+
embeddings.chunks = embeddings.chunks.slice(-LIMITS.EMBEDDINGS_MAX_CHUNKS);
|
|
2155
|
+
}
|
|
2156
|
+
saveEmbeddings(embeddings);
|
|
2157
|
+
}
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
// Silently skip embedding if model not available
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return embeddings;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
async function autoSummarizeContext(messages, model, force = false) {
|
|
2166
|
+
const { shouldSummarize, estimatedTokens, contextSize, ctxLen, tokenThreshold } = needsSummarize(messages);
|
|
1476
2167
|
|
|
1477
2168
|
if ((!force && !shouldSummarize) || messages.length <= 5) return messages;
|
|
1478
2169
|
|
|
1479
2170
|
const usagePercent = ctxLen
|
|
1480
2171
|
? Math.round((estimatedTokens / ctxLen) * 100)
|
|
1481
|
-
: Math.round((contextSize /
|
|
2172
|
+
: Math.round((contextSize / LIMITS.CONTEXT_BYTE_FALLBACK) * 100);
|
|
1482
2173
|
|
|
1483
2174
|
console.log();
|
|
1484
|
-
|
|
1485
|
-
`Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2175
|
+
const summaryIntroLines = [
|
|
2176
|
+
`Context: ~${chalk.red.bold(estimatedTokens.toLocaleString())} tokens / ${chalk.white(ctxLen ? ctxLen.toLocaleString() : '?')} max (${chalk.red.bold(usagePercent + '%')})`,
|
|
2177
|
+
chalk.gray(`${messages.length} messages, ${Math.round(contextSize / 1024)}KB raw`),
|
|
2178
|
+
chalk.cyan('Auto-summarizing to stay within context window before answering your prompt...'),
|
|
2179
|
+
chalk.gray(`Trigger: ${summaryTriggerPercent()}% of the active context window (${tokenThreshold.toLocaleString()} tokens)`),
|
|
2180
|
+
chalk.gray('This is an extra model call, so large contexts can pause here for a while.'),
|
|
2181
|
+
];
|
|
2182
|
+
if (summaryPhasesEnabled()) {
|
|
2183
|
+
summaryIntroLines.push('');
|
|
2184
|
+
summaryIntroLines.push(renderSummaryPhaseList(1));
|
|
2185
|
+
}
|
|
2186
|
+
console.log(box(summaryIntroLines.join('\n'), '🧠 Context Window Management', 'cyan'));
|
|
1490
2187
|
|
|
1491
|
-
const
|
|
2188
|
+
const summaryStart = Date.now();
|
|
2189
|
+
const elapsedSummaryTime = () => `${Math.max(0, Math.round((Date.now() - summaryStart) / 1000))}s`;
|
|
2190
|
+
const summarySpinner = ora(summaryPhaseText(1, 'Preparing summary request...')).start();
|
|
1492
2191
|
|
|
1493
2192
|
// Separate: system prompt, messages to summarize, recent messages to keep
|
|
1494
2193
|
const systemPrompt = messages[0];
|
|
1495
|
-
const recentCount =
|
|
2194
|
+
const recentCount = LIMITS.SUMMARY_RECENT_MSGS;
|
|
1496
2195
|
let recentMessages = messages.slice(-recentCount);
|
|
1497
2196
|
let oldMessages = messages.slice(1, -recentCount);
|
|
1498
2197
|
|
|
@@ -1535,33 +2234,45 @@ async function autoSummarizeContext(messages, model, force = false) {
|
|
|
1535
2234
|
.map(m => {
|
|
1536
2235
|
const role = m.role === 'user' ? 'User' : 'Assistant';
|
|
1537
2236
|
// Truncate very long messages (file contents, scan results, etc.)
|
|
1538
|
-
const text = m.content.length >
|
|
1539
|
-
? 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]'
|
|
1540
2239
|
: m.content;
|
|
1541
2240
|
return `${role}: ${text}`;
|
|
1542
2241
|
})
|
|
1543
2242
|
.join('\n\n');
|
|
1544
2243
|
|
|
2244
|
+
const conversationTokens = estimateTokens(conversationText);
|
|
2245
|
+
const conversationBytes = Buffer.byteLength(conversationText, 'utf8');
|
|
2246
|
+
summarySpinner.text = summaryPhaseText(1, `Preparing summary request from ${oldMessages.length} older messages (~${conversationTokens.toLocaleString()} tokens, ${formatBytes(conversationBytes)})`);
|
|
2247
|
+
let spinnerInterval = null;
|
|
2248
|
+
|
|
1545
2249
|
try {
|
|
1546
|
-
const
|
|
1547
|
-
model,
|
|
1548
|
-
...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
|
|
1549
|
-
messages: [
|
|
1550
|
-
{
|
|
1551
|
-
role: 'system',
|
|
1552
|
-
content: `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
|
|
2250
|
+
const summaryInstruction = `You are a conversation summarizer for an AI coding agent called Sapper. Produce a concise but thorough summary of the conversation below. Include:
|
|
1553
2251
|
- Key topics discussed and decisions made
|
|
1554
2252
|
- Files that were read, created, or modified (with paths)
|
|
1555
2253
|
- Important code changes or bugs found
|
|
1556
2254
|
- Any pending tasks or open questions
|
|
1557
2255
|
- Technical details that would be needed to continue the conversation
|
|
1558
|
-
- Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH) and on what files
|
|
2256
|
+
- Which tools were used (LIST, READ, WRITE, PATCH, SHELL, SEARCH, CHANGES, FETCH, MEMORY, OPEN) and on what files or URLs
|
|
1559
2257
|
- The active agent role (if any) and loaded skills
|
|
1560
2258
|
- Any tool usage patterns or workflows that were established
|
|
1561
2259
|
|
|
1562
2260
|
CRITICAL: The AI assistant uses tools with syntax like [TOOL:READ]path[/TOOL]. Make sure to note which tools were used so the assistant remembers to keep using them after this summary.
|
|
1563
2261
|
|
|
1564
|
-
Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
|
|
2262
|
+
Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points.`;
|
|
2263
|
+
const summaryInputTokens = estimateTokens(summaryInstruction) + estimateTokens(`Summarize this conversation:\n\n${conversationText}`);
|
|
2264
|
+
summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
|
|
2265
|
+
spinnerInterval = setInterval(() => {
|
|
2266
|
+
summarySpinner.text = summaryPhaseText(2, `Waiting for ${model} to summarize (~${summaryInputTokens.toLocaleString()} tokens, ${elapsedSummaryTime()} elapsed)`);
|
|
2267
|
+
}, 1000);
|
|
2268
|
+
|
|
2269
|
+
const summaryResponse = await ollama.chat({
|
|
2270
|
+
model,
|
|
2271
|
+
...(effectiveContextLength() ? { options: { num_ctx: effectiveContextLength() } } : {}),
|
|
2272
|
+
messages: [
|
|
2273
|
+
{
|
|
2274
|
+
role: 'system',
|
|
2275
|
+
content: summaryInstruction
|
|
1565
2276
|
},
|
|
1566
2277
|
{
|
|
1567
2278
|
role: 'user',
|
|
@@ -1570,34 +2281,14 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
|
|
|
1570
2281
|
],
|
|
1571
2282
|
stream: false
|
|
1572
2283
|
});
|
|
2284
|
+
clearInterval(spinnerInterval);
|
|
2285
|
+
spinnerInterval = null;
|
|
1573
2286
|
|
|
1574
2287
|
const summary = summaryResponse.message.content;
|
|
1575
2288
|
|
|
1576
2289
|
// Save old messages to embeddings before discarding
|
|
1577
|
-
|
|
1578
|
-
const
|
|
1579
|
-
.filter(m => m.role !== 'system')
|
|
1580
|
-
.map(m => m.content.substring(0, 500))
|
|
1581
|
-
.join('\n---\n');
|
|
1582
|
-
|
|
1583
|
-
if (textToEmbed.length > 50) {
|
|
1584
|
-
try {
|
|
1585
|
-
const embedding = await getEmbedding(textToEmbed);
|
|
1586
|
-
if (embedding) {
|
|
1587
|
-
embeddings.chunks.push({
|
|
1588
|
-
text: textToEmbed.substring(0, 2000),
|
|
1589
|
-
embedding,
|
|
1590
|
-
timestamp: Date.now()
|
|
1591
|
-
});
|
|
1592
|
-
if (embeddings.chunks.length > 100) {
|
|
1593
|
-
embeddings.chunks = embeddings.chunks.slice(-100);
|
|
1594
|
-
}
|
|
1595
|
-
saveEmbeddings(embeddings);
|
|
1596
|
-
}
|
|
1597
|
-
} catch (e) {
|
|
1598
|
-
// Silently skip embedding if model not available
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
2290
|
+
summarySpinner.text = summaryPhaseText(3, `Saving compressed context and memory (${elapsedSummaryTime()} elapsed)`);
|
|
2291
|
+
const embeddings = await saveOldMessagesToEmbeddings(oldMessages);
|
|
1601
2292
|
|
|
1602
2293
|
// Build agent role reminder if an agent is active
|
|
1603
2294
|
const agentReminder = currentAgent ? `\nNote: You are currently operating as the "${currentAgent}" agent. Stay in character.` : '';
|
|
@@ -1608,13 +2299,13 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
|
|
|
1608
2299
|
systemPrompt,
|
|
1609
2300
|
{
|
|
1610
2301
|
role: 'user',
|
|
1611
|
-
content: `[CONVERSATION SUMMARY - auto-generated]\n${summary}\n[END SUMMARY]\n\nUse this summary as context for our ongoing conversation. Continue using your tools (LIST, READ, WRITE, PATCH, SHELL, SEARCH) as needed.${agentReminder}${skillReminder}`
|
|
2302
|
+
content: `[CONVERSATION SUMMARY - auto-generated]\n${summary}\n[END SUMMARY]\n\nUse this summary as context for our ongoing conversation. Continue using your tools (LIST, READ, WRITE, PATCH, SHELL, SEARCH, CHANGES, FETCH, MEMORY, OPEN) as needed.${agentReminder}${skillReminder}`
|
|
1612
2303
|
},
|
|
1613
2304
|
{
|
|
1614
2305
|
role: 'assistant',
|
|
1615
2306
|
content: _useNativeToolsFlag
|
|
1616
|
-
? `Understood. I have the conversation summary and will continue helping you. I'll use my tools (list_directory, read_file, write_file, patch_file, search_files, run_shell) as needed.\n\nWhat would you like me to do next?`
|
|
1617
|
-
: `Understood. I have the conversation summary and will continue helping you. I'll keep using my tools to explore files,
|
|
2307
|
+
? `Understood. I have the conversation summary and will continue helping you. I'll use my tools (list_directory, read_file, write_file, patch_file, search_files, changes, fetch_web, recall_memory, open_url, run_shell) as needed.\n\nWhat would you like me to do next?`
|
|
2308
|
+
: `Understood. I have the conversation summary and will continue helping you. I'll keep using my tools to explore files, inspect changes, fetch references, recall memory, open URLs when needed, make edits, and run commands as needed:\n- [TOOL:LIST] to browse directories\n- [TOOL:READ] to read files\n- [TOOL:WRITE] to create/overwrite files\n- [TOOL:PATCH] to edit existing files\n- [TOOL:SEARCH] to find patterns\n- [TOOL:CHANGES] to inspect git changes\n- [TOOL:FETCH] to read web pages\n- [TOOL:MEMORY] to search saved memory\n- [TOOL:OPEN] to open URLs with approval\n- [TOOL:SHELL] to run commands\n\nWhat would you like me to do next?`
|
|
1618
2309
|
},
|
|
1619
2310
|
...recentMessages
|
|
1620
2311
|
];
|
|
@@ -1626,10 +2317,16 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
|
|
|
1626
2317
|
const newSize = JSON.stringify(newMessages).length;
|
|
1627
2318
|
const newTokens = estimateMessagesTokens(newMessages);
|
|
1628
2319
|
summarySpinner.stop();
|
|
2320
|
+
if (summaryPhasesEnabled()) {
|
|
2321
|
+
console.log(chalk.gray(` ${summaryPhaseText(4, 'Context ready. Returning to chat...')}`));
|
|
2322
|
+
}
|
|
1629
2323
|
console.log(chalk.green(`✅ Summarized! ~${chalk.white(estimatedTokens.toLocaleString())} → ~${chalk.white(newTokens.toLocaleString())} tokens (${messages.length} → ${newMessages.length} messages)`));
|
|
1630
2324
|
if (ctxLen) {
|
|
1631
2325
|
const newPercent = Math.round((newTokens / ctxLen) * 100);
|
|
1632
2326
|
console.log(chalk.gray(` 📊 Context window usage: ${newPercent}% of ${ctxLen.toLocaleString()} tokens`));
|
|
2327
|
+
if (newPercent >= 80) {
|
|
2328
|
+
console.log(chalk.yellow(' ⚠️ Context is still dense, so the next reply may still be slower than usual.'));
|
|
2329
|
+
}
|
|
1633
2330
|
}
|
|
1634
2331
|
if (embeddings.chunks.length > 0) {
|
|
1635
2332
|
console.log(chalk.gray(` 🧠 Old context saved to memory (${embeddings.chunks.length} memories)`));
|
|
@@ -1642,6 +2339,7 @@ Output ONLY the summary, no preamble. Keep it under 800 words. Use bullet points
|
|
|
1642
2339
|
|
|
1643
2340
|
return newMessages;
|
|
1644
2341
|
} catch (e) {
|
|
2342
|
+
if (spinnerInterval) clearInterval(spinnerInterval);
|
|
1645
2343
|
summarySpinner.stop();
|
|
1646
2344
|
console.log(chalk.yellow(`⚠️ Auto-summary failed: ${e.message}`));
|
|
1647
2345
|
console.log(chalk.gray(' Tip: Use /prune to manually reduce context.\n'));
|
|
@@ -1769,6 +2467,22 @@ function commandRow(command, description, width = 18) {
|
|
|
1769
2467
|
return `${padAnsi(UI.accent(command), width)} ${UI.slate('—')} ${UI.ink(description)}`;
|
|
1770
2468
|
}
|
|
1771
2469
|
|
|
2470
|
+
function renderViewport(content, { verticalAlign = 'top', minTopPadding = 0 } = {}) {
|
|
2471
|
+
const text = String(content ?? '').replace(/\n+$/, '');
|
|
2472
|
+
const rows = Math.max(12, process.stdout.rows || 24);
|
|
2473
|
+
const lineCount = text ? text.split('\n').length : 0;
|
|
2474
|
+
const centeredPadding = verticalAlign === 'center'
|
|
2475
|
+
? Math.max(0, Math.floor((rows - lineCount) / 2))
|
|
2476
|
+
: 0;
|
|
2477
|
+
const topPadding = Math.max(minTopPadding, centeredPadding);
|
|
2478
|
+
|
|
2479
|
+
console.clear();
|
|
2480
|
+
if (topPadding > 0) {
|
|
2481
|
+
process.stdout.write('\n'.repeat(topPadding));
|
|
2482
|
+
}
|
|
2483
|
+
process.stdout.write(`${text}\n`);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
1772
2486
|
function meter(current = 0, total = 0, width = 20) {
|
|
1773
2487
|
if (!total || total <= 0) return UI.slate('░'.repeat(width));
|
|
1774
2488
|
|
|
@@ -1788,7 +2502,54 @@ function promptShell(label, detail = '') {
|
|
|
1788
2502
|
return `${UI.slate(label)}${detail ? `\n${detail}` : ''}\n${UI.accent('› ')} `;
|
|
1789
2503
|
}
|
|
1790
2504
|
|
|
1791
|
-
function
|
|
2505
|
+
function renderedTerminalLineCount(text = '', width = process.stdout.columns || 80) {
|
|
2506
|
+
const terminalColumns = Math.max(1, width || 80);
|
|
2507
|
+
return String(text ?? '')
|
|
2508
|
+
.split('\n')
|
|
2509
|
+
.reduce((count, line) => count + Math.max(1, Math.ceil(Math.max(1, visibleLength(line)) / terminalColumns)), 0);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function clearPromptEcho(promptText, inputText = '') {
|
|
2513
|
+
const totalLines = renderedTerminalLineCount(`${promptText}${inputText}`);
|
|
2514
|
+
for (let index = 0; index < totalLines; index++) {
|
|
2515
|
+
process.stdout.write('\x1B[1A\x1B[2K');
|
|
2516
|
+
}
|
|
2517
|
+
process.stdout.write('\r');
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function streamPhaseMessage(message, type = 'neutral') {
|
|
2521
|
+
const colorFn = BADGE_STYLES[type] || UI.slate;
|
|
2522
|
+
return `${colorFn('[status]')} ${UI.slate(message)}`;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function showStreamPhase(message, type = 'neutral') {
|
|
2526
|
+
if (!streamPhaseStatusEnabled()) return;
|
|
2527
|
+
console.log(streamPhaseMessage(message, type));
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function renderStreamingHeartbeat({
|
|
2531
|
+
genTokenCount = 0,
|
|
2532
|
+
genStartTime,
|
|
2533
|
+
lastVisibleActivityAt,
|
|
2534
|
+
stage = 'generating',
|
|
2535
|
+
}) {
|
|
2536
|
+
const elapsedSeconds = Math.max((Date.now() - genStartTime) / 1000, 0.1);
|
|
2537
|
+
const elapsed = elapsedSeconds.toFixed(1);
|
|
2538
|
+
const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
|
|
2539
|
+
const idleThreshold = streamIdleNoticeSeconds();
|
|
2540
|
+
|
|
2541
|
+
if (stage === 'waiting-first') {
|
|
2542
|
+
const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s` : '';
|
|
2543
|
+
process.stdout.write(`\r ${UI.slate(`Waiting for first model chunk... ${elapsed}s elapsed${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const tps = genTokenCount / elapsedSeconds;
|
|
2548
|
+
const waitNote = idleSeconds >= idleThreshold ? ` · waiting ${idleSeconds}s for next chunk` : '';
|
|
2549
|
+
process.stdout.write(`\r ${UI.slate(`Generating... ${genTokenCount} tokens · ${elapsed}s · ${tps.toFixed(1)} t/s${waitNote}`)} ${UI.slate.italic('Ctrl+C to stop')}`);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function confirmPrompt(label, type = 'warning', optionsLabel = '[y/N] ') {
|
|
1792
2553
|
const colors = {
|
|
1793
2554
|
info: UI.accent,
|
|
1794
2555
|
success: UI.mint,
|
|
@@ -1798,16 +2559,273 @@ function confirmPrompt(label, type = 'warning') {
|
|
|
1798
2559
|
neutral: UI.slate,
|
|
1799
2560
|
};
|
|
1800
2561
|
const colorFn = colors[type] || UI.gold;
|
|
1801
|
-
return colorFn(`\n${label}? `) + UI.slate(
|
|
2562
|
+
return colorFn(`\n${label}? `) + UI.slate(optionsLabel);
|
|
1802
2563
|
}
|
|
1803
2564
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2565
|
+
function parseApprovalShortcut(input = '') {
|
|
2566
|
+
const trimmed = String(input ?? '').trim();
|
|
2567
|
+
if (!trimmed) return null;
|
|
2568
|
+
|
|
2569
|
+
const match = trimmed.match(/^(f|feedback|e|edit)\b(?:\s*[:=-]?\s*(.*))?$/i);
|
|
2570
|
+
if (!match) return null;
|
|
2571
|
+
|
|
2572
|
+
const command = match[1].toLowerCase();
|
|
2573
|
+
return {
|
|
2574
|
+
type: command.startsWith('e') ? 'edit' : 'feedback',
|
|
2575
|
+
detail: String(match[2] ?? '').trim(),
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
async function resolveApprovalInstruction(input, {
|
|
2580
|
+
feedbackPrompt = 'Feedback for Sapper: ',
|
|
2581
|
+
editPrompt = 'Edit instruction for Sapper: ',
|
|
2582
|
+
} = {}) {
|
|
2583
|
+
const shortcut = parseApprovalShortcut(input);
|
|
2584
|
+
if (!shortcut) return null;
|
|
2585
|
+
|
|
2586
|
+
let detail = shortcut.detail;
|
|
2587
|
+
if (!detail) {
|
|
2588
|
+
const promptLabel = shortcut.type === 'edit' ? editPrompt : feedbackPrompt;
|
|
2589
|
+
detail = String(await safeQuestion(chalk.cyan(promptLabel))).trim();
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
return {
|
|
2593
|
+
type: shortcut.type,
|
|
2594
|
+
detail,
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
const shellSessions = new Map();
|
|
2599
|
+
let shellSessionCounter = 0;
|
|
2600
|
+
const SHELL_OUTPUT_BUFFER_MAX_CHARS = 50000;
|
|
2601
|
+
|
|
2602
|
+
function createShellSession(command, cwd, proc) {
|
|
2603
|
+
const id = `shell-${++shellSessionCounter}`;
|
|
2604
|
+
const session = {
|
|
2605
|
+
id,
|
|
2606
|
+
command,
|
|
2607
|
+
cwd,
|
|
2608
|
+
proc,
|
|
2609
|
+
startedAt: Date.now(),
|
|
2610
|
+
output: '',
|
|
2611
|
+
reportedOffset: 0,
|
|
2612
|
+
completed: false,
|
|
2613
|
+
backgrounded: false,
|
|
2614
|
+
exitCode: null,
|
|
2615
|
+
signal: null,
|
|
2616
|
+
error: null,
|
|
2617
|
+
liveEchoEnabled: true,
|
|
2618
|
+
};
|
|
2619
|
+
shellSessions.set(id, session);
|
|
2620
|
+
return session;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
// Prune completed shell sessions to prevent memory leaks (keep last 20)
|
|
2624
|
+
function pruneCompletedShellSessions() {
|
|
2625
|
+
const completed = Array.from(shellSessions.entries()).filter(([, s]) => s.completed);
|
|
2626
|
+
const MAX_COMPLETED = 20;
|
|
2627
|
+
if (completed.length > MAX_COMPLETED) {
|
|
2628
|
+
completed
|
|
2629
|
+
.sort((a, b) => a[1].startedAt - b[1].startedAt)
|
|
2630
|
+
.slice(0, completed.length - MAX_COMPLETED)
|
|
2631
|
+
.forEach(([id]) => shellSessions.delete(id));
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
function activeShellSessionCount() {
|
|
2636
|
+
return Array.from(shellSessions.values()).filter(session => !session.completed).length;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
function appendShellSessionOutput(session, text) {
|
|
2640
|
+
if (!session || !text) return;
|
|
2641
|
+
session.output += text;
|
|
2642
|
+
if (session.output.length > SHELL_OUTPUT_BUFFER_MAX_CHARS) {
|
|
2643
|
+
const overflow = session.output.length - SHELL_OUTPUT_BUFFER_MAX_CHARS;
|
|
2644
|
+
session.output = session.output.slice(overflow);
|
|
2645
|
+
session.reportedOffset = Math.max(0, session.reportedOffset - overflow);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
function formatShellOutputChunk(text = '', emptyLabel = '(no output yet)') {
|
|
2650
|
+
const normalized = String(text ?? '').trim();
|
|
2651
|
+
if (!normalized) return emptyLabel;
|
|
2652
|
+
const maxChars = shellOutputChunkChars();
|
|
2653
|
+
if (normalized.length <= maxChars) return normalized;
|
|
2654
|
+
return `... (showing last ${maxChars.toLocaleString()} chars)\n${normalized.slice(-maxChars)}`;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
function shellSessionUsageHint(sessionId) {
|
|
2658
|
+
return `Use run_shell with command \"__shell_read__ ${sessionId}\" to inspect more output, \"__shell_list__\" to list sessions, or \"__shell_stop__ ${sessionId}\" to stop it.`;
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
function buildShellSessionResult(session, {
|
|
2662
|
+
includeOutput = true,
|
|
2663
|
+
onlyNewOutput = false,
|
|
2664
|
+
markReported = false,
|
|
2665
|
+
backgroundHandoff = false,
|
|
2666
|
+
} = {}) {
|
|
2667
|
+
const relevantOutput = onlyNewOutput
|
|
2668
|
+
? session.output.slice(session.reportedOffset)
|
|
2669
|
+
: session.output;
|
|
2670
|
+
|
|
2671
|
+
if (markReported) {
|
|
2672
|
+
session.reportedOffset = session.output.length;
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
const elapsedSeconds = Math.max(1, Math.round((Date.now() - session.startedAt) / 1000));
|
|
2676
|
+
const statusLine = session.completed
|
|
2677
|
+
? `Shell session ${session.id} completed in ${elapsedSeconds}s with exit code ${session.exitCode ?? 'unknown'}.`
|
|
2678
|
+
: `Shell session ${session.id} is still running in background after ${elapsedSeconds}s.`;
|
|
2679
|
+
|
|
2680
|
+
const lines = [
|
|
2681
|
+
statusLine,
|
|
2682
|
+
`Command: ${session.command}`,
|
|
2683
|
+
`Directory: ${session.cwd}`,
|
|
2684
|
+
];
|
|
2685
|
+
|
|
2686
|
+
if (session.error) {
|
|
2687
|
+
lines.push(`Error: ${session.error}`);
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
if (!session.completed || backgroundHandoff) {
|
|
2691
|
+
lines.push(shellSessionUsageHint(session.id));
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
if (includeOutput) {
|
|
2695
|
+
lines.push('');
|
|
2696
|
+
lines.push(onlyNewOutput ? 'Output since last check:' : backgroundHandoff ? 'Initial streamed output:' : 'Captured output:');
|
|
2697
|
+
lines.push(formatShellOutputChunk(relevantOutput, onlyNewOutput ? '(no new output since last check)' : '(no output yet)'));
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
return lines.join('\n');
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
function parseShellSessionCommand(command = '') {
|
|
2704
|
+
const trimmed = String(command ?? '').trim();
|
|
2705
|
+
if (!trimmed.startsWith('__shell_')) return null;
|
|
2706
|
+
|
|
2707
|
+
const [directive, ...rest] = trimmed.split(/\s+/);
|
|
2708
|
+
const sessionId = rest.join(' ').trim();
|
|
2709
|
+
|
|
2710
|
+
if (directive === '__shell_list__') return { action: 'list' };
|
|
2711
|
+
if (directive === '__shell_read__') return { action: 'read', sessionId };
|
|
2712
|
+
if (directive === '__shell_stop__') return { action: 'stop', sessionId };
|
|
2713
|
+
return { action: 'unknown', directive };
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
async function handleShellSessionCommand(command = '') {
|
|
2717
|
+
const parsed = parseShellSessionCommand(command);
|
|
2718
|
+
if (!parsed) return null;
|
|
2719
|
+
|
|
2720
|
+
if (parsed.action === 'unknown') {
|
|
2721
|
+
return `Unknown shell session command: ${parsed.directive}. Use __shell_list__, __shell_read__ <session_id>, or __shell_stop__ <session_id>.`;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
if (parsed.action === 'list') {
|
|
2725
|
+
const sessions = Array.from(shellSessions.values());
|
|
2726
|
+
if (sessions.length === 0) return 'No shell sessions are currently tracked.';
|
|
2727
|
+
return sessions.map(session => {
|
|
2728
|
+
const state = session.completed ? `done (exit ${session.exitCode ?? 'unknown'})` : 'running';
|
|
2729
|
+
return `${session.id} · ${state} · ${session.command}`;
|
|
2730
|
+
}).join('\n');
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
if (!parsed.sessionId) {
|
|
2734
|
+
return 'Missing shell session id. Use __shell_read__ <session_id> or __shell_stop__ <session_id>.';
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
const session = shellSessions.get(parsed.sessionId);
|
|
2738
|
+
if (!session) {
|
|
2739
|
+
return `Shell session not found: ${parsed.sessionId}. Use __shell_list__ to see available sessions.`;
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
if (parsed.action === 'read') {
|
|
2743
|
+
return buildShellSessionResult(session, {
|
|
2744
|
+
includeOutput: true,
|
|
2745
|
+
onlyNewOutput: true,
|
|
2746
|
+
markReported: true,
|
|
2747
|
+
backgroundHandoff: !session.completed,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
if (parsed.action === 'stop') {
|
|
2752
|
+
if (session.completed) {
|
|
2753
|
+
return buildShellSessionResult(session, {
|
|
2754
|
+
includeOutput: true,
|
|
2755
|
+
onlyNewOutput: false,
|
|
2756
|
+
markReported: true,
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
console.log();
|
|
2761
|
+
const confirmation = await safeQuestion(confirmPrompt(`Stop background shell session ${session.id}`, 'error', '[y/N] '));
|
|
2762
|
+
if (!['y', 'yes'].includes(String(confirmation ?? '').trim().toLowerCase())) {
|
|
2763
|
+
return `Stop request cancelled for shell session ${session.id}.`;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
try {
|
|
2767
|
+
session.proc.kill('SIGTERM');
|
|
2768
|
+
return `Sent SIGTERM to shell session ${session.id}. ${shellSessionUsageHint(session.id)}`;
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
return `Could not stop shell session ${session.id}: ${error.message}`;
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
return null;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
function getTrackedShellSessions() {
|
|
2778
|
+
return Array.from(shellSessions.values()).sort((left, right) => right.startedAt - left.startedAt);
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
function shellSessionStatusLabel(session) {
|
|
2782
|
+
if (!session) return 'unknown';
|
|
2783
|
+
if (!session.completed) return 'running';
|
|
2784
|
+
if (session.signal) return `stopped (${session.signal})`;
|
|
2785
|
+
return `done (${session.exitCode ?? 'unknown'})`;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
function renderShellSessionsPanel() {
|
|
2789
|
+
const sessions = getTrackedShellSessions();
|
|
2790
|
+
const activeCount = sessions.filter(session => !session.completed).length;
|
|
2791
|
+
const completedCount = sessions.length - activeCount;
|
|
2792
|
+
const lines = [
|
|
2793
|
+
`config ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`chunk ${shellOutputChunkChars()}`)}`,
|
|
2794
|
+
UI.slate(`visibility bg off keeps long shell commands fully attached and visible in the terminal`),
|
|
2795
|
+
`sessions ${chalk.white(`${activeCount} active`)} ${UI.slate('·')} ${chalk.white(`${completedCount} completed`)}`,
|
|
2796
|
+
];
|
|
2797
|
+
|
|
2798
|
+
if (sessions.length === 0) {
|
|
2799
|
+
lines.push(UI.slate('No background shell sessions are currently tracked.'));
|
|
2800
|
+
} else {
|
|
2801
|
+
for (const session of sessions.slice(0, 8)) {
|
|
2802
|
+
const elapsed = formatElapsed(Date.now() - session.startedAt);
|
|
2803
|
+
const lastOutputLine = String(session.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || '(no output yet)';
|
|
2804
|
+
lines.push(`${chalk.white(session.id)} ${UI.slate('·')} ${chalk.white(shellSessionStatusLabel(session))} ${UI.slate('·')} ${UI.slate(elapsed)}`);
|
|
2805
|
+
lines.push(` ${UI.ink(ellipsis(session.command, 90))}`);
|
|
2806
|
+
lines.push(` ${UI.slate(ellipsis(lastOutputLine, 90))}`);
|
|
2807
|
+
}
|
|
2808
|
+
if (sessions.length > 8) {
|
|
2809
|
+
lines.push(UI.slate(`Showing 8 of ${sessions.length} tracked sessions.`));
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
return box(lines.join('\n'), 'Shell Sessions', 'cyan');
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// ─── Markdown terminal rendering ───────────────────────────────────
|
|
2817
|
+
// Dynamic width helper (recalculated on every render)
|
|
2818
|
+
function mdWidth() {
|
|
2819
|
+
return Math.min(process.stdout.columns || 80, 120);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
// Base marked-terminal config (tables, emoji, reflow, text styles)
|
|
2823
|
+
marked.use(markedTerminal({
|
|
2824
|
+
code: chalk.cyan, // fallback when highlight fails
|
|
2825
|
+
blockquote: chalk.gray.italic,
|
|
2826
|
+
html: chalk.gray,
|
|
2827
|
+
heading: chalk.bold.cyan,
|
|
2828
|
+
firstHeading: chalk.bold.cyan,
|
|
1811
2829
|
table: chalk.white,
|
|
1812
2830
|
tableOptions: {
|
|
1813
2831
|
chars: {
|
|
@@ -1825,11 +2843,152 @@ marked.use(markedTerminal({
|
|
|
1825
2843
|
del: chalk.strikethrough,
|
|
1826
2844
|
link: chalk.underline.blue,
|
|
1827
2845
|
href: chalk.gray,
|
|
1828
|
-
showSectionPrefix:
|
|
2846
|
+
showSectionPrefix: false,
|
|
1829
2847
|
reflowText: true,
|
|
1830
|
-
|
|
2848
|
+
emoji: true,
|
|
2849
|
+
tab: 2,
|
|
2850
|
+
width: 120, // overridden dynamically below
|
|
1831
2851
|
}));
|
|
1832
2852
|
|
|
2853
|
+
// ─── Enhanced renderers (override marked-terminal defaults) ────────
|
|
2854
|
+
const HEADING_STYLES = [
|
|
2855
|
+
chalk.hex('#7cc4ff').bold.underline, // h1 – bright cyan underline
|
|
2856
|
+
chalk.hex('#7cc4ff').bold, // h2 – bright cyan bold
|
|
2857
|
+
chalk.hex('#9bbcff').bold, // h3 – soft blue bold
|
|
2858
|
+
chalk.hex('#b8d9ff'), // h4 – light blue
|
|
2859
|
+
chalk.hex('#8a95a6').bold, // h5 – slate bold
|
|
2860
|
+
chalk.hex('#8a95a6'), // h6 – slate
|
|
2861
|
+
];
|
|
2862
|
+
const HEADING_PREFIX = ['◆', '◇', '▸', '▹', '·', '·'];
|
|
2863
|
+
|
|
2864
|
+
function syntaxHighlight(code, lang) {
|
|
2865
|
+
try {
|
|
2866
|
+
if (!lang) return chalk.hex('#e6ebf2')(code);
|
|
2867
|
+
return highlightCode(code, { language: lang, ignoreIllegals: true });
|
|
2868
|
+
} catch {
|
|
2869
|
+
return chalk.hex('#e6ebf2')(code);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function framedCodeBlock(code, lang) {
|
|
2874
|
+
const width = mdWidth();
|
|
2875
|
+
const innerWidth = Math.max(20, width - 6); // " │ " prefix = 4, " │" suffix = 2
|
|
2876
|
+
const highlighted = syntaxHighlight(code, lang);
|
|
2877
|
+
const lines = highlighted.split('\n');
|
|
2878
|
+
|
|
2879
|
+
// Top rule with optional language label
|
|
2880
|
+
let topRule;
|
|
2881
|
+
if (lang) {
|
|
2882
|
+
const label = ` ${chalk.hex('#8a95a6').italic(lang)} `;
|
|
2883
|
+
const labelLen = lang.length + 2; // visible length of " lang "
|
|
2884
|
+
const preDashes = 2;
|
|
2885
|
+
const postDashes = Math.max(0, innerWidth + 2 - preDashes - labelLen);
|
|
2886
|
+
topRule = chalk.hex('#3d4f5f')(' ┌' + '─'.repeat(preDashes)) + label + chalk.hex('#3d4f5f')('─'.repeat(postDashes) + '┐');
|
|
2887
|
+
} else {
|
|
2888
|
+
topRule = chalk.hex('#3d4f5f')(' ┌' + '─'.repeat(innerWidth + 2) + '┐');
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// Code lines with left+right border
|
|
2892
|
+
const framedLines = lines.map(line => {
|
|
2893
|
+
const visLen = stripAnsi(line).length;
|
|
2894
|
+
const pad = Math.max(0, innerWidth - visLen);
|
|
2895
|
+
return chalk.hex('#3d4f5f')(' │ ') + line + ' '.repeat(pad) + chalk.hex('#3d4f5f')(' │');
|
|
2896
|
+
});
|
|
2897
|
+
|
|
2898
|
+
// Bottom rule
|
|
2899
|
+
const bottomRule = chalk.hex('#3d4f5f')(' └' + '─'.repeat(innerWidth + 2) + '┘');
|
|
2900
|
+
|
|
2901
|
+
return '\n' + topRule + '\n' + framedLines.join('\n') + '\n' + bottomRule + '\n';
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
const LIST_BULLETS = ['●', '○', '◦', '·'];
|
|
2905
|
+
|
|
2906
|
+
marked.use({
|
|
2907
|
+
renderer: {
|
|
2908
|
+
// ── Fenced code blocks: framed box with syntax highlighting ──
|
|
2909
|
+
code({ text, lang }) {
|
|
2910
|
+
return framedCodeBlock(text, lang || '');
|
|
2911
|
+
},
|
|
2912
|
+
|
|
2913
|
+
// ── Headings: level-aware icons + color gradient ──
|
|
2914
|
+
heading({ tokens, depth }) {
|
|
2915
|
+
const text = this.parser.parseInline(tokens);
|
|
2916
|
+
const level = Math.max(0, Math.min(depth - 1, 5));
|
|
2917
|
+
const style = HEADING_STYLES[level];
|
|
2918
|
+
const prefix = HEADING_PREFIX[level];
|
|
2919
|
+
const plain = stripAnsi(text);
|
|
2920
|
+
const underChar = level === 0 ? '═' : level === 1 ? '─' : '';
|
|
2921
|
+
const underline = underChar ? '\n ' + chalk.hex('#3d4f5f')(underChar.repeat(Math.min(plain.length + 4, mdWidth() - 4))) : '';
|
|
2922
|
+
return `\n ${chalk.hex('#3d4f5f')(prefix)} ${style(plain)}${underline}\n`;
|
|
2923
|
+
},
|
|
2924
|
+
|
|
2925
|
+
// ── Blockquotes: thick left bar with dimmed styling ──
|
|
2926
|
+
blockquote({ tokens }) {
|
|
2927
|
+
const body = this.parser.parse(tokens);
|
|
2928
|
+
const lines = body.split('\n').filter(l => l.trim() !== '');
|
|
2929
|
+
const bar = chalk.hex('#5a7a9a')('▌');
|
|
2930
|
+
const textStyle = chalk.hex('#9aafcc').italic;
|
|
2931
|
+
return '\n' + lines.map(line => {
|
|
2932
|
+
const clean = stripAnsi(line).trim();
|
|
2933
|
+
return ` ${bar} ${textStyle(clean)}`;
|
|
2934
|
+
}).join('\n') + '\n';
|
|
2935
|
+
},
|
|
2936
|
+
|
|
2937
|
+
// ── Lists: modern bullets ──
|
|
2938
|
+
list({ items, ordered }) {
|
|
2939
|
+
const result = items.map((item, i) => {
|
|
2940
|
+
// Parse inline tokens to properly render codespan, strong, em, etc.
|
|
2941
|
+
let body = '';
|
|
2942
|
+
for (const tok of item.tokens) {
|
|
2943
|
+
if (tok.tokens) {
|
|
2944
|
+
body += this.parser.parseInline(tok.tokens);
|
|
2945
|
+
} else if (tok.type === 'space') {
|
|
2946
|
+
body += '';
|
|
2947
|
+
} else {
|
|
2948
|
+
body += tok.text || '';
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
body = body.replace(/\n+$/, '');
|
|
2952
|
+
const lines = body.split('\n');
|
|
2953
|
+
if (ordered) {
|
|
2954
|
+
const num = chalk.hex('#7cc4ff')(`${i + 1}.`);
|
|
2955
|
+
const prefix = ` ${num} `;
|
|
2956
|
+
return lines.map((line, li) => {
|
|
2957
|
+
return li === 0 ? `${prefix}${line.trim()}` : ` ${line.trim()}`;
|
|
2958
|
+
}).join('\n');
|
|
2959
|
+
}
|
|
2960
|
+
const bullet = chalk.hex('#5a7a9a')(LIST_BULLETS[Math.min(item.depth || 0, LIST_BULLETS.length - 1)] || '●');
|
|
2961
|
+
const prefix = ` ${bullet} `;
|
|
2962
|
+
return lines.map((line, li) => {
|
|
2963
|
+
return li === 0 ? `${prefix}${line.trim()}` : ` ${line.trim()}`;
|
|
2964
|
+
}).join('\n');
|
|
2965
|
+
}).join('\n');
|
|
2966
|
+
return '\n' + result + '\n';
|
|
2967
|
+
},
|
|
2968
|
+
|
|
2969
|
+
// ── Horizontal rules: themed divider ──
|
|
2970
|
+
hr() {
|
|
2971
|
+
const w = Math.max(20, mdWidth() - 4);
|
|
2972
|
+
return '\n ' + chalk.hex('#3d4f5f')('─'.repeat(w)) + '\n';
|
|
2973
|
+
},
|
|
2974
|
+
|
|
2975
|
+
// ── Inline code: highlighted background effect ──
|
|
2976
|
+
codespan({ text }) {
|
|
2977
|
+
return chalk.bgHex('#1a2733').hex('#7cc4ff')(` ${text} `);
|
|
2978
|
+
},
|
|
2979
|
+
|
|
2980
|
+
// ── Links: visible URL with icon ──
|
|
2981
|
+
link({ href, tokens }) {
|
|
2982
|
+
const text = this.parser.parseInline(tokens);
|
|
2983
|
+
const plain = stripAnsi(text);
|
|
2984
|
+
if (plain === href) {
|
|
2985
|
+
return chalk.hex('#7cc4ff').underline(href);
|
|
2986
|
+
}
|
|
2987
|
+
return `${chalk.hex('#7cc4ff').underline(plain)} ${chalk.hex('#8a95a6')('→')} ${chalk.hex('#5a7a9a').underline(href)}`;
|
|
2988
|
+
},
|
|
2989
|
+
}
|
|
2990
|
+
});
|
|
2991
|
+
|
|
1833
2992
|
// Render markdown to terminal
|
|
1834
2993
|
function renderMarkdown(text) {
|
|
1835
2994
|
try {
|
|
@@ -1840,7 +2999,7 @@ function renderMarkdown(text) {
|
|
|
1840
2999
|
}
|
|
1841
3000
|
|
|
1842
3001
|
let stepMode = false;
|
|
1843
|
-
let debugMode = false; // Toggle with /debug command
|
|
3002
|
+
let debugMode = sapperConfig.debug || false; // Toggle with /debug command, or set in config
|
|
1844
3003
|
let abortStream = false; // Flag to interrupt AI response
|
|
1845
3004
|
|
|
1846
3005
|
// ═══════════════════════════════════════════════════════════════
|
|
@@ -1901,6 +3060,181 @@ async function safeQuestion(query) {
|
|
|
1901
3060
|
});
|
|
1902
3061
|
}
|
|
1903
3062
|
|
|
3063
|
+
function countLines(text = '') {
|
|
3064
|
+
if (!text) return 0;
|
|
3065
|
+
return String(text).split('\n').length;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
function formatPreviewLine(line = '', maxWidth = Math.max(32, terminalWidth(82) - 12)) {
|
|
3069
|
+
return ellipsis(String(line).replace(/\t/g, ' '), maxWidth);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
function buildPreviewBlock(lines, startIdx, endIdx, changeStart, changeEnd, marker, colorFn, maxLines = 14) {
|
|
3073
|
+
if (lines.length === 0) {
|
|
3074
|
+
return colorFn(`${marker} | (empty)`);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
const indexes = [];
|
|
3078
|
+
for (let index = startIdx; index <= endIdx; index++) {
|
|
3079
|
+
indexes.push(index);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const clipped = indexes.length > maxLines;
|
|
3083
|
+
const visibleIndexes = clipped
|
|
3084
|
+
? [
|
|
3085
|
+
...indexes.slice(0, Math.ceil(maxLines / 2)),
|
|
3086
|
+
-1,
|
|
3087
|
+
...indexes.slice(-(Math.floor(maxLines / 2)))
|
|
3088
|
+
]
|
|
3089
|
+
: indexes;
|
|
3090
|
+
const numberWidth = String(Math.max(endIdx + 1, 1)).length;
|
|
3091
|
+
const rows = [];
|
|
3092
|
+
|
|
3093
|
+
if (startIdx > 0) {
|
|
3094
|
+
rows.push(UI.slate(' ...'));
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
for (const index of visibleIndexes) {
|
|
3098
|
+
if (index === -1) {
|
|
3099
|
+
rows.push(UI.slate(' ...'));
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
const prefix = index >= changeStart && index <= changeEnd ? marker : ' ';
|
|
3104
|
+
const row = `${prefix} ${String(index + 1).padStart(numberWidth)} | ${formatPreviewLine(lines[index])}`;
|
|
3105
|
+
rows.push(prefix === marker ? colorFn(row) : UI.slate(row));
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
if (clipped || endIdx < lines.length - 1) {
|
|
3109
|
+
rows.push(UI.slate(' ...'));
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
return rows.join('\n');
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function buildFileChangePreview(oldContent = '', newContent = '') {
|
|
3116
|
+
const before = String(oldContent ?? '');
|
|
3117
|
+
const after = String(newContent ?? '');
|
|
3118
|
+
|
|
3119
|
+
if (before === after) {
|
|
3120
|
+
return UI.slate('No visible text changes.');
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
const oldLines = before ? before.split('\n') : [];
|
|
3124
|
+
const newLines = after ? after.split('\n') : [];
|
|
3125
|
+
|
|
3126
|
+
if (oldLines.length === 0) {
|
|
3127
|
+
return [
|
|
3128
|
+
chalk.green('New file content'),
|
|
3129
|
+
buildPreviewBlock(newLines, 0, Math.max(0, Math.min(newLines.length - 1, 13)), 0, Math.max(0, Math.min(newLines.length - 1, 13)), '+', chalk.green)
|
|
3130
|
+
].join('\n');
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
let start = 0;
|
|
3134
|
+
while (start < oldLines.length && start < newLines.length && oldLines[start] === newLines[start]) {
|
|
3135
|
+
start++;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
let oldEnd = oldLines.length - 1;
|
|
3139
|
+
let newEnd = newLines.length - 1;
|
|
3140
|
+
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
|
|
3141
|
+
oldEnd--;
|
|
3142
|
+
newEnd--;
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
const contextLines = 3;
|
|
3146
|
+
const oldStart = Math.max(0, start - contextLines);
|
|
3147
|
+
const newStart = Math.max(0, start - contextLines);
|
|
3148
|
+
const oldPreviewEnd = Math.min(oldLines.length - 1, Math.max(oldEnd, start - 1) + contextLines);
|
|
3149
|
+
const newPreviewEnd = Math.min(newLines.length - 1, Math.max(newEnd, start - 1) + contextLines);
|
|
3150
|
+
|
|
3151
|
+
return [
|
|
3152
|
+
chalk.red('Before'),
|
|
3153
|
+
buildPreviewBlock(oldLines, oldStart, oldPreviewEnd, start, oldEnd, '-', chalk.red),
|
|
3154
|
+
'',
|
|
3155
|
+
chalk.green('After'),
|
|
3156
|
+
buildPreviewBlock(newLines, newStart, newPreviewEnd, start, newEnd, '+', chalk.green),
|
|
3157
|
+
].join('\n');
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
function ensureParentDirectory(filePath) {
|
|
3161
|
+
const parentDir = dirname(filePath);
|
|
3162
|
+
if (parentDir && parentDir !== '.' && !fs.existsSync(parentDir)) {
|
|
3163
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function restoreFileSnapshot(filePath, originalContent, existedBefore) {
|
|
3168
|
+
if (existedBefore) {
|
|
3169
|
+
fs.writeFileSync(filePath, originalContent);
|
|
3170
|
+
} else if (fs.existsSync(filePath)) {
|
|
3171
|
+
fs.unlinkSync(filePath);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
async function reviewCandidateFile({ filePath, originalContent = '', newContent = '', title = 'File Review', successMessage }) {
|
|
3176
|
+
const existedBefore = fs.existsSync(filePath);
|
|
3177
|
+
|
|
3178
|
+
ensureParentDirectory(filePath);
|
|
3179
|
+
fs.writeFileSync(filePath, newContent);
|
|
3180
|
+
|
|
3181
|
+
while (true) {
|
|
3182
|
+
console.log();
|
|
3183
|
+
console.log(box(
|
|
3184
|
+
`${keyValue('File', chalk.white(filePath), 8)}\n` +
|
|
3185
|
+
`${keyValue('Status', chalk.white(existedBefore ? 'modified' : 'new file'), 8)}\n` +
|
|
3186
|
+
`${keyValue('Lines', chalk.white(`${countLines(originalContent)} -> ${countLines(newContent)}`), 8)}\n` +
|
|
3187
|
+
`${UI.slate('Candidate change written to disk. Review it in your editor now.')}\n` +
|
|
3188
|
+
`${UI.slate('Choose keep to accept it, ignore to revert it, diff to inspect, f for feedback, or e for edit instructions.')}`,
|
|
3189
|
+
title, 'yellow'
|
|
3190
|
+
));
|
|
3191
|
+
|
|
3192
|
+
const decisionInput = await safeQuestion(chalk.yellow('Review change ') + chalk.gray('[k]eep/[i]gnore/[d]iff/[f]eedback/[e]dit: '));
|
|
3193
|
+
const decisionRaw = String(decisionInput ?? '').trim();
|
|
3194
|
+
const decision = decisionRaw.toLowerCase();
|
|
3195
|
+
|
|
3196
|
+
if (['k', 'keep', 'y', 'yes'].includes(decision)) {
|
|
3197
|
+
return successMessage || `Successfully saved changes to ${filePath}`;
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
if (['i', 'ignore', 'n', 'no'].includes(decision)) {
|
|
3201
|
+
restoreFileSnapshot(filePath, originalContent, existedBefore);
|
|
3202
|
+
return existedBefore
|
|
3203
|
+
? `Ignored change and restored ${filePath}`
|
|
3204
|
+
: `Ignored change and removed ${filePath}`;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
if (decision === '' || decision === 'd' || decision === 'diff') {
|
|
3208
|
+
console.log();
|
|
3209
|
+
console.log(box(buildFileChangePreview(originalContent, newContent), 'Change Diff', 'yellow'));
|
|
3210
|
+
continue;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3213
|
+
const approvalInstruction = await resolveApprovalInstruction(decisionRaw, {
|
|
3214
|
+
feedbackPrompt: 'Feedback for this change: ',
|
|
3215
|
+
editPrompt: 'Edit instruction for this change: ',
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
if (approvalInstruction) {
|
|
3219
|
+
if (!approvalInstruction.detail) {
|
|
3220
|
+
console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose keep/ignore/diff.'));
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
|
|
3224
|
+
restoreFileSnapshot(filePath, originalContent, existedBefore);
|
|
3225
|
+
const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
|
|
3226
|
+
return `Change rejected by user for ${filePath}.\n${label}: ${approvalInstruction.detail}\nThe original file was restored. Revise the change and try again.`;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
if (decisionRaw) {
|
|
3230
|
+
restoreFileSnapshot(filePath, originalContent, existedBefore);
|
|
3231
|
+
return `Change rejected by user for ${filePath}.\nUser feedback: ${decisionRaw}\nThe original file was restored. Revise the change and try again.`;
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
console.log(UI.slate('Type k to keep, i to ignore, d to view the diff, f for feedback, e for edit instructions, or write feedback directly.'));
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
1904
3238
|
// Directories to ignore when listing files
|
|
1905
3239
|
const IGNORE_DIRS = new Set([
|
|
1906
3240
|
'node_modules', '.git', '.svn', '.hg', 'dist', 'build',
|
|
@@ -1919,9 +3253,11 @@ const CODE_EXTENSIONS = new Set([
|
|
|
1919
3253
|
]);
|
|
1920
3254
|
|
|
1921
3255
|
// Max file size to include (skip large files like bundled/minified)
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
3256
|
+
// File limits — configurable via .sapper/config.json
|
|
3257
|
+
function getMaxFileSize() { return sapperConfig.maxFileSize || DEFAULT_CONFIG.maxFileSize; }
|
|
3258
|
+
function getMaxScanSize() { return sapperConfig.maxScanSize || DEFAULT_CONFIG.maxScanSize; }
|
|
3259
|
+
function getMaxUrlSize() { return sapperConfig.maxUrlSize || DEFAULT_CONFIG.maxUrlSize; }
|
|
3260
|
+
function getPatchRetries() { return sapperConfig.patchRetries || DEFAULT_CONFIG.patchRetries; }
|
|
1925
3261
|
|
|
1926
3262
|
// ═══════════════════════════════════════════════════════════════
|
|
1927
3263
|
// URL FETCHING — Read web pages and learn from them
|
|
@@ -1930,7 +3266,7 @@ import https from 'https';
|
|
|
1930
3266
|
import http from 'http';
|
|
1931
3267
|
|
|
1932
3268
|
// Fetch a URL and return extracted text content
|
|
1933
|
-
function fetchUrl(url, timeout =
|
|
3269
|
+
function fetchUrl(url, timeout = LIMITS.FETCH_URL_TIMEOUT_MS) {
|
|
1934
3270
|
return new Promise((resolve, reject) => {
|
|
1935
3271
|
const lib = url.startsWith('https') ? https : http;
|
|
1936
3272
|
const req = lib.get(url, {
|
|
@@ -1955,9 +3291,9 @@ function fetchUrl(url, timeout = 15000) {
|
|
|
1955
3291
|
let size = 0;
|
|
1956
3292
|
res.on('data', (chunk) => {
|
|
1957
3293
|
size += chunk.length;
|
|
1958
|
-
if (size >
|
|
3294
|
+
if (size > getMaxUrlSize()) {
|
|
1959
3295
|
res.destroy();
|
|
1960
|
-
reject(new Error(`Page too large (>${Math.round(
|
|
3296
|
+
reject(new Error(`Page too large (>${Math.round(getMaxUrlSize()/1024)}KB)`));
|
|
1961
3297
|
return;
|
|
1962
3298
|
}
|
|
1963
3299
|
data += chunk;
|
|
@@ -1992,8 +3328,8 @@ function htmlToText(html) {
|
|
|
1992
3328
|
text = text.replace(/\n\s*\n/g, '\n\n');
|
|
1993
3329
|
text = text.trim();
|
|
1994
3330
|
// Limit to reasonable size
|
|
1995
|
-
if (text.length >
|
|
1996
|
-
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 ...]';
|
|
1997
3333
|
}
|
|
1998
3334
|
return text;
|
|
1999
3335
|
}
|
|
@@ -2073,6 +3409,20 @@ function shouldIgnore(nameOrPath) {
|
|
|
2073
3409
|
return ignored;
|
|
2074
3410
|
}
|
|
2075
3411
|
|
|
3412
|
+
// Format file attachments into a decorated string block for context messages
|
|
3413
|
+
function formatFileAttachments(fileAttachments) {
|
|
3414
|
+
let s = '\n\n══════════════════════════════════════\n';
|
|
3415
|
+
s += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
|
|
3416
|
+
s += '══════════════════════════════════════\n\n';
|
|
3417
|
+
for (const file of fileAttachments) {
|
|
3418
|
+
s += `┌─── ${file.path} ───\n`;
|
|
3419
|
+
s += file.content;
|
|
3420
|
+
if (!file.content.endsWith('\n')) s += '\n';
|
|
3421
|
+
s += `└─── END ${file.path} ───\n\n`;
|
|
3422
|
+
}
|
|
3423
|
+
return s;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
2076
3426
|
// Scan entire codebase and return summary
|
|
2077
3427
|
function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
|
|
2078
3428
|
if (depth > maxDepth) return { files: [], totalSize: 0 };
|
|
@@ -2102,11 +3452,11 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
|
|
|
2102
3452
|
|
|
2103
3453
|
try {
|
|
2104
3454
|
const stats = fs.statSync(fullPath);
|
|
2105
|
-
if (stats.size >
|
|
3455
|
+
if (stats.size > getMaxFileSize()) {
|
|
2106
3456
|
files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'too large' });
|
|
2107
3457
|
continue;
|
|
2108
3458
|
}
|
|
2109
|
-
if (totalSize + stats.size >
|
|
3459
|
+
if (totalSize + stats.size > getMaxScanSize()) {
|
|
2110
3460
|
files.push({ path: fullPath, size: stats.size, skipped: true, reason: 'total limit reached' });
|
|
2111
3461
|
continue;
|
|
2112
3462
|
}
|
|
@@ -2327,18 +3677,19 @@ async function pickModel(models) {
|
|
|
2327
3677
|
|
|
2328
3678
|
const render = () => {
|
|
2329
3679
|
const current = models[cursor];
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
3680
|
+
const lines = [
|
|
3681
|
+
BANNER,
|
|
3682
|
+
`${UI.slate(process.cwd())} ${UI.slate('·')} ${UI.slate(`v${CURRENT_VERSION}`)}`,
|
|
3683
|
+
divider(),
|
|
3684
|
+
sectionTitle('Model selection', 'use ↑↓ or j/k, enter to confirm', 'cyan'),
|
|
3685
|
+
''
|
|
3686
|
+
];
|
|
2336
3687
|
|
|
2337
3688
|
const startIdx = Math.max(0, Math.min(cursor - Math.floor(pageSize / 2), models.length - pageSize));
|
|
2338
3689
|
const endIdx = Math.min(startIdx + pageSize, models.length);
|
|
2339
3690
|
|
|
2340
3691
|
if (startIdx > 0) {
|
|
2341
|
-
|
|
3692
|
+
lines.push(UI.slate(' ↑ more models'));
|
|
2342
3693
|
}
|
|
2343
3694
|
|
|
2344
3695
|
for (let i = startIdx; i < endIdx; i++) {
|
|
@@ -2353,20 +3704,20 @@ async function pickModel(models) {
|
|
|
2353
3704
|
model.details?.parameter_size || null,
|
|
2354
3705
|
].filter(Boolean).join(' · ');
|
|
2355
3706
|
|
|
2356
|
-
|
|
3707
|
+
lines.push(`${marker} ${index} ${name}`);
|
|
2357
3708
|
if (meta) {
|
|
2358
|
-
|
|
3709
|
+
lines.push(` ${UI.slate(meta)}`);
|
|
2359
3710
|
}
|
|
2360
3711
|
}
|
|
2361
3712
|
|
|
2362
3713
|
if (endIdx < models.length) {
|
|
2363
|
-
|
|
3714
|
+
lines.push(UI.slate(' ↓ more models'));
|
|
2364
3715
|
}
|
|
2365
3716
|
|
|
2366
3717
|
const family = current.details?.family || current.details?.format || current.details?.parameter_size || 'local model';
|
|
2367
3718
|
const quant = current.details?.quantization_level || current.details?.quantization || 'default';
|
|
2368
|
-
|
|
2369
|
-
|
|
3719
|
+
lines.push('');
|
|
3720
|
+
lines.push(box(
|
|
2370
3721
|
`${keyValue('Selected', chalk.white.bold(current.name), 10)}\n` +
|
|
2371
3722
|
`${keyValue('Footprint', UI.ink(current.size ? formatBytes(current.size) : 'unknown'), 10)}\n` +
|
|
2372
3723
|
`${keyValue('Updated', UI.ink(current.modified_at ? formatRelativeTime(current.modified_at) : 'unknown'), 10)}\n` +
|
|
@@ -2374,6 +3725,8 @@ async function pickModel(models) {
|
|
|
2374
3725
|
`${keyValue('Quant', UI.ink(quant), 10)}`,
|
|
2375
3726
|
'Preview', 'gray'
|
|
2376
3727
|
));
|
|
3728
|
+
|
|
3729
|
+
renderViewport(lines.join('\n'), { verticalAlign: 'center' });
|
|
2377
3730
|
};
|
|
2378
3731
|
|
|
2379
3732
|
return new Promise((resolve) => {
|
|
@@ -2408,11 +3761,11 @@ async function pickModel(models) {
|
|
|
2408
3761
|
render();
|
|
2409
3762
|
} else if (key.name === 'return') {
|
|
2410
3763
|
cleanup();
|
|
2411
|
-
console.
|
|
3764
|
+
console.clear();
|
|
2412
3765
|
resolve(models[cursor].name);
|
|
2413
3766
|
} else if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
2414
3767
|
cleanup();
|
|
2415
|
-
console.
|
|
3768
|
+
console.clear();
|
|
2416
3769
|
resolve(models[cursor].name);
|
|
2417
3770
|
}
|
|
2418
3771
|
};
|
|
@@ -2421,48 +3774,289 @@ async function pickModel(models) {
|
|
|
2421
3774
|
});
|
|
2422
3775
|
}
|
|
2423
3776
|
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3777
|
+
let toolWorkingDirectory = process.cwd();
|
|
3778
|
+
|
|
3779
|
+
function getToolWorkingDirectory() {
|
|
3780
|
+
return toolWorkingDirectory || process.cwd();
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
function resolveToolPath(pathValue = '.', { allowEmpty = false } = {}) {
|
|
3784
|
+
let rawPath = typeof pathValue === 'string' ? pathValue.trim() : '';
|
|
3785
|
+
// Strip surrounding quotes that models sometimes add
|
|
3786
|
+
if ((rawPath.startsWith('"') && rawPath.endsWith('"')) || (rawPath.startsWith("'") && rawPath.endsWith("'"))) {
|
|
3787
|
+
rawPath = rawPath.slice(1, -1).trim();
|
|
3788
|
+
}
|
|
3789
|
+
if (!rawPath) {
|
|
3790
|
+
return allowEmpty ? '' : getToolWorkingDirectory();
|
|
3791
|
+
}
|
|
3792
|
+
if (rawPath === '/') {
|
|
3793
|
+
return getToolWorkingDirectory();
|
|
3794
|
+
}
|
|
3795
|
+
const resolved = isAbsolute(rawPath) ? rawPath : pathResolve(getToolWorkingDirectory(), rawPath);
|
|
3796
|
+
// Prevent path traversal outside the project directory
|
|
3797
|
+
const projectRoot = process.cwd();
|
|
3798
|
+
if (!resolved.startsWith(projectRoot + '/') && resolved !== projectRoot) {
|
|
3799
|
+
return projectRoot; // Fall back to project root for paths that escape sandbox
|
|
3800
|
+
}
|
|
3801
|
+
return resolved;
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
function resolveLineWindowCount(value, fallback = 20) {
|
|
3805
|
+
return normalizeIntegerInRange(value, fallback, 1, 400);
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
function readFileLineWindow(pathValue, mode = 'head', countValue = 20) {
|
|
3809
|
+
const trimmedPath = typeof pathValue === 'string' ? pathValue.trim() : '';
|
|
3810
|
+
if (!trimmedPath) return 'Error reading file: missing file path';
|
|
3811
|
+
|
|
3812
|
+
try {
|
|
3813
|
+
const resolvedPath = resolveToolPath(trimmedPath);
|
|
3814
|
+
const rawContent = fs.readFileSync(resolvedPath, 'utf8');
|
|
3815
|
+
const lines = rawContent === '' ? [] : rawContent.split('\n');
|
|
3816
|
+
const requestedCount = resolveLineWindowCount(countValue, 20);
|
|
3817
|
+
|
|
3818
|
+
if (lines.length === 0) {
|
|
3819
|
+
return `${mode === 'tail' ? 'Last' : 'First'} 0 lines of ${trimmedPath}:\n(empty file)`;
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
const slice = mode === 'tail'
|
|
3823
|
+
? lines.slice(-requestedCount)
|
|
3824
|
+
: lines.slice(0, requestedCount);
|
|
3825
|
+
const shownCount = slice.length;
|
|
3826
|
+
const lineLabel = shownCount === 1 ? 'line' : 'lines';
|
|
3827
|
+
const descriptor = mode === 'tail' ? 'last' : 'first';
|
|
3828
|
+
|
|
3829
|
+
return `Showing the ${descriptor} ${shownCount} ${lineLabel} of ${trimmedPath}:\n${slice.join('\n')}`;
|
|
3830
|
+
} catch (error) {
|
|
3831
|
+
return `Error reading file: ${error.message}`;
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
function findPathsByName(patternValue, startPathValue = '.') {
|
|
3836
|
+
const pattern = String(patternValue ?? '').trim();
|
|
3837
|
+
const startPath = typeof startPathValue === 'string' ? startPathValue.trim() : '';
|
|
3838
|
+
if (!pattern) return 'Error finding files: missing search pattern';
|
|
3839
|
+
|
|
3840
|
+
const resolvedStartPath = resolveToolPath(startPath || '.');
|
|
3841
|
+
if (!fs.existsSync(resolvedStartPath)) {
|
|
3842
|
+
return `Error finding files: ${startPath || '.'} does not exist`;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
let startStats;
|
|
3846
|
+
try {
|
|
3847
|
+
startStats = fs.statSync(resolvedStartPath);
|
|
3848
|
+
} catch (error) {
|
|
3849
|
+
return `Error finding files: ${error.message}`;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
if (!startStats.isDirectory()) {
|
|
3853
|
+
return `Error finding files: ${startPath || '.'} is not a directory`;
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
const matches = [];
|
|
3857
|
+
const maxResults = 100;
|
|
3858
|
+
const patternLower = pattern.toLowerCase();
|
|
3859
|
+
|
|
3860
|
+
const visit = (dirPath, displayPrefix = '') => {
|
|
3861
|
+
if (matches.length >= maxResults) return;
|
|
3862
|
+
|
|
3863
|
+
let entries = [];
|
|
2431
3864
|
try {
|
|
2432
|
-
|
|
3865
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
3866
|
+
} catch {
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
2433
3869
|
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
if (
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
const diffContent =
|
|
2447
|
-
`${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
|
|
2448
|
-
`${keyValue('Line', chalk.white(String(lineNum)), 8)}\n` +
|
|
2449
|
-
`${UI.slate('Preview')}\n` +
|
|
2450
|
-
chalk.red('- ' + oldLine) + '\n' +
|
|
2451
|
-
chalk.green('+ ' + newText);
|
|
2452
|
-
console.log(box(diffContent, 'Patch Review', 'yellow'));
|
|
2453
|
-
const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
|
|
2454
|
-
if (confirm.toLowerCase() === 'y') {
|
|
2455
|
-
fs.writeFileSync(trimmedPath, newContent);
|
|
2456
|
-
return `Successfully patched line ${lineNum} of ${trimmedPath}`;
|
|
2457
|
-
}
|
|
2458
|
-
return 'Patch rejected by user.';
|
|
3870
|
+
for (const entry of entries) {
|
|
3871
|
+
if (matches.length >= maxResults) return;
|
|
3872
|
+
if (entry.name.startsWith('.')) continue;
|
|
3873
|
+
if (shouldIgnore(entry.name)) continue;
|
|
3874
|
+
|
|
3875
|
+
const fullPath = join(dirPath, entry.name);
|
|
3876
|
+
const relativePath = displayPrefix ? `${displayPrefix}/${entry.name}` : entry.name;
|
|
3877
|
+
if (shouldIgnore(relativePath) || shouldIgnore(fullPath)) continue;
|
|
3878
|
+
|
|
3879
|
+
const displayPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
|
|
3880
|
+
if (entry.name.toLowerCase().includes(patternLower) || relativePath.toLowerCase().includes(patternLower)) {
|
|
3881
|
+
matches.push(displayPath);
|
|
2459
3882
|
}
|
|
2460
3883
|
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
3884
|
+
if (entry.isDirectory()) {
|
|
3885
|
+
visit(fullPath, relativePath);
|
|
3886
|
+
}
|
|
3887
|
+
}
|
|
3888
|
+
};
|
|
3889
|
+
|
|
3890
|
+
visit(resolvedStartPath);
|
|
3891
|
+
|
|
3892
|
+
if (matches.length === 0) {
|
|
3893
|
+
return `No files or directories found matching: ${pattern}`;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
const header = `Found ${matches.length} matching path${matches.length === 1 ? '' : 's'} for: ${pattern}`;
|
|
3897
|
+
const body = matches.join('\n');
|
|
3898
|
+
const truncated = matches.length >= maxResults ? '\n... (results truncated)' : '';
|
|
3899
|
+
return `${header}\n${body}${truncated}`;
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
function truncateToolText(textValue = '', maxChars = 24000) {
|
|
3903
|
+
const text = String(textValue ?? '').trim();
|
|
3904
|
+
if (!text) return '';
|
|
3905
|
+
if (text.length <= maxChars) return text;
|
|
3906
|
+
return `${text.slice(0, maxChars)}\n... (output truncated at ${maxChars.toLocaleString()} chars)`;
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
function shellQuote(value = '') {
|
|
3910
|
+
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
function runCapturedCommand(command, { cwd = getToolWorkingDirectory(), timeoutMs = 12000, maxOutput = 24000 } = {}) {
|
|
3914
|
+
return new Promise((resolve) => {
|
|
3915
|
+
const proc = spawn('sh', ['-c', command], { cwd });
|
|
3916
|
+
let stdout = '';
|
|
3917
|
+
let stderr = '';
|
|
3918
|
+
let stdoutTruncated = false;
|
|
3919
|
+
let stderrTruncated = false;
|
|
3920
|
+
let finished = false;
|
|
3921
|
+
|
|
3922
|
+
const appendLimited = (existing, chunkText, maxChars, setTruncated) => {
|
|
3923
|
+
if (existing.length >= maxChars) {
|
|
3924
|
+
setTruncated(true);
|
|
3925
|
+
return existing;
|
|
3926
|
+
}
|
|
3927
|
+
const remaining = maxChars - existing.length;
|
|
3928
|
+
if (chunkText.length > remaining) {
|
|
3929
|
+
setTruncated(true);
|
|
3930
|
+
return existing + chunkText.slice(0, remaining);
|
|
3931
|
+
}
|
|
3932
|
+
return existing + chunkText;
|
|
3933
|
+
};
|
|
3934
|
+
|
|
3935
|
+
const finish = (result) => {
|
|
3936
|
+
if (finished) return;
|
|
3937
|
+
finished = true;
|
|
3938
|
+
if (timer) clearTimeout(timer);
|
|
3939
|
+
const finalStdout = stdoutTruncated
|
|
3940
|
+
? `${stdout}\n... (stdout truncated at ${maxOutput.toLocaleString()} chars)`
|
|
3941
|
+
: stdout;
|
|
3942
|
+
const finalStderr = stderrTruncated
|
|
3943
|
+
? `${stderr}\n... (stderr truncated at ${maxOutput.toLocaleString()} chars)`
|
|
3944
|
+
: stderr;
|
|
3945
|
+
resolve({ ...result, stdout: finalStdout, stderr: finalStderr });
|
|
3946
|
+
};
|
|
3947
|
+
|
|
3948
|
+
const timer = timeoutMs > 0
|
|
3949
|
+
? setTimeout(() => {
|
|
3950
|
+
try { proc.kill('SIGTERM'); } catch (e) {}
|
|
3951
|
+
finish({ exitCode: 124, stdout, stderr: `${stderr}\nCommand timed out after ${timeoutMs}ms`.trim(), timedOut: true });
|
|
3952
|
+
}, timeoutMs)
|
|
3953
|
+
: null;
|
|
3954
|
+
|
|
3955
|
+
proc.stdout.on('data', (data) => {
|
|
3956
|
+
stdout = appendLimited(stdout, data.toString(), maxOutput, (value) => { stdoutTruncated = value; });
|
|
3957
|
+
});
|
|
3958
|
+
proc.stderr.on('data', (data) => {
|
|
3959
|
+
stderr = appendLimited(stderr, data.toString(), maxOutput, (value) => { stderrTruncated = value; });
|
|
3960
|
+
});
|
|
3961
|
+
|
|
3962
|
+
proc.on('error', (error) => {
|
|
3963
|
+
finish({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`.trim(), timedOut: false });
|
|
3964
|
+
});
|
|
3965
|
+
proc.on('close', (code) => {
|
|
3966
|
+
finish({ exitCode: code ?? 0, stdout, stderr, timedOut: false });
|
|
3967
|
+
});
|
|
3968
|
+
});
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
function normalizeFetchedWebContent(rawContent = '') {
|
|
3972
|
+
const trimmed = String(rawContent ?? '').trim();
|
|
3973
|
+
if (!trimmed) return '';
|
|
3974
|
+
|
|
3975
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
3976
|
+
try {
|
|
3977
|
+
return JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
3978
|
+
} catch (error) {
|
|
3979
|
+
return trimmed;
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
if (trimmed.startsWith('<') || trimmed.toLowerCase().includes('<html')) {
|
|
3984
|
+
return htmlToText(trimmed);
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
return trimmed;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
function keywordRecallMemory(query, embeddings, topK = 3) {
|
|
3991
|
+
const queryText = String(query ?? '').trim().toLowerCase();
|
|
3992
|
+
if (!queryText || !embeddings?.chunks?.length) return [];
|
|
3993
|
+
|
|
3994
|
+
const queryWords = Array.from(new Set(
|
|
3995
|
+
queryText.split(/[^a-z0-9_]+/i).map(word => word.trim()).filter(word => word.length >= 2)
|
|
3996
|
+
));
|
|
3997
|
+
const maxScore = Math.max(1, 4 + queryWords.length);
|
|
3998
|
+
|
|
3999
|
+
return embeddings.chunks
|
|
4000
|
+
.map((chunk) => {
|
|
4001
|
+
const text = String(chunk?.text ?? '');
|
|
4002
|
+
const lowered = text.toLowerCase();
|
|
4003
|
+
let score = 0;
|
|
4004
|
+
if (lowered.includes(queryText)) score += 4;
|
|
4005
|
+
for (const word of queryWords) {
|
|
4006
|
+
if (lowered.includes(word)) score += 1;
|
|
4007
|
+
}
|
|
4008
|
+
return { ...chunk, score: Math.min(1, score / maxScore) };
|
|
4009
|
+
})
|
|
4010
|
+
.filter(chunk => chunk.score > 0)
|
|
4011
|
+
.sort((a, b) => b.score - a.score || (b.timestamp || 0) - (a.timestamp || 0))
|
|
4012
|
+
.slice(0, topK);
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
const tools = {
|
|
4016
|
+
read: (path) => {
|
|
4017
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4018
|
+
if (!trimmedPath) return 'Error reading file: missing file path';
|
|
4019
|
+
try { return fs.readFileSync(resolveToolPath(trimmedPath), 'utf8'); }
|
|
4020
|
+
catch (error) { return `Error reading file: ${error.message}`; }
|
|
4021
|
+
},
|
|
4022
|
+
patch: async (path, oldText, newText) => {
|
|
4023
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4024
|
+
if (!trimmedPath) return 'Error patching file: missing file path';
|
|
4025
|
+
if (typeof oldText !== 'string' || typeof newText !== 'string') {
|
|
4026
|
+
return 'Error patching file: missing old_text or new_text';
|
|
4027
|
+
}
|
|
4028
|
+
try {
|
|
4029
|
+
const resolvedPath = resolveToolPath(trimmedPath);
|
|
4030
|
+
const content = fs.readFileSync(resolvedPath, 'utf8');
|
|
4031
|
+
|
|
4032
|
+
// --- Line-number mode: LINE:15|||new text ---
|
|
4033
|
+
const lineMatch = oldText.match(/^LINE:(\d+)$/);
|
|
4034
|
+
if (lineMatch) {
|
|
4035
|
+
const lineNum = parseInt(lineMatch[1], 10);
|
|
4036
|
+
const lines = content.split('\n');
|
|
4037
|
+
if (lineNum < 1 || lineNum > lines.length) {
|
|
4038
|
+
return `Error: Line ${lineNum} out of range (file has ${lines.length} lines) in ${trimmedPath}`;
|
|
4039
|
+
}
|
|
4040
|
+
lines[lineNum - 1] = newText;
|
|
4041
|
+
const newContent = lines.join('\n');
|
|
4042
|
+
if (newContent === content) {
|
|
4043
|
+
return `No changes needed in ${trimmedPath}`;
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
return reviewCandidateFile({
|
|
4047
|
+
filePath: resolvedPath,
|
|
4048
|
+
originalContent: content,
|
|
4049
|
+
newContent,
|
|
4050
|
+
title: 'Patch Review',
|
|
4051
|
+
successMessage: `Successfully patched line ${lineNum} of ${trimmedPath}`,
|
|
4052
|
+
});
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
// --- Exact match (try as-is first, then trimmed) ---
|
|
4056
|
+
let matchedOld = oldText;
|
|
4057
|
+
let newContent;
|
|
4058
|
+
if (content.includes(oldText)) {
|
|
4059
|
+
newContent = content.replace(oldText, newText);
|
|
2466
4060
|
} else if (content.includes(oldText.trim())) {
|
|
2467
4061
|
// Trimmed fallback — match what's actually in the file
|
|
2468
4062
|
matchedOld = oldText.trim();
|
|
@@ -2513,102 +4107,372 @@ const tools = {
|
|
|
2513
4107
|
`Tip: Use LINE:number mode instead, e.g. [TOOL:PATCH]${trimmedPath}:::LINE:42|||replacement text[/TOOL]`;
|
|
2514
4108
|
}
|
|
2515
4109
|
}
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
const diffContent =
|
|
2520
|
-
`${keyValue('File', chalk.white(trimmedPath), 8)}\n` +
|
|
2521
|
-
`${UI.slate('Preview')}\n` +
|
|
2522
|
-
chalk.red('- ' + matchedOld.split('\n').join('\n- ')) + '\n' +
|
|
2523
|
-
chalk.green('+ ' + (newContent === content.replace(matchedOld, newText.trim()) ? newText.trim() : newText).split('\n').join('\n+ '));
|
|
2524
|
-
console.log(box(diffContent, 'Patch Review', 'yellow'));
|
|
2525
|
-
|
|
2526
|
-
const confirm = await safeQuestion(confirmPrompt('Apply patch', 'warning'));
|
|
2527
|
-
if (confirm.toLowerCase() === 'y') {
|
|
2528
|
-
fs.writeFileSync(trimmedPath, newContent);
|
|
2529
|
-
return `Successfully patched ${trimmedPath}`;
|
|
4110
|
+
|
|
4111
|
+
if (newContent === content) {
|
|
4112
|
+
return `No changes needed in ${trimmedPath}`;
|
|
2530
4113
|
}
|
|
2531
|
-
|
|
4114
|
+
|
|
4115
|
+
return reviewCandidateFile({
|
|
4116
|
+
filePath: resolvedPath,
|
|
4117
|
+
originalContent: content,
|
|
4118
|
+
newContent,
|
|
4119
|
+
title: 'Patch Review',
|
|
4120
|
+
successMessage: `Successfully patched ${trimmedPath}`,
|
|
4121
|
+
});
|
|
2532
4122
|
} catch (error) { return `Error patching file: ${error.message}`; }
|
|
2533
4123
|
},
|
|
2534
4124
|
write: async (path, content) => {
|
|
2535
|
-
const trimmedPath = path.trim();
|
|
4125
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4126
|
+
if (!trimmedPath) return 'Error writing file: missing file path';
|
|
4127
|
+
try {
|
|
4128
|
+
const resolvedPath = resolveToolPath(trimmedPath);
|
|
4129
|
+
const fileExists = fs.existsSync(resolvedPath);
|
|
4130
|
+
const existingContent = fileExists ? fs.readFileSync(resolvedPath, 'utf8') : '';
|
|
4131
|
+
const nextContent = String(content ?? '');
|
|
4132
|
+
|
|
4133
|
+
if (fileExists && existingContent === nextContent) {
|
|
4134
|
+
return `No changes needed in ${trimmedPath}`;
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
return reviewCandidateFile({
|
|
4138
|
+
filePath: resolvedPath,
|
|
4139
|
+
originalContent: existingContent,
|
|
4140
|
+
newContent: nextContent,
|
|
4141
|
+
title: 'Write Review',
|
|
4142
|
+
successMessage: `Successfully saved changes to ${trimmedPath}`,
|
|
4143
|
+
});
|
|
4144
|
+
} catch (error) { return `Error writing file: ${error.message}`; }
|
|
4145
|
+
},
|
|
4146
|
+
mkdir: (path) => {
|
|
4147
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4148
|
+
if (!trimmedPath) return 'Error creating directory: missing directory path';
|
|
4149
|
+
try {
|
|
4150
|
+
fs.mkdirSync(resolveToolPath(trimmedPath), { recursive: true });
|
|
4151
|
+
return `Directory created: ${trimmedPath}`;
|
|
4152
|
+
} catch (error) { return `Error creating directory: ${error.message}`; }
|
|
4153
|
+
},
|
|
4154
|
+
pwd: () => getToolWorkingDirectory(),
|
|
4155
|
+
cd: (path) => {
|
|
4156
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4157
|
+
if (!trimmedPath) return 'Error changing directory: missing directory path';
|
|
4158
|
+
try {
|
|
4159
|
+
const resolvedPath = resolveToolPath(trimmedPath);
|
|
4160
|
+
const stats = fs.statSync(resolvedPath);
|
|
4161
|
+
if (!stats.isDirectory()) {
|
|
4162
|
+
return `Error changing directory: ${trimmedPath} is not a directory`;
|
|
4163
|
+
}
|
|
4164
|
+
toolWorkingDirectory = resolvedPath;
|
|
4165
|
+
return `Working directory changed to ${toolWorkingDirectory}`;
|
|
4166
|
+
} catch (error) {
|
|
4167
|
+
return `Error changing directory: ${error.message}`;
|
|
4168
|
+
}
|
|
4169
|
+
},
|
|
4170
|
+
rmdir: async (path) => {
|
|
4171
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4172
|
+
if (!trimmedPath) return 'Error removing directory: missing directory path';
|
|
4173
|
+
if (['.', '..', '/'].includes(trimmedPath)) {
|
|
4174
|
+
return `Error removing directory: refusing to remove ${trimmedPath}`;
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
try {
|
|
4178
|
+
const resolvedPath = resolveToolPath(trimmedPath);
|
|
4179
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
4180
|
+
return `Error removing directory: ${trimmedPath} does not exist`;
|
|
4181
|
+
}
|
|
4182
|
+
const stats = fs.statSync(resolvedPath);
|
|
4183
|
+
if (!stats.isDirectory()) {
|
|
4184
|
+
return `Error removing directory: ${trimmedPath} is not a directory`;
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
console.log();
|
|
4188
|
+
console.log(box(
|
|
4189
|
+
`${keyValue('Directory', chalk.white(trimmedPath), 11)}\n` +
|
|
4190
|
+
`${keyValue('Action', chalk.white('remove recursively'), 11)}\n` +
|
|
4191
|
+
`${UI.slate('This will permanently delete the directory and its contents.')}`,
|
|
4192
|
+
'Directory Removal', 'red'
|
|
4193
|
+
));
|
|
4194
|
+
const confirm = await safeQuestion(confirmPrompt('Remove directory', 'error'));
|
|
4195
|
+
if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
|
|
4196
|
+
return 'Directory removal blocked by user.';
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
fs.rmSync(resolvedPath, { recursive: true, force: false });
|
|
4200
|
+
return `Directory removed: ${trimmedPath}`;
|
|
4201
|
+
} catch (error) {
|
|
4202
|
+
return `Error removing directory: ${error.message}`;
|
|
4203
|
+
}
|
|
4204
|
+
},
|
|
4205
|
+
ls: (path) => tools.list(path),
|
|
4206
|
+
cat: (path) => tools.read(path),
|
|
4207
|
+
head: (path, lines) => readFileLineWindow(path, 'head', lines),
|
|
4208
|
+
tail: (path, lines) => readFileLineWindow(path, 'tail', lines),
|
|
4209
|
+
grep: (pattern) => tools.search(pattern),
|
|
4210
|
+
find: (pattern, startPath) => findPathsByName(pattern, startPath),
|
|
4211
|
+
changes: async (path) => {
|
|
4212
|
+
const trimmedPath = typeof path === 'string' ? path.trim() : '';
|
|
4213
|
+
const cwd = getToolWorkingDirectory();
|
|
4214
|
+
const quotedPath = trimmedPath ? ` -- ${shellQuote(trimmedPath)}` : '';
|
|
4215
|
+
|
|
4216
|
+
const repoCheck = await runCapturedCommand('git rev-parse --show-toplevel', {
|
|
4217
|
+
cwd,
|
|
4218
|
+
timeoutMs: 5000,
|
|
4219
|
+
maxOutput: 4000,
|
|
4220
|
+
});
|
|
4221
|
+
if (repoCheck.exitCode !== 0) {
|
|
4222
|
+
return 'Error: current tool working directory is not inside a git repository';
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
const statusCmd = trimmedPath
|
|
4226
|
+
? `git --no-pager status --short ${quotedPath}`
|
|
4227
|
+
: 'git --no-pager status --short --branch';
|
|
4228
|
+
const [statusResult, unstagedResult, stagedResult] = await Promise.all([
|
|
4229
|
+
runCapturedCommand(statusCmd, { cwd, timeoutMs: 8000, maxOutput: 12000 }),
|
|
4230
|
+
runCapturedCommand(`git --no-pager diff --no-ext-diff --minimal${quotedPath}`, { cwd, timeoutMs: 8000, maxOutput: 20000 }),
|
|
4231
|
+
runCapturedCommand(`git --no-pager diff --cached --no-ext-diff --minimal${quotedPath}`, { cwd, timeoutMs: 8000, maxOutput: 20000 }),
|
|
4232
|
+
]);
|
|
4233
|
+
|
|
4234
|
+
if (statusResult.exitCode !== 0) {
|
|
4235
|
+
return `Error reading git changes: ${statusResult.stderr.trim() || 'git status failed'}`;
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
const parts = [];
|
|
4239
|
+
const scopeLabel = trimmedPath ? ` for ${trimmedPath}` : '';
|
|
4240
|
+
parts.push(`Git changes${scopeLabel}:`);
|
|
4241
|
+
parts.push(statusResult.stdout.trim() || 'No changed files in working tree.');
|
|
4242
|
+
|
|
4243
|
+
const unstagedDiff = unstagedResult.stdout.trim();
|
|
4244
|
+
const stagedDiff = stagedResult.stdout.trim();
|
|
4245
|
+
|
|
4246
|
+
if (unstagedDiff) {
|
|
4247
|
+
parts.push(`Unstaged diff:\n${unstagedDiff}`);
|
|
4248
|
+
}
|
|
4249
|
+
if (stagedDiff) {
|
|
4250
|
+
parts.push(`Staged diff:\n${stagedDiff}`);
|
|
4251
|
+
}
|
|
4252
|
+
if (!unstagedDiff && !stagedDiff) {
|
|
4253
|
+
parts.push('No staged or unstaged diff output.');
|
|
4254
|
+
}
|
|
4255
|
+
|
|
4256
|
+
return truncateToolText(parts.join('\n\n'), 28000);
|
|
4257
|
+
},
|
|
4258
|
+
fetch_web: async (url) => {
|
|
4259
|
+
const trimmedUrl = String(url ?? '').trim();
|
|
4260
|
+
if (!trimmedUrl) return 'Error fetching web page: missing URL';
|
|
4261
|
+
if (!/^https?:\/\//i.test(trimmedUrl)) {
|
|
4262
|
+
return 'Error fetching web page: URL must start with http:// or https://';
|
|
4263
|
+
}
|
|
4264
|
+
|
|
4265
|
+
try {
|
|
4266
|
+
const rawContent = await fetchUrl(trimmedUrl);
|
|
4267
|
+
const text = normalizeFetchedWebContent(rawContent);
|
|
4268
|
+
if (!text) return `No readable content found at ${trimmedUrl}`;
|
|
4269
|
+
return `Fetched ${trimmedUrl}:\n\n${truncateToolText(text, 28000)}`;
|
|
4270
|
+
} catch (error) {
|
|
4271
|
+
return `Error fetching web page: ${error.message}`;
|
|
4272
|
+
}
|
|
4273
|
+
},
|
|
4274
|
+
recall_memory: async (query) => {
|
|
4275
|
+
const trimmedQuery = String(query ?? '').trim();
|
|
4276
|
+
if (!trimmedQuery) return 'Error searching memory: missing query';
|
|
4277
|
+
|
|
4278
|
+
const embeddings = loadEmbeddings();
|
|
4279
|
+
if (!embeddings.chunks.length) {
|
|
4280
|
+
return 'No saved memory yet. Use /prune or continue working so Sapper can store conversation memory.';
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
let relevant = await findRelevantContext(trimmedQuery, embeddings, 3);
|
|
4284
|
+
if (!relevant.length) {
|
|
4285
|
+
relevant = keywordRecallMemory(trimmedQuery, embeddings, 3);
|
|
4286
|
+
}
|
|
4287
|
+
if (!relevant.length) {
|
|
4288
|
+
return `No relevant memories found for: ${trimmedQuery}`;
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
const formatted = relevant.map((chunk, index) => {
|
|
4292
|
+
const timestamp = chunk.timestamp ? new Date(chunk.timestamp).toISOString() : 'unknown time';
|
|
4293
|
+
const score = typeof chunk.score === 'number'
|
|
4294
|
+
? `${Math.round(chunk.score * 100)}%`
|
|
4295
|
+
: 'n/a';
|
|
4296
|
+
const text = truncateToolText(chunk.text || '', 1200);
|
|
4297
|
+
return `[${index + 1}] ${timestamp} · relevance ${score}\n${text}`;
|
|
4298
|
+
}).join('\n\n');
|
|
4299
|
+
|
|
4300
|
+
return `Found ${relevant.length} memory match${relevant.length === 1 ? '' : 'es'} for: ${trimmedQuery}\n\n${formatted}`;
|
|
4301
|
+
},
|
|
4302
|
+
open_url: async (url) => {
|
|
4303
|
+
const trimmedUrl = String(url ?? '').trim();
|
|
4304
|
+
if (!trimmedUrl) return 'Error opening URL: missing URL';
|
|
4305
|
+
if (!/^(https?:|file:)/i.test(trimmedUrl)) {
|
|
4306
|
+
return 'Error opening URL: URL must start with http://, https://, or file:';
|
|
4307
|
+
}
|
|
4308
|
+
|
|
2536
4309
|
console.log();
|
|
2537
4310
|
console.log(box(
|
|
2538
|
-
`${keyValue('
|
|
2539
|
-
`${
|
|
2540
|
-
|
|
2541
|
-
chalk.gray(content?.substring(0, 300)?.split('\n').slice(0, 8).join('\n') + (content?.length > 300 ? '\n...' : '')),
|
|
2542
|
-
'Write Review', 'yellow'
|
|
4311
|
+
`${keyValue('URL', chalk.white(trimmedUrl), 11)}\n` +
|
|
4312
|
+
`${UI.slate('This will open the URL in your default browser.')}`,
|
|
4313
|
+
'Open URL', 'red'
|
|
2543
4314
|
));
|
|
2544
|
-
const confirm = await safeQuestion(confirmPrompt('
|
|
2545
|
-
if (
|
|
2546
|
-
|
|
2547
|
-
fs.writeFileSync(trimmedPath, content);
|
|
2548
|
-
return `Successfully saved changes to ${trimmedPath}`;
|
|
2549
|
-
} catch (error) { return `Error writing file: ${error.message}`; }
|
|
4315
|
+
const confirm = await safeQuestion(confirmPrompt('Open URL in browser', 'error'));
|
|
4316
|
+
if (!['y', 'yes'].includes(String(confirm ?? '').trim().toLowerCase())) {
|
|
4317
|
+
return 'Open URL blocked by user.';
|
|
2550
4318
|
}
|
|
2551
|
-
|
|
2552
|
-
},
|
|
2553
|
-
mkdir: (path) => {
|
|
4319
|
+
|
|
2554
4320
|
try {
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
4321
|
+
let command = 'open';
|
|
4322
|
+
let args = [trimmedUrl];
|
|
4323
|
+
if (process.platform === 'win32') {
|
|
4324
|
+
command = 'cmd';
|
|
4325
|
+
args = ['/c', 'start', '', trimmedUrl];
|
|
4326
|
+
} else if (process.platform !== 'darwin') {
|
|
4327
|
+
command = 'xdg-open';
|
|
4328
|
+
args = [trimmedUrl];
|
|
4329
|
+
}
|
|
4330
|
+
|
|
4331
|
+
const proc = spawn(command, args, {
|
|
4332
|
+
detached: true,
|
|
4333
|
+
stdio: 'ignore'
|
|
4334
|
+
});
|
|
4335
|
+
proc.unref();
|
|
4336
|
+
return `Opened URL in default browser: ${trimmedUrl}`;
|
|
4337
|
+
} catch (error) {
|
|
4338
|
+
return `Error opening URL: ${error.message}`;
|
|
4339
|
+
}
|
|
2558
4340
|
},
|
|
2559
4341
|
shell: async (cmd) => {
|
|
4342
|
+
const trimmedCmd = String(cmd ?? '').trim();
|
|
4343
|
+
if (!trimmedCmd) return 'Error executing shell: missing command';
|
|
4344
|
+
|
|
4345
|
+
const sessionCommandResult = await handleShellSessionCommand(trimmedCmd);
|
|
4346
|
+
if (sessionCommandResult !== null) {
|
|
4347
|
+
return sessionCommandResult;
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
const backgroundEligible = shouldBackgroundShellCommand(trimmedCmd);
|
|
2560
4351
|
console.log();
|
|
2561
4352
|
console.log(box(
|
|
2562
|
-
`${keyValue('Directory', chalk.white(
|
|
2563
|
-
`${UI.slate('Command')}\n${chalk.white.bold(
|
|
4353
|
+
`${keyValue('Directory', chalk.white(getToolWorkingDirectory()), 11)}\n` +
|
|
4354
|
+
`${UI.slate('Command')}\n${chalk.white.bold(trimmedCmd)}\n` +
|
|
4355
|
+
`${UI.slate('Type y to run, n to block, f for feedback, e for edit instructions, or write feedback directly.')}\n` +
|
|
4356
|
+
`${UI.slate(backgroundEligible ? `Background handoff ${shellBackgroundMode()} after ${shellBackgroundAfterSeconds()}s if still running.` : 'This command will stay attached unless it exits quickly.')}`,
|
|
2564
4357
|
'Shell Approval', 'red'
|
|
2565
4358
|
));
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
const
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
4359
|
+
while (true) {
|
|
4360
|
+
const confirmInput = await safeQuestion(confirmPrompt('Run shell command', 'error', '[y/N/f/e or text] '));
|
|
4361
|
+
const confirmRaw = String(confirmInput ?? '').trim();
|
|
4362
|
+
const confirm = confirmRaw.toLowerCase();
|
|
4363
|
+
|
|
4364
|
+
if (['y', 'yes'].includes(confirm)) {
|
|
4365
|
+
return new Promise((resolve) => {
|
|
4366
|
+
console.log(chalk.cyan(`\n[RUNNING] ${trimmedCmd}\n`));
|
|
4367
|
+
const proc = spawn('sh', ['-c', trimmedCmd], {
|
|
4368
|
+
cwd: getToolWorkingDirectory()
|
|
4369
|
+
});
|
|
4370
|
+
const session = createShellSession(trimmedCmd, getToolWorkingDirectory(), proc);
|
|
4371
|
+
let resolved = false;
|
|
4372
|
+
let backgroundTimer = null;
|
|
4373
|
+
|
|
4374
|
+
const finish = (result) => {
|
|
4375
|
+
if (resolved) return;
|
|
4376
|
+
resolved = true;
|
|
4377
|
+
if (backgroundTimer) {
|
|
4378
|
+
clearTimeout(backgroundTimer);
|
|
4379
|
+
backgroundTimer = null;
|
|
4380
|
+
}
|
|
4381
|
+
resolve(result);
|
|
4382
|
+
};
|
|
4383
|
+
|
|
4384
|
+
if (backgroundEligible) {
|
|
4385
|
+
backgroundTimer = setTimeout(() => {
|
|
4386
|
+
if (resolved || session.completed) return;
|
|
4387
|
+
session.backgrounded = true;
|
|
4388
|
+
session.liveEchoEnabled = false;
|
|
4389
|
+
showStreamPhase(`Shell command still running. Background session ${session.id} is active...`, 'warning');
|
|
4390
|
+
finish(buildShellSessionResult(session, {
|
|
4391
|
+
includeOutput: shellStreamToModelEnabled(),
|
|
4392
|
+
onlyNewOutput: false,
|
|
4393
|
+
markReported: shellStreamToModelEnabled(),
|
|
4394
|
+
backgroundHandoff: true,
|
|
4395
|
+
}));
|
|
4396
|
+
}, shellBackgroundAfterSeconds() * 1000);
|
|
2589
4397
|
}
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
4398
|
+
|
|
4399
|
+
proc.stdout.on('data', (data) => {
|
|
4400
|
+
const text = data.toString();
|
|
4401
|
+
appendShellSessionOutput(session, text);
|
|
4402
|
+
if (session.liveEchoEnabled) {
|
|
4403
|
+
process.stdout.write(text);
|
|
4404
|
+
}
|
|
4405
|
+
});
|
|
4406
|
+
proc.stderr.on('data', (data) => {
|
|
4407
|
+
const text = data.toString();
|
|
4408
|
+
appendShellSessionOutput(session, text);
|
|
4409
|
+
if (session.liveEchoEnabled) {
|
|
4410
|
+
process.stderr.write(text);
|
|
4411
|
+
}
|
|
4412
|
+
});
|
|
4413
|
+
proc.on('error', (error) => {
|
|
4414
|
+
session.completed = true;
|
|
4415
|
+
session.error = error.message;
|
|
4416
|
+
session.exitCode = 1;
|
|
4417
|
+
pruneCompletedShellSessions();
|
|
4418
|
+
finish(`Shell command failed to start: ${error.message}`);
|
|
4419
|
+
});
|
|
4420
|
+
proc.on('close', (code, signal) => {
|
|
4421
|
+
session.completed = true;
|
|
4422
|
+
session.exitCode = code;
|
|
4423
|
+
session.signal = signal;
|
|
4424
|
+
pruneCompletedShellSessions();
|
|
4425
|
+
|
|
4426
|
+
if (resolved) {
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4429
|
+
|
|
4430
|
+
if (process.stdin.isTTY) {
|
|
4431
|
+
try { process.stdin.setRawMode(false); } catch (e) {}
|
|
2598
4432
|
}
|
|
2599
|
-
|
|
2600
|
-
|
|
4433
|
+
|
|
4434
|
+
setTimeout(() => {
|
|
4435
|
+
recreateReadline();
|
|
4436
|
+
const maxOutput = 10000;
|
|
4437
|
+
let result = session.output.trim();
|
|
4438
|
+
if (result.length > maxOutput) {
|
|
4439
|
+
result = result.substring(0, maxOutput) + '\n... (output truncated)';
|
|
4440
|
+
}
|
|
4441
|
+
finish(result || `Command completed with exit code ${code}`);
|
|
4442
|
+
}, 200);
|
|
4443
|
+
});
|
|
2601
4444
|
});
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
if (['', 'n', 'no'].includes(confirm)) {
|
|
4448
|
+
return "Command blocked by user.";
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
const approvalInstruction = await resolveApprovalInstruction(confirmRaw, {
|
|
4452
|
+
feedbackPrompt: 'Feedback for this command: ',
|
|
4453
|
+
editPrompt: 'Edit instruction for this command: ',
|
|
2602
4454
|
});
|
|
4455
|
+
|
|
4456
|
+
if (approvalInstruction) {
|
|
4457
|
+
if (!approvalInstruction.detail) {
|
|
4458
|
+
console.log(UI.slate('Enter feedback or edit instructions for Sapper, or choose y/n.'));
|
|
4459
|
+
continue;
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
const label = approvalInstruction.type === 'edit' ? 'User edit instruction' : 'User feedback';
|
|
4463
|
+
return `Command blocked by user.\n${label}: ${approvalInstruction.detail}\nNo command was executed. Revise the command and ask again if needed.`;
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
return `Command blocked by user.\nUser feedback: ${confirmRaw}\nNo command was executed. Revise the command and ask again if needed.`;
|
|
2603
4467
|
}
|
|
2604
|
-
return "Command blocked by user.";
|
|
2605
4468
|
},
|
|
2606
4469
|
list: (path) => {
|
|
2607
4470
|
try {
|
|
2608
|
-
let dir = path.trim()
|
|
4471
|
+
let dir = typeof path === 'string' ? path.trim() : '';
|
|
4472
|
+
if (!dir) dir = '.';
|
|
2609
4473
|
// If AI sends "/" (root), treat as current directory "."
|
|
2610
4474
|
if (dir === '/') dir = '.';
|
|
2611
|
-
const entries = fs.readdirSync(dir);
|
|
4475
|
+
const entries = fs.readdirSync(resolveToolPath(dir));
|
|
2612
4476
|
// Filter out ignored files/directories (respects .sapperignore)
|
|
2613
4477
|
const filtered = entries.filter(entry => {
|
|
2614
4478
|
if (shouldIgnore(entry)) return false;
|
|
@@ -2626,16 +4490,35 @@ const tools = {
|
|
|
2626
4490
|
for (const { pattern: p, negate } of getSapperIgnorePatterns()) {
|
|
2627
4491
|
if (!negate && p.endsWith('/')) allIgnoreDirs.add(p.replace(/\/+$/, ''));
|
|
2628
4492
|
}
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
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');
|
|
2632
4504
|
|
|
2633
|
-
const proc = spawn('
|
|
4505
|
+
const proc = spawn('grep', args, { cwd: getToolWorkingDirectory() });
|
|
2634
4506
|
let output = '';
|
|
4507
|
+
let lineCount = 0;
|
|
2635
4508
|
|
|
2636
|
-
proc.stdout.on('data', (data) => {
|
|
2637
|
-
|
|
4509
|
+
proc.stdout.on('data', (data) => {
|
|
4510
|
+
const text = data.toString();
|
|
4511
|
+
const lines = text.split('\n');
|
|
4512
|
+
for (const line of lines) {
|
|
4513
|
+
if (lineCount >= 50) { proc.kill(); return; }
|
|
4514
|
+
if (line) { output += line + '\n'; lineCount++; }
|
|
4515
|
+
}
|
|
4516
|
+
});
|
|
4517
|
+
proc.stderr.on('data', () => {}); // ignore stderr
|
|
2638
4518
|
|
|
4519
|
+
proc.on('error', (err) => {
|
|
4520
|
+
resolve(`Error searching: ${err.message}`);
|
|
4521
|
+
});
|
|
2639
4522
|
proc.on('close', () => {
|
|
2640
4523
|
if (output.trim()) {
|
|
2641
4524
|
resolve(`Found matches:\n${output.trim()}`);
|
|
@@ -2721,6 +4604,12 @@ async function runSapper() {
|
|
|
2721
4604
|
const startupLines = [
|
|
2722
4605
|
`${statusBadge('workspace', 'info')} ${chalk.white(`${workspaceFileCount} files`)} ${UI.slate('·')} ${chalk.white(`${workspaceSymbolCount} symbols`)} ${UI.slate('·')} ${UI.slate(`indexed ${workspaceAgeMinutes}m ago`)}`,
|
|
2723
4606
|
`${statusBadge('memory', 'neutral')} ${chalk.white('.sapper/')} ${UI.slate('·')} ${UI.slate(`auto-attach ${sapperConfig.autoAttach ? 'on' : 'off'}`)}`,
|
|
4607
|
+
`${statusBadge('prompt', hasCustomPromptConfig() ? 'warning' : 'neutral')} ${UI.slate(hasCustomPromptConfig() ? 'custom prompt on' : 'default prompt')}`,
|
|
4608
|
+
`${statusBadge('thinking', 'neutral')} ${UI.slate(`mode ${thinkingMode()}`)}`,
|
|
4609
|
+
`${statusBadge('tools', 'action')} ${UI.slate(`limit ${toolRoundLimit()} rounds`)}`,
|
|
4610
|
+
`${statusBadge('shell', 'info')} ${UI.slate(`stream ${shellStreamToModelEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${UI.slate(`${activeShellSessionCount()} active`)}`,
|
|
4611
|
+
`${statusBadge('stream', 'neutral')} ${UI.slate(`heartbeat ${streamHeartbeatEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`phases ${streamPhaseStatusEnabled() ? 'on' : 'off'}`)}`,
|
|
4612
|
+
`${statusBadge('summary', 'info')} ${UI.slate(`phases ${summaryPhasesEnabled() ? 'on' : 'off'}`)} ${UI.slate('·')} ${UI.slate(`trigger ${summaryTriggerPercent()}%`)}`,
|
|
2724
4613
|
`${statusBadge('agents', 'action')} ${chalk.white(`${agentCount}`)} ${UI.slate('·')} ${statusBadge('skills', 'success')} ${chalk.white(`${skillCount}`)}`,
|
|
2725
4614
|
];
|
|
2726
4615
|
if (newlyCreated > 0) {
|
|
@@ -2774,7 +4663,15 @@ async function runSapper() {
|
|
|
2774
4663
|
process.exit(1);
|
|
2775
4664
|
}
|
|
2776
4665
|
|
|
2777
|
-
|
|
4666
|
+
// Use defaultModel from config if available and exists in local models
|
|
4667
|
+
let selectedModel;
|
|
4668
|
+
const configModel = sapperConfig.defaultModel;
|
|
4669
|
+
if (configModel && localModels.models.some(m => m.name === configModel)) {
|
|
4670
|
+
selectedModel = configModel;
|
|
4671
|
+
console.log(UI.slate(` Using configured model: ${configModel}`));
|
|
4672
|
+
} else {
|
|
4673
|
+
selectedModel = await pickModel(localModels.models) || localModels.models[0].name;
|
|
4674
|
+
}
|
|
2778
4675
|
|
|
2779
4676
|
// ─── Detect model capabilities & context window ───────────────────
|
|
2780
4677
|
let useNativeTools = false;
|
|
@@ -2797,48 +4694,147 @@ async function runSapper() {
|
|
|
2797
4694
|
break;
|
|
2798
4695
|
}
|
|
2799
4696
|
}
|
|
2800
|
-
}
|
|
2801
|
-
// Fallback: parse from parameters string (e.g. "num_ctx 4096")
|
|
2802
|
-
if (!modelContextLength && modelInfo.parameters) {
|
|
2803
|
-
const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
|
|
2804
|
-
if (match) modelContextLength = parseInt(match[1]);
|
|
2805
|
-
}
|
|
2806
|
-
if (modelContextLength) {
|
|
2807
|
-
contextLabel = `${modelContextLength.toLocaleString()} tokens`;
|
|
2808
|
-
} else {
|
|
2809
|
-
modelContextLength = 4096; // Conservative default
|
|
2810
|
-
contextLabel = '4,096 tokens (default)';
|
|
2811
|
-
}
|
|
2812
|
-
} catch (e) {
|
|
2813
|
-
modelContextLength = 4096;
|
|
2814
|
-
toolModeLabel = 'default mode';
|
|
2815
|
-
contextLabel = '4,096 tokens (fallback)';
|
|
2816
|
-
}
|
|
2817
|
-
// Show custom limit if set
|
|
2818
|
-
const effectiveCtx = effectiveContextLength();
|
|
2819
|
-
if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
|
|
2820
|
-
contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
|
|
2821
|
-
}
|
|
2822
|
-
console.log(box(
|
|
2823
|
-
`${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
|
|
2824
|
-
`${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
|
|
2825
|
-
`${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
|
|
2826
|
-
'Session', 'cyan'
|
|
2827
|
-
));
|
|
2828
|
-
console.log();
|
|
2829
|
-
_useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
|
|
2830
|
-
|
|
2831
|
-
// Native Ollama tool definitions (used when useNativeTools=true)
|
|
2832
|
-
const nativeToolDefs = [
|
|
4697
|
+
}
|
|
4698
|
+
// Fallback: parse from parameters string (e.g. "num_ctx 4096")
|
|
4699
|
+
if (!modelContextLength && modelInfo.parameters) {
|
|
4700
|
+
const match = modelInfo.parameters.match(/num_ctx\s+(\d+)/);
|
|
4701
|
+
if (match) modelContextLength = parseInt(match[1]);
|
|
4702
|
+
}
|
|
4703
|
+
if (modelContextLength) {
|
|
4704
|
+
contextLabel = `${modelContextLength.toLocaleString()} tokens`;
|
|
4705
|
+
} else {
|
|
4706
|
+
modelContextLength = 4096; // Conservative default
|
|
4707
|
+
contextLabel = '4,096 tokens (default)';
|
|
4708
|
+
}
|
|
4709
|
+
} catch (e) {
|
|
4710
|
+
modelContextLength = 4096;
|
|
4711
|
+
toolModeLabel = 'default mode';
|
|
4712
|
+
contextLabel = '4,096 tokens (fallback)';
|
|
4713
|
+
}
|
|
4714
|
+
// Show custom limit if set
|
|
4715
|
+
const effectiveCtx = effectiveContextLength();
|
|
4716
|
+
if (sapperConfig.contextLimit && effectiveCtx !== modelContextLength) {
|
|
4717
|
+
contextLabel = `${effectiveCtx.toLocaleString()} tokens (custom limit, model: ${modelContextLength.toLocaleString()})`;
|
|
4718
|
+
}
|
|
4719
|
+
console.log(box(
|
|
4720
|
+
`${statusBadge('model', 'action')} ${chalk.white.bold(selectedModel)}\n` +
|
|
4721
|
+
`${statusBadge('tools', useNativeTools ? 'success' : 'neutral')} ${UI.ink(toolModeLabel)}\n` +
|
|
4722
|
+
`${statusBadge('context', 'info')} ${UI.ink(contextLabel)}`,
|
|
4723
|
+
'Session', 'cyan'
|
|
4724
|
+
));
|
|
4725
|
+
console.log();
|
|
4726
|
+
_useNativeToolsFlag = useNativeTools; // Set global for buildSystemPrompt
|
|
4727
|
+
|
|
4728
|
+
// Native Ollama tool definitions (used when useNativeTools=true)
|
|
4729
|
+
const nativeToolDefs = [
|
|
4730
|
+
{
|
|
4731
|
+
type: 'function',
|
|
4732
|
+
function: {
|
|
4733
|
+
name: 'list_directory',
|
|
4734
|
+
description: 'List the contents of a directory. If path is omitted, use the current directory ".".',
|
|
4735
|
+
parameters: {
|
|
4736
|
+
type: 'object',
|
|
4737
|
+
properties: {
|
|
4738
|
+
path: { type: 'string', description: 'Directory path to list' }
|
|
4739
|
+
}
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
4742
|
+
},
|
|
4743
|
+
{
|
|
4744
|
+
type: 'function',
|
|
4745
|
+
function: {
|
|
4746
|
+
name: 'read_file',
|
|
4747
|
+
description: 'Read the full contents of a file',
|
|
4748
|
+
parameters: {
|
|
4749
|
+
type: 'object',
|
|
4750
|
+
properties: {
|
|
4751
|
+
path: { type: 'string', description: 'File path to read' }
|
|
4752
|
+
},
|
|
4753
|
+
required: ['path']
|
|
4754
|
+
}
|
|
4755
|
+
}
|
|
4756
|
+
},
|
|
4757
|
+
{
|
|
4758
|
+
type: 'function',
|
|
4759
|
+
function: {
|
|
4760
|
+
name: 'search_files',
|
|
4761
|
+
description: 'Search for a pattern across project files',
|
|
4762
|
+
parameters: {
|
|
4763
|
+
type: 'object',
|
|
4764
|
+
properties: {
|
|
4765
|
+
pattern: { type: 'string', description: 'Search pattern (text or regex)' }
|
|
4766
|
+
},
|
|
4767
|
+
required: ['pattern']
|
|
4768
|
+
}
|
|
4769
|
+
}
|
|
4770
|
+
},
|
|
4771
|
+
{
|
|
4772
|
+
type: 'function',
|
|
4773
|
+
function: {
|
|
4774
|
+
name: 'write_file',
|
|
4775
|
+
description: 'Create or overwrite a file with new content',
|
|
4776
|
+
parameters: {
|
|
4777
|
+
type: 'object',
|
|
4778
|
+
properties: {
|
|
4779
|
+
path: { type: 'string', description: 'File path to write' },
|
|
4780
|
+
content: { type: 'string', description: 'Content to write to the file' }
|
|
4781
|
+
},
|
|
4782
|
+
required: ['path', 'content']
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
},
|
|
4786
|
+
{
|
|
4787
|
+
type: 'function',
|
|
4788
|
+
function: {
|
|
4789
|
+
name: 'patch_file',
|
|
4790
|
+
description: 'Edit an existing file by replacing old text with new text. Prefer line_number mode.',
|
|
4791
|
+
parameters: {
|
|
4792
|
+
type: 'object',
|
|
4793
|
+
properties: {
|
|
4794
|
+
path: { type: 'string', description: 'File path to patch' },
|
|
4795
|
+
old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
|
|
4796
|
+
new_text: { type: 'string', description: 'Replacement text' }
|
|
4797
|
+
},
|
|
4798
|
+
required: ['path', 'old_text', 'new_text']
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
},
|
|
4802
|
+
{
|
|
4803
|
+
type: 'function',
|
|
4804
|
+
function: {
|
|
4805
|
+
name: 'create_directory',
|
|
4806
|
+
description: 'Create a directory (recursive)',
|
|
4807
|
+
parameters: {
|
|
4808
|
+
type: 'object',
|
|
4809
|
+
properties: {
|
|
4810
|
+
path: { type: 'string', description: 'Directory path to create' }
|
|
4811
|
+
},
|
|
4812
|
+
required: ['path']
|
|
4813
|
+
}
|
|
4814
|
+
}
|
|
4815
|
+
},
|
|
4816
|
+
{
|
|
4817
|
+
type: 'function',
|
|
4818
|
+
function: {
|
|
4819
|
+
name: 'ls',
|
|
4820
|
+
description: 'List directory contents. If path is omitted, use the current tool working directory.',
|
|
4821
|
+
parameters: {
|
|
4822
|
+
type: 'object',
|
|
4823
|
+
properties: {
|
|
4824
|
+
path: { type: 'string', description: 'Directory path to list' }
|
|
4825
|
+
}
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
},
|
|
2833
4829
|
{
|
|
2834
4830
|
type: 'function',
|
|
2835
4831
|
function: {
|
|
2836
|
-
name: '
|
|
2837
|
-
description: '
|
|
4832
|
+
name: 'cat',
|
|
4833
|
+
description: 'Read the full contents of a file.',
|
|
2838
4834
|
parameters: {
|
|
2839
4835
|
type: 'object',
|
|
2840
4836
|
properties: {
|
|
2841
|
-
path: { type: 'string', description: '
|
|
4837
|
+
path: { type: 'string', description: 'File path to read' }
|
|
2842
4838
|
},
|
|
2843
4839
|
required: ['path']
|
|
2844
4840
|
}
|
|
@@ -2847,12 +4843,13 @@ async function runSapper() {
|
|
|
2847
4843
|
{
|
|
2848
4844
|
type: 'function',
|
|
2849
4845
|
function: {
|
|
2850
|
-
name: '
|
|
2851
|
-
description: 'Read the
|
|
4846
|
+
name: 'head',
|
|
4847
|
+
description: 'Read the first lines of a file.',
|
|
2852
4848
|
parameters: {
|
|
2853
4849
|
type: 'object',
|
|
2854
4850
|
properties: {
|
|
2855
|
-
path: { type: 'string', description: 'File path to read' }
|
|
4851
|
+
path: { type: 'string', description: 'File path to read' },
|
|
4852
|
+
lines: { type: 'number', description: 'How many lines to show (default 20)' }
|
|
2856
4853
|
},
|
|
2857
4854
|
required: ['path']
|
|
2858
4855
|
}
|
|
@@ -2861,8 +4858,23 @@ async function runSapper() {
|
|
|
2861
4858
|
{
|
|
2862
4859
|
type: 'function',
|
|
2863
4860
|
function: {
|
|
2864
|
-
name: '
|
|
2865
|
-
description: '
|
|
4861
|
+
name: 'tail',
|
|
4862
|
+
description: 'Read the last lines of a file.',
|
|
4863
|
+
parameters: {
|
|
4864
|
+
type: 'object',
|
|
4865
|
+
properties: {
|
|
4866
|
+
path: { type: 'string', description: 'File path to read' },
|
|
4867
|
+
lines: { type: 'number', description: 'How many lines to show (default 20)' }
|
|
4868
|
+
},
|
|
4869
|
+
required: ['path']
|
|
4870
|
+
}
|
|
4871
|
+
}
|
|
4872
|
+
},
|
|
4873
|
+
{
|
|
4874
|
+
type: 'function',
|
|
4875
|
+
function: {
|
|
4876
|
+
name: 'grep',
|
|
4877
|
+
description: 'Search for matching text across project files.',
|
|
2866
4878
|
parameters: {
|
|
2867
4879
|
type: 'object',
|
|
2868
4880
|
properties: {
|
|
@@ -2875,53 +4887,117 @@ async function runSapper() {
|
|
|
2875
4887
|
{
|
|
2876
4888
|
type: 'function',
|
|
2877
4889
|
function: {
|
|
2878
|
-
name: '
|
|
2879
|
-
description: '
|
|
4890
|
+
name: 'find',
|
|
4891
|
+
description: 'Find files or directories by name.',
|
|
2880
4892
|
parameters: {
|
|
2881
4893
|
type: 'object',
|
|
2882
4894
|
properties: {
|
|
2883
|
-
|
|
2884
|
-
|
|
4895
|
+
pattern: { type: 'string', description: 'Name fragment to search for' },
|
|
4896
|
+
path: { type: 'string', description: 'Directory to search from (default current tool working directory)' }
|
|
2885
4897
|
},
|
|
2886
|
-
required: ['
|
|
4898
|
+
required: ['pattern']
|
|
2887
4899
|
}
|
|
2888
4900
|
}
|
|
2889
4901
|
},
|
|
2890
4902
|
{
|
|
2891
4903
|
type: 'function',
|
|
2892
4904
|
function: {
|
|
2893
|
-
name: '
|
|
2894
|
-
description: '
|
|
4905
|
+
name: 'pwd',
|
|
4906
|
+
description: 'Show the current tool working directory.',
|
|
4907
|
+
parameters: {
|
|
4908
|
+
type: 'object',
|
|
4909
|
+
properties: {}
|
|
4910
|
+
}
|
|
4911
|
+
}
|
|
4912
|
+
},
|
|
4913
|
+
{
|
|
4914
|
+
type: 'function',
|
|
4915
|
+
function: {
|
|
4916
|
+
name: 'cd',
|
|
4917
|
+
description: 'Change the tool working directory for later tool calls.',
|
|
2895
4918
|
parameters: {
|
|
2896
4919
|
type: 'object',
|
|
2897
4920
|
properties: {
|
|
2898
|
-
path: { type: 'string', description: '
|
|
2899
|
-
old_text: { type: 'string', description: 'Exact text to find and replace, or LINE:<number> for line-number mode' },
|
|
2900
|
-
new_text: { type: 'string', description: 'Replacement text' }
|
|
4921
|
+
path: { type: 'string', description: 'Directory path to switch to' }
|
|
2901
4922
|
},
|
|
2902
|
-
required: ['path'
|
|
4923
|
+
required: ['path']
|
|
2903
4924
|
}
|
|
2904
4925
|
}
|
|
2905
4926
|
},
|
|
2906
4927
|
{
|
|
2907
4928
|
type: 'function',
|
|
2908
4929
|
function: {
|
|
2909
|
-
name: '
|
|
2910
|
-
description: '
|
|
4930
|
+
name: 'rmdir',
|
|
4931
|
+
description: 'Remove a directory recursively after approval.',
|
|
2911
4932
|
parameters: {
|
|
2912
4933
|
type: 'object',
|
|
2913
4934
|
properties: {
|
|
2914
|
-
path: { type: 'string', description: 'Directory path to
|
|
4935
|
+
path: { type: 'string', description: 'Directory path to remove' }
|
|
2915
4936
|
},
|
|
2916
4937
|
required: ['path']
|
|
2917
4938
|
}
|
|
2918
4939
|
}
|
|
2919
4940
|
},
|
|
4941
|
+
{
|
|
4942
|
+
type: 'function',
|
|
4943
|
+
function: {
|
|
4944
|
+
name: 'changes',
|
|
4945
|
+
description: 'Show git status and diffs for the current repository or an optional file/directory path.',
|
|
4946
|
+
parameters: {
|
|
4947
|
+
type: 'object',
|
|
4948
|
+
properties: {
|
|
4949
|
+
path: { type: 'string', description: 'Optional file or directory path to scope the diff output' }
|
|
4950
|
+
}
|
|
4951
|
+
}
|
|
4952
|
+
}
|
|
4953
|
+
},
|
|
4954
|
+
{
|
|
4955
|
+
type: 'function',
|
|
4956
|
+
function: {
|
|
4957
|
+
name: 'fetch_web',
|
|
4958
|
+
description: 'Fetch a web page and return readable text content.',
|
|
4959
|
+
parameters: {
|
|
4960
|
+
type: 'object',
|
|
4961
|
+
properties: {
|
|
4962
|
+
url: { type: 'string', description: 'URL to fetch, starting with http:// or https://' }
|
|
4963
|
+
},
|
|
4964
|
+
required: ['url']
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
},
|
|
4968
|
+
{
|
|
4969
|
+
type: 'function',
|
|
4970
|
+
function: {
|
|
4971
|
+
name: 'recall_memory',
|
|
4972
|
+
description: 'Search Sapper\'s saved conversation memory for relevant prior context.',
|
|
4973
|
+
parameters: {
|
|
4974
|
+
type: 'object',
|
|
4975
|
+
properties: {
|
|
4976
|
+
query: { type: 'string', description: 'Memory search query' }
|
|
4977
|
+
},
|
|
4978
|
+
required: ['query']
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
},
|
|
4982
|
+
{
|
|
4983
|
+
type: 'function',
|
|
4984
|
+
function: {
|
|
4985
|
+
name: 'open_url',
|
|
4986
|
+
description: 'Open a URL in the default browser after approval.',
|
|
4987
|
+
parameters: {
|
|
4988
|
+
type: 'object',
|
|
4989
|
+
properties: {
|
|
4990
|
+
url: { type: 'string', description: 'URL to open, usually http://, https://, or file:' }
|
|
4991
|
+
},
|
|
4992
|
+
required: ['url']
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
},
|
|
2920
4996
|
{
|
|
2921
4997
|
type: 'function',
|
|
2922
4998
|
function: {
|
|
2923
4999
|
name: 'run_shell',
|
|
2924
|
-
description: 'Execute a shell command in the project directory',
|
|
5000
|
+
description: 'Execute a shell command in the project directory. Special commands: __shell_list__, __shell_read__ <session_id>, __shell_stop__ <session_id>.',
|
|
2925
5001
|
parameters: {
|
|
2926
5002
|
type: 'object',
|
|
2927
5003
|
properties: {
|
|
@@ -2940,6 +5016,20 @@ async function runSapper() {
|
|
|
2940
5016
|
}];
|
|
2941
5017
|
}
|
|
2942
5018
|
|
|
5019
|
+
// Auto-load defaultAgent from config if set
|
|
5020
|
+
if (!currentAgent && sapperConfig.defaultAgent) {
|
|
5021
|
+
const agents = loadAgents();
|
|
5022
|
+
const agentKey = sapperConfig.defaultAgent.toLowerCase();
|
|
5023
|
+
if (agents[agentKey]) {
|
|
5024
|
+
currentAgent = agentKey;
|
|
5025
|
+
currentAgentTools = agents[agentKey].tools || null;
|
|
5026
|
+
if (messages.length > 0 && messages[0]?.role === 'system') {
|
|
5027
|
+
messages[0].content = buildSystemPrompt(agents[agentKey].content);
|
|
5028
|
+
}
|
|
5029
|
+
console.log(UI.slate(` Using configured agent: ${agents[agentKey].name}`));
|
|
5030
|
+
}
|
|
5031
|
+
}
|
|
5032
|
+
|
|
2943
5033
|
// Log session start
|
|
2944
5034
|
logEntry('session_start', {
|
|
2945
5035
|
model: selectedModel,
|
|
@@ -2950,51 +5040,62 @@ async function runSapper() {
|
|
|
2950
5040
|
// Main conversation loop - never exits unless user types 'exit'
|
|
2951
5041
|
while (true) {
|
|
2952
5042
|
try {
|
|
5043
|
+
const previousConfig = JSON.stringify(sapperConfig);
|
|
5044
|
+
const reloadedConfig = loadConfig();
|
|
5045
|
+
if (JSON.stringify(reloadedConfig) !== previousConfig) {
|
|
5046
|
+
sapperConfig = reloadedConfig;
|
|
5047
|
+
if (messages.length > 0 && messages[0]?.role === 'system') {
|
|
5048
|
+
refreshSystemPrompt(messages);
|
|
5049
|
+
}
|
|
5050
|
+
console.log(chalk.gray(`↻ Reloaded ${CONFIG_FILE}`));
|
|
5051
|
+
console.log(chalk.gray(' System prompt and runtime settings refreshed from config.'));
|
|
5052
|
+
}
|
|
5053
|
+
|
|
2953
5054
|
// Context size check - auto-summarize when approaching effective context limit
|
|
2954
|
-
|
|
5055
|
+
let estimatedTokens = estimateMessagesTokens(messages);
|
|
2955
5056
|
const ctxLen = effectiveContextLength();
|
|
2956
|
-
const tokenThreshold =
|
|
5057
|
+
const tokenThreshold = summaryTokenThreshold(ctxLen);
|
|
2957
5058
|
if (estimatedTokens > tokenThreshold) {
|
|
2958
5059
|
messages = await autoSummarizeContext(messages, selectedModel);
|
|
5060
|
+
estimatedTokens = estimateMessagesTokens(messages);
|
|
2959
5061
|
}
|
|
2960
5062
|
|
|
2961
5063
|
// Build prompt label with active agent/skills
|
|
2962
5064
|
const contextPercent = ctxLen ? Math.round((estimatedTokens / ctxLen) * 100) : null;
|
|
2963
5065
|
const promptParts = [
|
|
2964
5066
|
statusBadge(selectedModel.split(':')[0] || selectedModel, 'action'),
|
|
2965
|
-
|
|
5067
|
+
activeAgentPromptBadge(),
|
|
2966
5068
|
];
|
|
2967
|
-
|
|
2968
|
-
|
|
5069
|
+
const skillsBadge = activeSkillsPromptBadge();
|
|
5070
|
+
if (skillsBadge) {
|
|
5071
|
+
promptParts.push(skillsBadge);
|
|
2969
5072
|
}
|
|
2970
5073
|
if (contextPercent !== null) {
|
|
2971
5074
|
const tone = contextPercent >= 85 ? 'error' : contextPercent >= 65 ? 'warning' : 'neutral';
|
|
2972
5075
|
promptParts.push(statusBadge(`${contextPercent}% ctx`, tone));
|
|
2973
5076
|
}
|
|
2974
5077
|
|
|
2975
|
-
const
|
|
5078
|
+
const promptDetailLines = [ctxLen
|
|
2976
5079
|
? `${meter(estimatedTokens, ctxLen, 24)} ${UI.slate(`${estimatedTokens.toLocaleString()}/${ctxLen.toLocaleString()} tokens`)}`
|
|
2977
|
-
: UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`)
|
|
5080
|
+
: UI.slate(`${estimatedTokens.toLocaleString()} estimated tokens`)
|
|
5081
|
+
];
|
|
5082
|
+
const modeSummary = activeModeSummary({ includeAgent: true, maxSkills: 3 });
|
|
5083
|
+
if (modeSummary) {
|
|
5084
|
+
promptDetailLines.push(UI.slate(modeSummary));
|
|
5085
|
+
}
|
|
5086
|
+
const promptDetail = promptDetailLines.join('\n');
|
|
2978
5087
|
|
|
2979
|
-
const
|
|
5088
|
+
const promptText = `\n${promptShell(promptParts.join(' '), promptDetail)}`;
|
|
5089
|
+
const input = await safeQuestion(promptText);
|
|
5090
|
+
clearPromptEcho(promptText, input);
|
|
2980
5091
|
|
|
2981
5092
|
// Block empty prompts
|
|
2982
5093
|
if (!input.trim()) {
|
|
2983
5094
|
continue;
|
|
2984
5095
|
}
|
|
2985
5096
|
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
const promptWidth = visibleLength(promptParts.join(' ')) + 4; // account for prompt chars
|
|
2989
|
-
const totalLen = promptWidth + input.length;
|
|
2990
|
-
const lines = Math.ceil(totalLen / (process.stdout.columns || 80));
|
|
2991
|
-
for (let i = 0; i < lines; i++) {
|
|
2992
|
-
process.stdout.write('\x1B[1A\x1B[2K');
|
|
2993
|
-
}
|
|
2994
|
-
// Reprint clean version
|
|
2995
|
-
const preview = input.length > 120 ? input.substring(0, 120) + chalk.gray('...') : input;
|
|
2996
|
-
console.log(UI.accent('› ') + chalk.white(preview));
|
|
2997
|
-
}
|
|
5097
|
+
const preview = input.length > LIMITS.INPUT_PREVIEW_CHARS ? input.substring(0, LIMITS.INPUT_PREVIEW_CHARS) + chalk.gray('...') : input;
|
|
5098
|
+
console.log(UI.accent('› ') + chalk.white(preview));
|
|
2998
5099
|
|
|
2999
5100
|
if (input.toLowerCase() === 'exit') {
|
|
3000
5101
|
const stats = getSessionStats();
|
|
@@ -3056,7 +5157,11 @@ async function runSapper() {
|
|
|
3056
5157
|
console.log(commandRow('/fetch <url>', 'Fetch a web page into context'));
|
|
3057
5158
|
console.log(commandRow('/reset /clear', 'Clear all current context'));
|
|
3058
5159
|
console.log(commandRow('/prune', 'Summarize long context and store memory'));
|
|
3059
|
-
console.log(commandRow('/
|
|
5160
|
+
console.log(commandRow('/summary', 'Show or change auto-summary settings'));
|
|
5161
|
+
console.log(commandRow('/shell', 'Inspect shell config and background sessions'));
|
|
5162
|
+
console.log(commandRow('/shell read <id>', 'Read output from a tracked shell session'));
|
|
5163
|
+
console.log(commandRow('/shell stop <id>', 'Stop a tracked shell session'));
|
|
5164
|
+
console.log(commandRow('/context', 'Inspect token usage, summary trigger, and model window'));
|
|
3060
5165
|
console.log(commandRow('/ctx <limit>', 'Set context window limit (e.g. /ctx 64k)'));
|
|
3061
5166
|
console.log(commandRow('/debug', 'Toggle regex and tool debug output'));
|
|
3062
5167
|
console.log(commandRow('/log', 'Show the session activity timeline'));
|
|
@@ -3064,6 +5169,13 @@ async function runSapper() {
|
|
|
3064
5169
|
console.log(commandRow('/log file', 'Show log file path and history'));
|
|
3065
5170
|
console.log(commandRow('/help', 'Open this command view again'));
|
|
3066
5171
|
console.log(commandRow('exit', 'Quit Sapper'));
|
|
5172
|
+
console.log(UI.slate(' Summary settings: /summary | /summary phases off | /summary trigger 60'));
|
|
5173
|
+
console.log(UI.slate(' Tool config: .sapper/config.json -> toolRoundLimit (default 40)'));
|
|
5174
|
+
console.log(UI.slate(' Shell config: .sapper/config.json -> shell.streamToModel, shell.backgroundMode [off|auto|on], shell.backgroundAfterSeconds, shell.outputChunkChars'));
|
|
5175
|
+
console.log(UI.slate(' Want to see all live shell output? Set shell.backgroundMode to off. thinking.mode only controls model reasoning.'));
|
|
5176
|
+
console.log(UI.slate(' Streaming config: .sapper/config.json -> streaming.showPhaseStatus, streaming.showHeartbeat, streaming.idleNoticeSeconds'));
|
|
5177
|
+
console.log(UI.slate(' Thinking config: .sapper/config.json -> thinking.mode [auto|on|off]'));
|
|
5178
|
+
console.log(UI.slate(' Prompt config: .sapper/config.json -> prompt.prepend, prompt.append, prompt.coreOverride'));
|
|
3067
5179
|
console.log();
|
|
3068
5180
|
console.log(sectionTitle('Agents', 'specialist modes and skills', 'cyan'));
|
|
3069
5181
|
console.log(commandRow('/agents', 'List available agents'));
|
|
@@ -3141,8 +5253,8 @@ async function runSapper() {
|
|
|
3141
5253
|
console.log(` ${chalk.gray(sym.file)}:${chalk.cyan(sym.line)}`);
|
|
3142
5254
|
}
|
|
3143
5255
|
|
|
3144
|
-
if (results.length >
|
|
3145
|
-
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`));
|
|
3146
5258
|
}
|
|
3147
5259
|
|
|
3148
5260
|
// Offer to add file to context
|
|
@@ -3211,7 +5323,7 @@ async function runSapper() {
|
|
|
3211
5323
|
let contextContent = `\n📄 ${matchingFile}:\n`;
|
|
3212
5324
|
contextContent += fs.readFileSync(matchingFile, 'utf8');
|
|
3213
5325
|
|
|
3214
|
-
for (const relFile of related.slice(0,
|
|
5326
|
+
for (const relFile of related.slice(0, LIMITS.WORKSPACE_RELATED_DEPTH)) { // Limit to N related
|
|
3215
5327
|
try {
|
|
3216
5328
|
contextContent += `\n\n📄 ${relFile} (related):\n`;
|
|
3217
5329
|
contextContent += fs.readFileSync(relFile, 'utf8');
|
|
@@ -3288,20 +5400,112 @@ async function runSapper() {
|
|
|
3288
5400
|
continue;
|
|
3289
5401
|
}
|
|
3290
5402
|
|
|
5403
|
+
if (input.toLowerCase().startsWith('/summary')) {
|
|
5404
|
+
const arg = input.substring(8).trim();
|
|
5405
|
+
|
|
5406
|
+
if (!arg) {
|
|
5407
|
+
const effective = effectiveContextLength();
|
|
5408
|
+
const threshold = summaryTokenThreshold(effective);
|
|
5409
|
+
const lines = [
|
|
5410
|
+
`phases ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`,
|
|
5411
|
+
`trigger ${chalk.white.bold(summaryTriggerPercent() + '%')} ${UI.slate(`(~${threshold.toLocaleString()} tokens)`)}`,
|
|
5412
|
+
`config file ${chalk.white(CONFIG_FILE)}`,
|
|
5413
|
+
];
|
|
5414
|
+
console.log();
|
|
5415
|
+
console.log(box(lines.join('\n'), 'Summary Settings', 'cyan'));
|
|
5416
|
+
console.log(UI.slate(' Usage: /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
|
|
5417
|
+
continue;
|
|
5418
|
+
}
|
|
5419
|
+
|
|
5420
|
+
const [subcommandRaw, ...rest] = arg.split(/\s+/);
|
|
5421
|
+
const subcommand = subcommandRaw.toLowerCase();
|
|
5422
|
+
const value = rest.join(' ').trim();
|
|
5423
|
+
|
|
5424
|
+
if (subcommand === 'reset' || subcommand === 'default') {
|
|
5425
|
+
sapperConfig.summaryPhases = DEFAULT_CONFIG.summaryPhases;
|
|
5426
|
+
sapperConfig.summarizeTriggerPercent = DEFAULT_CONFIG.summarizeTriggerPercent;
|
|
5427
|
+
saveConfig(sapperConfig);
|
|
5428
|
+
console.log(chalk.green(`✅ Summary settings reset: phases ${summaryPhasesEnabled() ? 'ON' : 'OFF'}, trigger ${summaryTriggerPercent()}%`));
|
|
5429
|
+
continue;
|
|
5430
|
+
}
|
|
5431
|
+
|
|
5432
|
+
if (subcommand === 'phases' || subcommand === 'phase') {
|
|
5433
|
+
let nextValue = null;
|
|
5434
|
+
|
|
5435
|
+
if (!value) {
|
|
5436
|
+
nextValue = !summaryPhasesEnabled();
|
|
5437
|
+
} else {
|
|
5438
|
+
const normalized = value.toLowerCase();
|
|
5439
|
+
if (['on', 'true', 'yes', '1', 'enable', 'enabled'].includes(normalized)) {
|
|
5440
|
+
nextValue = true;
|
|
5441
|
+
} else if (['off', 'false', 'no', '0', 'disable', 'disabled'].includes(normalized)) {
|
|
5442
|
+
nextValue = false;
|
|
5443
|
+
} else if (['toggle', 'flip'].includes(normalized)) {
|
|
5444
|
+
nextValue = !summaryPhasesEnabled();
|
|
5445
|
+
}
|
|
5446
|
+
}
|
|
5447
|
+
|
|
5448
|
+
if (nextValue === null) {
|
|
5449
|
+
console.log(chalk.yellow('Usage: /summary phases [on|off]'));
|
|
5450
|
+
continue;
|
|
5451
|
+
}
|
|
5452
|
+
|
|
5453
|
+
sapperConfig.summaryPhases = nextValue;
|
|
5454
|
+
saveConfig(sapperConfig);
|
|
5455
|
+
console.log(chalk.green(`✅ Summary phases: ${summaryPhasesEnabled() ? chalk.green('ON') : chalk.red('OFF')}`));
|
|
5456
|
+
continue;
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
if (subcommand === 'trigger' || subcommand === 'percent' || subcommand === 'threshold') {
|
|
5460
|
+
if (!value) {
|
|
5461
|
+
console.log(chalk.yellow('Usage: /summary trigger <percent>'));
|
|
5462
|
+
console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
|
|
5463
|
+
continue;
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5466
|
+
const parsedTrigger = parseSummaryTriggerInput(value);
|
|
5467
|
+
if (parsedTrigger === null) {
|
|
5468
|
+
console.log(chalk.yellow(`Invalid summary trigger: ${value}`));
|
|
5469
|
+
console.log(chalk.gray(' Examples: /summary trigger 65, /summary trigger 70%, /summary trigger 0.6'));
|
|
5470
|
+
continue;
|
|
5471
|
+
}
|
|
5472
|
+
|
|
5473
|
+
sapperConfig.summarizeTriggerPercent = parsedTrigger;
|
|
5474
|
+
saveConfig(sapperConfig);
|
|
5475
|
+
const effective = effectiveContextLength();
|
|
5476
|
+
const threshold = summaryTokenThreshold(effective);
|
|
5477
|
+
console.log(chalk.green(`✅ Summary trigger set to ${chalk.white.bold(summaryTriggerPercent() + '%')}`));
|
|
5478
|
+
console.log(chalk.gray(` Auto-summary will start near ${threshold.toLocaleString()} tokens.`));
|
|
5479
|
+
continue;
|
|
5480
|
+
}
|
|
5481
|
+
|
|
5482
|
+
console.log(chalk.yellow(`Unknown summary option: ${subcommand}`));
|
|
5483
|
+
console.log(chalk.gray(' Usage: /summary | /summary phases [on|off] | /summary trigger <percent> | /summary reset'));
|
|
5484
|
+
continue;
|
|
5485
|
+
}
|
|
5486
|
+
|
|
3291
5487
|
if (input.toLowerCase() === '/context') {
|
|
3292
5488
|
const contextSize = JSON.stringify(messages).length;
|
|
3293
5489
|
const estTokens = estimateMessagesTokens(messages);
|
|
3294
5490
|
const ctxLen = effectiveContextLength();
|
|
5491
|
+
const triggerPercent = summaryTriggerPercent();
|
|
5492
|
+
const promptConfig = getPromptConfig();
|
|
3295
5493
|
const contextLines = [
|
|
3296
5494
|
`messages ${chalk.white(String(messages.length))} ${UI.slate('·')} raw ${chalk.white(Math.round(contextSize / 1024) + 'KB')} ${UI.slate('·')} tokens ${chalk.white('~' + estTokens.toLocaleString())}`,
|
|
3297
5495
|
];
|
|
5496
|
+
contextLines.push(`prompt ${chalk.white(hasCustomPromptConfig() ? 'customized' : 'default')} ${UI.slate('·')} ${chalk.white(`prepend ${promptConfig.prepend.trim() ? 'yes' : 'no'}`)} ${UI.slate('·')} ${chalk.white(`append ${promptConfig.append.trim() ? 'yes' : 'no'}`)}`);
|
|
5497
|
+
contextLines.push(`thinking ${chalk.white(thinkingMode())} ${UI.slate('·')} ${UI.slate(thinkingMode() === 'auto' ? 'simple prompts skip reasoning' : thinkingMode() === 'off' ? 'reasoning hidden for all prompts' : 'reasoning enabled for all prompts')}`);
|
|
5498
|
+
contextLines.push(`tools ${chalk.white(`limit ${toolRoundLimit()} rounds`)} ${UI.slate('·')} ${UI.slate('per prompt turn')}`);
|
|
5499
|
+
contextLines.push(`shell ${chalk.white(shellStreamToModelEnabled() ? 'stream on' : 'stream off')} ${UI.slate('·')} ${chalk.white(`bg ${shellBackgroundMode()}`)} ${UI.slate('·')} ${chalk.white(`after ${shellBackgroundAfterSeconds()}s`)} ${UI.slate('·')} ${chalk.white(`${activeShellSessionCount()} active`)}`);
|
|
5500
|
+
contextLines.push(`stream ${chalk.white(streamHeartbeatEnabled() ? 'heartbeat on' : 'heartbeat off')} ${UI.slate('·')} ${chalk.white(streamPhaseStatusEnabled() ? 'phase status on' : 'phase status off')} ${UI.slate('·')} ${chalk.white(`idle ${streamIdleNoticeSeconds()}s`)}`);
|
|
3298
5501
|
if (ctxLen) {
|
|
3299
5502
|
const usagePercent = Math.round((estTokens / ctxLen) * 100);
|
|
3300
|
-
const threshold =
|
|
5503
|
+
const threshold = summaryTokenThreshold(ctxLen);
|
|
3301
5504
|
const limitLabel = sapperConfig.contextLimit
|
|
3302
5505
|
? `${ctxLen.toLocaleString()} tokens ${chalk.cyan('(custom)')}`
|
|
3303
5506
|
: `${ctxLen.toLocaleString()} tokens`;
|
|
3304
5507
|
contextLines.push(`limit ${chalk.white(limitLabel)} ${UI.slate('·')} usage ${chalk.white(usagePercent + '%')}`);
|
|
5508
|
+
contextLines.push(`summary ${chalk.white(`trigger ${triggerPercent}%`)} ${UI.slate('·')} ${chalk.white(summaryPhasesEnabled() ? 'phases on' : 'phases off')}`);
|
|
3305
5509
|
contextLines.push(`${meter(estTokens, ctxLen, 28)} ${UI.slate(`summarize near ${threshold.toLocaleString()} tokens`)}`);
|
|
3306
5510
|
}
|
|
3307
5511
|
if (lastPromptTokens > 0) {
|
|
@@ -3311,6 +5515,38 @@ async function runSapper() {
|
|
|
3311
5515
|
console.log(box(contextLines.join('\n'), 'Context', 'gray'));
|
|
3312
5516
|
continue;
|
|
3313
5517
|
}
|
|
5518
|
+
|
|
5519
|
+
if (input.toLowerCase().startsWith('/shell')) {
|
|
5520
|
+
const arg = input.substring(6).trim();
|
|
5521
|
+
|
|
5522
|
+
if (!arg || ['sessions', 'session', 'list', 'ls', 'status'].includes(arg.toLowerCase())) {
|
|
5523
|
+
console.log();
|
|
5524
|
+
console.log(renderShellSessionsPanel());
|
|
5525
|
+
console.log(UI.slate(' Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
|
|
5526
|
+
continue;
|
|
5527
|
+
}
|
|
5528
|
+
|
|
5529
|
+
const [subcommandRaw, ...rest] = arg.split(/\s+/);
|
|
5530
|
+
const subcommand = subcommandRaw.toLowerCase();
|
|
5531
|
+
const sessionId = rest.join(' ').trim();
|
|
5532
|
+
|
|
5533
|
+
if (['read', 'show', 'tail'].includes(subcommand)) {
|
|
5534
|
+
const result = await handleShellSessionCommand(`__shell_read__ ${sessionId}`);
|
|
5535
|
+
console.log();
|
|
5536
|
+
console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Read', 'cyan'));
|
|
5537
|
+
continue;
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
if (['stop', 'kill', 'end'].includes(subcommand)) {
|
|
5541
|
+
const result = await handleShellSessionCommand(`__shell_stop__ ${sessionId}`);
|
|
5542
|
+
console.log();
|
|
5543
|
+
console.log(box(String(result), sessionId ? `Shell ${sessionId}` : 'Shell Stop', 'red'));
|
|
5544
|
+
continue;
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
console.log(chalk.yellow('Usage: /shell | /shell sessions | /shell read <session_id> | /shell stop <session_id>'));
|
|
5548
|
+
continue;
|
|
5549
|
+
}
|
|
3314
5550
|
|
|
3315
5551
|
// Handle debug mode toggle
|
|
3316
5552
|
if (input.toLowerCase() === '/debug') {
|
|
@@ -3340,14 +5576,14 @@ async function runSapper() {
|
|
|
3340
5576
|
const logFiles = fs.readdirSync(LOGS_DIR).filter(f => f.endsWith('.md')).sort().reverse();
|
|
3341
5577
|
if (logFiles.length > 0) {
|
|
3342
5578
|
console.log(chalk.cyan(`\n📋 All session logs:`));
|
|
3343
|
-
logFiles.slice(0,
|
|
5579
|
+
logFiles.slice(0, LIMITS.LOG_FILES_DISPLAY_MAX).forEach((f, i) => {
|
|
3344
5580
|
const stats = fs.statSync(`${LOGS_DIR}/${f}`);
|
|
3345
5581
|
const isCurrent = f === `session-${sessionId}.md`;
|
|
3346
5582
|
const label = isCurrent ? chalk.green(' ← current') : '';
|
|
3347
5583
|
console.log(chalk.gray(` ${i + 1}. `) + chalk.white(f) + chalk.gray(` (${Math.round(stats.size / 1024)}KB)`) + label);
|
|
3348
5584
|
});
|
|
3349
|
-
if (logFiles.length >
|
|
3350
|
-
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`));
|
|
3351
5587
|
}
|
|
3352
5588
|
}
|
|
3353
5589
|
} catch (e) {}
|
|
@@ -3447,6 +5683,7 @@ async function runSapper() {
|
|
|
3447
5683
|
const agentMd = `---\nname: "${agentTitle}"\ndescription: "${description || agentTitle + ' assistant'}"\ntools: [read, edit, write, list, search, shell]\n---\n\n# ${agentTitle}\n\nYou are a ${agentTitle} AI assistant working within Sapper.\n${description ? `Your role: ${description}\n` : ''}\nAdapt your responses to match this role. Use Sapper's tools (file read/write, shell commands, search) when needed to assist the user.\n`;
|
|
3448
5684
|
|
|
3449
5685
|
fs.writeFileSync(agentFile, agentMd);
|
|
5686
|
+
invalidateLoaderCache('agents');
|
|
3450
5687
|
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
3451
5688
|
console.log(chalk.gray(` File: ${agentFile}`));
|
|
3452
5689
|
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
@@ -3568,13 +5805,14 @@ async function runSapper() {
|
|
|
3568
5805
|
const agentTitle = await safeQuestion(chalk.cyan('Agent title/role: '));
|
|
3569
5806
|
const agentExpertise = await safeQuestion(chalk.cyan('Areas of expertise (comma-separated): '));
|
|
3570
5807
|
const agentStyle = await safeQuestion(chalk.cyan('Communication style (e.g., professional, casual, technical): '));
|
|
3571
|
-
const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,search,shell: '));
|
|
5808
|
+
const agentToolsInput = await safeQuestion(chalk.cyan('Allowed tools (comma-sep, or Enter for all): ') + chalk.gray('read,edit,write,list,ls,search,grep,find,shell,mkdir,rmdir,pwd,cd,cat,head,tail,changes,fetch,memory,open: '));
|
|
3572
5809
|
|
|
3573
5810
|
const expertiseList = agentExpertise.split(',').map(e => `- ${e.trim()}`).join('\n');
|
|
3574
5811
|
const toolsLine = agentToolsInput.trim() ? `tools: [${agentToolsInput.trim()}]` : 'tools: [read, edit, write, list, search, shell]';
|
|
3575
5812
|
const agentMd = `---\nname: "${agentTitle.trim() || agentName}"\ndescription: "${agentExpertise.trim() || agentTitle.trim() || agentName}"\n${toolsLine}\n---\n\n# ${agentTitle.trim() || agentName}\n\nYou are a ${agentTitle.trim() || agentName} AI assistant working within Sapper.\n\n## Your Expertise\n${expertiseList}\n\n## Communication Style\n${agentStyle.trim() || 'Professional and helpful'}.\n\nWhen the user asks for help, leverage your expertise and Sapper's tools to provide comprehensive assistance.\n`;
|
|
3576
5813
|
|
|
3577
5814
|
fs.writeFileSync(agentFile, agentMd);
|
|
5815
|
+
invalidateLoaderCache('agents');
|
|
3578
5816
|
console.log(chalk.green(`\n✅ Agent "${agentName}" created!`));
|
|
3579
5817
|
console.log(chalk.gray(` File: ${agentFile}`));
|
|
3580
5818
|
console.log(chalk.cyan(` Use it: /${agentName} <your prompt>`));
|
|
@@ -3615,6 +5853,7 @@ async function runSapper() {
|
|
|
3615
5853
|
: `---\nname: ${skillTitle.trim() || skillName}\ndescription: "${descLine}"${argHintLine}\n---\n\n# ${skillTitle.trim() || skillName}\n\nBest practices and knowledge for ${skillTitle.trim() || skillName}:\n- [Add your knowledge points here]\n- [Add patterns and conventions]\n- [Add common solutions]\n\n## Commands Reference\n| User says | Action |\n|-----------|--------|\n| "example command" | What the AI should do |\n\n## Procedures\n- [Add step-by-step procedures here]\n`;
|
|
3616
5854
|
|
|
3617
5855
|
fs.writeFileSync(skillFile, skillMd);
|
|
5856
|
+
invalidateLoaderCache('skills');
|
|
3618
5857
|
console.log(chalk.green(`\n✅ Skill "${skillName}" created!`));
|
|
3619
5858
|
console.log(chalk.gray(` File: ${skillFile}`));
|
|
3620
5859
|
console.log(chalk.cyan(` Load it: /use ${skillName}`));
|
|
@@ -3732,7 +5971,7 @@ async function runSapper() {
|
|
|
3732
5971
|
console.log(chalk.green(`Found ${relevant.length} relevant memories:\n`));
|
|
3733
5972
|
relevant.forEach((chunk, i) => {
|
|
3734
5973
|
console.log(box(
|
|
3735
|
-
chalk.gray(chunk.text.substring(0,
|
|
5974
|
+
chalk.gray(chunk.text.substring(0, LIMITS.MEMORY_PREVIEW_CHARS) + '...') + '\n' +
|
|
3736
5975
|
chalk.cyan(`Similarity: ${(chunk.score * 100).toFixed(1)}%`),
|
|
3737
5976
|
`Memory ${i + 1}`, 'magenta'
|
|
3738
5977
|
));
|
|
@@ -3802,12 +6041,12 @@ async function runSapper() {
|
|
|
3802
6041
|
continue;
|
|
3803
6042
|
}
|
|
3804
6043
|
const stats = fs.statSync(filePath);
|
|
3805
|
-
if (stats.size >
|
|
6044
|
+
if (stats.size > getMaxFileSize()) {
|
|
3806
6045
|
console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
|
|
3807
6046
|
console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach ║`));
|
|
3808
6047
|
console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
|
|
3809
6048
|
console.log(chalk.yellow(` File: ${filePath}`));
|
|
3810
|
-
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(
|
|
6049
|
+
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB (limit: ${Math.round(getMaxFileSize()/1024)}KB)`));
|
|
3811
6050
|
console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
|
|
3812
6051
|
continue;
|
|
3813
6052
|
}
|
|
@@ -3831,16 +6070,7 @@ async function runSapper() {
|
|
|
3831
6070
|
}
|
|
3832
6071
|
|
|
3833
6072
|
// Build message with attachments
|
|
3834
|
-
|
|
3835
|
-
attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
|
|
3836
|
-
attachedContent += '══════════════════════════════════════\n\n';
|
|
3837
|
-
|
|
3838
|
-
for (const file of fileAttachments) {
|
|
3839
|
-
attachedContent += `┌─── ${file.path} ───\n`;
|
|
3840
|
-
attachedContent += file.content;
|
|
3841
|
-
if (!file.content.endsWith('\n')) attachedContent += '\n';
|
|
3842
|
-
attachedContent += `└─── END ${file.path} ───\n\n`;
|
|
3843
|
-
}
|
|
6073
|
+
const attachedContent = formatFileAttachments(fileAttachments);
|
|
3844
6074
|
|
|
3845
6075
|
messages.push({ role: 'user', content: prompt + attachedContent });
|
|
3846
6076
|
// Continue to AI response (don't use 'continue' here)
|
|
@@ -3862,11 +6092,11 @@ async function runSapper() {
|
|
|
3862
6092
|
}
|
|
3863
6093
|
const stats = fs.statSync(filePath);
|
|
3864
6094
|
if (stats.isFile()) {
|
|
3865
|
-
if (stats.size >
|
|
6095
|
+
if (stats.size > getMaxFileSize()) {
|
|
3866
6096
|
console.log(chalk.red.bold(`\n╔══════════════════════════════════════════════════════════╗`));
|
|
3867
6097
|
console.log(chalk.red.bold(`║ ⛔ FILE TOO LARGE — Cannot attach @${filePath.padEnd(22).slice(0, 22)}║`));
|
|
3868
6098
|
console.log(chalk.red.bold(`╚══════════════════════════════════════════════════════════╝`));
|
|
3869
|
-
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(
|
|
6099
|
+
console.log(chalk.yellow(` Size: ${Math.round(stats.size/1024)}KB — exceeds ${Math.round(getMaxFileSize()/1024)}KB limit`));
|
|
3870
6100
|
console.log(chalk.gray(` Tip: Use a smaller file or increase limit in .sapper/config.json\n`));
|
|
3871
6101
|
} else {
|
|
3872
6102
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
@@ -3880,7 +6110,7 @@ async function runSapper() {
|
|
|
3880
6110
|
try {
|
|
3881
6111
|
if (!fileAttachments.some(f => f.path === relFile)) {
|
|
3882
6112
|
const relStats = fs.statSync(relFile);
|
|
3883
|
-
if (relStats.size <=
|
|
6113
|
+
if (relStats.size <= getMaxFileSize()) {
|
|
3884
6114
|
const relContent = fs.readFileSync(relFile, 'utf8');
|
|
3885
6115
|
fileAttachments.push({ path: relFile, content: relContent, size: relStats.size, related: true });
|
|
3886
6116
|
console.log(chalk.gray(` ↳ +${relFile} (related)`));
|
|
@@ -3901,16 +6131,7 @@ async function runSapper() {
|
|
|
3901
6131
|
|
|
3902
6132
|
// Build the final message with attachments
|
|
3903
6133
|
if (fileAttachments.length > 0) {
|
|
3904
|
-
|
|
3905
|
-
attachedContent += `📎 ATTACHED FILES (${fileAttachments.length})\n`;
|
|
3906
|
-
attachedContent += '══════════════════════════════════════\n\n';
|
|
3907
|
-
|
|
3908
|
-
for (const file of fileAttachments) {
|
|
3909
|
-
attachedContent += `┌─── ${file.path} ───\n`;
|
|
3910
|
-
attachedContent += file.content;
|
|
3911
|
-
if (!file.content.endsWith('\n')) attachedContent += '\n';
|
|
3912
|
-
attachedContent += `└─── END ${file.path} ───\n\n`;
|
|
3913
|
-
}
|
|
6134
|
+
const attachedContent = formatFileAttachments(fileAttachments);
|
|
3914
6135
|
|
|
3915
6136
|
processedInput = input + attachedContent;
|
|
3916
6137
|
}
|
|
@@ -3981,9 +6202,28 @@ async function runSapper() {
|
|
|
3981
6202
|
} // End of if (!agentHandled)
|
|
3982
6203
|
|
|
3983
6204
|
let toolRounds = 0; // Prevent infinite loops
|
|
3984
|
-
const MAX_TOOL_ROUNDS =
|
|
6205
|
+
const MAX_TOOL_ROUNDS = toolRoundLimit();
|
|
3985
6206
|
const patchFailures = {}; // Track consecutive PATCH failures per file: { path: count }
|
|
3986
|
-
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
|
+
}
|
|
6226
|
+
const turnThinkingEnabled = shouldUseThinkingForInput(input);
|
|
3987
6227
|
|
|
3988
6228
|
let active = true;
|
|
3989
6229
|
while (active) {
|
|
@@ -3998,17 +6238,20 @@ async function runSapper() {
|
|
|
3998
6238
|
if (effectiveContextLength()) {
|
|
3999
6239
|
chatOpts.options = { num_ctx: effectiveContextLength() };
|
|
4000
6240
|
}
|
|
4001
|
-
//
|
|
4002
|
-
chatOpts.think =
|
|
6241
|
+
// Thinking can be forced on, forced off, or auto-disabled for simple prompts.
|
|
6242
|
+
chatOpts.think = turnThinkingEnabled;
|
|
4003
6243
|
if (useNativeTools) {
|
|
4004
6244
|
// Filter tool defs by agent restrictions if any
|
|
4005
6245
|
if (currentAgentTools) {
|
|
4006
6246
|
const toolNameMap = {
|
|
4007
6247
|
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
4008
|
-
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
|
|
6248
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
|
|
6249
|
+
ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
|
|
6250
|
+
pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
|
|
6251
|
+
fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
|
|
4009
6252
|
};
|
|
4010
6253
|
chatOpts.tools = nativeToolDefs.filter(t =>
|
|
4011
|
-
currentAgentTools
|
|
6254
|
+
isToolAllowedForAgent(currentAgentTools, toolNameMap[t.function.name])
|
|
4012
6255
|
);
|
|
4013
6256
|
} else {
|
|
4014
6257
|
chatOpts.tools = nativeToolDefs;
|
|
@@ -4026,7 +6269,7 @@ async function runSapper() {
|
|
|
4026
6269
|
|
|
4027
6270
|
let msg = '';
|
|
4028
6271
|
let thinkMsg = ''; // Thinking/reasoning content from thinking models
|
|
4029
|
-
const MAX_RESPONSE_LENGTH =
|
|
6272
|
+
const MAX_RESPONSE_LENGTH = LIMITS.MAX_RESPONSE_LENGTH;
|
|
4030
6273
|
let lastChunkTime = Date.now();
|
|
4031
6274
|
let repetitionCount = 0;
|
|
4032
6275
|
let lastContent = '';
|
|
@@ -4037,10 +6280,55 @@ async function runSapper() {
|
|
|
4037
6280
|
let chunkPromptTokens = 0; // Track actual tokens from Ollama
|
|
4038
6281
|
let chunkEvalTokens = 0;
|
|
4039
6282
|
let isThinking = false; // Track if we're currently in thinking mode
|
|
6283
|
+
let thinkingContinuationNeedsPrefix = false;
|
|
6284
|
+
let lastThinkingIdleNoticeAt = 0;
|
|
4040
6285
|
const genStartTime = Date.now(); // Track generation elapsed time
|
|
4041
6286
|
let genTokenCount = 0; // Count response tokens as they stream
|
|
6287
|
+
let lastVisibleActivityAt = Date.now();
|
|
6288
|
+
let heartbeatInterval = null;
|
|
4042
6289
|
|
|
4043
|
-
|
|
6290
|
+
const activeAgent = getActiveAgentMeta();
|
|
6291
|
+
const responseTitle = activeAgent ? activeAgent.name || currentAgent : 'Sapper';
|
|
6292
|
+
const responseSubtitleParts = [selectedModel];
|
|
6293
|
+
if (activeAgent && currentAgent) {
|
|
6294
|
+
responseSubtitleParts.push(`/${currentAgent}`);
|
|
6295
|
+
}
|
|
6296
|
+
console.log(sectionTitle(responseTitle, responseSubtitleParts.join(' · '), activeAgent ? 'magenta' : 'cyan'));
|
|
6297
|
+
const responseModeSummary = activeModeSummary({ includeAgent: !activeAgent, maxSkills: 4 });
|
|
6298
|
+
if (responseModeSummary) {
|
|
6299
|
+
console.log(UI.slate(responseModeSummary));
|
|
6300
|
+
}
|
|
6301
|
+
const MAX_THINKING_IDLE_SECONDS = 300; // Abort if model stalls in thinking >5min
|
|
6302
|
+
if (streamHeartbeatEnabled()) {
|
|
6303
|
+
heartbeatInterval = setInterval(() => {
|
|
6304
|
+
if (abortStream) return;
|
|
6305
|
+
|
|
6306
|
+
if (isThinking) {
|
|
6307
|
+
const idleSeconds = Math.max(0, Math.floor((Date.now() - lastVisibleActivityAt) / 1000));
|
|
6308
|
+
const idleThreshold = streamIdleNoticeSeconds();
|
|
6309
|
+
if (idleSeconds >= MAX_THINKING_IDLE_SECONDS) {
|
|
6310
|
+
process.stdout.write(`\n${UI.slate(' │ ')}${chalk.yellow(`⚠ thinking stalled ${idleSeconds}s — aborting stream`)}\n`);
|
|
6311
|
+
abortStream = true;
|
|
6312
|
+
return;
|
|
6313
|
+
}
|
|
6314
|
+
if (idleSeconds >= idleThreshold && Date.now() - lastThinkingIdleNoticeAt >= 5000) {
|
|
6315
|
+
process.stdout.write(`\n${UI.slate(' │ ')}${UI.slate.italic(`... waiting ${idleSeconds}s for more reasoning`)}\n`);
|
|
6316
|
+
thinkingContinuationNeedsPrefix = true;
|
|
6317
|
+
lastThinkingIdleNoticeAt = Date.now();
|
|
6318
|
+
}
|
|
6319
|
+
return;
|
|
6320
|
+
}
|
|
6321
|
+
|
|
6322
|
+
renderStreamingHeartbeat({
|
|
6323
|
+
genTokenCount,
|
|
6324
|
+
genStartTime,
|
|
6325
|
+
lastVisibleActivityAt,
|
|
6326
|
+
stage: genTokenCount > 0 ? 'generating' : 'waiting-first',
|
|
6327
|
+
});
|
|
6328
|
+
}, 1000);
|
|
6329
|
+
}
|
|
6330
|
+
let streamErrored = null;
|
|
6331
|
+
try {
|
|
4044
6332
|
for await (const chunk of response) {
|
|
4045
6333
|
// Check if user pressed Ctrl+C
|
|
4046
6334
|
if (abortStream) {
|
|
@@ -4059,10 +6347,13 @@ async function runSapper() {
|
|
|
4059
6347
|
// Live-stream thinking — dim italic, wrap at line breaks
|
|
4060
6348
|
const lines = thinking.split('\n');
|
|
4061
6349
|
for (let li = 0; li < lines.length; li++) {
|
|
4062
|
-
if (li > 0) process.stdout.write(`\n${UI.slate(' │ ')}`);
|
|
6350
|
+
if (li > 0 || thinkingContinuationNeedsPrefix) process.stdout.write(`\n${UI.slate(' │ ')}`);
|
|
6351
|
+
thinkingContinuationNeedsPrefix = false;
|
|
4063
6352
|
process.stdout.write(UI.slate.italic(lines[li]));
|
|
4064
6353
|
}
|
|
4065
6354
|
thinkMsg += thinking;
|
|
6355
|
+
lastVisibleActivityAt = Date.now();
|
|
6356
|
+
lastThinkingIdleNoticeAt = 0;
|
|
4066
6357
|
}
|
|
4067
6358
|
|
|
4068
6359
|
const content = chunk.message.content;
|
|
@@ -4073,10 +6364,13 @@ async function runSapper() {
|
|
|
4073
6364
|
}
|
|
4074
6365
|
msg += content;
|
|
4075
6366
|
genTokenCount++;
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
6367
|
+
lastVisibleActivityAt = Date.now();
|
|
6368
|
+
renderStreamingHeartbeat({
|
|
6369
|
+
genTokenCount,
|
|
6370
|
+
genStartTime,
|
|
6371
|
+
lastVisibleActivityAt,
|
|
6372
|
+
stage: 'generating',
|
|
6373
|
+
});
|
|
4080
6374
|
}
|
|
4081
6375
|
|
|
4082
6376
|
// Capture token stats from the final chunk (done: true)
|
|
@@ -4089,14 +6383,14 @@ async function runSapper() {
|
|
|
4089
6383
|
}
|
|
4090
6384
|
|
|
4091
6385
|
// Smart loop detection: check for repetitive content patterns
|
|
4092
|
-
if (msg.length >
|
|
4093
|
-
const recentContent = msg.slice(-
|
|
4094
|
-
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);
|
|
4095
6389
|
|
|
4096
6390
|
// If last 500 chars are very similar to previous 500, might be looping
|
|
4097
6391
|
if (recentContent === previousContent) {
|
|
4098
6392
|
repetitionCount++;
|
|
4099
|
-
if (repetitionCount >
|
|
6393
|
+
if (repetitionCount > LIMITS.REPETITION_COUNT) {
|
|
4100
6394
|
console.log(chalk.red('\n\n⚠️ REPETITIVE OUTPUT DETECTED: Stopping to prevent loop.'));
|
|
4101
6395
|
wasRepetitionStopped = true;
|
|
4102
6396
|
break;
|
|
@@ -4112,9 +6406,34 @@ async function runSapper() {
|
|
|
4112
6406
|
// Don't break - just warn. User can Ctrl+C if needed
|
|
4113
6407
|
}
|
|
4114
6408
|
}
|
|
6409
|
+
} catch (streamErr) {
|
|
6410
|
+
streamErrored = streamErr;
|
|
6411
|
+
} finally {
|
|
6412
|
+
if (heartbeatInterval) {
|
|
6413
|
+
clearInterval(heartbeatInterval);
|
|
6414
|
+
heartbeatInterval = null;
|
|
6415
|
+
}
|
|
6416
|
+
}
|
|
6417
|
+
if (streamErrored) {
|
|
6418
|
+
if (isThinking) {
|
|
6419
|
+
isThinking = false;
|
|
6420
|
+
process.stdout.write(`\n${UI.slate(' └─')}\n`);
|
|
6421
|
+
}
|
|
6422
|
+
process.stdout.write('\r\x1b[K');
|
|
6423
|
+
console.error(chalk.red(`\n❌ Stream error: ${streamErrored.message || streamErrored}`));
|
|
6424
|
+
logEntry('error', { message: `Stream error: ${streamErrored.message || streamErrored}` });
|
|
6425
|
+
active = false;
|
|
6426
|
+
continue;
|
|
6427
|
+
}
|
|
6428
|
+
if (isThinking) {
|
|
6429
|
+
isThinking = false;
|
|
6430
|
+
process.stdout.write(`\n${UI.slate(' └─')}\n`);
|
|
6431
|
+
}
|
|
4115
6432
|
// Clear progress line and render formatted markdown
|
|
4116
6433
|
process.stdout.write('\r\x1b[K');
|
|
6434
|
+
showStreamPhase('Finalizing streamed response...');
|
|
4117
6435
|
if (msg.trim()) {
|
|
6436
|
+
showStreamPhase('Rendering markdown output...');
|
|
4118
6437
|
console.log(renderMarkdown(msg));
|
|
4119
6438
|
} else {
|
|
4120
6439
|
console.log();
|
|
@@ -4176,22 +6495,27 @@ async function runSapper() {
|
|
|
4176
6495
|
// Map native function names to tool executors
|
|
4177
6496
|
const nativeToolNameMap = {
|
|
4178
6497
|
list_directory: 'LIST', read_file: 'READ', search_files: 'SEARCH',
|
|
4179
|
-
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
|
|
6498
|
+
write_file: 'WRITE', patch_file: 'PATCH', create_directory: 'MKDIR',
|
|
6499
|
+
ls: 'LS', cat: 'CAT', head: 'HEAD', tail: 'TAIL', grep: 'GREP', find: 'FIND',
|
|
6500
|
+
pwd: 'PWD', cd: 'CD', rmdir: 'RMDIR', changes: 'CHANGES',
|
|
6501
|
+
fetch_web: 'FETCH', recall_memory: 'MEMORY', open_url: 'OPEN', run_shell: 'SHELL'
|
|
4180
6502
|
};
|
|
4181
6503
|
|
|
6504
|
+
showStreamPhase(`Running ${nativeToolCalls.length} native tool call${nativeToolCalls.length === 1 ? '' : 's'}...`);
|
|
6505
|
+
|
|
4182
6506
|
for (const tc of nativeToolCalls) {
|
|
4183
6507
|
const fn = tc.function;
|
|
4184
6508
|
const toolType = nativeToolNameMap[fn.name] || fn.name.toUpperCase();
|
|
4185
6509
|
const args = fn.arguments || {};
|
|
4186
6510
|
|
|
4187
6511
|
// Enforce agent tool restrictions
|
|
4188
|
-
if (currentAgentTools && !currentAgentTools
|
|
6512
|
+
if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, toolType)) {
|
|
4189
6513
|
console.log(chalk.yellow(`\n⚠️ Tool ${toolType} blocked — not in agent's allowed tools`));
|
|
4190
6514
|
messages.push({ role: 'tool', content: `Error: Tool ${toolType} is not allowed for the current agent.`, tool_name: fn.name });
|
|
4191
6515
|
continue;
|
|
4192
6516
|
}
|
|
4193
6517
|
|
|
4194
|
-
const displayPath = args.path || args.pattern || args.command || '';
|
|
6518
|
+
const displayPath = args.path || args.pattern || args.url || args.query || args.command || '';
|
|
4195
6519
|
console.log();
|
|
4196
6520
|
console.log(statusBadge(toolType, 'action') + chalk.gray(' → ') + chalk.white(displayPath));
|
|
4197
6521
|
|
|
@@ -4202,8 +6526,8 @@ async function runSapper() {
|
|
|
4202
6526
|
try {
|
|
4203
6527
|
switch (fn.name) {
|
|
4204
6528
|
case 'list_directory':
|
|
4205
|
-
result = tools.list(args.path);
|
|
4206
|
-
logEntry('file', { action: 'list', path: args.path });
|
|
6529
|
+
result = tools.list(args.path ?? '.');
|
|
6530
|
+
logEntry('file', { action: 'list', path: args.path ?? '.' });
|
|
4207
6531
|
break;
|
|
4208
6532
|
case 'read_file':
|
|
4209
6533
|
result = tools.read(args.path);
|
|
@@ -4218,26 +6542,66 @@ async function runSapper() {
|
|
|
4218
6542
|
logEntry('file', { action: 'write', path: args.path, size: args.content?.length || 0, userApproved: result.includes('Successfully') });
|
|
4219
6543
|
break;
|
|
4220
6544
|
case 'patch_file': {
|
|
4221
|
-
const
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
} else {
|
|
4226
|
-
result = await tools.patch(args.path, args.old_text, args.new_text);
|
|
4227
|
-
if (result.includes('Successfully')) {
|
|
4228
|
-
patchFailures[patchKey] = 0;
|
|
4229
|
-
} else if (result.startsWith('Error:')) {
|
|
4230
|
-
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
4231
|
-
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES})`;
|
|
4232
|
-
}
|
|
4233
|
-
}
|
|
4234
|
-
logEntry('file', { action: 'patch', path: args.path, userApproved: result.includes('Successfully') });
|
|
6545
|
+
const pr = await patchWithRetry(args.path, args.old_text, args.new_text);
|
|
6546
|
+
result = pr.result;
|
|
6547
|
+
toolSuccess = pr.success;
|
|
6548
|
+
logEntry('file', { action: 'patch', path: args.path, userApproved: pr.success });
|
|
4235
6549
|
break;
|
|
4236
6550
|
}
|
|
4237
6551
|
case 'create_directory':
|
|
4238
6552
|
result = tools.mkdir(args.path);
|
|
4239
6553
|
logEntry('file', { action: 'mkdir', path: args.path });
|
|
4240
6554
|
break;
|
|
6555
|
+
case 'ls':
|
|
6556
|
+
result = tools.ls(args.path ?? '.');
|
|
6557
|
+
logEntry('file', { action: 'list', path: args.path ?? '.' });
|
|
6558
|
+
break;
|
|
6559
|
+
case 'cat':
|
|
6560
|
+
result = tools.cat(args.path);
|
|
6561
|
+
logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
|
|
6562
|
+
break;
|
|
6563
|
+
case 'head':
|
|
6564
|
+
result = tools.head(args.path, args.lines);
|
|
6565
|
+
logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
|
|
6566
|
+
break;
|
|
6567
|
+
case 'tail':
|
|
6568
|
+
result = tools.tail(args.path, args.lines);
|
|
6569
|
+
logEntry('file', { action: 'read', path: args.path, size: result?.length || 0 });
|
|
6570
|
+
break;
|
|
6571
|
+
case 'grep':
|
|
6572
|
+
result = await tools.grep(args.pattern);
|
|
6573
|
+
logEntry('tool', { toolType: 'GREP', path: args.pattern, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
6574
|
+
break;
|
|
6575
|
+
case 'find':
|
|
6576
|
+
result = tools.find(args.pattern, args.path ?? '.');
|
|
6577
|
+
logEntry('tool', { toolType: 'FIND', path: args.pattern, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6578
|
+
break;
|
|
6579
|
+
case 'pwd':
|
|
6580
|
+
result = tools.pwd();
|
|
6581
|
+
break;
|
|
6582
|
+
case 'cd':
|
|
6583
|
+
result = tools.cd(args.path);
|
|
6584
|
+
break;
|
|
6585
|
+
case 'rmdir':
|
|
6586
|
+
result = await tools.rmdir(args.path);
|
|
6587
|
+
logEntry('file', { action: 'rmdir', path: args.path, userApproved: !String(result).includes('blocked') });
|
|
6588
|
+
break;
|
|
6589
|
+
case 'changes':
|
|
6590
|
+
result = await tools.changes(args.path);
|
|
6591
|
+
logEntry('tool', { toolType: 'CHANGES', path: args.path ?? '.', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6592
|
+
break;
|
|
6593
|
+
case 'fetch_web':
|
|
6594
|
+
result = await tools.fetch_web(args.url);
|
|
6595
|
+
logEntry('tool', { toolType: 'FETCH', path: args.url, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6596
|
+
break;
|
|
6597
|
+
case 'recall_memory':
|
|
6598
|
+
result = await tools.recall_memory(args.query);
|
|
6599
|
+
logEntry('tool', { toolType: 'MEMORY', path: args.query, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6600
|
+
break;
|
|
6601
|
+
case 'open_url':
|
|
6602
|
+
result = await tools.open_url(args.url);
|
|
6603
|
+
logEntry('tool', { toolType: 'OPEN', path: args.url, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
|
|
6604
|
+
break;
|
|
4241
6605
|
case 'run_shell':
|
|
4242
6606
|
result = await tools.shell(args.command);
|
|
4243
6607
|
logEntry('shell', { command: args.command, duration: Date.now() - toolStart, userApproved: !result.includes('blocked'), exitCode: result.match(/code (\d+)/)?.[1] ?? null });
|
|
@@ -4261,8 +6625,11 @@ async function runSapper() {
|
|
|
4261
6625
|
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
4262
6626
|
|
|
4263
6627
|
if (hitToolLimit) {
|
|
6628
|
+
showStreamPhase('Tool limit reached. Requesting final answer...');
|
|
4264
6629
|
resetTerminal();
|
|
4265
6630
|
messages.push({ role: 'user', content: 'STOP using tools now. Provide your analysis based on what you have.' });
|
|
6631
|
+
} else {
|
|
6632
|
+
showStreamPhase('Tool results ready. Continuing response generation...');
|
|
4266
6633
|
}
|
|
4267
6634
|
continue; // Loop back for AI to process tool results
|
|
4268
6635
|
}
|
|
@@ -4308,7 +6675,7 @@ async function runSapper() {
|
|
|
4308
6675
|
const toolAttempt = msg.match(/\[TOOL:[^\]]*\][^\[]{0,100}/s);
|
|
4309
6676
|
if (toolAttempt) {
|
|
4310
6677
|
console.log(chalk.yellow(` Raw tool attempt (first 150 chars):`));
|
|
4311
|
-
console.log(chalk.gray(` "${toolAttempt[0].substring(0,
|
|
6678
|
+
console.log(chalk.gray(` "${toolAttempt[0].substring(0, LIMITS.DEBUG_TOOL_PREVIEW)}..."`));
|
|
4312
6679
|
}
|
|
4313
6680
|
}
|
|
4314
6681
|
console.log(chalk.magenta('═══════════════════════════════\n'));
|
|
@@ -4331,11 +6698,13 @@ async function runSapper() {
|
|
|
4331
6698
|
if (lastAiLog) lastAiLog.toolCount = toolMatches.length;
|
|
4332
6699
|
}
|
|
4333
6700
|
|
|
6701
|
+
showStreamPhase(`Running ${toolMatches.length} parsed tool call${toolMatches.length === 1 ? '' : 's'}...`);
|
|
6702
|
+
|
|
4334
6703
|
for (const match of toolMatches) {
|
|
4335
6704
|
const [_, type, path, content] = match;
|
|
4336
6705
|
|
|
4337
6706
|
// Enforce tool restrictions from active agent
|
|
4338
|
-
if (currentAgentTools && !currentAgentTools
|
|
6707
|
+
if (currentAgentTools && !isToolAllowedForAgent(currentAgentTools, type)) {
|
|
4339
6708
|
console.log();
|
|
4340
6709
|
console.log(chalk.yellow(`⚠️ Tool ${type.toUpperCase()} blocked — not in agent's allowed tools: [${currentAgentTools.join(', ')}]`));
|
|
4341
6710
|
const result = `Error: Tool ${type.toUpperCase()} is not allowed for the current agent. Allowed tools: ${currentAgentTools.join(', ')}. Use only the allowed tools.`;
|
|
@@ -4354,14 +6723,40 @@ async function runSapper() {
|
|
|
4354
6723
|
result = tools.list(path);
|
|
4355
6724
|
logEntry('file', { action: 'list', path });
|
|
4356
6725
|
}
|
|
6726
|
+
else if (type.toLowerCase() === 'ls') {
|
|
6727
|
+
result = tools.ls(path);
|
|
6728
|
+
logEntry('file', { action: 'list', path: path || '.' });
|
|
6729
|
+
}
|
|
4357
6730
|
else if (type.toLowerCase() === 'read') {
|
|
4358
6731
|
result = tools.read(path);
|
|
4359
6732
|
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
4360
6733
|
}
|
|
6734
|
+
else if (type.toLowerCase() === 'cat') {
|
|
6735
|
+
result = tools.cat(path);
|
|
6736
|
+
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
6737
|
+
}
|
|
6738
|
+
else if (type.toLowerCase() === 'head') {
|
|
6739
|
+
result = tools.head(path, content);
|
|
6740
|
+
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
6741
|
+
}
|
|
6742
|
+
else if (type.toLowerCase() === 'tail') {
|
|
6743
|
+
result = tools.tail(path, content);
|
|
6744
|
+
logEntry('file', { action: 'read', path, size: result?.length || 0 });
|
|
6745
|
+
}
|
|
4361
6746
|
else if (type.toLowerCase() === 'mkdir') {
|
|
4362
6747
|
result = tools.mkdir(path);
|
|
4363
6748
|
logEntry('file', { action: 'mkdir', path });
|
|
4364
6749
|
}
|
|
6750
|
+
else if (type.toLowerCase() === 'rmdir') {
|
|
6751
|
+
result = await tools.rmdir(path);
|
|
6752
|
+
logEntry('file', { action: 'rmdir', path, userApproved: !String(result).includes('blocked') });
|
|
6753
|
+
}
|
|
6754
|
+
else if (type.toLowerCase() === 'pwd') {
|
|
6755
|
+
result = tools.pwd();
|
|
6756
|
+
}
|
|
6757
|
+
else if (type.toLowerCase() === 'cd') {
|
|
6758
|
+
result = tools.cd(path);
|
|
6759
|
+
}
|
|
4365
6760
|
else if (type.toLowerCase() === 'write') {
|
|
4366
6761
|
if (!content || content.trim() === '') {
|
|
4367
6762
|
result = 'Error: WRITE requires content. Use [TOOL:WRITE]path]content here[/TOOL]';
|
|
@@ -4375,37 +6770,55 @@ async function runSapper() {
|
|
|
4375
6770
|
else if (type.toLowerCase() === 'patch') {
|
|
4376
6771
|
// PATCH format: [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL]
|
|
4377
6772
|
// Also supports line mode: [TOOL:PATCH]path:::LINE:15|||new text[/TOOL]
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
6773
|
+
// Accept ||| as primary separator, ||: as fallback (small models sometimes mistype)
|
|
6774
|
+
let parts = null;
|
|
6775
|
+
const sepIdx = content?.indexOf('|||');
|
|
6776
|
+
if (sepIdx > -1) {
|
|
6777
|
+
parts = [content.substring(0, sepIdx), content.substring(sepIdx + 3)];
|
|
4383
6778
|
} else {
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
4387
|
-
parts = content?.split('||:');
|
|
4388
|
-
}
|
|
4389
|
-
if (parts && parts.length === 2) {
|
|
4390
|
-
result = await tools.patch(path, parts[0], parts[1]);
|
|
4391
|
-
const approved = result.includes('Successfully');
|
|
4392
|
-
if (!approved && result.startsWith('Error:')) {
|
|
4393
|
-
patchFailures[patchKey] = (patchFailures[patchKey] || 0) + 1;
|
|
4394
|
-
result += `\n(Attempt ${patchFailures[patchKey]}/${MAX_PATCH_RETRIES} — after ${MAX_PATCH_RETRIES} failures, PATCH will be blocked on this file)`;
|
|
4395
|
-
} else if (approved) {
|
|
4396
|
-
patchFailures[patchKey] = 0; // Reset on success
|
|
4397
|
-
}
|
|
4398
|
-
logEntry('file', { action: 'patch', path, userApproved: approved });
|
|
4399
|
-
} else {
|
|
4400
|
-
result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
|
|
4401
|
-
toolSuccess = false;
|
|
6779
|
+
const sepIdx2 = content?.indexOf('||:');
|
|
6780
|
+
if (sepIdx2 > -1) {
|
|
6781
|
+
parts = [content.substring(0, sepIdx2), content.substring(sepIdx2 + 3)];
|
|
4402
6782
|
}
|
|
4403
6783
|
}
|
|
6784
|
+
if (parts && parts.length === 2) {
|
|
6785
|
+
const pr = await patchWithRetry(path, parts[0], parts[1]);
|
|
6786
|
+
result = pr.result;
|
|
6787
|
+
toolSuccess = pr.success;
|
|
6788
|
+
logEntry('file', { action: 'patch', path, userApproved: pr.success });
|
|
6789
|
+
} else {
|
|
6790
|
+
result = 'Error: PATCH requires format [TOOL:PATCH]path:::OLD_TEXT|||NEW_TEXT[/TOOL] or [TOOL:PATCH]path:::LINE:number|||NEW_TEXT[/TOOL]';
|
|
6791
|
+
toolSuccess = false;
|
|
6792
|
+
}
|
|
4404
6793
|
}
|
|
4405
6794
|
else if (type.toLowerCase() === 'search') {
|
|
4406
6795
|
result = await tools.search(path);
|
|
4407
6796
|
logEntry('tool', { toolType: 'SEARCH', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
4408
6797
|
}
|
|
6798
|
+
else if (type.toLowerCase() === 'grep') {
|
|
6799
|
+
result = await tools.grep(path);
|
|
6800
|
+
logEntry('tool', { toolType: 'GREP', path, duration: Date.now() - toolStart, success: true, resultSize: result?.length });
|
|
6801
|
+
}
|
|
6802
|
+
else if (type.toLowerCase() === 'find') {
|
|
6803
|
+
result = tools.find(path, content);
|
|
6804
|
+
logEntry('tool', { toolType: 'FIND', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6805
|
+
}
|
|
6806
|
+
else if (type.toLowerCase() === 'changes') {
|
|
6807
|
+
result = await tools.changes(path);
|
|
6808
|
+
logEntry('tool', { toolType: 'CHANGES', path: path || '.', duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6809
|
+
}
|
|
6810
|
+
else if (type.toLowerCase() === 'fetch') {
|
|
6811
|
+
result = await tools.fetch_web(path);
|
|
6812
|
+
logEntry('tool', { toolType: 'FETCH', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6813
|
+
}
|
|
6814
|
+
else if (type.toLowerCase() === 'memory') {
|
|
6815
|
+
result = await tools.recall_memory(path);
|
|
6816
|
+
logEntry('tool', { toolType: 'MEMORY', path, duration: Date.now() - toolStart, success: !String(result).startsWith('Error:'), resultSize: result?.length });
|
|
6817
|
+
}
|
|
6818
|
+
else if (type.toLowerCase() === 'open') {
|
|
6819
|
+
result = await tools.open_url(path);
|
|
6820
|
+
logEntry('tool', { toolType: 'OPEN', path, duration: Date.now() - toolStart, success: String(result).startsWith('Opened URL'), resultSize: result?.length });
|
|
6821
|
+
}
|
|
4409
6822
|
else if (type.toLowerCase() === 'shell') {
|
|
4410
6823
|
result = await tools.shell(path);
|
|
4411
6824
|
const approved = !result.includes('blocked');
|
|
@@ -4413,7 +6826,7 @@ async function runSapper() {
|
|
|
4413
6826
|
}
|
|
4414
6827
|
|
|
4415
6828
|
// Log tool execution (for non-shell, non-file specific ones)
|
|
4416
|
-
if (!['list', 'read', 'mkdir', 'write', 'patch', 'search', 'shell'].includes(type.toLowerCase())) {
|
|
6829
|
+
if (!['list', 'ls', 'read', 'cat', 'head', 'tail', 'mkdir', 'rmdir', 'pwd', 'cd', 'write', 'patch', 'search', 'grep', 'find', 'changes', 'fetch', 'memory', 'open', 'shell'].includes(type.toLowerCase())) {
|
|
4417
6830
|
logEntry('tool', { toolType: type.toUpperCase(), path, duration: Date.now() - toolStart, success: toolSuccess, resultSize: result?.length, error: toolSuccess ? undefined : result });
|
|
4418
6831
|
}
|
|
4419
6832
|
|
|
@@ -4422,17 +6835,20 @@ async function runSapper() {
|
|
|
4422
6835
|
ensureSapperDir();
|
|
4423
6836
|
fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
|
|
4424
6837
|
|
|
4425
|
-
if (toolMatches.length >
|
|
6838
|
+
if (toolMatches.length > LIMITS.TOOL_WARN_THRESHOLD) {
|
|
4426
6839
|
console.log(chalk.yellow('\n⚠️ Reading 30+ files! This might take time.'));
|
|
4427
6840
|
}
|
|
4428
6841
|
|
|
4429
6842
|
// If tool limit was reached, stop after processing this round
|
|
4430
6843
|
if (hitToolLimit) {
|
|
6844
|
+
showStreamPhase('Tool limit reached. Requesting final answer...');
|
|
4431
6845
|
resetTerminal();
|
|
4432
6846
|
messages.push({
|
|
4433
6847
|
role: 'user',
|
|
4434
6848
|
content: 'STOP using tools now. You have enough information. Please provide your analysis based on what you have read.'
|
|
4435
6849
|
});
|
|
6850
|
+
} else {
|
|
6851
|
+
showStreamPhase('Tool results ready. Continuing response generation...');
|
|
4436
6852
|
}
|
|
4437
6853
|
} else {
|
|
4438
6854
|
// No tools found - check if malformed command
|