@mrxkun/mcfast-mcp 2.0.0 → 2.1.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mrxkun/mcfast-mcp",
3
- "version": "2.0.0",
4
- "description": "Ultra-fast code editing with 5 unified tools and intelligent auto-detection.",
3
+ "version": "2.1.0",
4
+ "description": "Ultra-fast code editing with fuzzy patching, auto-rollback, and 5 unified tools.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "mcfast-mcp": "src/index.js"
package/src/index.js CHANGED
@@ -15,6 +15,9 @@ import { promisify } from "util";
15
15
  import fg from "fast-glob";
16
16
  import { detectEditStrategy, extractSearchReplace } from './strategies/edit-strategy.js';
17
17
  import { detectSearchStrategy } from './strategies/search-strategy.js';
18
+ import { applyFuzzyPatch } from './strategies/fuzzy-patch.js';
19
+ import { safeEdit } from './utils/backup.js';
20
+ import { formatError } from './utils/error-formatter.js';
18
21
 
19
22
  const execAsync = promisify(exec);
20
23
 
@@ -494,7 +497,63 @@ async function handleEdit({ instruction, files, code_edit, dryRun = false }) {
494
497
 
495
498
  console.error(`${colors.cyan}[EDIT STRATEGY]${colors.reset} ${strategy}`);
496
499
 
497
- // Strategy 1: Search/Replace (deterministic, fastest)
500
+ // Strategy 1: Fuzzy Patch (unified diff format)
501
+ if (strategy === 'fuzzy_patch') {
502
+ const diffText = code_edit || instruction;
503
+ const filePath = Object.keys(files)[0]; // Assume single file for now
504
+ const fileContent = files[filePath];
505
+
506
+ try {
507
+ // Apply fuzzy patch with backup/rollback
508
+ let patchResult;
509
+ const editResult = await safeEdit(filePath, async () => {
510
+ patchResult = applyFuzzyPatch(fileContent, diffText);
511
+
512
+ if (!patchResult.success) {
513
+ throw new Error(patchResult.message);
514
+ }
515
+
516
+ // Write patched content
517
+ await fs.writeFile(filePath, patchResult.content, 'utf8');
518
+ });
519
+
520
+ if (!editResult.success) {
521
+ // Rollback occurred
522
+ return {
523
+ content: [{
524
+ type: "text",
525
+ text: formatError('syntax_error', {
526
+ filePath,
527
+ error: editResult.error,
528
+ backupPath: editResult.backupPath
529
+ })
530
+ }],
531
+ isError: true
532
+ };
533
+ }
534
+
535
+ return {
536
+ content: [{
537
+ type: "text",
538
+ text: `✅ Fuzzy Patch Applied Successfully\n\n${patchResult.message}\nConfidence: ${(patchResult.confidence * 100).toFixed(1)}%\n\nBackup: ${editResult.backupPath}`
539
+ }]
540
+ };
541
+
542
+ } catch (error) {
543
+ return {
544
+ content: [{
545
+ type: "text",
546
+ text: formatError('fuzzy_match_failed', {
547
+ filePath,
548
+ error: error.message
549
+ })
550
+ }],
551
+ isError: true
552
+ };
553
+ }
554
+ }
555
+
556
+ // Strategy 2: Search/Replace (deterministic, fastest)
498
557
  if (strategy === 'search_replace') {
499
558
  const extracted = extractSearchReplace(instruction);
500
559
  if (extracted) {
@@ -507,10 +566,8 @@ async function handleEdit({ instruction, files, code_edit, dryRun = false }) {
507
566
  }
508
567
  }
509
568
 
510
- // Strategy 2: Placeholder Merge (token-efficient)
569
+ // Strategy 3: Placeholder Merge (token-efficient)
511
570
  if (strategy === 'placeholder_merge' && code_edit) {
512
- // Use edit_file logic for placeholder-based editing
513
- // For now, we'll route to Mercury with a hint
514
571
  return await handleApplyFast({
515
572
  instruction: `${instruction}\n\nUSE PLACEHOLDER MERGE STRATEGY. Code snippet:\n${code_edit}`,
516
573
  files,
@@ -519,7 +576,7 @@ async function handleEdit({ instruction, files, code_edit, dryRun = false }) {
519
576
  });
520
577
  }
521
578
 
522
- // Strategy 3: Mercury Intelligent (most flexible, default)
579
+ // Strategy 4: Mercury Intelligent (most flexible, default)
523
580
  return await handleApplyFast({
524
581
  instruction,
525
582
  files,
@@ -3,6 +3,8 @@
3
3
  * Determines the best editing strategy based on input parameters
4
4
  */
5
5
 
6
+ import { isDiffBasedEdit } from './fuzzy-patch.js';
7
+
6
8
  /**
7
9
  * Detect if instruction is a simple search/replace
8
10
  */
@@ -62,19 +64,24 @@ export function hasPlaceholders(codeEdit) {
62
64
 
63
65
  /**
64
66
  * Determine the best edit strategy
65
- * @returns {'search_replace' | 'placeholder_merge' | 'mercury_intelligent'}
67
+ * @returns {'fuzzy_patch' | 'search_replace' | 'placeholder_merge' | 'mercury_intelligent'}
66
68
  */
67
69
  export function detectEditStrategy({ instruction, code_edit, files }) {
68
- // Priority 1: Search/Replace (fastest, deterministic)
70
+ // Priority 1: Fuzzy Patch (unified diff format)
71
+ if (isDiffBasedEdit(instruction) || isDiffBasedEdit(code_edit || '')) {
72
+ return 'fuzzy_patch';
73
+ }
74
+
75
+ // Priority 2: Search/Replace (fastest, deterministic)
69
76
  if (isSearchReplace(instruction)) {
70
77
  return 'search_replace';
71
78
  }
72
79
 
73
- // Priority 2: Placeholder Merge (token-efficient)
80
+ // Priority 3: Placeholder Merge (token-efficient)
74
81
  if (code_edit && hasPlaceholders(code_edit)) {
75
82
  return 'placeholder_merge';
76
83
  }
77
84
 
78
- // Priority 3: Mercury Intelligent (most flexible)
85
+ // Priority 4: Mercury Intelligent (most flexible)
79
86
  return 'mercury_intelligent';
80
87
  }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Fuzzy Patching Engine for mcfast v2.1
3
+ * Applies code changes with tolerance for whitespace and minor formatting differences
4
+ */
5
+
6
+ /**
7
+ * Calculate Levenshtein distance between two strings
8
+ * Used for fuzzy matching to find best location for patch
9
+ */
10
+ function levenshteinDistance(str1, str2) {
11
+ const len1 = str1.length;
12
+ const len2 = str2.length;
13
+ const matrix = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0));
14
+
15
+ for (let i = 0; i <= len1; i++) matrix[i][0] = i;
16
+ for (let j = 0; j <= len2; j++) matrix[0][j] = j;
17
+
18
+ for (let i = 1; i <= len1; i++) {
19
+ for (let j = 1; j <= len2; j++) {
20
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
21
+ matrix[i][j] = Math.min(
22
+ matrix[i - 1][j] + 1, // deletion
23
+ matrix[i][j - 1] + 1, // insertion
24
+ matrix[i - 1][j - 1] + cost // substitution
25
+ );
26
+ }
27
+ }
28
+
29
+ return matrix[len1][len2];
30
+ }
31
+
32
+ /**
33
+ * Normalize whitespace for comparison
34
+ * Converts tabs to spaces, trims lines, removes trailing whitespace
35
+ */
36
+ function normalizeWhitespace(text) {
37
+ return text
38
+ .split('\n')
39
+ .map(line => line.replace(/\t/g, ' ').trimEnd())
40
+ .join('\n')
41
+ .trim();
42
+ }
43
+
44
+ /**
45
+ * Calculate similarity score between two strings (0-1)
46
+ * 1.0 = identical, 0.0 = completely different
47
+ */
48
+ function similarityScore(str1, str2) {
49
+ const normalized1 = normalizeWhitespace(str1);
50
+ const normalized2 = normalizeWhitespace(str2);
51
+
52
+ const maxLen = Math.max(normalized1.length, normalized2.length);
53
+ if (maxLen === 0) return 1.0;
54
+
55
+ const distance = levenshteinDistance(normalized1, normalized2);
56
+ return 1.0 - (distance / maxLen);
57
+ }
58
+
59
+ /**
60
+ * Parse unified diff format
61
+ * Returns array of hunks with line numbers and changes
62
+ */
63
+ export function parseDiff(diffText) {
64
+ const hunks = [];
65
+ const lines = diffText.split('\n');
66
+ let currentHunk = null;
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i];
70
+
71
+ // Parse hunk header: @@ -1,3 +1,4 @@
72
+ if (line.startsWith('@@')) {
73
+ if (currentHunk) hunks.push(currentHunk);
74
+
75
+ const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
76
+ if (match) {
77
+ currentHunk = {
78
+ oldStart: parseInt(match[1]),
79
+ oldLines: parseInt(match[2] || '1'),
80
+ newStart: parseInt(match[3]),
81
+ newLines: parseInt(match[4] || '1'),
82
+ lines: []
83
+ };
84
+ }
85
+ } else if (currentHunk) {
86
+ // Parse diff lines
87
+ if (line.startsWith('-')) {
88
+ currentHunk.lines.push({ type: 'remove', content: line.slice(1) });
89
+ } else if (line.startsWith('+')) {
90
+ currentHunk.lines.push({ type: 'add', content: line.slice(1) });
91
+ } else if (line.startsWith(' ')) {
92
+ currentHunk.lines.push({ type: 'context', content: line.slice(1) });
93
+ }
94
+ }
95
+ }
96
+
97
+ if (currentHunk) hunks.push(currentHunk);
98
+ return hunks;
99
+ }
100
+
101
+ /**
102
+ * Find best match location for a pattern in target text
103
+ * Returns { index, score, lineNumber } or null if no good match
104
+ */
105
+ export function findBestMatch(targetLines, patternLines, threshold = 0.8) {
106
+ let bestMatch = null;
107
+ let bestScore = 0;
108
+
109
+ // Sliding window search
110
+ for (let i = 0; i <= targetLines.length - patternLines.length; i++) {
111
+ const window = targetLines.slice(i, i + patternLines.length);
112
+ const windowText = window.join('\n');
113
+ const patternText = patternLines.join('\n');
114
+
115
+ const score = similarityScore(windowText, patternText);
116
+
117
+ if (score > bestScore && score >= threshold) {
118
+ bestScore = score;
119
+ bestMatch = {
120
+ index: i,
121
+ score: score,
122
+ lineNumber: i + 1
123
+ };
124
+ }
125
+ }
126
+
127
+ return bestMatch;
128
+ }
129
+
130
+ /**
131
+ * Apply a single hunk to file content with fuzzy matching
132
+ * Returns { success, content, confidence, message }
133
+ */
134
+ export function applyHunk(fileContent, hunk, threshold = 0.8) {
135
+ const lines = fileContent.split('\n');
136
+
137
+ // Extract context lines (lines that should match)
138
+ const contextLines = hunk.lines
139
+ .filter(l => l.type === 'context' || l.type === 'remove')
140
+ .map(l => l.content);
141
+
142
+ // Find best match location
143
+ const match = findBestMatch(lines, contextLines, threshold);
144
+
145
+ if (!match) {
146
+ return {
147
+ success: false,
148
+ content: fileContent,
149
+ confidence: 0,
150
+ message: `Could not find matching location (threshold: ${threshold})`
151
+ };
152
+ }
153
+
154
+ // Apply changes at matched location
155
+ const newLines = [...lines];
156
+ let offset = match.index;
157
+
158
+ for (const diffLine of hunk.lines) {
159
+ if (diffLine.type === 'remove' || diffLine.type === 'context') {
160
+ // Remove or skip context line
161
+ if (diffLine.type === 'remove') {
162
+ newLines.splice(offset, 1);
163
+ } else {
164
+ offset++;
165
+ }
166
+ } else if (diffLine.type === 'add') {
167
+ // Insert new line
168
+ newLines.splice(offset, 0, diffLine.content);
169
+ offset++;
170
+ }
171
+ }
172
+
173
+ return {
174
+ success: true,
175
+ content: newLines.join('\n'),
176
+ confidence: match.score,
177
+ message: `Applied at line ${match.lineNumber} (confidence: ${(match.score * 100).toFixed(1)}%)`
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Apply complete diff with fuzzy matching
183
+ * Main entry point for fuzzy patching
184
+ */
185
+ export function applyFuzzyPatch(fileContent, diffText, options = {}) {
186
+ const threshold = options.threshold || parseFloat(process.env.MCFAST_FUZZY_THRESHOLD || '0.8');
187
+ const hunks = parseDiff(diffText);
188
+
189
+ if (hunks.length === 0) {
190
+ return {
191
+ success: false,
192
+ content: fileContent,
193
+ message: 'No valid hunks found in diff'
194
+ };
195
+ }
196
+
197
+ let currentContent = fileContent;
198
+ const results = [];
199
+
200
+ for (const hunk of hunks) {
201
+ const result = applyHunk(currentContent, hunk, threshold);
202
+ results.push(result);
203
+
204
+ if (!result.success) {
205
+ return {
206
+ success: false,
207
+ content: fileContent,
208
+ message: `Failed to apply hunk: ${result.message}`,
209
+ partialResults: results
210
+ };
211
+ }
212
+
213
+ currentContent = result.content;
214
+ }
215
+
216
+ const avgConfidence = results.reduce((sum, r) => sum + r.confidence, 0) / results.length;
217
+
218
+ return {
219
+ success: true,
220
+ content: currentContent,
221
+ confidence: avgConfidence,
222
+ message: `Applied ${hunks.length} hunk(s) successfully`,
223
+ details: results
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Detect if instruction is a diff-based edit
229
+ * Returns true if instruction contains unified diff markers
230
+ */
231
+ export function isDiffBasedEdit(instruction) {
232
+ if (!instruction) return false;
233
+ // Check for unified diff hunk markers (@@)
234
+ return instruction.includes('@@');
235
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Backup and Rollback Manager for mcfast v2.1
3
+ * Handles file backups before edits and auto-rollback on errors
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import path from 'path';
8
+ import { exec } from 'child_process';
9
+ import { promisify } from 'util';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ const BACKUP_DIR = '.mcfast-backup';
14
+ const MAX_BACKUPS = 5;
15
+
16
+ /**
17
+ * Create backup of a file before editing
18
+ * Returns backup path
19
+ */
20
+ export async function createBackup(filePath) {
21
+ const timestamp = Date.now();
22
+ const fileName = path.basename(filePath);
23
+ const backupPath = path.join(BACKUP_DIR, `${fileName}.${timestamp}`);
24
+
25
+ // Ensure backup directory exists
26
+ await fs.mkdir(BACKUP_DIR, { recursive: true });
27
+
28
+ // Copy file to backup
29
+ await fs.copyFile(filePath, backupPath);
30
+
31
+ // Cleanup old backups
32
+ await cleanupOldBackups(fileName);
33
+
34
+ return backupPath;
35
+ }
36
+
37
+ /**
38
+ * Restore file from backup
39
+ */
40
+ export async function restoreFromBackup(filePath, backupPath) {
41
+ await fs.copyFile(backupPath, filePath);
42
+ }
43
+
44
+ /**
45
+ * Cleanup old backups, keeping only the most recent MAX_BACKUPS
46
+ */
47
+ async function cleanupOldBackups(fileName) {
48
+ try {
49
+ const files = await fs.readdir(BACKUP_DIR);
50
+ const backups = files
51
+ .filter(f => f.startsWith(fileName + '.'))
52
+ .map(f => ({
53
+ name: f,
54
+ timestamp: parseInt(f.split('.').pop())
55
+ }))
56
+ .sort((a, b) => b.timestamp - a.timestamp);
57
+
58
+ // Remove old backups
59
+ for (let i = MAX_BACKUPS; i < backups.length; i++) {
60
+ await fs.unlink(path.join(BACKUP_DIR, backups[i].name));
61
+ }
62
+ } catch (err) {
63
+ // Ignore cleanup errors
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Validate JavaScript/TypeScript syntax
69
+ * Returns { valid: boolean, error?: string }
70
+ */
71
+ export async function validateJavaScript(filePath) {
72
+ try {
73
+ const { stderr } = await execAsync(`node --check "${filePath}"`, {
74
+ timeout: 5000
75
+ });
76
+
77
+ if (stderr && stderr.includes('SyntaxError')) {
78
+ return { valid: false, error: stderr };
79
+ }
80
+
81
+ return { valid: true };
82
+ } catch (error) {
83
+ return { valid: false, error: error.message };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Validate Python syntax
89
+ */
90
+ export async function validatePython(filePath) {
91
+ try {
92
+ await execAsync(`python -m py_compile "${filePath}"`, {
93
+ timeout: 5000
94
+ });
95
+ return { valid: true };
96
+ } catch (error) {
97
+ return { valid: false, error: error.message };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Generic file validation (check for truncation, empty file)
103
+ */
104
+ export async function validateGeneric(filePath, originalSize) {
105
+ try {
106
+ const stats = await fs.stat(filePath);
107
+
108
+ // Check if file was truncated
109
+ if (stats.size === 0 && originalSize > 0) {
110
+ return { valid: false, error: 'File was truncated to 0 bytes' };
111
+ }
112
+
113
+ // Check if file size changed dramatically (>90% reduction)
114
+ if (originalSize > 0 && stats.size < originalSize * 0.1) {
115
+ return { valid: false, error: 'File size reduced by >90%, possible corruption' };
116
+ }
117
+
118
+ return { valid: true };
119
+ } catch (error) {
120
+ return { valid: false, error: error.message };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Validate file based on extension
126
+ */
127
+ export async function validateFile(filePath, originalSize) {
128
+ const ext = path.extname(filePath).toLowerCase();
129
+
130
+ // JavaScript/TypeScript
131
+ if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'].includes(ext)) {
132
+ return await validateJavaScript(filePath);
133
+ }
134
+
135
+ // Python
136
+ if (['.py'].includes(ext)) {
137
+ return await validatePython(filePath);
138
+ }
139
+
140
+ // Generic validation for other files
141
+ return await validateGeneric(filePath, originalSize);
142
+ }
143
+
144
+ /**
145
+ * Safe edit with automatic rollback
146
+ * Returns { success, backupPath?, error? }
147
+ */
148
+ export async function safeEdit(filePath, editFn) {
149
+ let backupPath = null;
150
+ let originalSize = 0;
151
+
152
+ try {
153
+ // Get original size
154
+ const stats = await fs.stat(filePath);
155
+ originalSize = stats.size;
156
+
157
+ // Create backup
158
+ backupPath = await createBackup(filePath);
159
+
160
+ // Apply edit
161
+ await editFn();
162
+
163
+ // Validate result
164
+ const validation = await validateFile(filePath, originalSize);
165
+
166
+ if (!validation.valid) {
167
+ // Rollback on validation failure
168
+ await restoreFromBackup(filePath, backupPath);
169
+ return {
170
+ success: false,
171
+ error: `Validation failed: ${validation.error}`,
172
+ rolledBack: true
173
+ };
174
+ }
175
+
176
+ return {
177
+ success: true,
178
+ backupPath
179
+ };
180
+
181
+ } catch (error) {
182
+ // Rollback on any error
183
+ if (backupPath) {
184
+ try {
185
+ await restoreFromBackup(filePath, backupPath);
186
+ } catch (rollbackError) {
187
+ // Rollback failed, critical error
188
+ return {
189
+ success: false,
190
+ error: error.message,
191
+ rollbackError: rollbackError.message
192
+ };
193
+ }
194
+ }
195
+
196
+ return {
197
+ success: false,
198
+ error: error.message,
199
+ rolledBack: !!backupPath
200
+ };
201
+ }
202
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Error Formatter for mcfast v2.1
3
+ * Provides actionable error messages with context and suggestions
4
+ */
5
+
6
+ /**
7
+ * Format error with context and suggestions
8
+ */
9
+ export function formatError(errorType, context = {}) {
10
+ const formatters = {
11
+ 'pattern_not_found': () => ({
12
+ title: '❌ Edit Failed: Pattern not found in target file',
13
+ suggestions: [
14
+ 'The code may have changed since last read',
15
+ 'Try using \'read\' tool to get current content',
16
+ 'Use fuzzy mode for tolerance: { "fuzzy": true }'
17
+ ],
18
+ context: {
19
+ 'Looking for': context.pattern ? `"${context.pattern.substring(0, 50)}..."` : 'N/A',
20
+ 'In file': context.filePath || 'N/A',
21
+ 'File size': context.fileSize ? `${context.fileSize} lines` : 'N/A'
22
+ }
23
+ }),
24
+
25
+ 'syntax_error': () => ({
26
+ title: '❌ Syntax Error Detected (Auto-Rolled Back)',
27
+ suggestions: [
28
+ 'The edit introduced a syntax error',
29
+ 'File has been restored from backup',
30
+ 'Review the instruction and try again'
31
+ ],
32
+ context: {
33
+ 'File': context.filePath || 'N/A',
34
+ 'Error': context.error || 'N/A',
35
+ 'Backup': context.backupPath || 'N/A'
36
+ }
37
+ }),
38
+
39
+ 'fuzzy_match_failed': () => ({
40
+ title: '❌ Fuzzy Patch Failed: No suitable match found',
41
+ suggestions: [
42
+ `Confidence threshold: ${context.threshold || 0.8} (adjust with MCFAST_FUZZY_THRESHOLD)`,
43
+ 'The diff may be too different from current file state',
44
+ 'Try using Mercury AI for complex changes'
45
+ ],
46
+ context: {
47
+ 'File': context.filePath || 'N/A',
48
+ 'Best match score': context.bestScore ? `${(context.bestScore * 100).toFixed(1)}%` : 'N/A'
49
+ }
50
+ }),
51
+
52
+ 'file_not_found': () => ({
53
+ title: '❌ File Not Found',
54
+ suggestions: [
55
+ 'Verify the file path is correct',
56
+ 'Use \'list_files\' to browse available files',
57
+ 'Check if file was moved or deleted'
58
+ ],
59
+ context: {
60
+ 'Path': context.filePath || 'N/A'
61
+ }
62
+ }),
63
+
64
+ 'permission_denied': () => ({
65
+ title: '❌ Permission Denied',
66
+ suggestions: [
67
+ 'Check file permissions',
68
+ 'Ensure you have write access to the file',
69
+ 'Try running with appropriate permissions'
70
+ ],
71
+ context: {
72
+ 'File': context.filePath || 'N/A'
73
+ }
74
+ }),
75
+
76
+ 'generic': () => ({
77
+ title: '❌ Operation Failed',
78
+ suggestions: [
79
+ 'Check the error message below for details',
80
+ 'Verify your input parameters',
81
+ 'Try again or use a different approach'
82
+ ],
83
+ context: {
84
+ 'Error': context.error || 'Unknown error'
85
+ }
86
+ })
87
+ };
88
+
89
+ const formatter = formatters[errorType] || formatters['generic'];
90
+ const formatted = formatter();
91
+
92
+ // Build output
93
+ let output = `${formatted.title}\n\n`;
94
+
95
+ if (formatted.suggestions.length > 0) {
96
+ output += 'Suggestions:\n';
97
+ formatted.suggestions.forEach(s => {
98
+ output += ` • ${s}\n`;
99
+ });
100
+ output += '\n';
101
+ }
102
+
103
+ if (formatted.context && Object.keys(formatted.context).length > 0) {
104
+ output += 'Context:\n';
105
+ Object.entries(formatted.context).forEach(([key, value]) => {
106
+ output += ` ${key}: ${value}\n`;
107
+ });
108
+ }
109
+
110
+ return output;
111
+ }
112
+
113
+ /**
114
+ * Wrap error with formatting
115
+ */
116
+ export function createFormattedError(errorType, context) {
117
+ const message = formatError(errorType, context);
118
+ const error = new Error(message);
119
+ error.formatted = true;
120
+ error.errorType = errorType;
121
+ return error;
122
+ }