@kirosnn/mosaic 0.0.7
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/.mosaic/mosaic.local.jsonc +0 -0
- package/MOSAIC.md +188 -0
- package/README.md +127 -0
- package/docs/mosaic.png +0 -0
- package/package.json +42 -0
- package/src/agent/Agent.ts +131 -0
- package/src/agent/context.ts +96 -0
- package/src/agent/index.ts +2 -0
- package/src/agent/prompts/systemPrompt.ts +138 -0
- package/src/agent/prompts/toolsPrompt.ts +139 -0
- package/src/agent/provider/anthropic.ts +122 -0
- package/src/agent/provider/google.ts +124 -0
- package/src/agent/provider/mistral.ts +117 -0
- package/src/agent/provider/ollama.ts +531 -0
- package/src/agent/provider/openai.ts +220 -0
- package/src/agent/provider/xai.ts +122 -0
- package/src/agent/tools/bash.ts +20 -0
- package/src/agent/tools/definitions.ts +27 -0
- package/src/agent/tools/edit.ts +23 -0
- package/src/agent/tools/executor.ts +751 -0
- package/src/agent/tools/explore.ts +18 -0
- package/src/agent/tools/exploreExecutor.ts +320 -0
- package/src/agent/tools/glob.ts +16 -0
- package/src/agent/tools/grep.ts +19 -0
- package/src/agent/tools/index.ts +4 -0
- package/src/agent/tools/list.ts +20 -0
- package/src/agent/tools/question.ts +20 -0
- package/src/agent/tools/read.ts +15 -0
- package/src/agent/tools/write.ts +21 -0
- package/src/agent/types.ts +155 -0
- package/src/components/App.tsx +174 -0
- package/src/components/CommandsModal.tsx +77 -0
- package/src/components/CustomInput.tsx +328 -0
- package/src/components/Main.tsx +1112 -0
- package/src/components/Notification.tsx +91 -0
- package/src/components/SelectList.tsx +47 -0
- package/src/components/Setup.tsx +528 -0
- package/src/components/ShortcutsModal.tsx +67 -0
- package/src/components/Welcome.tsx +39 -0
- package/src/components/main/ApprovalPanel.tsx +134 -0
- package/src/components/main/ChatPage.tsx +516 -0
- package/src/components/main/HomePage.tsx +111 -0
- package/src/components/main/QuestionPanel.tsx +85 -0
- package/src/components/main/ThinkingIndicator.tsx +101 -0
- package/src/components/main/types.ts +55 -0
- package/src/components/main/wrapText.ts +41 -0
- package/src/index.tsx +212 -0
- package/src/utils/approvalBridge.ts +129 -0
- package/src/utils/commands/echo.ts +22 -0
- package/src/utils/commands/help.ts +25 -0
- package/src/utils/commands/index.ts +68 -0
- package/src/utils/commands/init.ts +68 -0
- package/src/utils/commands/redo.ts +74 -0
- package/src/utils/commands/registry.ts +29 -0
- package/src/utils/commands/sessions.ts +129 -0
- package/src/utils/commands/types.ts +20 -0
- package/src/utils/commands/undo.ts +75 -0
- package/src/utils/commands/web.ts +77 -0
- package/src/utils/config.ts +357 -0
- package/src/utils/diff.ts +201 -0
- package/src/utils/diffRendering.tsx +62 -0
- package/src/utils/exploreBridge.ts +87 -0
- package/src/utils/fileChangeTracker.ts +98 -0
- package/src/utils/fileChangesBridge.ts +18 -0
- package/src/utils/history.ts +106 -0
- package/src/utils/markdown.tsx +232 -0
- package/src/utils/models.ts +304 -0
- package/src/utils/questionBridge.ts +122 -0
- package/src/utils/terminalUtils.ts +25 -0
- package/src/utils/toolFormatting.ts +384 -0
- package/src/utils/undoRedo.ts +429 -0
- package/src/utils/undoRedoBridge.ts +45 -0
- package/src/utils/undoRedoDb.ts +338 -0
- package/src/utils/uninstall.ts +45 -0
- package/src/utils/version.ts +3 -0
- package/src/web/app.tsx +606 -0
- package/src/web/assets/css/ChatPage.css +212 -0
- package/src/web/assets/css/FileExplorer.css +202 -0
- package/src/web/assets/css/HomePage.css +119 -0
- package/src/web/assets/css/Markdown.css +178 -0
- package/src/web/assets/css/MessageItem.css +160 -0
- package/src/web/assets/css/Sidebar.css +208 -0
- package/src/web/assets/css/SidebarModal.css +137 -0
- package/src/web/assets/css/ThinkingIndicator.css +47 -0
- package/src/web/assets/css/ToolMessage.css +148 -0
- package/src/web/assets/css/global.css +226 -0
- package/src/web/assets/fonts/Geist-Black.woff2 +0 -0
- package/src/web/assets/fonts/Geist-BlackItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-Bold.woff2 +0 -0
- package/src/web/assets/fonts/Geist-BoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-ExtraBold.woff2 +0 -0
- package/src/web/assets/fonts/Geist-ExtraBoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-ExtraLight.woff2 +0 -0
- package/src/web/assets/fonts/Geist-ExtraLightItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-Italic[wght].woff2 +0 -0
- package/src/web/assets/fonts/Geist-Light.woff2 +0 -0
- package/src/web/assets/fonts/Geist-LightItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-Medium.woff2 +0 -0
- package/src/web/assets/fonts/Geist-MediumItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-Regular.woff2 +0 -0
- package/src/web/assets/fonts/Geist-RegularItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-SemiBold.woff2 +0 -0
- package/src/web/assets/fonts/Geist-SemiBoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/Geist-Thin.woff2 +0 -0
- package/src/web/assets/fonts/Geist-ThinItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Black.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-BlackItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Bold.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-BoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-ExtraBold.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-ExtraBoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-ExtraLight.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-ExtraLightItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Italic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Italic[wght].woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Light.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-LightItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Medium.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-MediumItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Regular.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-SemiBold.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-SemiBoldItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-Thin.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono-ThinItalic.woff2 +0 -0
- package/src/web/assets/fonts/GeistMono[wght].woff2 +0 -0
- package/src/web/assets/fonts/Geist[wght].woff2 +0 -0
- package/src/web/assets/fonts/blauer-nue-regular.woff2 +0 -0
- package/src/web/assets/fonts/neue-montreal-regular.woff2 +0 -0
- package/src/web/assets/images/favicon-v2.svg +6 -0
- package/src/web/assets/images/favicon.png +0 -0
- package/src/web/assets/images/foruse.svg +5 -0
- package/src/web/assets/images/logo_black.svg +5 -0
- package/src/web/assets/images/logo_white.svg +5 -0
- package/src/web/assets/images/logoblack.png +0 -0
- package/src/web/assets/images/logowhite.png +0 -0
- package/src/web/build.ts +23 -0
- package/src/web/components/ApprovalPanel.tsx +191 -0
- package/src/web/components/ChatPage.tsx +273 -0
- package/src/web/components/FileExplorer.tsx +162 -0
- package/src/web/components/HomePage.tsx +121 -0
- package/src/web/components/MessageItem.tsx +178 -0
- package/src/web/components/Modal.tsx +30 -0
- package/src/web/components/QuestionPanel.tsx +149 -0
- package/src/web/components/Setup.tsx +211 -0
- package/src/web/components/Sidebar.tsx +292 -0
- package/src/web/components/ThinkingIndicator.tsx +85 -0
- package/src/web/logo_black.svg +5 -0
- package/src/web/logo_white.svg +5 -0
- package/src/web/router.ts +46 -0
- package/src/web/server.tsx +662 -0
- package/src/web/storage.ts +92 -0
- package/src/web/types.ts +17 -0
- package/src/web/utils.ts +61 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, appendFile, stat, mkdir } from 'fs/promises';
|
|
2
|
+
import { join, resolve, dirname, extname, sep } from 'path';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import { requestApproval } from '../../utils/approvalBridge';
|
|
6
|
+
import { shouldRequireApprovals } from '../../utils/config';
|
|
7
|
+
import { generateDiff, formatDiffForDisplay } from '../../utils/diff';
|
|
8
|
+
import { captureFileSnapshot } from '../../utils/undoRedo';
|
|
9
|
+
import { trackFileChange, trackFileCreated, trackFileDeleted } from '../../utils/fileChangeTracker';
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
export interface ToolResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
result?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
userMessage?: string;
|
|
18
|
+
diff?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pathValidationCache = new Map<string, boolean>();
|
|
22
|
+
const globPatternCache = new Map<string, RegExp>();
|
|
23
|
+
|
|
24
|
+
function validatePath(fullPath: string, workspace: string): boolean {
|
|
25
|
+
const cacheKey = `${fullPath}|${workspace}`;
|
|
26
|
+
const cached = pathValidationCache.get(cacheKey);
|
|
27
|
+
if (cached !== undefined) return cached;
|
|
28
|
+
|
|
29
|
+
const result = fullPath.startsWith(workspace);
|
|
30
|
+
pathValidationCache.set(cacheKey, result);
|
|
31
|
+
|
|
32
|
+
if (pathValidationCache.size > 1000) {
|
|
33
|
+
const firstKey = pathValidationCache.keys().next().value;
|
|
34
|
+
if (firstKey) pathValidationCache.delete(firstKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
41
|
+
'node_modules',
|
|
42
|
+
'.git',
|
|
43
|
+
'.svn',
|
|
44
|
+
'.hg',
|
|
45
|
+
'dist',
|
|
46
|
+
'build',
|
|
47
|
+
'.next',
|
|
48
|
+
'.nuxt',
|
|
49
|
+
'.output',
|
|
50
|
+
'coverage',
|
|
51
|
+
'.cache',
|
|
52
|
+
'.parcel-cache',
|
|
53
|
+
'.turbo',
|
|
54
|
+
'__pycache__',
|
|
55
|
+
'.pytest_cache',
|
|
56
|
+
'venv',
|
|
57
|
+
'.venv',
|
|
58
|
+
'env',
|
|
59
|
+
'.env',
|
|
60
|
+
'vendor',
|
|
61
|
+
'target',
|
|
62
|
+
'.idea',
|
|
63
|
+
'.vscode',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function matchGlob(filename: string, pattern: string): boolean {
|
|
67
|
+
let regex = globPatternCache.get(pattern);
|
|
68
|
+
|
|
69
|
+
if (!regex) {
|
|
70
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
71
|
+
|
|
72
|
+
let regexPattern = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
73
|
+
|
|
74
|
+
regexPattern = regexPattern
|
|
75
|
+
.replace(/\*\*\//g, '(?:(?:[^/]+/)*)')
|
|
76
|
+
.replace(/\/\*\*$/g, '(?:/.*)?')
|
|
77
|
+
.replace(/\*\*/g, '.*')
|
|
78
|
+
.replace(/\*/g, '[^/]*')
|
|
79
|
+
.replace(/\?/g, '[^/]');
|
|
80
|
+
|
|
81
|
+
regex = new RegExp(`^${regexPattern}$`, 'i');
|
|
82
|
+
globPatternCache.set(pattern, regex);
|
|
83
|
+
|
|
84
|
+
if (globPatternCache.size > 100) {
|
|
85
|
+
const firstKey = globPatternCache.keys().next().value;
|
|
86
|
+
if (firstKey) globPatternCache.delete(firstKey);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalizedFilename = filename.replace(/\\/g, '/');
|
|
91
|
+
return regex.test(normalizedFilename);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function searchInFile(filePath: string, query: string, caseSensitive: boolean): Promise<Array<{ line: number; content: string }>> {
|
|
95
|
+
try {
|
|
96
|
+
const content = await readFile(filePath, 'utf-8');
|
|
97
|
+
const searchQuery = caseSensitive ? query : query.toLowerCase();
|
|
98
|
+
const matches: Array<{ line: number; content: string }> = [];
|
|
99
|
+
|
|
100
|
+
let lineNumber = 1;
|
|
101
|
+
let lineStart = 0;
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i <= content.length; i++) {
|
|
104
|
+
if (i === content.length || content[i] === '\n') {
|
|
105
|
+
const rawLine = content.slice(lineStart, i);
|
|
106
|
+
const lineContent = caseSensitive ? rawLine : rawLine.toLowerCase();
|
|
107
|
+
|
|
108
|
+
if (lineContent.includes(searchQuery)) {
|
|
109
|
+
matches.push({ line: lineNumber, content: rawLine });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lineNumber++;
|
|
113
|
+
lineStart = i + 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return matches;
|
|
118
|
+
} catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface WalkResult {
|
|
124
|
+
path: string;
|
|
125
|
+
isDirectory: boolean;
|
|
126
|
+
excluded?: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function walkDirectory(dir: string, filePattern?: string, includeHidden = false): Promise<WalkResult[]> {
|
|
130
|
+
const results: WalkResult[] = [];
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
134
|
+
const subDirPromises: Promise<WalkResult[]>[] = [];
|
|
135
|
+
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
if (!includeHidden && entry.name.startsWith('.')) continue;
|
|
138
|
+
|
|
139
|
+
const fullPath = join(dir, entry.name);
|
|
140
|
+
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
if (EXCLUDED_DIRECTORIES.has(entry.name)) {
|
|
143
|
+
results.push({ path: fullPath, isDirectory: true, excluded: true });
|
|
144
|
+
} else {
|
|
145
|
+
subDirPromises.push(walkDirectory(fullPath, filePattern, includeHidden));
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
if (!filePattern || matchGlob(entry.name, filePattern)) {
|
|
149
|
+
results.push({ path: fullPath, isDirectory: false });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (subDirPromises.length > 0) {
|
|
155
|
+
const subResults = await Promise.all(subDirPromises);
|
|
156
|
+
for (const subResult of subResults) {
|
|
157
|
+
results.push(...subResult);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
return results;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function listFilesRecursive(dirPath: string, workspace: string, filterPattern?: string, includeHidden = false): Promise<WalkResult[]> {
|
|
168
|
+
const fullPath = resolve(workspace, dirPath);
|
|
169
|
+
const files = await walkDirectory(fullPath, filterPattern, includeHidden);
|
|
170
|
+
const separator = workspace.endsWith(sep) ? '' : sep;
|
|
171
|
+
|
|
172
|
+
return files.map(file => ({
|
|
173
|
+
...file,
|
|
174
|
+
path: file.path.replace(workspace + separator, '')
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function findFilesByPattern(pattern: string, searchPath: string): Promise<string[]> {
|
|
179
|
+
const results: string[] = [];
|
|
180
|
+
|
|
181
|
+
const hasDoubleStar = pattern.includes('**');
|
|
182
|
+
|
|
183
|
+
if (hasDoubleStar) {
|
|
184
|
+
const files = await walkDirectory(searchPath, undefined, false);
|
|
185
|
+
const separator = searchPath.endsWith(sep) ? '' : sep;
|
|
186
|
+
const root = searchPath + separator;
|
|
187
|
+
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
if (file.excluded) continue;
|
|
190
|
+
|
|
191
|
+
let relativePath = file.path;
|
|
192
|
+
if (file.path.startsWith(root)) {
|
|
193
|
+
relativePath = file.path.slice(root.length);
|
|
194
|
+
} else if (file.path.toLowerCase().startsWith(root.toLowerCase())) {
|
|
195
|
+
relativePath = file.path.slice(root.length);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (matchGlob(relativePath, pattern)) {
|
|
199
|
+
results.push(relativePath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const entries = await readdir(searchPath, { withFileTypes: true });
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.name.startsWith('.')) continue;
|
|
206
|
+
if (matchGlob(entry.name, pattern) && entry.isFile()) {
|
|
207
|
+
results.push(entry.name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function generatePreview(toolName: string, args: Record<string, unknown>, workspace: string) {
|
|
216
|
+
switch (toolName) {
|
|
217
|
+
case 'write': {
|
|
218
|
+
const path = args.path as string;
|
|
219
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
220
|
+
const fullPath = resolve(workspace, path);
|
|
221
|
+
|
|
222
|
+
if (!content || content.trim() === '') {
|
|
223
|
+
return {
|
|
224
|
+
title: `Write (${path})`,
|
|
225
|
+
content: 'No new content in the file',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let oldContent = '';
|
|
230
|
+
try {
|
|
231
|
+
oldContent = await readFile(fullPath, 'utf-8');
|
|
232
|
+
} catch {
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const diff = generateDiff(oldContent, content);
|
|
236
|
+
const diffLines = formatDiffForDisplay(diff);
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
title: `Write (${path})`,
|
|
240
|
+
content: diffLines.join('\n'),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'edit': {
|
|
245
|
+
const path = args.path as string;
|
|
246
|
+
const oldContent = args.old_content as string;
|
|
247
|
+
const newContent = args.new_content as string;
|
|
248
|
+
const occurrence = ((args.occurrence === null ? undefined : (args.occurrence as number | undefined)) ?? 1);
|
|
249
|
+
|
|
250
|
+
const oldLines = oldContent.split('\n');
|
|
251
|
+
const newLines = newContent.split('\n');
|
|
252
|
+
|
|
253
|
+
const formattedLines: string[] = [];
|
|
254
|
+
|
|
255
|
+
let startLineNumber = 1;
|
|
256
|
+
try {
|
|
257
|
+
const fullPath = resolve(workspace, path);
|
|
258
|
+
const fileContent = await readFile(fullPath, 'utf-8');
|
|
259
|
+
const fileLines = fileContent.split('\n');
|
|
260
|
+
|
|
261
|
+
let occurrenceCount = 0;
|
|
262
|
+
for (let i = 0; i <= fileLines.length - oldLines.length; i++) {
|
|
263
|
+
let match = true;
|
|
264
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
265
|
+
if (fileLines[i + j] !== oldLines[j]) {
|
|
266
|
+
match = false;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (match) {
|
|
271
|
+
occurrenceCount++;
|
|
272
|
+
if (occurrenceCount === occurrence) {
|
|
273
|
+
startLineNumber = i + 1;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for (let i = 0; i < oldLines.length; i++) {
|
|
282
|
+
formattedLines.push(`-${String(startLineNumber + i).padStart(4)} | ${oldLines[i] ?? ''}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (let i = 0; i < newLines.length; i++) {
|
|
286
|
+
formattedLines.push(`+${String(startLineNumber + i).padStart(4)} | ${newLines[i] ?? ''}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
title: `Edit (${path})`,
|
|
291
|
+
content: formattedLines.join('\n'),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'bash': {
|
|
296
|
+
let command = args.command as string;
|
|
297
|
+
|
|
298
|
+
const cleanCommand = command.replace(/\s+--timeout\s+\d+$/, '');
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
title: `Command (${cleanCommand})`,
|
|
302
|
+
content: cleanCommand,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
default:
|
|
307
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function executeTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
|
|
312
|
+
const workspace = process.cwd();
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const needsApproval = (toolName === 'write' || toolName === 'edit' || toolName === 'bash') && shouldRequireApprovals();
|
|
316
|
+
|
|
317
|
+
if (needsApproval) {
|
|
318
|
+
const preview = await generatePreview(toolName, args, workspace);
|
|
319
|
+
const approvalResult = await requestApproval(toolName as 'write' | 'edit' | 'bash', args, preview);
|
|
320
|
+
|
|
321
|
+
if (!approvalResult.approved) {
|
|
322
|
+
if (approvalResult.customResponse) {
|
|
323
|
+
const userMessage = `Operation cancelled by user`;
|
|
324
|
+
const agentError = `OPERATION REJECTED BY USER with custom instructions: "${approvalResult.customResponse}"
|
|
325
|
+
|
|
326
|
+
The user provided specific instructions for what to do instead. Follow their instructions carefully.
|
|
327
|
+
|
|
328
|
+
DO NOT use the question tool since the user already provided clear instructions in their custom response.`;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
error: agentError,
|
|
333
|
+
userMessage: userMessage,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let operationDescription = '';
|
|
338
|
+
let suggestedOptions = '';
|
|
339
|
+
switch (toolName) {
|
|
340
|
+
case 'write':
|
|
341
|
+
operationDescription = `writing to file "${args.path}"`;
|
|
342
|
+
suggestedOptions = 'Options could be: "Modify the content", "Write to a different file", "Cancel operation"';
|
|
343
|
+
break;
|
|
344
|
+
case 'edit':
|
|
345
|
+
operationDescription = `editing file "${args.path}"`;
|
|
346
|
+
suggestedOptions = 'Options could be: "Modify the changes", "Edit a different part", "Cancel operation"';
|
|
347
|
+
break;
|
|
348
|
+
case 'bash':
|
|
349
|
+
operationDescription = `executing command: ${args.command}`;
|
|
350
|
+
suggestedOptions = 'Options could be: "Modify the command", "Use a different command", "Cancel operation"';
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const agentError = `OPERATION REJECTED BY USER: ${operationDescription}
|
|
355
|
+
|
|
356
|
+
REQUIRED ACTION: You MUST use the question tool immediately to ask the user why they rejected this and what they want to do instead.
|
|
357
|
+
|
|
358
|
+
Example question tool usage:
|
|
359
|
+
question(
|
|
360
|
+
prompt: "Why did you reject ${operationDescription}?",
|
|
361
|
+
options: [
|
|
362
|
+
{ label: "${suggestedOptions.split(', ')[0]?.replace('Options could be: ', '').replace(/"/g, '')}", value: "modify" },
|
|
363
|
+
{ label: "${suggestedOptions.split(', ')[1]?.replace(/"/g, '')}", value: "alternative" },
|
|
364
|
+
{ label: "${suggestedOptions.split(', ')[2]?.replace(/"/g, '')}", value: "cancel" }
|
|
365
|
+
]
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
DO NOT continue without using the question tool. DO NOT ask in plain text.`;
|
|
369
|
+
|
|
370
|
+
const userMessage = `Operation cancelled by user`;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
success: false,
|
|
374
|
+
error: agentError,
|
|
375
|
+
userMessage: userMessage,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
switch (toolName) {
|
|
381
|
+
case 'read': {
|
|
382
|
+
const path = args.path as string;
|
|
383
|
+
const fullPath = resolve(workspace, path);
|
|
384
|
+
|
|
385
|
+
if (!validatePath(fullPath, workspace)) {
|
|
386
|
+
return {
|
|
387
|
+
success: false,
|
|
388
|
+
error: 'Access denied: path is outside workspace'
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
393
|
+
return {
|
|
394
|
+
success: true,
|
|
395
|
+
result: content
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case 'write': {
|
|
400
|
+
const path = args.path as string;
|
|
401
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
402
|
+
const append = args.append === true;
|
|
403
|
+
const fullPath = resolve(workspace, path);
|
|
404
|
+
|
|
405
|
+
if (!validatePath(fullPath, workspace)) {
|
|
406
|
+
return {
|
|
407
|
+
success: false,
|
|
408
|
+
error: 'Access denied: path is outside workspace'
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
captureFileSnapshot(path);
|
|
413
|
+
|
|
414
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
415
|
+
|
|
416
|
+
let oldContent = '';
|
|
417
|
+
try {
|
|
418
|
+
oldContent = await readFile(fullPath, 'utf-8');
|
|
419
|
+
} catch {
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (append) {
|
|
423
|
+
await appendFile(fullPath, content, 'utf-8');
|
|
424
|
+
return {
|
|
425
|
+
success: true,
|
|
426
|
+
result: `Content appended successfully to: ${path}`
|
|
427
|
+
};
|
|
428
|
+
} else {
|
|
429
|
+
await writeFile(fullPath, content, 'utf-8');
|
|
430
|
+
|
|
431
|
+
if (!content || content.trim() === '') {
|
|
432
|
+
return {
|
|
433
|
+
success: true,
|
|
434
|
+
result: `No new content in the file`,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
trackFileChange(path, oldContent, content);
|
|
439
|
+
|
|
440
|
+
const diff = generateDiff(oldContent, content);
|
|
441
|
+
const diffLines = formatDiffForDisplay(diff);
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
success: true,
|
|
445
|
+
result: `File written successfully: ${path}`,
|
|
446
|
+
diff: diffLines,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case 'list': {
|
|
452
|
+
const path = args.path as string;
|
|
453
|
+
const recursive = args.recursive === null ? undefined : (args.recursive as boolean | undefined);
|
|
454
|
+
const filter = args.filter === null ? undefined : (args.filter as string | undefined);
|
|
455
|
+
const includeHidden = args.include_hidden === null ? undefined : (args.include_hidden as boolean | undefined);
|
|
456
|
+
const fullPath = resolve(workspace, path);
|
|
457
|
+
|
|
458
|
+
if (!validatePath(fullPath, workspace)) {
|
|
459
|
+
return {
|
|
460
|
+
success: false,
|
|
461
|
+
error: 'Access denied: path is outside workspace'
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (recursive) {
|
|
466
|
+
const files = await listFilesRecursive(path, workspace, filter, includeHidden);
|
|
467
|
+
const fileStats = await Promise.all(
|
|
468
|
+
files.map(async (file) => {
|
|
469
|
+
if (file.excluded) {
|
|
470
|
+
return {
|
|
471
|
+
path: file.path,
|
|
472
|
+
type: 'directory',
|
|
473
|
+
excluded: true
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const filePath = resolve(workspace, file.path);
|
|
477
|
+
const stats = await stat(filePath);
|
|
478
|
+
return {
|
|
479
|
+
path: file.path,
|
|
480
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
481
|
+
size: stats.size,
|
|
482
|
+
};
|
|
483
|
+
})
|
|
484
|
+
);
|
|
485
|
+
return {
|
|
486
|
+
success: true,
|
|
487
|
+
result: JSON.stringify(fileStats, null, 2)
|
|
488
|
+
};
|
|
489
|
+
} else {
|
|
490
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
491
|
+
let filteredEntries = entries;
|
|
492
|
+
|
|
493
|
+
if (!includeHidden) {
|
|
494
|
+
filteredEntries = filteredEntries.filter(entry => !entry.name.startsWith('.'));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (filter) {
|
|
498
|
+
const regex = new RegExp(filter.replace(/\*/g, '.*').replace(/\?/g, '.'));
|
|
499
|
+
filteredEntries = filteredEntries.filter(entry => regex.test(entry.name));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const files = filteredEntries.map(entry => ({
|
|
503
|
+
name: entry.name,
|
|
504
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
505
|
+
...(entry.isDirectory() && EXCLUDED_DIRECTORIES.has(entry.name) ? { excluded: true } : {})
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
success: true,
|
|
510
|
+
result: JSON.stringify(files, null, 2)
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
case 'bash': {
|
|
516
|
+
let command = args.command as string;
|
|
517
|
+
let timeout = 30000;
|
|
518
|
+
|
|
519
|
+
const timeoutMatch = command.match(/\s+--timeout\s+(\d+)$/);
|
|
520
|
+
if (timeoutMatch) {
|
|
521
|
+
timeout = Math.min(parseInt(timeoutMatch[1] || '30000', 10), 90000);
|
|
522
|
+
command = command.replace(/\s+--timeout\s+\d+$/, '');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
527
|
+
cwd: workspace,
|
|
528
|
+
timeout
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const output = (stdout || '') + (stderr || '');
|
|
532
|
+
return {
|
|
533
|
+
success: true,
|
|
534
|
+
result: output || 'Command executed with no output'
|
|
535
|
+
};
|
|
536
|
+
} catch (error: unknown) {
|
|
537
|
+
const execError = error as { stdout?: string; stderr?: string; message?: string; code?: number };
|
|
538
|
+
const errorMessage = execError.message || String(error);
|
|
539
|
+
|
|
540
|
+
if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
|
|
541
|
+
const partialOutput = (execError.stdout || '') + (execError.stderr || '');
|
|
542
|
+
const output = partialOutput
|
|
543
|
+
? `Command output (timed out after ${timeout}ms):\n${partialOutput}\n\n[Process continues running in background]`
|
|
544
|
+
: `Command timed out after ${timeout}ms and produced no output.\n\n[Process may be running in background]`;
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
success: true,
|
|
548
|
+
result: output
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const output = (execError.stdout || '') + (execError.stderr || '');
|
|
553
|
+
const exitCode = execError.code;
|
|
554
|
+
const fullOutput = output
|
|
555
|
+
? `Command exited with code ${exitCode ?? 'unknown'}:\n${output}`
|
|
556
|
+
: `Command failed: ${errorMessage}`;
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
success: true,
|
|
560
|
+
result: fullOutput
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
case 'glob': {
|
|
566
|
+
const pattern = args.pattern as string;
|
|
567
|
+
const searchPath = (args.path === null ? undefined : (args.path as string | undefined)) || '.';
|
|
568
|
+
const fullPath = resolve(workspace, searchPath);
|
|
569
|
+
|
|
570
|
+
if (!validatePath(fullPath, workspace)) {
|
|
571
|
+
return {
|
|
572
|
+
success: false,
|
|
573
|
+
error: 'Access denied: path is outside workspace'
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const files = await findFilesByPattern(pattern, fullPath);
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
success: true,
|
|
581
|
+
result: JSON.stringify(files)
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
case 'grep': {
|
|
586
|
+
const pattern = args.pattern as string;
|
|
587
|
+
const query = args.query as string;
|
|
588
|
+
const searchPath = (args.path === null ? undefined : (args.path as string | undefined)) || '.';
|
|
589
|
+
const caseSensitive = ((args.case_sensitive === null ? undefined : (args.case_sensitive as boolean | undefined)) ?? false);
|
|
590
|
+
const maxResults = ((args.max_results === null ? undefined : (args.max_results as number | undefined)) ?? 100);
|
|
591
|
+
const fullPath = resolve(workspace, searchPath);
|
|
592
|
+
|
|
593
|
+
if (!validatePath(fullPath, workspace)) {
|
|
594
|
+
return {
|
|
595
|
+
success: false,
|
|
596
|
+
error: 'Access denied: path is outside workspace'
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const files = await findFilesByPattern(pattern, fullPath);
|
|
601
|
+
|
|
602
|
+
const results: Array<{ file: string; matches: Array<{ line: number; content: string }> }> = [];
|
|
603
|
+
let totalResults = 0;
|
|
604
|
+
|
|
605
|
+
const BATCH_SIZE = 10;
|
|
606
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
607
|
+
if (totalResults >= maxResults) break;
|
|
608
|
+
|
|
609
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
610
|
+
const batchResults = await Promise.all(
|
|
611
|
+
batch.map(async (file) => {
|
|
612
|
+
const filePath = resolve(fullPath, file);
|
|
613
|
+
const matches = await searchInFile(filePath, query, caseSensitive);
|
|
614
|
+
return { file: join(searchPath, file), matches };
|
|
615
|
+
})
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
for (const { file, matches } of batchResults) {
|
|
619
|
+
if (totalResults >= maxResults) break;
|
|
620
|
+
if (matches.length > 0) {
|
|
621
|
+
results.push({
|
|
622
|
+
file,
|
|
623
|
+
matches: matches.slice(0, maxResults - totalResults)
|
|
624
|
+
});
|
|
625
|
+
totalResults += matches.length;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
success: true,
|
|
632
|
+
result: JSON.stringify(results, null, 2)
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
case 'edit': {
|
|
637
|
+
const path = args.path as string;
|
|
638
|
+
const oldContent = args.old_content as string;
|
|
639
|
+
const newContent = args.new_content as string;
|
|
640
|
+
const occurrence = ((args.occurrence === null ? undefined : (args.occurrence as number | undefined)) ?? 1);
|
|
641
|
+
const fullPath = resolve(workspace, path);
|
|
642
|
+
|
|
643
|
+
if (!validatePath(fullPath, workspace)) {
|
|
644
|
+
return {
|
|
645
|
+
success: false,
|
|
646
|
+
error: 'Access denied: path is outside workspace'
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
captureFileSnapshot(path);
|
|
651
|
+
|
|
652
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
653
|
+
|
|
654
|
+
let content = '';
|
|
655
|
+
try {
|
|
656
|
+
content = await readFile(fullPath, 'utf-8');
|
|
657
|
+
} catch {
|
|
658
|
+
content = '';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (oldContent === '' && content === '') {
|
|
662
|
+
await writeFile(fullPath, newContent, 'utf-8');
|
|
663
|
+
|
|
664
|
+
trackFileCreated(path, newContent);
|
|
665
|
+
|
|
666
|
+
const diff = generateDiff('', newContent);
|
|
667
|
+
const diffLines = formatDiffForDisplay(diff);
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
success: true,
|
|
671
|
+
result: `File created and edited successfully: ${path}`,
|
|
672
|
+
diff: diffLines,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const parts = content.split(oldContent);
|
|
677
|
+
|
|
678
|
+
if (parts.length < occurrence + 1) {
|
|
679
|
+
return {
|
|
680
|
+
success: false,
|
|
681
|
+
error: `Could not find occurrence ${occurrence} of the specified content`
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const before = parts.slice(0, occurrence).join(oldContent);
|
|
686
|
+
const after = parts.slice(occurrence).join(oldContent);
|
|
687
|
+
const updatedContent = before + newContent + after;
|
|
688
|
+
|
|
689
|
+
await writeFile(fullPath, updatedContent, 'utf-8');
|
|
690
|
+
|
|
691
|
+
trackFileChange(path, content, updatedContent);
|
|
692
|
+
|
|
693
|
+
const diff = generateDiff(content, updatedContent);
|
|
694
|
+
const diffLines = formatDiffForDisplay(diff);
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
success: true,
|
|
698
|
+
result: `File edited successfully: ${path}`,
|
|
699
|
+
diff: diffLines,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
case 'create_directory': {
|
|
704
|
+
const path = args.path as string;
|
|
705
|
+
const extension = extname(path || '');
|
|
706
|
+
const knownFileExtensions = new Set([
|
|
707
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
|
|
708
|
+
'.py', '.go', '.java', '.kt', '.rb', '.php', '.rs',
|
|
709
|
+
'.c', '.cc', '.cpp', '.h', '.hpp',
|
|
710
|
+
'.json', '.yaml', '.yml', '.toml', '.ini',
|
|
711
|
+
'.md', '.txt', '.env',
|
|
712
|
+
'.sh', '.bat', '.ps1',
|
|
713
|
+
'.html', '.css', '.scss', '.less',
|
|
714
|
+
]);
|
|
715
|
+
if (extension && knownFileExtensions.has(extension.toLowerCase())) {
|
|
716
|
+
return {
|
|
717
|
+
success: false,
|
|
718
|
+
error: `Refusing to create a directory at "${path}" because it looks like a file path. Use write with path "${path}" to create a file instead.`
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const fullPath = resolve(workspace, path);
|
|
722
|
+
|
|
723
|
+
if (!validatePath(fullPath, workspace)) {
|
|
724
|
+
return {
|
|
725
|
+
success: false,
|
|
726
|
+
error: 'Access denied: path is outside workspace'
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
await mkdir(fullPath, { recursive: true });
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
success: true,
|
|
734
|
+
result: `Directory created: ${path}`
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
default:
|
|
740
|
+
return {
|
|
741
|
+
success: false,
|
|
742
|
+
error: `Unknown tool: ${toolName}`
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
} catch (error) {
|
|
746
|
+
return {
|
|
747
|
+
success: false,
|
|
748
|
+
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
}
|