@kirosnn/mosaic 0.0.91 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -6
  3. package/package.json +55 -48
  4. package/src/agent/Agent.ts +353 -131
  5. package/src/agent/context.ts +4 -4
  6. package/src/agent/prompts/systemPrompt.ts +209 -70
  7. package/src/agent/prompts/toolsPrompt.ts +285 -138
  8. package/src/agent/provider/anthropic.ts +109 -105
  9. package/src/agent/provider/google.ts +111 -107
  10. package/src/agent/provider/mistral.ts +95 -95
  11. package/src/agent/provider/ollama.ts +73 -17
  12. package/src/agent/provider/openai.ts +146 -102
  13. package/src/agent/provider/rateLimit.ts +178 -0
  14. package/src/agent/provider/reasoning.ts +29 -0
  15. package/src/agent/provider/xai.ts +108 -104
  16. package/src/agent/tools/definitions.ts +15 -1
  17. package/src/agent/tools/executor.ts +717 -98
  18. package/src/agent/tools/exploreExecutor.ts +20 -22
  19. package/src/agent/tools/fetch.ts +58 -0
  20. package/src/agent/tools/glob.ts +20 -4
  21. package/src/agent/tools/grep.ts +64 -9
  22. package/src/agent/tools/plan.ts +27 -0
  23. package/src/agent/tools/question.ts +7 -1
  24. package/src/agent/tools/read.ts +2 -0
  25. package/src/agent/types.ts +15 -14
  26. package/src/components/App.tsx +50 -8
  27. package/src/components/CustomInput.tsx +461 -77
  28. package/src/components/Main.tsx +1459 -1112
  29. package/src/components/Setup.tsx +1 -1
  30. package/src/components/ShortcutsModal.tsx +11 -8
  31. package/src/components/Welcome.tsx +1 -1
  32. package/src/components/main/ApprovalPanel.tsx +4 -3
  33. package/src/components/main/ChatPage.tsx +858 -516
  34. package/src/components/main/HomePage.tsx +58 -39
  35. package/src/components/main/QuestionPanel.tsx +52 -7
  36. package/src/components/main/ThinkingIndicator.tsx +13 -2
  37. package/src/components/main/types.ts +11 -10
  38. package/src/index.tsx +53 -25
  39. package/src/mcp/approvalPolicy.ts +148 -0
  40. package/src/mcp/cli/add.ts +185 -0
  41. package/src/mcp/cli/doctor.ts +77 -0
  42. package/src/mcp/cli/index.ts +85 -0
  43. package/src/mcp/cli/list.ts +50 -0
  44. package/src/mcp/cli/logs.ts +24 -0
  45. package/src/mcp/cli/manage.ts +99 -0
  46. package/src/mcp/cli/show.ts +53 -0
  47. package/src/mcp/cli/tools.ts +77 -0
  48. package/src/mcp/config.ts +223 -0
  49. package/src/mcp/index.ts +80 -0
  50. package/src/mcp/processManager.ts +299 -0
  51. package/src/mcp/rateLimiter.ts +50 -0
  52. package/src/mcp/registry.ts +151 -0
  53. package/src/mcp/schemaConverter.ts +100 -0
  54. package/src/mcp/servers/navigation.ts +854 -0
  55. package/src/mcp/toolCatalog.ts +169 -0
  56. package/src/mcp/types.ts +95 -0
  57. package/src/utils/approvalBridge.ts +45 -12
  58. package/src/utils/approvalModeBridge.ts +17 -0
  59. package/src/utils/commands/approvals.ts +48 -0
  60. package/src/utils/commands/compact.ts +30 -0
  61. package/src/utils/commands/echo.ts +1 -1
  62. package/src/utils/commands/image.ts +109 -0
  63. package/src/utils/commands/index.ts +9 -7
  64. package/src/utils/commands/new.ts +15 -0
  65. package/src/utils/commands/types.ts +3 -0
  66. package/src/utils/config.ts +3 -1
  67. package/src/utils/diffRendering.tsx +13 -16
  68. package/src/utils/exploreBridge.ts +10 -0
  69. package/src/utils/history.ts +82 -40
  70. package/src/utils/imageBridge.ts +28 -0
  71. package/src/utils/images.ts +31 -0
  72. package/src/utils/markdown.tsx +163 -99
  73. package/src/utils/models.ts +31 -16
  74. package/src/utils/notificationBridge.ts +23 -0
  75. package/src/utils/questionBridge.ts +36 -1
  76. package/src/utils/tokenEstimator.ts +32 -0
  77. package/src/utils/toolFormatting.ts +428 -48
  78. package/src/web/app.tsx +65 -5
  79. package/src/web/assets/css/ChatPage.css +102 -30
  80. package/src/web/assets/css/MessageItem.css +26 -29
  81. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  82. package/src/web/assets/css/ToolMessage.css +36 -14
  83. package/src/web/components/ChatPage.tsx +228 -105
  84. package/src/web/components/HomePage.tsx +3 -3
  85. package/src/web/components/MessageItem.tsx +80 -81
  86. package/src/web/components/QuestionPanel.tsx +72 -12
  87. package/src/web/components/Setup.tsx +1 -1
  88. package/src/web/components/Sidebar.tsx +1 -3
  89. package/src/web/components/ThinkingIndicator.tsx +41 -21
  90. package/src/web/router.ts +1 -1
  91. package/src/web/server.tsx +894 -662
  92. package/src/web/storage.ts +23 -1
  93. package/src/web/types.ts +7 -6
  94. package/src/utils/commands/redo.ts +0 -74
  95. package/src/utils/commands/sessions.ts +0 -129
  96. package/src/utils/commands/undo.ts +0 -75
  97. package/src/utils/undoRedo.ts +0 -429
  98. package/src/utils/undoRedoBridge.ts +0 -45
  99. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,15 +1,185 @@
1
- import { readFile, writeFile, readdir, appendFile, stat, mkdir } from 'fs/promises';
1
+ import { readFile, writeFile, readdir, appendFile, stat, mkdir, realpath } from 'fs/promises';
2
2
  import { join, resolve, dirname, extname, sep } from 'path';
3
3
  import { exec } from 'child_process';
4
4
  import { promisify } from 'util';
5
5
  import { requestApproval } from '../../utils/approvalBridge';
6
6
  import { shouldRequireApprovals } from '../../utils/config';
7
7
  import { generateDiff, formatDiffForDisplay } from '../../utils/diff';
8
- import { captureFileSnapshot } from '../../utils/undoRedo';
9
- import { trackFileChange, trackFileCreated, trackFileDeleted } from '../../utils/fileChangeTracker';
8
+ import { trackFileChange, trackFileCreated } from '../../utils/fileChangeTracker';
9
+ import TurndownService from 'turndown';
10
+ import { Readability } from '@mozilla/readability';
11
+ import { parseHTML } from 'linkedom';
10
12
 
11
13
  const execAsync = promisify(exec);
12
14
 
15
+ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0';
16
+ const DEFAULT_FETCH_MAX_LENGTH = 10000;
17
+ const DEFAULT_FETCH_TIMEOUT = 30000;
18
+
19
+ function extractContentFromHtml(html: string, url: string): { content: string; title: string | null; isSPA: boolean } {
20
+ const { document } = parseHTML(html);
21
+
22
+ const turndown = new TurndownService({
23
+ headingStyle: 'atx',
24
+ codeBlockStyle: 'fenced',
25
+ emDelimiter: '*',
26
+ });
27
+
28
+ turndown.addRule('removeScripts', {
29
+ filter: ['script', 'style', 'noscript'],
30
+ replacement: () => '',
31
+ });
32
+
33
+ turndown.addRule('preserveLinks', {
34
+ filter: 'a',
35
+ replacement: (content, node) => {
36
+ const element = node as HTMLAnchorElement;
37
+ const href = element.getAttribute('href');
38
+ if (!href || href.startsWith('#')) return content;
39
+
40
+ try {
41
+ const absoluteUrl = new URL(href, url).toString();
42
+ return `[${content}](${absoluteUrl})`;
43
+ } catch {
44
+ return `[${content}](${href})`;
45
+ }
46
+ },
47
+ });
48
+
49
+ turndown.addRule('preserveImages', {
50
+ filter: 'img',
51
+ replacement: (_content, node) => {
52
+ const element = node as HTMLImageElement;
53
+ const src = element.getAttribute('src');
54
+ const alt = element.getAttribute('alt') || '';
55
+ if (!src) return '';
56
+
57
+ try {
58
+ const absoluteUrl = new URL(src, url).toString();
59
+ return `![${alt}](${absoluteUrl})`;
60
+ } catch {
61
+ return `![${alt}](${src})`;
62
+ }
63
+ },
64
+ });
65
+
66
+ const reader = new Readability(document as unknown as Document, {
67
+ charThreshold: 0,
68
+ });
69
+ const article = reader.parse();
70
+
71
+ if (article && article.content) {
72
+ const content = turndown.turndown(article.content).trim();
73
+ if (content.length > 50) {
74
+ return {
75
+ content,
76
+ title: article.title || document.title || null,
77
+ isSPA: false,
78
+ };
79
+ }
80
+ }
81
+
82
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
83
+ const bodyContent = bodyMatch ? bodyMatch[1] : html;
84
+ const markdownContent = turndown.turndown(bodyContent || '').trim();
85
+
86
+ if (markdownContent.length > 50) {
87
+ return {
88
+ content: markdownContent,
89
+ title: document.title || null,
90
+ isSPA: false,
91
+ };
92
+ }
93
+
94
+ const isSPA = html.includes('id="root"') ||
95
+ html.includes('id="app"') ||
96
+ html.includes('id="__next"') ||
97
+ html.includes('data-reactroot') ||
98
+ html.includes('ng-app');
99
+
100
+ const metaTags: string[] = [];
101
+ const metaDescription = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i);
102
+ const metaOgTitle = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i);
103
+ const metaOgDescription = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i);
104
+
105
+ if (metaOgTitle) metaTags.push(`**Title:** ${metaOgTitle[1]}`);
106
+ if (metaDescription) metaTags.push(`**Description:** ${metaDescription[1]}`);
107
+ if (metaOgDescription && metaOgDescription[1] !== metaDescription?.[1]) {
108
+ metaTags.push(`**OG Description:** ${metaOgDescription[1]}`);
109
+ }
110
+
111
+ let content = '';
112
+ if (isSPA) {
113
+ content = `*This appears to be a Single Page Application (SPA/React/Vue/Angular). The content is rendered client-side with JavaScript and cannot be extracted via simple HTTP fetch.*\n\n`;
114
+ if (metaTags.length > 0) {
115
+ content += `**Available metadata:**\n${metaTags.join('\n')}\n\n`;
116
+ }
117
+ content += `*To see the actual content, you would need a headless browser. Try using raw=true to see the HTML source.*`;
118
+ } else if (markdownContent) {
119
+ content = markdownContent;
120
+ } else {
121
+ content = `*No readable content could be extracted from this page.*\n\n`;
122
+ if (metaTags.length > 0) {
123
+ content += `**Available metadata:**\n${metaTags.join('\n')}`;
124
+ }
125
+ }
126
+
127
+ return {
128
+ content,
129
+ title: document.title || null,
130
+ isSPA,
131
+ };
132
+ }
133
+
134
+ async function fetchUrlContent(
135
+ url: string,
136
+ options: {
137
+ raw?: boolean;
138
+ timeout?: number;
139
+ userAgent?: string;
140
+ } = {}
141
+ ): Promise<{ content: string; contentType: string; title: string | null; status: number; statusText: string; isSPA?: boolean }> {
142
+ const { raw = false, timeout = DEFAULT_FETCH_TIMEOUT, userAgent = DEFAULT_USER_AGENT } = options;
143
+
144
+ const controller = new AbortController();
145
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
146
+
147
+ try {
148
+ const response = await globalThis.fetch(url, {
149
+ headers: {
150
+ 'User-Agent': userAgent,
151
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
152
+ 'Accept-Language': 'en-US,en;q=0.5',
153
+ },
154
+ signal: controller.signal,
155
+ redirect: 'follow',
156
+ });
157
+
158
+ const status = response.status;
159
+ const statusText = response.statusText;
160
+
161
+ if (!response.ok) {
162
+ throw new Error(`HTTP ${status} ${statusText}`);
163
+ }
164
+
165
+ const contentType = response.headers.get('content-type') || '';
166
+ const text = await response.text();
167
+
168
+ const isHtml = contentType.includes('text/html') ||
169
+ text.slice(0, 500).toLowerCase().includes('<html') ||
170
+ text.slice(0, 500).toLowerCase().includes('<!doctype html');
171
+
172
+ if (isHtml && !raw) {
173
+ const { content, title, isSPA } = extractContentFromHtml(text, url);
174
+ return { content, contentType, title, isSPA, status, statusText };
175
+ }
176
+
177
+ return { content: text, contentType, title: null, status, statusText };
178
+ } finally {
179
+ clearTimeout(timeoutId);
180
+ }
181
+ }
182
+
13
183
  export interface ToolResult {
14
184
  success: boolean;
15
185
  result?: string;
@@ -18,23 +188,23 @@ export interface ToolResult {
18
188
  diff?: string[];
19
189
  }
20
190
 
21
- const pathValidationCache = new Map<string, boolean>();
22
191
  const globPatternCache = new Map<string, RegExp>();
23
192
 
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;
193
+ async function validatePath(fullPath: string, workspace: string): Promise<boolean> {
194
+ const normalizedWorkspace = workspace.endsWith(sep) ? workspace : workspace + sep;
28
195
 
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);
196
+ try {
197
+ const resolved = await realpath(fullPath);
198
+ return resolved === workspace || resolved.startsWith(normalizedWorkspace);
199
+ } catch {
200
+ const parent = dirname(fullPath);
201
+ try {
202
+ const resolvedParent = await realpath(parent);
203
+ return resolvedParent === workspace || resolvedParent.startsWith(normalizedWorkspace);
204
+ } catch {
205
+ return fullPath === workspace || fullPath.startsWith(normalizedWorkspace);
206
+ }
35
207
  }
36
-
37
- return result;
38
208
  }
39
209
 
40
210
  const EXCLUDED_DIRECTORIES = new Set([
@@ -69,14 +239,14 @@ function matchGlob(filename: string, pattern: string): boolean {
69
239
  if (!regex) {
70
240
  const normalizedPattern = pattern.replace(/\\/g, '/');
71
241
 
72
- let regexPattern = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
242
+ let regexPattern = normalizedPattern.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
73
243
 
74
244
  regexPattern = regexPattern
75
- .replace(/\*\*\//g, '(?:(?:[^/]+/)*)')
76
- .replace(/\/\*\*$/g, '(?:/.*)?')
77
- .replace(/\*\*/g, '.*')
78
- .replace(/\*/g, '[^/]*')
79
- .replace(/\?/g, '[^/]');
245
+ .replace(/\\\*\\\*\\\//g, '(?:(?:[^/]+/)*)')
246
+ .replace(/\\\/\*\\\*$/g, '(?:/.*)?')
247
+ .replace(/\\\*\\\*/g, '.*')
248
+ .replace(/\\\*/g, '[^/]*')
249
+ .replace(/\\\?/g, '[^/]');
80
250
 
81
251
  regex = new RegExp(`^${regexPattern}$`, 'i');
82
252
  globPatternCache.set(pattern, regex);
@@ -91,32 +261,190 @@ function matchGlob(filename: string, pattern: string): boolean {
91
261
  return regex.test(normalizedFilename);
92
262
  }
93
263
 
94
- async function searchInFile(filePath: string, query: string, caseSensitive: boolean): Promise<Array<{ line: number; content: string }>> {
264
+ interface SearchResult {
265
+ matches: Array<{ line: number; content: string; context?: { before: string[]; after: string[] } }>;
266
+ error?: string;
267
+ matchCount?: number;
268
+ skipped?: boolean;
269
+ skipReason?: string;
270
+ }
271
+
272
+ interface SearchOptions {
273
+ caseSensitive: boolean;
274
+ isRegex: boolean;
275
+ wholeWord: boolean;
276
+ multiline: boolean;
277
+ contextBefore: number;
278
+ contextAfter: number;
279
+ maxFileSize: number;
280
+ invertMatch: boolean;
281
+ }
282
+
283
+ const DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
284
+
285
+ function isBinaryFile(buffer: Buffer, bytesToCheck = 8000): boolean {
286
+ const checkLength = Math.min(buffer.length, bytesToCheck);
287
+ let nullCount = 0;
288
+ let controlCount = 0;
289
+
290
+ for (let i = 0; i < checkLength; i++) {
291
+ const byte = buffer[i];
292
+ if (byte === 0) {
293
+ nullCount++;
294
+ if (nullCount > 1) return true;
295
+ }
296
+ if (byte !== undefined && byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
297
+ controlCount++;
298
+ if (controlCount > checkLength * 0.1) return true;
299
+ }
300
+ }
301
+
302
+ return false;
303
+ }
304
+
305
+ function escapeRegexForLiteral(str: string): string {
306
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
307
+ }
308
+
309
+ function buildSearchRegex(query: string, options: SearchOptions): { regex: RegExp; error?: undefined } | { regex?: undefined; error: string } {
310
+ try {
311
+ let pattern = query;
312
+
313
+ if (!options.isRegex) {
314
+ pattern = escapeRegexForLiteral(query);
315
+ }
316
+
317
+ if (options.wholeWord) {
318
+ if (options.isRegex) {
319
+ pattern = `(?:^|\\b)${pattern}(?:\\b|$)`;
320
+ } else {
321
+ pattern = `\\b${pattern}\\b`;
322
+ }
323
+ }
324
+
325
+ let flags = 'g';
326
+ if (!options.caseSensitive) flags += 'i';
327
+ if (options.multiline) flags += 'm';
328
+
329
+ return { regex: new RegExp(pattern, flags) };
330
+ } catch (e) {
331
+ return { error: e instanceof Error ? e.message : 'Invalid pattern' };
332
+ }
333
+ }
334
+
335
+ async function searchInFile(filePath: string, query: string, options: SearchOptions): Promise<SearchResult> {
95
336
  try {
96
- const content = await readFile(filePath, 'utf-8');
97
- const searchQuery = caseSensitive ? query : query.toLowerCase();
98
- const matches: Array<{ line: number; content: string }> = [];
337
+ const stats = await stat(filePath);
338
+
339
+ if (stats.size > options.maxFileSize) {
340
+ return {
341
+ matches: [],
342
+ skipped: true,
343
+ skipReason: `File too large (${Math.round(stats.size / 1024)}KB > ${Math.round(options.maxFileSize / 1024)}KB)`
344
+ };
345
+ }
346
+
347
+ const buffer = await readFile(filePath);
348
+
349
+ if (isBinaryFile(buffer)) {
350
+ return {
351
+ matches: [],
352
+ skipped: true,
353
+ skipReason: 'Binary file'
354
+ };
355
+ }
356
+
357
+ const content = buffer.toString('utf-8');
358
+ const lines = content.split('\n');
99
359
 
100
- let lineNumber = 1;
101
- let lineStart = 0;
360
+ const regexResult = buildSearchRegex(query, options);
361
+ if (regexResult.error || !regexResult.regex) {
362
+ return { matches: [], error: regexResult.error ?? 'Failed to build search pattern' };
363
+ }
364
+ const regex: RegExp = regexResult.regex;
365
+
366
+ if (options.invertMatch) {
367
+ const hasMatch = lines.some(line => regex.test(line));
368
+ return {
369
+ matches: [],
370
+ matchCount: hasMatch ? 0 : 1,
371
+ };
372
+ }
102
373
 
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();
374
+ if (options.multiline && options.isRegex) {
375
+ const multilineMatches: Array<{ line: number; content: string }> = [];
376
+ let match;
377
+ regex.lastIndex = 0;
107
378
 
108
- if (lineContent.includes(searchQuery)) {
109
- matches.push({ line: lineNumber, content: rawLine });
379
+ while ((match = regex.exec(content)) !== null) {
380
+ const matchStart = match.index;
381
+ let lineNumber = 1;
382
+ for (let i = 0; i < matchStart; i++) {
383
+ if (content[i] === '\n') lineNumber++;
110
384
  }
111
385
 
112
- lineNumber++;
113
- lineStart = i + 1;
386
+ const matchedText = match[0];
387
+ const matchLines = matchedText.split('\n');
388
+
389
+ multilineMatches.push({
390
+ line: lineNumber,
391
+ content: matchLines.length > 1
392
+ ? `${matchLines[0]}... (+${matchLines.length - 1} lines)`
393
+ : matchedText.slice(0, 200)
394
+ });
395
+
396
+ if (regex.lastIndex === match.index) {
397
+ regex.lastIndex++;
398
+ }
114
399
  }
400
+
401
+ return { matches: multilineMatches, matchCount: multilineMatches.length };
115
402
  }
116
403
 
117
- return matches;
118
- } catch {
119
- return [];
404
+ const matches: Array<{ line: number; content: string; context?: { before: string[]; after: string[] } }> = [];
405
+ let matchCount = 0;
406
+
407
+ for (let i = 0; i < lines.length; i++) {
408
+ const line = lines[i];
409
+ if (line === undefined) continue;
410
+
411
+ regex.lastIndex = 0;
412
+ if (regex.test(line)) {
413
+ matchCount++;
414
+
415
+ const contextBefore: string[] = [];
416
+ const contextAfter: string[] = [];
417
+
418
+ if (options.contextBefore > 0) {
419
+ for (let j = Math.max(0, i - options.contextBefore); j < i; j++) {
420
+ const ctxLine = lines[j];
421
+ if (ctxLine !== undefined) contextBefore.push(ctxLine);
422
+ }
423
+ }
424
+
425
+ if (options.contextAfter > 0) {
426
+ for (let j = i + 1; j <= Math.min(lines.length - 1, i + options.contextAfter); j++) {
427
+ const ctxLine = lines[j];
428
+ if (ctxLine !== undefined) contextAfter.push(ctxLine);
429
+ }
430
+ }
431
+
432
+ const hasContext = contextBefore.length > 0 || contextAfter.length > 0;
433
+
434
+ matches.push({
435
+ line: i + 1,
436
+ content: line,
437
+ ...(hasContext && { context: { before: contextBefore, after: contextAfter } })
438
+ });
439
+ }
440
+ }
441
+
442
+ return { matches, matchCount };
443
+ } catch (error) {
444
+ return {
445
+ matches: [],
446
+ error: error instanceof Error ? error.message : 'Unknown error'
447
+ };
120
448
  }
121
449
  }
122
450
 
@@ -126,12 +454,18 @@ interface WalkResult {
126
454
  excluded?: boolean;
127
455
  }
128
456
 
129
- async function walkDirectory(dir: string, filePattern?: string, includeHidden = false): Promise<WalkResult[]> {
457
+ interface WalkOutput {
458
+ results: WalkResult[];
459
+ errors: string[];
460
+ }
461
+
462
+ async function walkDirectory(dir: string, filePattern?: string, includeHidden = false): Promise<WalkOutput> {
130
463
  const results: WalkResult[] = [];
464
+ const errors: string[] = [];
131
465
 
132
466
  try {
133
467
  const entries = await readdir(dir, { withFileTypes: true });
134
- const subDirPromises: Promise<WalkResult[]>[] = [];
468
+ const subDirPromises: Promise<WalkOutput>[] = [];
135
469
 
136
470
  for (const entry of entries) {
137
471
  if (!includeHidden && entry.name.startsWith('.')) continue;
@@ -152,27 +486,31 @@ async function walkDirectory(dir: string, filePattern?: string, includeHidden =
152
486
  }
153
487
 
154
488
  if (subDirPromises.length > 0) {
155
- const subResults = await Promise.all(subDirPromises);
156
- for (const subResult of subResults) {
157
- results.push(...subResult);
489
+ const subOutputs = await Promise.all(subDirPromises);
490
+ for (const sub of subOutputs) {
491
+ results.push(...sub.results);
492
+ errors.push(...sub.errors);
158
493
  }
159
494
  }
160
- } catch {
161
- return results;
495
+ } catch (e) {
496
+ errors.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
162
497
  }
163
498
 
164
- return results;
499
+ return { results, errors };
165
500
  }
166
501
 
167
- async function listFilesRecursive(dirPath: string, workspace: string, filterPattern?: string, includeHidden = false): Promise<WalkResult[]> {
502
+ async function listFilesRecursive(dirPath: string, workspace: string, filterPattern?: string, includeHidden = false): Promise<WalkOutput> {
168
503
  const fullPath = resolve(workspace, dirPath);
169
- const files = await walkDirectory(fullPath, filterPattern, includeHidden);
504
+ const { results, errors } = await walkDirectory(fullPath, filterPattern, includeHidden);
170
505
  const separator = workspace.endsWith(sep) ? '' : sep;
171
506
 
172
- return files.map(file => ({
173
- ...file,
174
- path: file.path.replace(workspace + separator, '')
175
- }));
507
+ return {
508
+ results: results.map(file => ({
509
+ ...file,
510
+ path: file.path.replace(workspace + separator, '')
511
+ })),
512
+ errors,
513
+ };
176
514
  }
177
515
 
178
516
  async function findFilesByPattern(pattern: string, searchPath: string): Promise<string[]> {
@@ -181,7 +519,7 @@ async function findFilesByPattern(pattern: string, searchPath: string): Promise<
181
519
  const hasDoubleStar = pattern.includes('**');
182
520
 
183
521
  if (hasDoubleStar) {
184
- const files = await walkDirectory(searchPath, undefined, false);
522
+ const { results: files } = await walkDirectory(searchPath, undefined, false);
185
523
  const separator = searchPath.endsWith(sep) ? '' : sep;
186
524
  const root = searchPath + separator;
187
525
 
@@ -380,9 +718,11 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
380
718
  switch (toolName) {
381
719
  case 'read': {
382
720
  const path = args.path as string;
721
+ const startLine = args.start_line as number | undefined;
722
+ const endLine = args.end_line as number | undefined;
383
723
  const fullPath = resolve(workspace, path);
384
724
 
385
- if (!validatePath(fullPath, workspace)) {
725
+ if (!await validatePath(fullPath, workspace)) {
386
726
  return {
387
727
  success: false,
388
728
  error: 'Access denied: path is outside workspace'
@@ -390,6 +730,26 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
390
730
  }
391
731
 
392
732
  const content = await readFile(fullPath, 'utf-8');
733
+
734
+ if (startLine !== undefined || endLine !== undefined) {
735
+ const lines = content.split('\n');
736
+ const start = (startLine ?? 1) - 1;
737
+ const end = endLine ?? lines.length;
738
+
739
+ if (start < 0 || start >= lines.length) {
740
+ return {
741
+ success: false,
742
+ error: `Start line ${startLine} is out of bounds (1-${lines.length})`
743
+ };
744
+ }
745
+
746
+ const selectedLines = lines.slice(start, end);
747
+ return {
748
+ success: true,
749
+ result: selectedLines.join('\n')
750
+ };
751
+ }
752
+
393
753
  return {
394
754
  success: true,
395
755
  result: content
@@ -398,19 +758,18 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
398
758
 
399
759
  case 'write': {
400
760
  const path = args.path as string;
401
- const content = typeof args.content === 'string' ? args.content : '';
761
+ let content = typeof args.content === 'string' ? args.content : '';
762
+ if (content) content = content.trimEnd();
402
763
  const append = args.append === true;
403
764
  const fullPath = resolve(workspace, path);
404
765
 
405
- if (!validatePath(fullPath, workspace)) {
766
+ if (!await validatePath(fullPath, workspace)) {
406
767
  return {
407
768
  success: false,
408
769
  error: 'Access denied: path is outside workspace'
409
770
  };
410
771
  }
411
772
 
412
- captureFileSnapshot(path);
413
-
414
773
  await mkdir(dirname(fullPath), { recursive: true });
415
774
 
416
775
  let oldContent = '';
@@ -455,7 +814,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
455
814
  const includeHidden = args.include_hidden === null ? undefined : (args.include_hidden as boolean | undefined);
456
815
  const fullPath = resolve(workspace, path);
457
816
 
458
- if (!validatePath(fullPath, workspace)) {
817
+ if (!await validatePath(fullPath, workspace)) {
459
818
  return {
460
819
  success: false,
461
820
  error: 'Access denied: path is outside workspace'
@@ -463,7 +822,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
463
822
  }
464
823
 
465
824
  if (recursive) {
466
- const files = await listFilesRecursive(path, workspace, filter, includeHidden);
825
+ const { results: files, errors: walkErrors } = await listFilesRecursive(path, workspace, filter, includeHidden);
467
826
  const fileStats = await Promise.all(
468
827
  files.map(async (file) => {
469
828
  if (file.excluded) {
@@ -474,17 +833,29 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
474
833
  };
475
834
  }
476
835
  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
- };
836
+ try {
837
+ const stats = await stat(filePath);
838
+ return {
839
+ path: file.path,
840
+ type: stats.isDirectory() ? 'directory' : 'file',
841
+ size: stats.size,
842
+ };
843
+ } catch {
844
+ return {
845
+ path: file.path,
846
+ type: 'unknown',
847
+ error: 'access denied',
848
+ };
849
+ }
483
850
  })
484
851
  );
852
+ const output: Record<string, unknown> = { files: fileStats };
853
+ if (walkErrors.length > 0) {
854
+ output.errors = walkErrors.slice(0, 10);
855
+ }
485
856
  return {
486
857
  success: true,
487
- result: JSON.stringify(fileStats, null, 2)
858
+ result: JSON.stringify(output, null, 2)
488
859
  };
489
860
  } else {
490
861
  const entries = await readdir(fullPath, { withFileTypes: true });
@@ -495,7 +866,11 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
495
866
  }
496
867
 
497
868
  if (filter) {
498
- const regex = new RegExp(filter.replace(/\*/g, '.*').replace(/\?/g, '.'));
869
+ const escapedFilter = filter
870
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
871
+ .replace(/\*/g, '.*')
872
+ .replace(/\?/g, '.');
873
+ const regex = new RegExp(`^${escapedFilter}$`, 'i');
499
874
  filteredEntries = filteredEntries.filter(entry => regex.test(entry.name));
500
875
  }
501
876
 
@@ -544,7 +919,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
544
919
  : `Command timed out after ${timeout}ms and produced no output.\n\n[Process may be running in background]`;
545
920
 
546
921
  return {
547
- success: true,
922
+ success: false,
548
923
  result: output
549
924
  };
550
925
  }
@@ -556,7 +931,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
556
931
  : `Command failed: ${errorMessage}`;
557
932
 
558
933
  return {
559
- success: true,
934
+ success: false,
560
935
  result: fullOutput
561
936
  };
562
937
  }
@@ -567,7 +942,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
567
942
  const searchPath = (args.path === null ? undefined : (args.path as string | undefined)) || '.';
568
943
  const fullPath = resolve(workspace, searchPath);
569
944
 
570
- if (!validatePath(fullPath, workspace)) {
945
+ if (!await validatePath(fullPath, workspace)) {
571
946
  return {
572
947
  success: false,
573
948
  error: 'Access denied: path is outside workspace'
@@ -583,72 +958,227 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
583
958
  }
584
959
 
585
960
  case 'grep': {
586
- const pattern = args.pattern as string;
961
+ const { FILE_TYPE_EXTENSIONS } = await import('./grep.ts');
962
+
963
+ const pattern = args.pattern === null ? undefined : (args.pattern as string | undefined);
964
+ const fileType = args.file_type === null ? undefined : (args.file_type as string | undefined);
587
965
  const query = args.query as string;
588
966
  const searchPath = (args.path === null ? undefined : (args.path as string | undefined)) || '.';
589
967
  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);
968
+ const isRegex = ((args.regex === null ? undefined : (args.regex as boolean | undefined)) ?? false);
969
+ const wholeWord = ((args.whole_word === null ? undefined : (args.whole_word as boolean | undefined)) ?? false);
970
+ const multiline = ((args.multiline === null ? undefined : (args.multiline as boolean | undefined)) ?? false);
971
+ const context = ((args.context === null ? undefined : (args.context as number | undefined)) ?? 0);
972
+ const contextBefore = ((args.context_before === null ? undefined : (args.context_before as number | undefined)) ?? context);
973
+ const contextAfter = ((args.context_after === null ? undefined : (args.context_after as number | undefined)) ?? context);
974
+ const maxResults = ((args.max_results === null ? undefined : (args.max_results as number | undefined)) ?? 500);
975
+ const maxFileSize = ((args.max_file_size === null ? undefined : (args.max_file_size as number | undefined)) ?? DEFAULT_MAX_FILE_SIZE);
976
+ const includeHidden = ((args.include_hidden === null ? undefined : (args.include_hidden as boolean | undefined)) ?? false);
977
+ const excludePattern = args.exclude_pattern === null ? undefined : (args.exclude_pattern as string | undefined);
978
+ const outputMode = ((args.output_mode === null ? undefined : (args.output_mode as string | undefined)) ?? 'matches') as 'matches' | 'files' | 'count';
979
+ const invertMatch = ((args.invert_match === null ? undefined : (args.invert_match as boolean | undefined)) ?? false);
980
+
591
981
  const fullPath = resolve(workspace, searchPath);
592
982
 
593
- if (!validatePath(fullPath, workspace)) {
983
+ if (!await validatePath(fullPath, workspace)) {
594
984
  return {
595
985
  success: false,
596
986
  error: 'Access denied: path is outside workspace'
597
987
  };
598
988
  }
599
989
 
600
- const files = await findFilesByPattern(pattern, fullPath);
990
+ const testSearchOptions: SearchOptions = {
991
+ caseSensitive,
992
+ isRegex,
993
+ wholeWord,
994
+ multiline,
995
+ contextBefore: 0,
996
+ contextAfter: 0,
997
+ maxFileSize: DEFAULT_MAX_FILE_SIZE,
998
+ invertMatch: false,
999
+ };
1000
+ const regexTest = buildSearchRegex(query, testSearchOptions);
1001
+ if (regexTest.error) {
1002
+ return {
1003
+ success: false,
1004
+ error: `Invalid search pattern: ${regexTest.error}`
1005
+ };
1006
+ }
1007
+
1008
+ const normalizedFileType = typeof fileType === 'string' ? fileType.trim().toLowerCase() : undefined;
1009
+ const fileTypeParts = normalizedFileType
1010
+ ? normalizedFileType.split(',').map(p => p.trim()).filter(Boolean)
1011
+ : [];
1012
+ const resolvedExtensions = fileTypeParts.length > 0
1013
+ ? Array.from(new Set(fileTypeParts.flatMap((part) => {
1014
+ const mapped = FILE_TYPE_EXTENSIONS[part];
1015
+ if (mapped && mapped.length > 0) return mapped;
1016
+ if (part.startsWith('.')) return [part];
1017
+ return [`.${part}`];
1018
+ })))
1019
+ : undefined;
1020
+
1021
+ let finalPattern: string;
1022
+ if (pattern) {
1023
+ finalPattern = pattern.includes('**') ? pattern : `**/${pattern}`;
1024
+ } else if (resolvedExtensions && resolvedExtensions.length === 1) {
1025
+ finalPattern = `**/*${resolvedExtensions[0]}`;
1026
+ } else {
1027
+ finalPattern = '**/*';
1028
+ }
1029
+
1030
+ let allFiles = await findFilesByPattern(finalPattern, fullPath);
1031
+
1032
+ if (!includeHidden) {
1033
+ allFiles = allFiles.filter(f => !f.split('/').some(part => part.startsWith('.')));
1034
+ }
1035
+
1036
+ if (resolvedExtensions && !pattern) {
1037
+ allFiles = allFiles.filter(f => resolvedExtensions.some(ext => f.toLowerCase().endsWith(ext)));
1038
+ }
601
1039
 
602
- const results: Array<{ file: string; matches: Array<{ line: number; content: string }> }> = [];
1040
+ if (excludePattern) {
1041
+ allFiles = allFiles.filter(f => !matchGlob(f, excludePattern));
1042
+ }
1043
+
1044
+ const searchOptions: SearchOptions = {
1045
+ caseSensitive,
1046
+ isRegex,
1047
+ wholeWord,
1048
+ multiline,
1049
+ contextBefore,
1050
+ contextAfter,
1051
+ maxFileSize,
1052
+ invertMatch,
1053
+ };
1054
+
1055
+ type MatchType = { line: number; content: string; context?: { before: string[]; after: string[] } };
1056
+ const results: Array<{ file: string; matches: MatchType[]; count?: number }> = [];
1057
+ const skippedFiles: Array<{ file: string; reason: string }> = [];
603
1058
  let totalResults = 0;
1059
+ let totalMatchCount = 0;
604
1060
 
605
- const BATCH_SIZE = 10;
606
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
607
- if (totalResults >= maxResults) break;
1061
+ const BATCH_SIZE = 15;
1062
+ for (let i = 0; i < allFiles.length; i += BATCH_SIZE) {
1063
+ if (!invertMatch && totalResults >= maxResults) break;
608
1064
 
609
- const batch = files.slice(i, i + BATCH_SIZE);
1065
+ const batch = allFiles.slice(i, i + BATCH_SIZE);
610
1066
  const batchResults = await Promise.all(
611
1067
  batch.map(async (file) => {
612
1068
  const filePath = resolve(fullPath, file);
613
- const matches = await searchInFile(filePath, query, caseSensitive);
614
- return { file: join(searchPath, file), matches };
1069
+ const searchResult = await searchInFile(filePath, query, searchOptions);
1070
+ return {
1071
+ file: join(searchPath, file),
1072
+ matches: searchResult.matches,
1073
+ matchCount: searchResult.matchCount ?? searchResult.matches.length,
1074
+ skipped: searchResult.skipped,
1075
+ skipReason: searchResult.skipReason,
1076
+ };
615
1077
  })
616
1078
  );
617
1079
 
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;
1080
+ for (const { file, matches, matchCount, skipped, skipReason } of batchResults) {
1081
+ if (skipped && skipReason) {
1082
+ skippedFiles.push({ file, reason: skipReason });
1083
+ continue;
1084
+ }
1085
+
1086
+ if (invertMatch) {
1087
+ if (matchCount === 1) {
1088
+ results.push({ file, matches: [], count: 1 });
1089
+ totalResults++;
1090
+ }
1091
+ continue;
1092
+ }
1093
+
1094
+ if (matches.length > 0 || matchCount > 0) {
1095
+ totalMatchCount += matchCount;
1096
+
1097
+ if (outputMode === 'files') {
1098
+ results.push({ file, matches: [] });
1099
+ totalResults++;
1100
+ } else if (outputMode === 'count') {
1101
+ results.push({ file, matches: [], count: matchCount });
1102
+ totalResults++;
1103
+ } else {
1104
+ const remainingSlots = maxResults - totalResults;
1105
+ const matchesToInclude = matches.slice(0, remainingSlots);
1106
+ results.push({ file, matches: matchesToInclude, count: matchCount });
1107
+ totalResults += matchesToInclude.length;
1108
+ }
626
1109
  }
627
1110
  }
628
1111
  }
629
1112
 
1113
+ let formattedResult: string;
1114
+
1115
+ const skippedDetails = skippedFiles.length > 0
1116
+ ? skippedFiles.slice(0, 5).map(s => ({ file: s.file, reason: s.reason }))
1117
+ : undefined;
1118
+
1119
+ if (outputMode === 'files') {
1120
+ const filesOnly = results.map(r => r.file);
1121
+ const summary = {
1122
+ files_found: filesOnly.length,
1123
+ files: filesOnly,
1124
+ ...(skippedFiles.length > 0 && { skipped: skippedFiles.length, skipped_details: skippedDetails })
1125
+ };
1126
+ formattedResult = JSON.stringify(summary, null, 2);
1127
+ } else if (outputMode === 'count') {
1128
+ const counts = results.map(r => ({ file: r.file, count: r.count ?? 0 }));
1129
+ const summary = {
1130
+ total_matches: totalMatchCount,
1131
+ files_with_matches: counts.length,
1132
+ counts,
1133
+ ...(skippedFiles.length > 0 && { skipped: skippedFiles.length, skipped_details: skippedDetails })
1134
+ };
1135
+ formattedResult = JSON.stringify(summary, null, 2);
1136
+ } else {
1137
+ const summary = {
1138
+ total_matches: totalMatchCount,
1139
+ files_searched: allFiles.length,
1140
+ files_with_matches: results.length,
1141
+ ...(skippedFiles.length > 0 && { skipped_files: skippedFiles.length, skipped_details: skippedDetails }),
1142
+ ...(totalResults >= maxResults && { truncated: true, max_results: maxResults }),
1143
+ results: results.map(r => ({
1144
+ file: r.file,
1145
+ match_count: r.count ?? r.matches.length,
1146
+ matches: r.matches.map(m => {
1147
+ if (m.context && (m.context.before.length > 0 || m.context.after.length > 0)) {
1148
+ return {
1149
+ line: m.line,
1150
+ content: m.content,
1151
+ context: m.context
1152
+ };
1153
+ }
1154
+ return { line: m.line, content: m.content };
1155
+ })
1156
+ }))
1157
+ };
1158
+ formattedResult = JSON.stringify(summary, null, 2);
1159
+ }
1160
+
630
1161
  return {
631
1162
  success: true,
632
- result: JSON.stringify(results, null, 2)
1163
+ result: formattedResult
633
1164
  };
634
1165
  }
635
1166
 
636
1167
  case 'edit': {
637
1168
  const path = args.path as string;
638
1169
  const oldContent = args.old_content as string;
639
- const newContent = args.new_content as string;
1170
+ let newContent = args.new_content as string;
1171
+ if (newContent) newContent = newContent.trimEnd();
640
1172
  const occurrence = ((args.occurrence === null ? undefined : (args.occurrence as number | undefined)) ?? 1);
641
1173
  const fullPath = resolve(workspace, path);
642
1174
 
643
- if (!validatePath(fullPath, workspace)) {
1175
+ if (!await validatePath(fullPath, workspace)) {
644
1176
  return {
645
1177
  success: false,
646
1178
  error: 'Access denied: path is outside workspace'
647
1179
  };
648
1180
  }
649
1181
 
650
- captureFileSnapshot(path);
651
-
652
1182
  await mkdir(dirname(fullPath), { recursive: true });
653
1183
 
654
1184
  let content = '';
@@ -720,7 +1250,7 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
720
1250
  }
721
1251
  const fullPath = resolve(workspace, path);
722
1252
 
723
- if (!validatePath(fullPath, workspace)) {
1253
+ if (!await validatePath(fullPath, workspace)) {
724
1254
  return {
725
1255
  success: false,
726
1256
  error: 'Access denied: path is outside workspace'
@@ -735,6 +1265,95 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
735
1265
  };
736
1266
  }
737
1267
 
1268
+ case 'fetch': {
1269
+ const url = args.url as string;
1270
+ const maxLength = (args.max_length as number | undefined) ?? DEFAULT_FETCH_MAX_LENGTH;
1271
+ const startIndex = (args.start_index as number | undefined) ?? 0;
1272
+ const raw = (args.raw as boolean | undefined) ?? false;
1273
+ const timeout = (args.timeout as number | undefined) ?? DEFAULT_FETCH_TIMEOUT;
1274
+
1275
+ try {
1276
+ new URL(url);
1277
+ } catch {
1278
+ return {
1279
+ success: false,
1280
+ error: `Invalid URL: ${url}`,
1281
+ };
1282
+ }
1283
+
1284
+ try {
1285
+ let fetchResult = await fetchUrlContent(url, { raw, timeout });
1286
+ let { content, contentType, title, isSPA, status, statusText } = fetchResult;
1287
+
1288
+ if (isSPA && !raw) {
1289
+ const rawResult = await fetchUrlContent(url, { raw: true, timeout });
1290
+ content = rawResult.content;
1291
+ contentType = rawResult.contentType;
1292
+ title = rawResult.title;
1293
+ status = rawResult.status;
1294
+ statusText = rawResult.statusText;
1295
+ isSPA = false;
1296
+ }
1297
+
1298
+ const totalLength = content.length;
1299
+
1300
+ if (startIndex >= totalLength) {
1301
+ return {
1302
+ success: false,
1303
+ error: `Start index ${startIndex} exceeds content length ${totalLength}`,
1304
+ };
1305
+ }
1306
+
1307
+ const extractedContent = content.slice(startIndex, startIndex + maxLength);
1308
+ const truncated = startIndex + maxLength < totalLength;
1309
+ const nextStartIndex = truncated ? startIndex + maxLength : undefined;
1310
+
1311
+ const parts: string[] = [];
1312
+
1313
+ if (title) {
1314
+ parts.push(`# ${title}\n`);
1315
+ }
1316
+
1317
+ parts.push(`**URL:** ${url}`);
1318
+ parts.push(`**Status:** ${status} ${statusText}`);
1319
+ parts.push(`**Content-Type:** ${contentType}`);
1320
+ parts.push(`**Length:** ${extractedContent.length} / ${totalLength} characters`);
1321
+
1322
+ if (fetchResult.isSPA) {
1323
+ parts.push(`**Note:** SPA detected (React/Vue/Angular). Showing raw HTML source.`);
1324
+ }
1325
+
1326
+ if (truncated && nextStartIndex !== undefined) {
1327
+ parts.push(`**Status:** Content truncated. Use start_index=${nextStartIndex} to continue reading.`);
1328
+ }
1329
+
1330
+ parts.push('\n---\n');
1331
+ parts.push(extractedContent);
1332
+
1333
+ if (truncated && nextStartIndex !== undefined) {
1334
+ parts.push(`\n\n---\n*Content truncated at ${extractedContent.length} characters. Call fetch again with start_index=${nextStartIndex} to continue reading.*`);
1335
+ }
1336
+
1337
+ return {
1338
+ success: true,
1339
+ result: parts.join('\n'),
1340
+ };
1341
+ } catch (error) {
1342
+ const message = error instanceof Error ? error.message : String(error);
1343
+
1344
+ if (message.includes('abort')) {
1345
+ return {
1346
+ success: false,
1347
+ error: `Request timed out after ${timeout}ms`,
1348
+ };
1349
+ }
1350
+
1351
+ return {
1352
+ success: false,
1353
+ error: `Failed to fetch ${url}: ${message}`,
1354
+ };
1355
+ }
1356
+ }
738
1357
 
739
1358
  default:
740
1359
  return {
@@ -748,4 +1367,4 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
748
1367
  error: error instanceof Error ? error.message : 'Unknown error occurred'
749
1368
  };
750
1369
  }
751
- }
1370
+ }