@kirosnn/mosaic 0.0.91 → 0.71.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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -2
  3. package/package.json +52 -47
  4. package/src/agent/prompts/systemPrompt.ts +198 -68
  5. package/src/agent/prompts/toolsPrompt.ts +217 -135
  6. package/src/agent/provider/anthropic.ts +19 -15
  7. package/src/agent/provider/google.ts +21 -17
  8. package/src/agent/provider/ollama.ts +80 -41
  9. package/src/agent/provider/openai.ts +107 -67
  10. package/src/agent/provider/reasoning.ts +29 -0
  11. package/src/agent/provider/xai.ts +19 -15
  12. package/src/agent/tools/definitions.ts +9 -5
  13. package/src/agent/tools/executor.ts +655 -46
  14. package/src/agent/tools/exploreExecutor.ts +12 -12
  15. package/src/agent/tools/fetch.ts +58 -0
  16. package/src/agent/tools/glob.ts +20 -4
  17. package/src/agent/tools/grep.ts +62 -8
  18. package/src/agent/tools/plan.ts +27 -0
  19. package/src/agent/tools/read.ts +2 -0
  20. package/src/agent/types.ts +6 -6
  21. package/src/components/App.tsx +67 -25
  22. package/src/components/CustomInput.tsx +274 -68
  23. package/src/components/Main.tsx +323 -168
  24. package/src/components/ShortcutsModal.tsx +11 -8
  25. package/src/components/main/ChatPage.tsx +217 -58
  26. package/src/components/main/HomePage.tsx +5 -1
  27. package/src/components/main/ThinkingIndicator.tsx +11 -1
  28. package/src/components/main/types.ts +11 -10
  29. package/src/index.tsx +3 -5
  30. package/src/utils/approvalBridge.ts +29 -8
  31. package/src/utils/approvalModeBridge.ts +17 -0
  32. package/src/utils/commands/approvals.ts +48 -0
  33. package/src/utils/commands/image.ts +109 -0
  34. package/src/utils/commands/index.ts +5 -1
  35. package/src/utils/diffRendering.tsx +13 -14
  36. package/src/utils/history.ts +82 -40
  37. package/src/utils/imageBridge.ts +28 -0
  38. package/src/utils/images.ts +31 -0
  39. package/src/utils/models.ts +0 -7
  40. package/src/utils/notificationBridge.ts +23 -0
  41. package/src/utils/toolFormatting.ts +162 -43
  42. package/src/web/app.tsx +94 -34
  43. package/src/web/assets/css/ChatPage.css +102 -30
  44. package/src/web/assets/css/MessageItem.css +26 -29
  45. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  46. package/src/web/assets/css/ToolMessage.css +36 -14
  47. package/src/web/components/ChatPage.tsx +228 -105
  48. package/src/web/components/HomePage.tsx +6 -6
  49. package/src/web/components/MessageItem.tsx +88 -89
  50. package/src/web/components/Setup.tsx +1 -1
  51. package/src/web/components/Sidebar.tsx +1 -1
  52. package/src/web/components/ThinkingIndicator.tsx +40 -21
  53. package/src/web/router.ts +1 -1
  54. package/src/web/server.tsx +187 -39
  55. package/src/web/storage.ts +23 -1
  56. package/src/web/types.ts +7 -6
@@ -7,9 +7,180 @@ import { shouldRequireApprovals } from '../../utils/config';
7
7
  import { generateDiff, formatDiffForDisplay } from '../../utils/diff';
8
8
  import { captureFileSnapshot } from '../../utils/undoRedo';
9
9
  import { trackFileChange, trackFileCreated, trackFileDeleted } from '../../utils/fileChangeTracker';
10
+ import TurndownService from 'turndown';
11
+ import { Readability } from '@mozilla/readability';
12
+ import { parseHTML } from 'linkedom';
10
13
 
11
14
  const execAsync = promisify(exec);
12
15
 
16
+ const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0';
17
+ const DEFAULT_FETCH_MAX_LENGTH = 10000;
18
+ const DEFAULT_FETCH_TIMEOUT = 30000;
19
+
20
+ function extractContentFromHtml(html: string, url: string): { content: string; title: string | null; isSPA: boolean } {
21
+ const { document } = parseHTML(html);
22
+
23
+ const turndown = new TurndownService({
24
+ headingStyle: 'atx',
25
+ codeBlockStyle: 'fenced',
26
+ emDelimiter: '*',
27
+ });
28
+
29
+ turndown.addRule('removeScripts', {
30
+ filter: ['script', 'style', 'noscript'],
31
+ replacement: () => '',
32
+ });
33
+
34
+ turndown.addRule('preserveLinks', {
35
+ filter: 'a',
36
+ replacement: (content, node) => {
37
+ const element = node as HTMLAnchorElement;
38
+ const href = element.getAttribute('href');
39
+ if (!href || href.startsWith('#')) return content;
40
+
41
+ try {
42
+ const absoluteUrl = new URL(href, url).toString();
43
+ return `[${content}](${absoluteUrl})`;
44
+ } catch {
45
+ return `[${content}](${href})`;
46
+ }
47
+ },
48
+ });
49
+
50
+ turndown.addRule('preserveImages', {
51
+ filter: 'img',
52
+ replacement: (_content, node) => {
53
+ const element = node as HTMLImageElement;
54
+ const src = element.getAttribute('src');
55
+ const alt = element.getAttribute('alt') || '';
56
+ if (!src) return '';
57
+
58
+ try {
59
+ const absoluteUrl = new URL(src, url).toString();
60
+ return `![${alt}](${absoluteUrl})`;
61
+ } catch {
62
+ return `![${alt}](${src})`;
63
+ }
64
+ },
65
+ });
66
+
67
+ const reader = new Readability(document as unknown as Document, {
68
+ charThreshold: 0,
69
+ });
70
+ const article = reader.parse();
71
+
72
+ if (article && article.content) {
73
+ const content = turndown.turndown(article.content).trim();
74
+ if (content.length > 50) {
75
+ return {
76
+ content,
77
+ title: article.title || document.title || null,
78
+ isSPA: false,
79
+ };
80
+ }
81
+ }
82
+
83
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
84
+ const bodyContent = bodyMatch ? bodyMatch[1] : html;
85
+ const markdownContent = turndown.turndown(bodyContent || '').trim();
86
+
87
+ if (markdownContent.length > 50) {
88
+ return {
89
+ content: markdownContent,
90
+ title: document.title || null,
91
+ isSPA: false,
92
+ };
93
+ }
94
+
95
+ const isSPA = html.includes('id="root"') ||
96
+ html.includes('id="app"') ||
97
+ html.includes('id="__next"') ||
98
+ html.includes('data-reactroot') ||
99
+ html.includes('ng-app');
100
+
101
+ const metaTags: string[] = [];
102
+ const metaDescription = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i);
103
+ const metaOgTitle = html.match(/<meta[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i);
104
+ const metaOgDescription = html.match(/<meta[^>]*property=["']og:description["'][^>]*content=["']([^"']+)["']/i);
105
+
106
+ if (metaOgTitle) metaTags.push(`**Title:** ${metaOgTitle[1]}`);
107
+ if (metaDescription) metaTags.push(`**Description:** ${metaDescription[1]}`);
108
+ if (metaOgDescription && metaOgDescription[1] !== metaDescription?.[1]) {
109
+ metaTags.push(`**OG Description:** ${metaOgDescription[1]}`);
110
+ }
111
+
112
+ let content = '';
113
+ if (isSPA) {
114
+ 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`;
115
+ if (metaTags.length > 0) {
116
+ content += `**Available metadata:**\n${metaTags.join('\n')}\n\n`;
117
+ }
118
+ content += `*To see the actual content, you would need a headless browser. Try using raw=true to see the HTML source.*`;
119
+ } else if (markdownContent) {
120
+ content = markdownContent;
121
+ } else {
122
+ content = `*No readable content could be extracted from this page.*\n\n`;
123
+ if (metaTags.length > 0) {
124
+ content += `**Available metadata:**\n${metaTags.join('\n')}`;
125
+ }
126
+ }
127
+
128
+ return {
129
+ content,
130
+ title: document.title || null,
131
+ isSPA,
132
+ };
133
+ }
134
+
135
+ async function fetchUrlContent(
136
+ url: string,
137
+ options: {
138
+ raw?: boolean;
139
+ timeout?: number;
140
+ userAgent?: string;
141
+ } = {}
142
+ ): Promise<{ content: string; contentType: string; title: string | null; status: number; statusText: string; isSPA?: boolean }> {
143
+ const { raw = false, timeout = DEFAULT_FETCH_TIMEOUT, userAgent = DEFAULT_USER_AGENT } = options;
144
+
145
+ const controller = new AbortController();
146
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
147
+
148
+ try {
149
+ const response = await globalThis.fetch(url, {
150
+ headers: {
151
+ 'User-Agent': userAgent,
152
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
153
+ 'Accept-Language': 'en-US,en;q=0.5',
154
+ },
155
+ signal: controller.signal,
156
+ redirect: 'follow',
157
+ });
158
+
159
+ const status = response.status;
160
+ const statusText = response.statusText;
161
+
162
+ if (!response.ok) {
163
+ throw new Error(`HTTP ${status} ${statusText}`);
164
+ }
165
+
166
+ const contentType = response.headers.get('content-type') || '';
167
+ const text = await response.text();
168
+
169
+ const isHtml = contentType.includes('text/html') ||
170
+ text.slice(0, 500).toLowerCase().includes('<html') ||
171
+ text.slice(0, 500).toLowerCase().includes('<!doctype html');
172
+
173
+ if (isHtml && !raw) {
174
+ const { content, title, isSPA } = extractContentFromHtml(text, url);
175
+ return { content, contentType, title, isSPA, status, statusText };
176
+ }
177
+
178
+ return { content: text, contentType, title: null, status, statusText };
179
+ } finally {
180
+ clearTimeout(timeoutId);
181
+ }
182
+ }
183
+
13
184
  export interface ToolResult {
14
185
  success: boolean;
15
186
  result?: string;
@@ -21,6 +192,7 @@ export interface ToolResult {
21
192
  const pathValidationCache = new Map<string, boolean>();
22
193
  const globPatternCache = new Map<string, RegExp>();
23
194
 
195
+
24
196
  function validatePath(fullPath: string, workspace: string): boolean {
25
197
  const cacheKey = `${fullPath}|${workspace}`;
26
198
  const cached = pathValidationCache.get(cacheKey);
@@ -69,14 +241,14 @@ function matchGlob(filename: string, pattern: string): boolean {
69
241
  if (!regex) {
70
242
  const normalizedPattern = pattern.replace(/\\/g, '/');
71
243
 
72
- let regexPattern = normalizedPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
244
+ let regexPattern = normalizedPattern.replace(/[.+^${}()|[\]\\*?]/g, '\\$&');
73
245
 
74
246
  regexPattern = regexPattern
75
- .replace(/\*\*\//g, '(?:(?:[^/]+/)*)')
76
- .replace(/\/\*\*$/g, '(?:/.*)?')
77
- .replace(/\*\*/g, '.*')
78
- .replace(/\*/g, '[^/]*')
79
- .replace(/\?/g, '[^/]');
247
+ .replace(/\\\*\\\*\\\//g, '(?:(?:[^/]+/)*)')
248
+ .replace(/\\\/\*\\\*$/g, '(?:/.*)?')
249
+ .replace(/\\\*\\\*/g, '.*')
250
+ .replace(/\\\*/g, '[^/]*')
251
+ .replace(/\\\?/g, '[^/]');
80
252
 
81
253
  regex = new RegExp(`^${regexPattern}$`, 'i');
82
254
  globPatternCache.set(pattern, regex);
@@ -91,32 +263,199 @@ function matchGlob(filename: string, pattern: string): boolean {
91
263
  return regex.test(normalizedFilename);
92
264
  }
93
265
 
94
- async function searchInFile(filePath: string, query: string, caseSensitive: boolean): Promise<Array<{ line: number; content: string }>> {
266
+ interface SearchResult {
267
+ matches: Array<{ line: number; content: string; context?: { before: string[]; after: string[] } }>;
268
+ error?: string;
269
+ matchCount?: number;
270
+ skipped?: boolean;
271
+ skipReason?: string;
272
+ }
273
+
274
+ interface SearchOptions {
275
+ caseSensitive: boolean;
276
+ isRegex: boolean;
277
+ wholeWord: boolean;
278
+ multiline: boolean;
279
+ contextBefore: number;
280
+ contextAfter: number;
281
+ maxFileSize: number;
282
+ invertMatch: boolean;
283
+ }
284
+
285
+ const DEFAULT_MAX_FILE_SIZE = 1024 * 1024;
286
+
287
+ function isValidRegex(pattern: string): { valid: boolean; error?: string } {
95
288
  try {
96
- const content = await readFile(filePath, 'utf-8');
97
- const searchQuery = caseSensitive ? query : query.toLowerCase();
98
- const matches: Array<{ line: number; content: string }> = [];
289
+ new RegExp(pattern);
290
+ return { valid: true };
291
+ } catch (e) {
292
+ return { valid: false, error: e instanceof Error ? e.message : 'Invalid regular expression' };
293
+ }
294
+ }
295
+
296
+ function isBinaryFile(buffer: Buffer, bytesToCheck = 8000): boolean {
297
+ const checkLength = Math.min(buffer.length, bytesToCheck);
298
+ let nullCount = 0;
299
+ let controlCount = 0;
300
+
301
+ for (let i = 0; i < checkLength; i++) {
302
+ const byte = buffer[i];
303
+ if (byte === 0) {
304
+ nullCount++;
305
+ if (nullCount > 1) return true;
306
+ }
307
+ if (byte !== undefined && byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
308
+ controlCount++;
309
+ if (controlCount > checkLength * 0.1) return true;
310
+ }
311
+ }
312
+
313
+ return false;
314
+ }
315
+
316
+ function escapeRegexForLiteral(str: string): string {
317
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
318
+ }
319
+
320
+ function buildSearchRegex(query: string, options: SearchOptions): { regex: RegExp; error?: undefined } | { regex?: undefined; error: string } {
321
+ try {
322
+ let pattern = query;
323
+
324
+ if (!options.isRegex) {
325
+ pattern = escapeRegexForLiteral(query);
326
+ }
327
+
328
+ if (options.wholeWord) {
329
+ if (options.isRegex) {
330
+ pattern = `(?:^|\\b)${pattern}(?:\\b|$)`;
331
+ } else {
332
+ pattern = `\\b${pattern}\\b`;
333
+ }
334
+ }
335
+
336
+ let flags = 'g';
337
+ if (!options.caseSensitive) flags += 'i';
338
+ if (options.multiline) flags += 'm';
339
+
340
+ return { regex: new RegExp(pattern, flags) };
341
+ } catch (e) {
342
+ return { error: e instanceof Error ? e.message : 'Invalid pattern' };
343
+ }
344
+ }
345
+
346
+ async function searchInFile(filePath: string, query: string, options: SearchOptions): Promise<SearchResult> {
347
+ try {
348
+ const stats = await stat(filePath);
349
+
350
+ if (stats.size > options.maxFileSize) {
351
+ return {
352
+ matches: [],
353
+ skipped: true,
354
+ skipReason: `File too large (${Math.round(stats.size / 1024)}KB > ${Math.round(options.maxFileSize / 1024)}KB)`
355
+ };
356
+ }
357
+
358
+ const buffer = await readFile(filePath);
99
359
 
100
- let lineNumber = 1;
101
- let lineStart = 0;
360
+ if (isBinaryFile(buffer)) {
361
+ return {
362
+ matches: [],
363
+ skipped: true,
364
+ skipReason: 'Binary file'
365
+ };
366
+ }
367
+
368
+ const content = buffer.toString('utf-8');
369
+ const lines = content.split('\n');
370
+
371
+ const regexResult = buildSearchRegex(query, options);
372
+ if (regexResult.error || !regexResult.regex) {
373
+ return { matches: [], error: regexResult.error ?? 'Failed to build search pattern' };
374
+ }
375
+ const regex: RegExp = regexResult.regex;
376
+
377
+ if (options.invertMatch) {
378
+ const hasMatch = lines.some(line => regex.test(line));
379
+ return {
380
+ matches: [],
381
+ matchCount: hasMatch ? 0 : 1,
382
+ };
383
+ }
102
384
 
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();
385
+ if (options.multiline && options.isRegex) {
386
+ const multilineMatches: Array<{ line: number; content: string }> = [];
387
+ let match;
388
+ regex.lastIndex = 0;
107
389
 
108
- if (lineContent.includes(searchQuery)) {
109
- matches.push({ line: lineNumber, content: rawLine });
390
+ while ((match = regex.exec(content)) !== null) {
391
+ const matchStart = match.index;
392
+ let lineNumber = 1;
393
+ for (let i = 0; i < matchStart; i++) {
394
+ if (content[i] === '\n') lineNumber++;
110
395
  }
111
396
 
112
- lineNumber++;
113
- lineStart = i + 1;
397
+ const matchedText = match[0];
398
+ const matchLines = matchedText.split('\n');
399
+
400
+ multilineMatches.push({
401
+ line: lineNumber,
402
+ content: matchLines.length > 1
403
+ ? `${matchLines[0]}... (+${matchLines.length - 1} lines)`
404
+ : matchedText.slice(0, 200)
405
+ });
406
+
407
+ if (regex.lastIndex === match.index) {
408
+ regex.lastIndex++;
409
+ }
114
410
  }
411
+
412
+ return { matches: multilineMatches, matchCount: multilineMatches.length };
115
413
  }
116
414
 
117
- return matches;
118
- } catch {
119
- return [];
415
+ const matches: Array<{ line: number; content: string; context?: { before: string[]; after: string[] } }> = [];
416
+ let matchCount = 0;
417
+
418
+ for (let i = 0; i < lines.length; i++) {
419
+ const line = lines[i];
420
+ if (line === undefined) continue;
421
+
422
+ regex.lastIndex = 0;
423
+ if (regex.test(line)) {
424
+ matchCount++;
425
+
426
+ const contextBefore: string[] = [];
427
+ const contextAfter: string[] = [];
428
+
429
+ if (options.contextBefore > 0) {
430
+ for (let j = Math.max(0, i - options.contextBefore); j < i; j++) {
431
+ const ctxLine = lines[j];
432
+ if (ctxLine !== undefined) contextBefore.push(ctxLine);
433
+ }
434
+ }
435
+
436
+ if (options.contextAfter > 0) {
437
+ for (let j = i + 1; j <= Math.min(lines.length - 1, i + options.contextAfter); j++) {
438
+ const ctxLine = lines[j];
439
+ if (ctxLine !== undefined) contextAfter.push(ctxLine);
440
+ }
441
+ }
442
+
443
+ const hasContext = contextBefore.length > 0 || contextAfter.length > 0;
444
+
445
+ matches.push({
446
+ line: i + 1,
447
+ content: line,
448
+ ...(hasContext && { context: { before: contextBefore, after: contextAfter } })
449
+ });
450
+ }
451
+ }
452
+
453
+ return { matches, matchCount };
454
+ } catch (error) {
455
+ return {
456
+ matches: [],
457
+ error: error instanceof Error ? error.message : 'Unknown error'
458
+ };
120
459
  }
121
460
  }
122
461
 
@@ -380,6 +719,8 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
380
719
  switch (toolName) {
381
720
  case 'read': {
382
721
  const path = args.path as string;
722
+ const startLine = args.start_line as number | undefined;
723
+ const endLine = args.end_line as number | undefined;
383
724
  const fullPath = resolve(workspace, path);
384
725
 
385
726
  if (!validatePath(fullPath, workspace)) {
@@ -390,6 +731,26 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
390
731
  }
391
732
 
392
733
  const content = await readFile(fullPath, 'utf-8');
734
+
735
+ if (startLine !== undefined || endLine !== undefined) {
736
+ const lines = content.split('\n');
737
+ const start = (startLine ?? 1) - 1;
738
+ const end = endLine ?? lines.length;
739
+
740
+ if (start < 0 || start >= lines.length) {
741
+ return {
742
+ success: false,
743
+ error: `Start line ${startLine} is out of bounds (1-${lines.length})`
744
+ };
745
+ }
746
+
747
+ const selectedLines = lines.slice(start, end);
748
+ return {
749
+ success: true,
750
+ result: selectedLines.join('\n')
751
+ };
752
+ }
753
+
393
754
  return {
394
755
  success: true,
395
756
  result: content
@@ -398,7 +759,8 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
398
759
 
399
760
  case 'write': {
400
761
  const path = args.path as string;
401
- const content = typeof args.content === 'string' ? args.content : '';
762
+ let content = typeof args.content === 'string' ? args.content : '';
763
+ if (content) content = content.trimEnd(); // Ensure no trailing empty lines
402
764
  const append = args.append === true;
403
765
  const fullPath = resolve(workspace, path);
404
766
 
@@ -495,7 +857,11 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
495
857
  }
496
858
 
497
859
  if (filter) {
498
- const regex = new RegExp(filter.replace(/\*/g, '.*').replace(/\?/g, '.'));
860
+ const escapedFilter = filter
861
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
862
+ .replace(/\*/g, '.*')
863
+ .replace(/\?/g, '.');
864
+ const regex = new RegExp(`^${escapedFilter}$`, 'i');
499
865
  filteredEntries = filteredEntries.filter(entry => regex.test(entry.name));
500
866
  }
501
867
 
@@ -583,11 +949,26 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
583
949
  }
584
950
 
585
951
  case 'grep': {
586
- const pattern = args.pattern as string;
952
+ const { FILE_TYPE_EXTENSIONS } = await import('./grep.ts');
953
+
954
+ const pattern = args.pattern === null ? undefined : (args.pattern as string | undefined);
955
+ const fileType = args.file_type === null ? undefined : (args.file_type as string | undefined);
587
956
  const query = args.query as string;
588
957
  const searchPath = (args.path === null ? undefined : (args.path as string | undefined)) || '.';
589
958
  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);
959
+ const isRegex = ((args.regex === null ? undefined : (args.regex as boolean | undefined)) ?? false);
960
+ const wholeWord = ((args.whole_word === null ? undefined : (args.whole_word as boolean | undefined)) ?? false);
961
+ const multiline = ((args.multiline === null ? undefined : (args.multiline as boolean | undefined)) ?? false);
962
+ const context = ((args.context === null ? undefined : (args.context as number | undefined)) ?? 0);
963
+ const contextBefore = ((args.context_before === null ? undefined : (args.context_before as number | undefined)) ?? context);
964
+ const contextAfter = ((args.context_after === null ? undefined : (args.context_after as number | undefined)) ?? context);
965
+ const maxResults = ((args.max_results === null ? undefined : (args.max_results as number | undefined)) ?? 500);
966
+ const maxFileSize = ((args.max_file_size === null ? undefined : (args.max_file_size as number | undefined)) ?? DEFAULT_MAX_FILE_SIZE);
967
+ const includeHidden = ((args.include_hidden === null ? undefined : (args.include_hidden as boolean | undefined)) ?? false);
968
+ const excludePattern = args.exclude_pattern === null ? undefined : (args.exclude_pattern as string | undefined);
969
+ const outputMode = ((args.output_mode === null ? undefined : (args.output_mode as string | undefined)) ?? 'matches') as 'matches' | 'files' | 'count';
970
+ const invertMatch = ((args.invert_match === null ? undefined : (args.invert_match as boolean | undefined)) ?? false);
971
+
591
972
  const fullPath = resolve(workspace, searchPath);
592
973
 
593
974
  if (!validatePath(fullPath, workspace)) {
@@ -597,46 +978,185 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
597
978
  };
598
979
  }
599
980
 
600
- const files = await findFilesByPattern(pattern, fullPath);
981
+ const testSearchOptions: SearchOptions = {
982
+ caseSensitive,
983
+ isRegex,
984
+ wholeWord,
985
+ multiline,
986
+ contextBefore: 0,
987
+ contextAfter: 0,
988
+ maxFileSize: DEFAULT_MAX_FILE_SIZE,
989
+ invertMatch: false,
990
+ };
991
+ const regexTest = buildSearchRegex(query, testSearchOptions);
992
+ if (regexTest.error) {
993
+ return {
994
+ success: false,
995
+ error: `Invalid search pattern: ${regexTest.error}`
996
+ };
997
+ }
998
+
999
+ let finalPattern: string;
1000
+ if (pattern) {
1001
+ finalPattern = pattern.includes('**') ? pattern : `**/${pattern}`;
1002
+ } else if (fileType) {
1003
+ const extensions = FILE_TYPE_EXTENSIONS[fileType.toLowerCase()];
1004
+ if (!extensions) {
1005
+ return {
1006
+ success: false,
1007
+ error: `Unknown file type: ${fileType}. Available types: ${Object.keys(FILE_TYPE_EXTENSIONS).join(', ')}`
1008
+ };
1009
+ }
1010
+ if (extensions.length === 1) {
1011
+ finalPattern = `**/*${extensions[0]}`;
1012
+ } else {
1013
+ finalPattern = '**/*';
1014
+ }
1015
+ } else {
1016
+ finalPattern = '**/*';
1017
+ }
1018
+
1019
+ let allFiles = await findFilesByPattern(finalPattern, fullPath);
1020
+
1021
+ if (!includeHidden) {
1022
+ allFiles = allFiles.filter(f => !f.split('/').some(part => part.startsWith('.')));
1023
+ }
1024
+
1025
+ if (fileType && !pattern) {
1026
+ const extensions = FILE_TYPE_EXTENSIONS[fileType.toLowerCase()];
1027
+ if (extensions) {
1028
+ allFiles = allFiles.filter(f => extensions.some(ext => f.toLowerCase().endsWith(ext)));
1029
+ }
1030
+ }
601
1031
 
602
- const results: Array<{ file: string; matches: Array<{ line: number; content: string }> }> = [];
1032
+ if (excludePattern) {
1033
+ allFiles = allFiles.filter(f => !matchGlob(f, excludePattern));
1034
+ }
1035
+
1036
+ const searchOptions: SearchOptions = {
1037
+ caseSensitive,
1038
+ isRegex,
1039
+ wholeWord,
1040
+ multiline,
1041
+ contextBefore,
1042
+ contextAfter,
1043
+ maxFileSize,
1044
+ invertMatch,
1045
+ };
1046
+
1047
+ type MatchType = { line: number; content: string; context?: { before: string[]; after: string[] } };
1048
+ const results: Array<{ file: string; matches: MatchType[]; count?: number }> = [];
1049
+ const skippedFiles: Array<{ file: string; reason: string }> = [];
603
1050
  let totalResults = 0;
1051
+ let totalMatchCount = 0;
604
1052
 
605
- const BATCH_SIZE = 10;
606
- for (let i = 0; i < files.length; i += BATCH_SIZE) {
607
- if (totalResults >= maxResults) break;
1053
+ const BATCH_SIZE = 15;
1054
+ for (let i = 0; i < allFiles.length; i += BATCH_SIZE) {
1055
+ if (!invertMatch && totalResults >= maxResults) break;
608
1056
 
609
- const batch = files.slice(i, i + BATCH_SIZE);
1057
+ const batch = allFiles.slice(i, i + BATCH_SIZE);
610
1058
  const batchResults = await Promise.all(
611
1059
  batch.map(async (file) => {
612
1060
  const filePath = resolve(fullPath, file);
613
- const matches = await searchInFile(filePath, query, caseSensitive);
614
- return { file: join(searchPath, file), matches };
1061
+ const searchResult = await searchInFile(filePath, query, searchOptions);
1062
+ return {
1063
+ file: join(searchPath, file),
1064
+ matches: searchResult.matches,
1065
+ matchCount: searchResult.matchCount ?? searchResult.matches.length,
1066
+ skipped: searchResult.skipped,
1067
+ skipReason: searchResult.skipReason,
1068
+ };
615
1069
  })
616
1070
  );
617
1071
 
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;
1072
+ for (const { file, matches, matchCount, skipped, skipReason } of batchResults) {
1073
+ if (skipped && skipReason) {
1074
+ skippedFiles.push({ file, reason: skipReason });
1075
+ continue;
1076
+ }
1077
+
1078
+ if (invertMatch) {
1079
+ if (matchCount === 1) {
1080
+ results.push({ file, matches: [], count: 1 });
1081
+ totalResults++;
1082
+ }
1083
+ continue;
1084
+ }
1085
+
1086
+ if (matches.length > 0 || matchCount > 0) {
1087
+ totalMatchCount += matchCount;
1088
+
1089
+ if (outputMode === 'files') {
1090
+ results.push({ file, matches: [] });
1091
+ totalResults++;
1092
+ } else if (outputMode === 'count') {
1093
+ results.push({ file, matches: [], count: matchCount });
1094
+ totalResults++;
1095
+ } else {
1096
+ const remainingSlots = maxResults - totalResults;
1097
+ const matchesToInclude = matches.slice(0, remainingSlots);
1098
+ results.push({ file, matches: matchesToInclude, count: matchCount });
1099
+ totalResults += matchesToInclude.length;
1100
+ }
626
1101
  }
627
1102
  }
628
1103
  }
629
1104
 
1105
+ let formattedResult: string;
1106
+
1107
+ if (outputMode === 'files') {
1108
+ const filesOnly = results.map(r => r.file);
1109
+ const summary = {
1110
+ files_found: filesOnly.length,
1111
+ files: filesOnly,
1112
+ ...(skippedFiles.length > 0 && { skipped: skippedFiles.length })
1113
+ };
1114
+ formattedResult = JSON.stringify(summary, null, 2);
1115
+ } else if (outputMode === 'count') {
1116
+ const counts = results.map(r => ({ file: r.file, count: r.count ?? 0 }));
1117
+ const summary = {
1118
+ total_matches: totalMatchCount,
1119
+ files_with_matches: counts.length,
1120
+ counts,
1121
+ ...(skippedFiles.length > 0 && { skipped: skippedFiles.length })
1122
+ };
1123
+ formattedResult = JSON.stringify(summary, null, 2);
1124
+ } else {
1125
+ const summary = {
1126
+ total_matches: totalMatchCount,
1127
+ files_searched: allFiles.length,
1128
+ files_with_matches: results.length,
1129
+ ...(skippedFiles.length > 0 && { skipped_files: skippedFiles.length }),
1130
+ ...(totalResults >= maxResults && { truncated: true, max_results: maxResults }),
1131
+ results: results.map(r => ({
1132
+ file: r.file,
1133
+ match_count: r.count ?? r.matches.length,
1134
+ matches: r.matches.map(m => {
1135
+ if (m.context && (m.context.before.length > 0 || m.context.after.length > 0)) {
1136
+ return {
1137
+ line: m.line,
1138
+ content: m.content,
1139
+ context: m.context
1140
+ };
1141
+ }
1142
+ return { line: m.line, content: m.content };
1143
+ })
1144
+ }))
1145
+ };
1146
+ formattedResult = JSON.stringify(summary, null, 2);
1147
+ }
1148
+
630
1149
  return {
631
1150
  success: true,
632
- result: JSON.stringify(results, null, 2)
1151
+ result: formattedResult
633
1152
  };
634
1153
  }
635
1154
 
636
1155
  case 'edit': {
637
1156
  const path = args.path as string;
638
1157
  const oldContent = args.old_content as string;
639
- const newContent = args.new_content as string;
1158
+ let newContent = args.new_content as string;
1159
+ if (newContent) newContent = newContent.trimEnd(); // Ensure no trailing empty lines
640
1160
  const occurrence = ((args.occurrence === null ? undefined : (args.occurrence as number | undefined)) ?? 1);
641
1161
  const fullPath = resolve(workspace, path);
642
1162
 
@@ -735,6 +1255,95 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
735
1255
  };
736
1256
  }
737
1257
 
1258
+ case 'fetch': {
1259
+ const url = args.url as string;
1260
+ const maxLength = (args.max_length as number | undefined) ?? DEFAULT_FETCH_MAX_LENGTH;
1261
+ const startIndex = (args.start_index as number | undefined) ?? 0;
1262
+ const raw = (args.raw as boolean | undefined) ?? false;
1263
+ const timeout = (args.timeout as number | undefined) ?? DEFAULT_FETCH_TIMEOUT;
1264
+
1265
+ try {
1266
+ new URL(url);
1267
+ } catch {
1268
+ return {
1269
+ success: false,
1270
+ error: `Invalid URL: ${url}`,
1271
+ };
1272
+ }
1273
+
1274
+ try {
1275
+ let fetchResult = await fetchUrlContent(url, { raw, timeout });
1276
+ let { content, contentType, title, isSPA, status, statusText } = fetchResult;
1277
+
1278
+ if (isSPA && !raw) {
1279
+ const rawResult = await fetchUrlContent(url, { raw: true, timeout });
1280
+ content = rawResult.content;
1281
+ contentType = rawResult.contentType;
1282
+ title = rawResult.title;
1283
+ status = rawResult.status;
1284
+ statusText = rawResult.statusText;
1285
+ isSPA = false;
1286
+ }
1287
+
1288
+ const totalLength = content.length;
1289
+
1290
+ if (startIndex >= totalLength) {
1291
+ return {
1292
+ success: false,
1293
+ error: `Start index ${startIndex} exceeds content length ${totalLength}`,
1294
+ };
1295
+ }
1296
+
1297
+ const extractedContent = content.slice(startIndex, startIndex + maxLength);
1298
+ const truncated = startIndex + maxLength < totalLength;
1299
+ const nextStartIndex = truncated ? startIndex + maxLength : undefined;
1300
+
1301
+ const parts: string[] = [];
1302
+
1303
+ if (title) {
1304
+ parts.push(`# ${title}\n`);
1305
+ }
1306
+
1307
+ parts.push(`**URL:** ${url}`);
1308
+ parts.push(`**Status:** ${status} ${statusText}`);
1309
+ parts.push(`**Content-Type:** ${contentType}`);
1310
+ parts.push(`**Length:** ${extractedContent.length} / ${totalLength} characters`);
1311
+
1312
+ if (fetchResult.isSPA) {
1313
+ parts.push(`**Note:** SPA detected (React/Vue/Angular). Showing raw HTML source.`);
1314
+ }
1315
+
1316
+ if (truncated && nextStartIndex !== undefined) {
1317
+ parts.push(`**Status:** Content truncated. Use start_index=${nextStartIndex} to continue reading.`);
1318
+ }
1319
+
1320
+ parts.push('\n---\n');
1321
+ parts.push(extractedContent);
1322
+
1323
+ if (truncated && nextStartIndex !== undefined) {
1324
+ parts.push(`\n\n---\n*Content truncated at ${extractedContent.length} characters. Call fetch again with start_index=${nextStartIndex} to continue reading.*`);
1325
+ }
1326
+
1327
+ return {
1328
+ success: true,
1329
+ result: parts.join('\n'),
1330
+ };
1331
+ } catch (error) {
1332
+ const message = error instanceof Error ? error.message : String(error);
1333
+
1334
+ if (message.includes('abort')) {
1335
+ return {
1336
+ success: false,
1337
+ error: `Request timed out after ${timeout}ms`,
1338
+ };
1339
+ }
1340
+
1341
+ return {
1342
+ success: false,
1343
+ error: `Failed to fetch ${url}: ${message}`,
1344
+ };
1345
+ }
1346
+ }
738
1347
 
739
1348
  default:
740
1349
  return {
@@ -748,4 +1357,4 @@ DO NOT continue without using the question tool. DO NOT ask in plain text.`;
748
1357
  error: error instanceof Error ? error.message : 'Unknown error occurred'
749
1358
  };
750
1359
  }
751
- }
1360
+ }