starmux 0.12.0 → 0.13.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 (93) hide show
  1. package/dist/src/cli/mac-mcp-gateway.js +4 -3
  2. package/dist/src/cli/mac-mcp-gateway.js.map +1 -1
  3. package/dist/src/cli/starmux.js +1 -1
  4. package/dist/src/cli/starmux.js.map +1 -1
  5. package/dist/src/runtime-paths.js +3 -3
  6. package/dist/src/runtime-paths.js.map +1 -1
  7. package/dist/vendor/desktop-commander/LICENSE +21 -0
  8. package/dist/vendor/desktop-commander/dist/bootstrap.js +22 -0
  9. package/dist/vendor/desktop-commander/dist/command-manager.js +246 -0
  10. package/dist/vendor/desktop-commander/dist/config-field-definitions.js +37 -0
  11. package/dist/vendor/desktop-commander/dist/config-manager.js +634 -0
  12. package/dist/vendor/desktop-commander/dist/config-recovery.js +81 -0
  13. package/dist/vendor/desktop-commander/dist/config.js +19 -0
  14. package/dist/vendor/desktop-commander/dist/custom-stdio.js +358 -0
  15. package/dist/vendor/desktop-commander/dist/error-handlers.js +15 -0
  16. package/dist/vendor/desktop-commander/dist/handlers/edit-search-handlers.js +6 -0
  17. package/dist/vendor/desktop-commander/dist/handlers/filesystem-handlers.js +450 -0
  18. package/dist/vendor/desktop-commander/dist/handlers/history-handlers.js +67 -0
  19. package/dist/vendor/desktop-commander/dist/handlers/index.js +7 -0
  20. package/dist/vendor/desktop-commander/dist/handlers/process-handlers.js +15 -0
  21. package/dist/vendor/desktop-commander/dist/handlers/search-answers.js +172 -0
  22. package/dist/vendor/desktop-commander/dist/handlers/search-handlers.js +136 -0
  23. package/dist/vendor/desktop-commander/dist/handlers/terminal-handlers.js +35 -0
  24. package/dist/vendor/desktop-commander/dist/index.js +88 -0
  25. package/dist/vendor/desktop-commander/dist/search-manager.js +1262 -0
  26. package/dist/vendor/desktop-commander/dist/server.js +1414 -0
  27. package/dist/vendor/desktop-commander/dist/terminal-manager.js +757 -0
  28. package/dist/vendor/desktop-commander/dist/tools/config.js +211 -0
  29. package/dist/vendor/desktop-commander/dist/tools/edit.js +455 -0
  30. package/dist/vendor/desktop-commander/dist/tools/feedback.js +10 -0
  31. package/dist/vendor/desktop-commander/dist/tools/filesystem.js +1047 -0
  32. package/dist/vendor/desktop-commander/dist/tools/fuzzySearch.js +74 -0
  33. package/dist/vendor/desktop-commander/dist/tools/fuzzySearchCore.js +125 -0
  34. package/dist/vendor/desktop-commander/dist/tools/improved-process-tools.js +726 -0
  35. package/dist/vendor/desktop-commander/dist/tools/mime-types.js +28 -0
  36. package/dist/vendor/desktop-commander/dist/tools/pdf/extract-images.js +131 -0
  37. package/dist/vendor/desktop-commander/dist/tools/pdf/index.js +4 -0
  38. package/dist/vendor/desktop-commander/dist/tools/pdf/lib/pdf2md.js +78 -0
  39. package/dist/vendor/desktop-commander/dist/tools/pdf/manipulations.js +112 -0
  40. package/dist/vendor/desktop-commander/dist/tools/pdf/markdown.js +30 -0
  41. package/dist/vendor/desktop-commander/dist/tools/pdf/utils.js +45 -0
  42. package/dist/vendor/desktop-commander/dist/tools/process.js +90 -0
  43. package/dist/vendor/desktop-commander/dist/tools/prompts.js +259 -0
  44. package/dist/vendor/desktop-commander/dist/tools/schemas.js +242 -0
  45. package/dist/vendor/desktop-commander/dist/tools/usage.js +24 -0
  46. package/dist/vendor/desktop-commander/dist/types.js +1 -0
  47. package/dist/vendor/desktop-commander/dist/ui/config-editor/config-editor-runtime.js +150 -0
  48. package/dist/vendor/desktop-commander/dist/ui/config-editor/index.html +13 -0
  49. package/dist/vendor/desktop-commander/dist/ui/config-editor/styles.css +587 -0
  50. package/dist/vendor/desktop-commander/dist/ui/contracts.js +57 -0
  51. package/dist/vendor/desktop-commander/dist/ui/file-preview/index.html +16 -0
  52. package/dist/vendor/desktop-commander/dist/ui/file-preview/preview-runtime.js +471 -0
  53. package/dist/vendor/desktop-commander/dist/ui/file-preview/shared/preview-file-types.js +59 -0
  54. package/dist/vendor/desktop-commander/dist/ui/file-preview/styles.css +1631 -0
  55. package/dist/vendor/desktop-commander/dist/ui/resources.js +93 -0
  56. package/dist/vendor/desktop-commander/dist/utils/ab-test.js +122 -0
  57. package/dist/vendor/desktop-commander/dist/utils/atomic-write.js +80 -0
  58. package/dist/vendor/desktop-commander/dist/utils/capture.js +40 -0
  59. package/dist/vendor/desktop-commander/dist/utils/dockerPrompt.js +9 -0
  60. package/dist/vendor/desktop-commander/dist/utils/exit-process.js +31 -0
  61. package/dist/vendor/desktop-commander/dist/utils/feature-flags.js +18 -0
  62. package/dist/vendor/desktop-commander/dist/utils/files/base.js +5 -0
  63. package/dist/vendor/desktop-commander/dist/utils/files/binary.js +65 -0
  64. package/dist/vendor/desktop-commander/dist/utils/files/docx.js +694 -0
  65. package/dist/vendor/desktop-commander/dist/utils/files/excel.js +497 -0
  66. package/dist/vendor/desktop-commander/dist/utils/files/factory.js +168 -0
  67. package/dist/vendor/desktop-commander/dist/utils/files/image.js +89 -0
  68. package/dist/vendor/desktop-commander/dist/utils/files/index.js +14 -0
  69. package/dist/vendor/desktop-commander/dist/utils/files/pdf.js +124 -0
  70. package/dist/vendor/desktop-commander/dist/utils/files/text.js +423 -0
  71. package/dist/vendor/desktop-commander/dist/utils/fuzzySearchLogger.js +126 -0
  72. package/dist/vendor/desktop-commander/dist/utils/internal-facts.js +23 -0
  73. package/dist/vendor/desktop-commander/dist/utils/lazy-package.js +44 -0
  74. package/dist/vendor/desktop-commander/dist/utils/lineEndingHandler.js +77 -0
  75. package/dist/vendor/desktop-commander/dist/utils/logger.js +72 -0
  76. package/dist/vendor/desktop-commander/dist/utils/mcp-ui-ab-test.js +62 -0
  77. package/dist/vendor/desktop-commander/dist/utils/open-browser.js +2 -0
  78. package/dist/vendor/desktop-commander/dist/utils/process-detection.js +260 -0
  79. package/dist/vendor/desktop-commander/dist/utils/process-tree.js +223 -0
  80. package/dist/vendor/desktop-commander/dist/utils/rename.js +74 -0
  81. package/dist/vendor/desktop-commander/dist/utils/ripgrep-resolver.js +69 -0
  82. package/dist/vendor/desktop-commander/dist/utils/shell.js +187 -0
  83. package/dist/vendor/desktop-commander/dist/utils/system-info.js +727 -0
  84. package/dist/vendor/desktop-commander/dist/utils/terminal-timestamps.js +18 -0
  85. package/dist/vendor/desktop-commander/dist/utils/toolHistory.js +309 -0
  86. package/dist/vendor/desktop-commander/dist/utils/trackTools.js +55 -0
  87. package/dist/vendor/desktop-commander/dist/utils/unsupportedParams.js +66 -0
  88. package/dist/vendor/desktop-commander/dist/utils/usageTracker.js +225 -0
  89. package/dist/vendor/desktop-commander/dist/utils/welcome-onboarding.js +2 -0
  90. package/dist/vendor/desktop-commander/dist/utils/withTimeout.js +85 -0
  91. package/dist/vendor/desktop-commander/dist/version.js +1 -0
  92. package/dist/vendor/desktop-commander/package.json +15 -0
  93. package/package.json +14 -2
@@ -0,0 +1,211 @@
1
+ import { configManager } from '../config-manager.js';
2
+ import { SetConfigValueArgsSchema } from './schemas.js';
3
+ import { getSystemInfo } from '../utils/system-info.js';
4
+ import { detectAvailableShells } from '../utils/shell.js';
5
+ import { currentClient } from '../server.js';
6
+ import { featureFlagManager } from '../utils/feature-flags.js';
7
+ import { CONFIG_FIELD_DEFINITIONS, CONFIG_FIELD_KEYS, isConfigFieldKey, } from '../config-field-definitions.js';
8
+ const ALLOWED_CONFIG_KEYS = new Set(CONFIG_FIELD_KEYS);
9
+ /**
10
+ * Get the entire config including system information
11
+ */
12
+ export async function getConfig() {
13
+ console.error('getConfig called');
14
+ try {
15
+ const config = await configManager.getConfig();
16
+ // Add system information and current client to the config response
17
+ const systemInfo = getSystemInfo();
18
+ // Get memory usage
19
+ const memoryUsage = process.memoryUsage();
20
+ const memory = {
21
+ rss: `${(memoryUsage.rss / 1024 / 1024).toFixed(2)} MB`,
22
+ heapTotal: `${(memoryUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
23
+ heapUsed: `${(memoryUsage.heapUsed / 1024 / 1024).toFixed(2)} MB`,
24
+ external: `${(memoryUsage.external / 1024 / 1024).toFixed(2)} MB`,
25
+ arrayBuffers: `${(memoryUsage.arrayBuffers / 1024 / 1024).toFixed(2)} MB`
26
+ };
27
+ const configWithSystemInfo = {
28
+ ...config,
29
+ currentClient,
30
+ featureFlags: featureFlagManager.getAll(),
31
+ systemInfo: {
32
+ ...systemInfo,
33
+ memory
34
+ }
35
+ };
36
+ const availableShells = detectAvailableShells();
37
+ console.error(`getConfig result: ${JSON.stringify(configWithSystemInfo, null, 2)}`);
38
+ return {
39
+ content: [{
40
+ type: "text",
41
+ text: `Current configuration:\n${JSON.stringify(configWithSystemInfo, null, 2)}`
42
+ }],
43
+ structuredContent: {
44
+ config: configWithSystemInfo,
45
+ uiHints: {
46
+ availableShells,
47
+ },
48
+ entries: CONFIG_FIELD_KEYS.map((key) => {
49
+ const definition = CONFIG_FIELD_DEFINITIONS[key];
50
+ const value = configWithSystemInfo[key];
51
+ return {
52
+ key,
53
+ value,
54
+ valueType: definition.valueType,
55
+ editable: true,
56
+ };
57
+ }),
58
+ },
59
+ };
60
+ }
61
+ catch (error) {
62
+ console.error(`Error in getConfig: ${error instanceof Error ? error.message : String(error)}`);
63
+ console.error(error instanceof Error && error.stack ? error.stack : 'No stack trace available');
64
+ // Return empty config rather than crashing
65
+ return {
66
+ content: [{
67
+ type: "text",
68
+ text: `Error getting configuration: ${error instanceof Error ? error.message : String(error)}\nUsing empty configuration.`
69
+ }],
70
+ };
71
+ }
72
+ }
73
+ /**
74
+ * Set a specific config value
75
+ */
76
+ export async function setConfigValue(args) {
77
+ console.error(`setConfigValue called with args: ${JSON.stringify(args)}`);
78
+ try {
79
+ const parsed = SetConfigValueArgsSchema.safeParse(args);
80
+ if (!parsed.success) {
81
+ console.error(`Invalid arguments for set_config_value: ${parsed.error}`);
82
+ return {
83
+ content: [{
84
+ type: "text",
85
+ text: `Invalid arguments: ${parsed.error}`
86
+ }],
87
+ isError: true
88
+ };
89
+ }
90
+ if (!isConfigFieldKey(parsed.data.key)) {
91
+ return {
92
+ content: [{
93
+ type: "text",
94
+ text: `Key "${parsed.data.key}" is not configurable via this tool. Allowed keys: ${[...ALLOWED_CONFIG_KEYS].join(', ')}`
95
+ }],
96
+ isError: true
97
+ };
98
+ }
99
+ try {
100
+ const fieldDefinition = CONFIG_FIELD_DEFINITIONS[parsed.data.key];
101
+ // Parse string values that should be arrays or objects
102
+ let valueToStore = parsed.data.value;
103
+ // If the value is a string that looks like an array or object, try to parse it
104
+ if (typeof valueToStore === 'string' &&
105
+ (valueToStore.startsWith('[') || valueToStore.startsWith('{'))) {
106
+ try {
107
+ valueToStore = JSON.parse(valueToStore);
108
+ console.error(`Parsed string value to object/array: ${JSON.stringify(valueToStore)}`);
109
+ }
110
+ catch (parseError) {
111
+ console.error(`Failed to parse string as JSON, using as-is: ${parseError}`);
112
+ }
113
+ }
114
+ // Special handling for known array configuration keys
115
+ if (fieldDefinition.valueType === 'array' && !Array.isArray(valueToStore)) {
116
+ if (typeof valueToStore === 'string') {
117
+ const originalString = valueToStore;
118
+ try {
119
+ const parsedValue = JSON.parse(originalString);
120
+ valueToStore = parsedValue;
121
+ }
122
+ catch (parseError) {
123
+ console.error(`Failed to parse string as array for ${parsed.data.key}: ${parseError}`);
124
+ // If parsing failed and it's a single value, convert to an array with one item
125
+ if (!originalString.includes('[')) {
126
+ valueToStore = [originalString];
127
+ }
128
+ }
129
+ }
130
+ else if (valueToStore !== null) {
131
+ // If not a string or array (and not null), convert to an array with one item
132
+ valueToStore = [String(valueToStore)];
133
+ }
134
+ // Ensure the value is an array after all our conversions; null stays
135
+ // null and clears the value back to its default, as for number fields
136
+ if (valueToStore !== null && !Array.isArray(valueToStore)) {
137
+ console.error(`Value for ${parsed.data.key} is still not an array, converting to array`);
138
+ valueToStore = [String(valueToStore)];
139
+ }
140
+ }
141
+ // Harden boolean fields against stringly-typed inputs like "false".
142
+ if (fieldDefinition.valueType === 'boolean') {
143
+ if (typeof valueToStore === 'string') {
144
+ const normalized = valueToStore.trim().toLowerCase();
145
+ if (normalized === 'true') {
146
+ valueToStore = true;
147
+ }
148
+ else if (normalized === 'false') {
149
+ valueToStore = false;
150
+ }
151
+ }
152
+ if (typeof valueToStore !== 'boolean') {
153
+ return {
154
+ content: [{
155
+ type: "text",
156
+ text: `Value for ${parsed.data.key} must be boolean true/false.`
157
+ }],
158
+ isError: true
159
+ };
160
+ }
161
+ }
162
+ // Numbers may arrive as strings ("5000"); null clears the value back to its default.
163
+ if (fieldDefinition.valueType === 'number' && valueToStore !== null) {
164
+ const numeric = typeof valueToStore === 'string' && valueToStore.trim() !== '' ? Number(valueToStore) : valueToStore;
165
+ if (typeof numeric !== 'number' || !Number.isFinite(numeric)) {
166
+ return {
167
+ content: [{
168
+ type: "text",
169
+ text: `Value for ${parsed.data.key} must be a number.`
170
+ }],
171
+ isError: true
172
+ };
173
+ }
174
+ valueToStore = numeric;
175
+ }
176
+ // Not saved: in effect all the same, as the answer below says, and saved with the held changes
177
+ await configManager.setValue(parsed.data.key, valueToStore, { holdIfNotSaved: true });
178
+ // Get the updated configuration to show the user
179
+ const updatedConfig = await configManager.getConfig();
180
+ console.error(`setConfigValue: Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore)}`);
181
+ return {
182
+ content: [{
183
+ type: "text",
184
+ text: `Successfully set ${parsed.data.key} to ${JSON.stringify(valueToStore, null, 2)}\n\nUpdated configuration:\n${JSON.stringify(updatedConfig, null, 2)}`
185
+ }],
186
+ };
187
+ }
188
+ catch (saveError) {
189
+ console.error(`Error saving config: ${saveError.message}`);
190
+ // Continue with in-memory change but report error
191
+ return {
192
+ content: [{
193
+ type: "text",
194
+ text: `Value changed in memory but couldn't be saved to disk: ${saveError.message}`
195
+ }],
196
+ isError: true
197
+ };
198
+ }
199
+ }
200
+ catch (error) {
201
+ console.error(`Error in setConfigValue: ${error instanceof Error ? error.message : String(error)}`);
202
+ console.error(error instanceof Error && error.stack ? error.stack : 'No stack trace available');
203
+ return {
204
+ content: [{
205
+ type: "text",
206
+ text: `Error setting value: ${error instanceof Error ? error.message : String(error)}`
207
+ }],
208
+ isError: true
209
+ };
210
+ }
211
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Text file editing via search/replace with fuzzy matching support.
3
+ *
4
+ * TECHNICAL DEBT / ARCHITECTURAL NOTE:
5
+ * This file contains text editing logic that should ideally live in TextFileHandler.editRange()
6
+ * to be consistent with how Excel editing works (ExcelFileHandler.editRange()).
7
+ *
8
+ * Current inconsistency:
9
+ * - Excel: edit_block → ExcelFileHandler.editRange() ✓ uses file handler
10
+ * - Text: edit_block → performSearchReplace() here → bypasses TextFileHandler
11
+ *
12
+ * Future refactor should:
13
+ * 1. Move performSearchReplace() + fuzzy logic into TextFileHandler.editRange()
14
+ * 2. Make this file a thin dispatch layer that routes to appropriate FileHandler
15
+ * 3. Unify the editRange() signature to handle both text search/replace and structured edits
16
+ */
17
+ import { getDefaultEditorMetadata, writeFile, readFileInternal, validatePath } from './filesystem.js';
18
+ import { runFuzzySearchInWorker, getSimilarityRatio } from './fuzzySearch.js';
19
+ import { capture } from '../utils/capture.js';
20
+ import { createErrorResponse } from '../error-handlers.js';
21
+ import { EditBlockArgsSchema } from "./schemas.js";
22
+ import path from 'path';
23
+ import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js';
24
+ import { configManager } from '../config-manager.js';
25
+ import { fuzzySearchLogger } from '../utils/fuzzySearchLogger.js';
26
+ import { resolvePreviewFileType } from '../ui/file-preview/shared/preview-file-types.js';
27
+ import { resolveAbsolutePath } from '../handlers/filesystem-handlers.js';
28
+ /**
29
+ * Threshold for fuzzy matching - similarity must be at least this value to be considered
30
+ * (0-1 scale where 1 is perfect match and 0 is completely different)
31
+ */
32
+ const FUZZY_THRESHOLD = 0.7;
33
+ /**
34
+ * Extract character code data from diff
35
+ * @param expected The string that was searched for
36
+ * @param actual The string that was found
37
+ * @returns Character code statistics
38
+ */
39
+ function getCharacterCodeData(expected, actual) {
40
+ // Find common prefix and suffix
41
+ let prefixLength = 0;
42
+ const minLength = Math.min(expected.length, actual.length);
43
+ // Determine common prefix length
44
+ while (prefixLength < minLength &&
45
+ expected[prefixLength] === actual[prefixLength]) {
46
+ prefixLength++;
47
+ }
48
+ // Determine common suffix length
49
+ let suffixLength = 0;
50
+ while (suffixLength < minLength - prefixLength &&
51
+ expected[expected.length - 1 - suffixLength] === actual[actual.length - 1 - suffixLength]) {
52
+ suffixLength++;
53
+ }
54
+ // Extract the different parts
55
+ const expectedDiff = expected.substring(prefixLength, expected.length - suffixLength);
56
+ const actualDiff = actual.substring(prefixLength, actual.length - suffixLength);
57
+ // Count unique character codes in the diff
58
+ const characterCodes = new Map();
59
+ const fullDiff = expectedDiff + actualDiff;
60
+ for (let i = 0; i < fullDiff.length; i++) {
61
+ const charCode = fullDiff.charCodeAt(i);
62
+ characterCodes.set(charCode, (characterCodes.get(charCode) || 0) + 1);
63
+ }
64
+ // Create character codes string report
65
+ const charCodeReport = [];
66
+ characterCodes.forEach((count, code) => {
67
+ // Include character representation for better readability
68
+ const char = String.fromCharCode(code);
69
+ // Make special characters more readable
70
+ const charDisplay = code < 32 || code > 126 ? `\\x${code.toString(16).padStart(2, '0')}` : char;
71
+ charCodeReport.push(`${code}:${count}[${charDisplay}]`);
72
+ });
73
+ // Sort by character code for consistency
74
+ charCodeReport.sort((a, b) => {
75
+ const codeA = parseInt(a.split(':')[0]);
76
+ const codeB = parseInt(b.split(':')[0]);
77
+ return codeA - codeB;
78
+ });
79
+ return {
80
+ report: charCodeReport.join(','),
81
+ uniqueCount: characterCodes.size,
82
+ diffLength: fullDiff.length
83
+ };
84
+ }
85
+ export async function performSearchReplace(filePath, block, expectedReplacements = 1, origin) {
86
+ // Get file extension for telemetry using path module
87
+ const fileExtension = path.extname(filePath).toLowerCase();
88
+ // Capture file extension and string sizes in telemetry without capturing the file path
89
+ capture('server_edit_block', {
90
+ fileExtension: fileExtension,
91
+ oldStringLength: block.search.length,
92
+ oldStringLines: block.search.split('\n').length,
93
+ newStringLength: block.replace.length,
94
+ newStringLines: block.replace.split('\n').length,
95
+ expectedReplacements: expectedReplacements
96
+ });
97
+ // Check for empty search string to prevent infinite loops
98
+ if (block.search === "") {
99
+ // Capture file extension in telemetry without capturing the file path
100
+ capture('server_edit_block_empty_search', { fileExtension: fileExtension, expectedReplacements });
101
+ return {
102
+ content: [{
103
+ type: "text",
104
+ text: "Empty search strings are not allowed. Please provide a non-empty string to search for."
105
+ }],
106
+ };
107
+ }
108
+ // Read file directly to preserve line endings - critical for edit operations
109
+ const validPath = await validatePath(filePath);
110
+ const content = await readFileInternal(validPath, 0, Number.MAX_SAFE_INTEGER);
111
+ // Make sure content is a string
112
+ if (typeof content !== 'string') {
113
+ capture('server_edit_block_content_not_string', { fileExtension: fileExtension, expectedReplacements });
114
+ throw new Error('Wrong content for file ' + filePath);
115
+ }
116
+ // Get the line limit from configuration
117
+ const config = await configManager.getConfig();
118
+ const MAX_LINES = config.fileWriteLineLimit ?? 50; // Default to 50 if not set
119
+ // Detect file's line ending style
120
+ const fileLineEnding = detectLineEnding(content);
121
+ // Normalize search string to match file's line endings
122
+ const normalizedSearch = normalizeLineEndings(block.search, fileLineEnding);
123
+ // First try exact match
124
+ let tempContent = content;
125
+ let count = 0;
126
+ let pos = tempContent.indexOf(normalizedSearch);
127
+ // Count the matches a replacement changes: they can't overlap, so the next
128
+ // search starts after this match (split/join below replaces the same ones)
129
+ while (pos !== -1) {
130
+ count++;
131
+ pos = tempContent.indexOf(normalizedSearch, pos + normalizedSearch.length);
132
+ }
133
+ // If exact match found and count matches expected replacements, proceed with exact replacement
134
+ if (count > 0 && count === expectedReplacements) {
135
+ // Replace all occurrences
136
+ let newContent = content;
137
+ // If we're only replacing one occurrence, replace it directly
138
+ if (expectedReplacements === 1) {
139
+ const searchIndex = newContent.indexOf(normalizedSearch);
140
+ newContent =
141
+ newContent.substring(0, searchIndex) +
142
+ normalizeLineEndings(block.replace, fileLineEnding) +
143
+ newContent.substring(searchIndex + normalizedSearch.length);
144
+ }
145
+ else {
146
+ // Replace all occurrences using split and join for multiple replacements
147
+ newContent = newContent.split(normalizedSearch).join(normalizeLineEndings(block.replace, fileLineEnding));
148
+ }
149
+ // Check if search or replace text has too many lines
150
+ const searchLines = block.search.split('\n').length;
151
+ const replaceLines = block.replace.split('\n').length;
152
+ const maxLines = Math.max(searchLines, replaceLines);
153
+ let warningMessage = "";
154
+ if (maxLines > MAX_LINES) {
155
+ const problemText = searchLines > replaceLines ? 'search text' : 'replacement text';
156
+ warningMessage = `\n\nWARNING: The ${problemText} has ${maxLines} lines (maximum: ${MAX_LINES}).
157
+
158
+ RECOMMENDATION: For large search/replace operations, consider breaking them into smaller chunks with fewer lines.`;
159
+ }
160
+ await writeFile(filePath, newContent);
161
+ capture('server_edit_block_exact_success', { fileExtension: fileExtension, expectedReplacements, hasWarning: warningMessage !== "" });
162
+ const resolvedEditPath = resolveAbsolutePath(filePath);
163
+ // Show a partial preview centered on the edited area
164
+ const newLines = newContent.split('\n');
165
+ const totalLines = newLines.length;
166
+ const changePos = content.indexOf(normalizedSearch);
167
+ const changeStartLine = changePos >= 0 ? newContent.substring(0, changePos).split('\n').length - 1 : 0;
168
+ const changeLineCount = block.replace.split('\n').length;
169
+ const contextLines = 10;
170
+ const previewStart = Math.max(0, changeStartLine - contextLines);
171
+ const previewEnd = Math.min(totalLines, changeStartLine + changeLineCount + contextLines);
172
+ const previewContent = newLines.slice(previewStart, previewEnd).join('\n');
173
+ const previewLineCount = previewEnd - previewStart;
174
+ const remaining = totalLines - previewEnd;
175
+ const statusLine = `[Reading ${previewLineCount} lines from ${previewStart === 0 ? 'start' : `line ${previewStart}`} (total: ${totalLines} lines, ${remaining} remaining)]\n\n`;
176
+ return {
177
+ content: [{
178
+ type: "text",
179
+ text: `${statusLine}${previewContent}`
180
+ }],
181
+ // structuredContent only travels on widget-initiated calls: it is the
182
+ // widget's save-success signal (assertSuccessfulEditBlockResult) and
183
+ // metadata source. LLM-facing responses don't need it — nothing there
184
+ // consumes it, and the widget re-reads by path instead.
185
+ ...(origin === 'ui' ? {
186
+ structuredContent: {
187
+ fileName: path.basename(resolvedEditPath),
188
+ filePath: resolvedEditPath,
189
+ fileType: resolvePreviewFileType(resolvedEditPath),
190
+ ...await getDefaultEditorMetadata(resolvedEditPath),
191
+ },
192
+ } : {}),
193
+ };
194
+ }
195
+ // If exact match found but count doesn't match expected, inform the user
196
+ if (count > 0 && count !== expectedReplacements) {
197
+ capture('server_edit_block_unexpected_count', { fileExtension: fileExtension, expectedReplacements, expectedReplacementsCount: count });
198
+ return {
199
+ content: [{
200
+ type: "text",
201
+ text: `Expected ${expectedReplacements} occurrences but found ${count} in ${filePath}. ` +
202
+ `Double check and make sure you understand all occurencies and if you want to replace all ${count} occurrences, set expected_replacements to ${count}. ` +
203
+ `If there are many occurrancies and you want to change some of them and keep the rest. Do it one by one, by adding more lines around each occurrence.` +
204
+ `If you want to replace a specific occurrence, make your search string more unique by adding more lines around search string.`
205
+ }],
206
+ };
207
+ }
208
+ // If exact match not found, try fuzzy search
209
+ if (count === 0) {
210
+ // Track fuzzy search time
211
+ const startTime = performance.now();
212
+ // Perform fuzzy search in a worker thread so the main event loop stays
213
+ // responsive to pings and parallel tool calls during the scan
214
+ const fuzzyResult = await runFuzzySearchInWorker(content, block.search);
215
+ const similarity = getSimilarityRatio(block.search, fuzzyResult.value);
216
+ // Calculate execution time in milliseconds
217
+ const executionTime = performance.now() - startTime;
218
+ // Generate diff and gather character code data
219
+ const diff = highlightDifferences(block.search, fuzzyResult.value);
220
+ // Count character codes in diff
221
+ const characterCodeData = getCharacterCodeData(block.search, fuzzyResult.value);
222
+ // Create comprehensive log entry
223
+ const logEntry = {
224
+ timestamp: new Date(),
225
+ searchText: block.search,
226
+ foundText: fuzzyResult.value,
227
+ similarity: similarity,
228
+ executionTime: executionTime,
229
+ exactMatchCount: count,
230
+ expectedReplacements: expectedReplacements,
231
+ fuzzyThreshold: FUZZY_THRESHOLD,
232
+ belowThreshold: similarity < FUZZY_THRESHOLD,
233
+ diff: diff,
234
+ searchLength: block.search.length,
235
+ foundLength: fuzzyResult.value.length,
236
+ fileExtension: fileExtension,
237
+ characterCodes: characterCodeData.report,
238
+ uniqueCharacterCount: characterCodeData.uniqueCount,
239
+ diffLength: characterCodeData.diffLength
240
+ };
241
+ // Log to file
242
+ await fuzzySearchLogger.log(logEntry);
243
+ // Combine all fuzzy search data for single capture
244
+ //
245
+ // character_codes (characterCodeData.report) is deliberately NOT sent.
246
+ // It is a frequency map of the characters in the diff between the
247
+ // search text and the file text, so for a short diff it is close to
248
+ // recoverable file content — and mcp-privacy-policy.md states that
249
+ // file contents are not collected. It was added as diagnostics for the
250
+ // CRLF/LF normalization work in #103 (May 2025) to show whether failed
251
+ // matches came from \r (13), \n (10) or non-breaking spaces; that
252
+ // question is long since answered.
253
+ //
254
+ // The scalars below are kept: they are counts and lengths, and carry
255
+ // no recoverable content. The full report still goes to the local
256
+ // fuzzy-search.log for anyone debugging on their own machine.
257
+ const fuzzySearchData = {
258
+ similarity: similarity,
259
+ execution_time_ms: executionTime,
260
+ search_length: block.search.length,
261
+ file_size: content.length,
262
+ threshold: FUZZY_THRESHOLD,
263
+ found_text_length: fuzzyResult.value.length,
264
+ unique_character_count: characterCodeData.uniqueCount,
265
+ total_diff_length: characterCodeData.diffLength
266
+ };
267
+ // Check if the fuzzy match is "close enough"
268
+ if (similarity >= FUZZY_THRESHOLD) {
269
+ // Capture the fuzzy search event with all data
270
+ capture('server_fuzzy_search_performed', fuzzySearchData);
271
+ // If we allow fuzzy matches, we would make the replacement here
272
+ // For now, we'll return a detailed message about the fuzzy match
273
+ return {
274
+ content: [{
275
+ type: "text",
276
+ text: `Exact match not found, but found a similar text with ${Math.round(similarity * 100)}% similarity (found in ${executionTime.toFixed(2)}ms):\n\n` +
277
+ `Differences:\n${diff}\n\n` +
278
+ `To replace this text, use the exact text found in the file.\n\n` +
279
+ `Log entry saved for analysis. Use the following command to check the log:\n` +
280
+ `Check log: ${await fuzzySearchLogger.getLogPath()}`
281
+ }], // TODO
282
+ };
283
+ }
284
+ else {
285
+ // If the fuzzy match isn't close enough
286
+ // Still capture the fuzzy search event with all data
287
+ capture('server_fuzzy_search_performed', {
288
+ ...fuzzySearchData,
289
+ below_threshold: true
290
+ });
291
+ return {
292
+ content: [{
293
+ type: "text",
294
+ text: `Search content not found in ${filePath}. The closest match was "${fuzzyResult.value}" ` +
295
+ `with only ${Math.round(similarity * 100)}% similarity, which is below the ${Math.round(FUZZY_THRESHOLD * 100)}% threshold. ` +
296
+ `(Fuzzy search completed in ${executionTime.toFixed(2)}ms)\n\n` +
297
+ `Log entry saved for analysis. Use the following command to check the log:\n` +
298
+ `Check log: ${await fuzzySearchLogger.getLogPath()}`
299
+ }],
300
+ };
301
+ }
302
+ }
303
+ throw new Error("Unexpected error during search and replace operation.");
304
+ }
305
+ /**
306
+ * Generates a character-level diff using standard {-removed-}{+added+} format
307
+ * @param expected The string that was searched for
308
+ * @param actual The string that was found
309
+ * @returns A formatted string showing character-level differences
310
+ */
311
+ function highlightDifferences(expected, actual) {
312
+ // Implementation of a simplified character-level diff
313
+ // Find common prefix and suffix
314
+ let prefixLength = 0;
315
+ const minLength = Math.min(expected.length, actual.length);
316
+ // Determine common prefix length
317
+ while (prefixLength < minLength &&
318
+ expected[prefixLength] === actual[prefixLength]) {
319
+ prefixLength++;
320
+ }
321
+ // Determine common suffix length
322
+ let suffixLength = 0;
323
+ while (suffixLength < minLength - prefixLength &&
324
+ expected[expected.length - 1 - suffixLength] === actual[actual.length - 1 - suffixLength]) {
325
+ suffixLength++;
326
+ }
327
+ // Extract the common and different parts
328
+ const commonPrefix = expected.substring(0, prefixLength);
329
+ const commonSuffix = expected.substring(expected.length - suffixLength);
330
+ const expectedDiff = expected.substring(prefixLength, expected.length - suffixLength);
331
+ const actualDiff = actual.substring(prefixLength, actual.length - suffixLength);
332
+ // Format the output as a character-level diff
333
+ return `${commonPrefix}{-${expectedDiff}-}{+${actualDiff}+}${commonSuffix}`;
334
+ }
335
+ /** Why a file handler's edit failed, as edit_block reports it */
336
+ function editFailure(result) {
337
+ return result.errors?.map(e => e.error).join('; ') || 'Unknown error';
338
+ }
339
+ /**
340
+ * Handle edit_block command
341
+ *
342
+ * 1. Text files: String replacement (old_string/new_string)
343
+ * - Uses fuzzy matching for resilience
344
+ * - Handles expected_replacements parameter
345
+ *
346
+ * 2. Structured files (Excel): Range rewrite (range + content)
347
+ * - Bulk updates to cell ranges (e.g., "Sheet1!A1:C10")
348
+ * - Whole sheet replacement (e.g., "Sheet1")
349
+ * - More powerful and simpler than surgical location-based edits
350
+ * - Supports chunking for large datasets (e.g., 1000 rows at a time)
351
+
352
+ */
353
+ export async function handleEditBlock(args) {
354
+ const parsed = EditBlockArgsSchema.parse(args);
355
+ // Note: Check for truthy range to handle empty strings from AI clients that send all optional params
356
+ const hasRange = parsed.range !== undefined && parsed.range !== '';
357
+ const hasContent = parsed.content !== undefined && parsed.content !== '';
358
+ // Validate path and resolve handler once — used by both dispatch paths below
359
+ let validatedPath;
360
+ let handler;
361
+ try {
362
+ validatedPath = await validatePath(parsed.file_path);
363
+ const { getFileHandler } = await import('../utils/files/factory.js');
364
+ handler = await getFileHandler(validatedPath);
365
+ }
366
+ catch (error) {
367
+ const errorMessage = error instanceof Error ? error.message : String(error);
368
+ return createErrorResponse(errorMessage);
369
+ }
370
+ const hasEditRange = 'editRange' in handler && typeof handler.editRange === 'function';
371
+ // Path 1: Range rewrite (Excel, etc.) — range + content
372
+ if (hasRange && hasContent) {
373
+ // Parse content if it's a JSON string (AI often sends arrays as JSON strings)
374
+ let content = parsed.content;
375
+ if (typeof content === 'string') {
376
+ try {
377
+ content = JSON.parse(content);
378
+ }
379
+ catch {
380
+ // Leave as-is if not valid JSON - let handler decide
381
+ }
382
+ }
383
+ if (hasEditRange) {
384
+ try {
385
+ // parsed.range is guaranteed non-empty string by hasRange check above
386
+ const result = await handler.editRange(validatedPath, parsed.range, content, parsed.options);
387
+ if (!result.success) {
388
+ return createErrorResponse(editFailure(result));
389
+ }
390
+ const resolvedRangePath = resolveAbsolutePath(parsed.file_path);
391
+ return {
392
+ content: [{
393
+ type: "text",
394
+ text: `Successfully updated range ${parsed.range} in ${parsed.file_path}`
395
+ }],
396
+ ...(parsed.origin === 'ui' ? {
397
+ structuredContent: {
398
+ fileName: path.basename(resolvedRangePath),
399
+ filePath: resolvedRangePath,
400
+ fileType: resolvePreviewFileType(resolvedRangePath),
401
+ ...await getDefaultEditorMetadata(resolvedRangePath),
402
+ },
403
+ } : {}),
404
+ };
405
+ }
406
+ catch (error) {
407
+ const errorMessage = error instanceof Error ? error.message : String(error);
408
+ return createErrorResponse(errorMessage);
409
+ }
410
+ }
411
+ return createErrorResponse(`Range-based editing not supported for ${parsed.file_path}. For text files, use old_string and new_string parameters instead. If your client requires range/content parameters, set them to empty strings ("").`);
412
+ }
413
+ // Path 2: Text replacement — old_string + new_string
414
+ if (parsed.old_string === undefined || parsed.new_string === undefined) {
415
+ return createErrorResponse(`Text replacement requires both old_string and new_string parameters`);
416
+ }
417
+ // If the handler implements editRange it owns text-replacement for its file type
418
+ // (e.g. DocxFileHandler does find/replace on pretty-printed XML rather than raw bytes).
419
+ // Plain text files fall through to performSearchReplace.
420
+ if (hasEditRange) {
421
+ try {
422
+ const result = await handler.editRange(validatedPath, '', {
423
+ old_string: parsed.old_string,
424
+ new_string: parsed.new_string,
425
+ expected_replacements: parsed.expected_replacements,
426
+ });
427
+ if (result.success) {
428
+ const resolvedEditRangePath = resolveAbsolutePath(parsed.file_path);
429
+ return {
430
+ content: [{
431
+ type: "text",
432
+ text: `Successfully applied ${result.editsApplied} edit(s) to ${parsed.file_path}`
433
+ }],
434
+ ...(parsed.origin === 'ui' ? {
435
+ structuredContent: {
436
+ fileName: path.basename(resolvedEditRangePath),
437
+ filePath: resolvedEditRangePath,
438
+ fileType: resolvePreviewFileType(resolvedEditRangePath),
439
+ ...await getDefaultEditorMetadata(resolvedEditRangePath),
440
+ },
441
+ } : {}),
442
+ };
443
+ }
444
+ return createErrorResponse(editFailure(result));
445
+ }
446
+ catch (error) {
447
+ const errorMessage = error instanceof Error ? error.message : String(error);
448
+ return createErrorResponse(errorMessage);
449
+ }
450
+ }
451
+ return performSearchReplace(parsed.file_path, {
452
+ search: parsed.old_string,
453
+ replace: parsed.new_string
454
+ }, parsed.expected_replacements, parsed.origin);
455
+ }
@@ -0,0 +1,10 @@
1
+ export async function giveFeedbackToDesktopCommander() {
2
+ return {
3
+ content: [
4
+ {
5
+ type: 'text',
6
+ text: 'Telemetry and external feedback forms are disabled in Starmux.',
7
+ },
8
+ ],
9
+ };
10
+ }