nex-code 0.3.4 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,94 +0,0 @@
1
- /**
2
- * cli/file-history.js — In-session undo/redo for file changes
3
- */
4
-
5
- const fs = require('fs');
6
-
7
- const MAX_HISTORY = 50;
8
-
9
- const undoStack = [];
10
- const redoStack = [];
11
-
12
- /**
13
- * Record a file change for undo/redo support.
14
- * @param {string} tool - Tool that made the change (write_file, edit_file, patch_file)
15
- * @param {string} filePath - Absolute path to the file
16
- * @param {string|null} oldContent - Previous content (null if file was newly created)
17
- * @param {string} newContent - New content after the change
18
- */
19
- function recordChange(tool, filePath, oldContent, newContent) {
20
- undoStack.push({
21
- tool,
22
- filePath,
23
- oldContent,
24
- newContent,
25
- timestamp: Date.now(),
26
- });
27
- // Trim to max history
28
- while (undoStack.length > MAX_HISTORY) undoStack.shift();
29
- // New edit clears redo stack
30
- redoStack.length = 0;
31
- }
32
-
33
- /**
34
- * Undo the last file change.
35
- * @returns {{ tool: string, filePath: string, diff: string }|null} Info about what was undone, or null if nothing to undo
36
- */
37
- function undo() {
38
- if (undoStack.length === 0) return null;
39
- const entry = undoStack.pop();
40
-
41
- if (entry.oldContent === null) {
42
- // File was newly created — delete it
43
- try { fs.unlinkSync(entry.filePath); } catch { /* ignore */ }
44
- } else {
45
- fs.writeFileSync(entry.filePath, entry.oldContent, 'utf-8');
46
- }
47
-
48
- redoStack.push(entry);
49
- return {
50
- tool: entry.tool,
51
- filePath: entry.filePath,
52
- wasCreated: entry.oldContent === null,
53
- };
54
- }
55
-
56
- /**
57
- * Redo the last undone change.
58
- * @returns {{ tool: string, filePath: string }|null} Info about what was redone, or null if nothing to redo
59
- */
60
- function redo() {
61
- if (redoStack.length === 0) return null;
62
- const entry = redoStack.pop();
63
-
64
- fs.writeFileSync(entry.filePath, entry.newContent, 'utf-8');
65
- undoStack.push(entry);
66
-
67
- return {
68
- tool: entry.tool,
69
- filePath: entry.filePath,
70
- };
71
- }
72
-
73
- /**
74
- * Get change history.
75
- * @param {number} [limit=10] - Max entries to return
76
- * @returns {Array<{ tool: string, filePath: string, timestamp: number }>}
77
- */
78
- function getHistory(limit = 10) {
79
- return undoStack.slice(-limit).reverse().map((e) => ({
80
- tool: e.tool,
81
- filePath: e.filePath,
82
- timestamp: e.timestamp,
83
- }));
84
- }
85
-
86
- function getUndoCount() { return undoStack.length; }
87
- function getRedoCount() { return redoStack.length; }
88
-
89
- function clearHistory() {
90
- undoStack.length = 0;
91
- redoStack.length = 0;
92
- }
93
-
94
- module.exports = { recordChange, undo, redo, getHistory, getUndoCount, getRedoCount, clearHistory };
package/cli/format.js DELETED
@@ -1,211 +0,0 @@
1
- /**
2
- * cli/format.js — Terminal Formatting Functions
3
- * Tool summaries, result formatting, and color utilities
4
- */
5
-
6
- const C = {
7
- reset: '\x1b[0m',
8
- bold: '\x1b[1m',
9
- dim: '\x1b[2m',
10
- white: '\x1b[37m',
11
- red: '\x1b[31m',
12
- green: '\x1b[32m',
13
- yellow: '\x1b[33m',
14
- blue: '\x1b[34m',
15
- magenta: '\x1b[35m',
16
- cyan: '\x1b[36m',
17
- gray: '\x1b[90m',
18
- bgRed: '\x1b[41m',
19
- bgGreen: '\x1b[42m',
20
- brightCyan: '\x1b[96m',
21
- brightMagenta: '\x1b[95m',
22
- brightBlue: '\x1b[94m',
23
- };
24
-
25
- function formatToolCall(name, args) {
26
- let preview;
27
- switch (name) {
28
- case 'write_file':
29
- preview = `path=${args.path} (${(args.content || '').length} chars)`;
30
- break;
31
- case 'edit_file':
32
- preview = `path=${args.path}`;
33
- break;
34
- case 'bash':
35
- preview = args.command?.substring(0, 100) || '';
36
- break;
37
- default:
38
- preview = JSON.stringify(args).substring(0, 120);
39
- }
40
- return `${C.yellow} ▸ ${name}${C.reset} ${C.dim}${preview}${C.reset}`;
41
- }
42
-
43
- function formatResult(text, maxLines = 8) {
44
- const lines = text.split('\n');
45
- const shown = lines.slice(0, maxLines);
46
- const more = lines.length - maxLines;
47
- let out = shown.map((l) => `${C.green} ${l}${C.reset}`).join('\n');
48
- if (more > 0) out += `\n${C.gray} ...+${more} more lines${C.reset}`;
49
- return out;
50
- }
51
-
52
- /**
53
- * Returns spinner text for a tool execution, or null if the tool
54
- * should not show a spinner (interactive or has its own spinner).
55
- */
56
- function getToolSpinnerText(name, args) {
57
- switch (name) {
58
- // Tools with their own spinner or interactive UI — skip
59
- case 'bash':
60
- case 'ask_user':
61
- case 'write_file':
62
- case 'edit_file':
63
- case 'patch_file':
64
- case 'task_list':
65
- case 'spawn_agents':
66
- return null;
67
-
68
- case 'read_file':
69
- return `Reading: ${args.path || 'file'}`;
70
- case 'list_directory':
71
- return `Listing: ${args.path || '.'}`;
72
- case 'search_files':
73
- return `Searching: ${args.pattern || '...'}`;
74
- case 'glob':
75
- return `Glob: ${args.pattern || '...'}`;
76
- case 'grep':
77
- return `Grep: ${args.pattern || '...'}`;
78
- case 'web_fetch':
79
- return `Fetching: ${(args.url || '').substring(0, 60)}`;
80
- case 'web_search':
81
- return `Searching web: ${(args.query || '').substring(0, 50)}`;
82
- case 'git_status':
83
- return 'Git status...';
84
- case 'git_diff':
85
- return `Git diff${args.file ? `: ${args.file}` : ''}...`;
86
- case 'git_log':
87
- return `Git log${args.file ? `: ${args.file}` : ''}...`;
88
- default:
89
- return `Running: ${name}`;
90
- }
91
- }
92
-
93
- /**
94
- * Compact 1-line summary for a tool execution result.
95
- * Used by the agent loop in quiet mode.
96
- */
97
- function formatToolSummary(name, args, result, isError) {
98
- const r = String(result || '');
99
- const icon = isError ? `${C.red}✗${C.reset}` : `${C.green}✓${C.reset}`;
100
-
101
- if (isError) {
102
- const errMsg = r.split('\n')[0].replace(/^ERROR:\s*/i, '').substring(0, 60);
103
- return ` ${icon} ${C.dim}${name}${C.reset} ${C.red}→ ${errMsg}${C.reset}`;
104
- }
105
-
106
- let detail;
107
- switch (name) {
108
- case 'read_file': {
109
- const resultLines = r.split('\n').filter(Boolean);
110
- const count = resultLines.length;
111
- // Detect partial reads: last line number tells us total file size
112
- const lastLine = resultLines[resultLines.length - 1];
113
- const lastLineNum = lastLine ? parseInt(lastLine.match(/^(\d+):/)?.[1] || '0') : 0;
114
- const isPartial = args.line_start || args.line_end;
115
- if (isPartial && lastLineNum > count) {
116
- detail = `${args.path || 'file'} (lines ${args.line_start || 1}-${lastLineNum})`;
117
- } else {
118
- detail = `${args.path || 'file'} (${count} lines)`;
119
- }
120
- break;
121
- }
122
- case 'write_file': {
123
- const chars = (args.content || '').length;
124
- detail = `${args.path || 'file'} (${chars} chars)`;
125
- break;
126
- }
127
- case 'edit_file':
128
- detail = `${args.path || 'file'} → edited`;
129
- break;
130
- case 'patch_file': {
131
- const n = (args.patches || []).length;
132
- detail = `${args.path || 'file'} (${n} patches)`;
133
- break;
134
- }
135
- case 'bash': {
136
- const cmd = (args.command || '').substring(0, 40);
137
- const suffix = (args.command || '').length > 40 ? '...' : '';
138
- // Only match EXIT at the very start of the output (our error format)
139
- const exitMatch = r.match(/^EXIT (\d+)/);
140
- if (exitMatch) {
141
- detail = `${cmd}${suffix} → exit ${exitMatch[1]}`;
142
- } else {
143
- detail = `${cmd}${suffix} → ok`;
144
- }
145
- break;
146
- }
147
- case 'grep':
148
- case 'search_files': {
149
- if (r.includes('(no matches)') || r === 'no matches') {
150
- detail = `${args.pattern || '...'} → no matches`;
151
- } else {
152
- const lines = r.split('\n').filter(Boolean).length;
153
- detail = `${args.pattern || '...'} → ${lines} matches`;
154
- }
155
- break;
156
- }
157
- case 'glob': {
158
- if (r === '(no matches)') {
159
- detail = `${args.pattern || '...'} → no matches`;
160
- } else {
161
- const files = r.split('\n').filter(Boolean).length;
162
- detail = `${args.pattern || '...'} → ${files} files`;
163
- }
164
- break;
165
- }
166
- case 'list_directory': {
167
- const entries = r === '(empty)' ? 0 : r.split('\n').filter(Boolean).length;
168
- detail = `${args.path || '.'} → ${entries} entries`;
169
- break;
170
- }
171
- case 'git_status': {
172
- const branchMatch = r.match(/Branch:\s*(\S+)/);
173
- const changeLines = r.split('\n').filter(l => /^\s*[MADRCU?!]/.test(l)).length;
174
- detail = branchMatch ? `${branchMatch[1]}, ${changeLines} changes` : 'done';
175
- break;
176
- }
177
- case 'git_diff':
178
- case 'git_log':
179
- detail = 'done';
180
- break;
181
- case 'web_fetch':
182
- detail = `${(args.url || '').substring(0, 50)} → fetched`;
183
- break;
184
- case 'web_search': {
185
- const blocks = r.split('\n\n').filter(Boolean).length;
186
- detail = `${(args.query || '').substring(0, 40)} → ${blocks} results`;
187
- break;
188
- }
189
- case 'task_list':
190
- detail = `${args.action || 'list'} → done`;
191
- break;
192
- case 'spawn_agents': {
193
- const n = (args.agents || []).length;
194
- // Count successful vs failed agents from result
195
- const doneCount = (r.match(/✓ Agent/g) || []).length;
196
- const failCount = (r.match(/✗ Agent/g) || []).length;
197
- if (failCount > 0) {
198
- detail = `${n} agents → ${doneCount}✓ ${failCount}✗`;
199
- } else {
200
- detail = `${n} agents → done`;
201
- }
202
- break;
203
- }
204
- default:
205
- detail = 'done';
206
- }
207
-
208
- return ` ${icon} ${C.dim}${name} ${detail}${C.reset}`;
209
- }
210
-
211
- module.exports = { C, formatToolCall, formatResult, getToolSpinnerText, formatToolSummary };
@@ -1,270 +0,0 @@
1
- /**
2
- * cli/fuzzy-match.js — Fuzzy Text Matching for Edit Operations
3
- * Handles whitespace normalization and approximate string matching
4
- * to recover from common LLM edit failures.
5
- */
6
-
7
- const { levenshtein } = require('./tool-validator');
8
-
9
- // Constants for fuzzy matching
10
- const MAX_CANDIDATES = 200;
11
- const SIMILARITY_THRESHOLD = 0.3; // Reject if distance > 30% of target length
12
- const TAB_SPACES = 2;
13
-
14
- /**
15
- * Normalize whitespace for comparison:
16
- * - Tabs → 2 spaces
17
- * - Trim trailing whitespace per line
18
- * - Collapse multiple inline spaces to one (except leading indentation)
19
- * - Normalize line endings to \n
20
- */
21
- function normalizeWhitespace(text) {
22
- return text
23
- .replace(/\r\n/g, '\n')
24
- .replace(/\r/g, '\n')
25
- .replace(/\t/g, ' '.repeat(TAB_SPACES))
26
- .split('\n')
27
- .map(line => {
28
- const trimmed = line.replace(/\s+$/, '');
29
- const match = trimmed.match(/^(\s*)(.*)/);
30
- if (!match) return trimmed;
31
- const [, indent, rest] = match;
32
- return indent + rest.replace(/ {2,}/g, ' ');
33
- })
34
- .join('\n');
35
- }
36
-
37
- /**
38
- * Try to find `needle` in `haystack` with whitespace normalization.
39
- * Returns the ACTUAL text from haystack (not the normalized version),
40
- * or null if not found.
41
- *
42
- * Strategy:
43
- * 1. Exact match (fast path — caller should check this first)
44
- * 2. Normalize both and find the match, then map back to original positions
45
- */
46
- function fuzzyFindText(haystack, needle) {
47
- // Fast path: exact match
48
- if (haystack.includes(needle)) return needle;
49
-
50
- const normHaystack = normalizeWhitespace(haystack);
51
- const normNeedle = normalizeWhitespace(needle);
52
-
53
- if (!normHaystack.includes(normNeedle)) return null;
54
-
55
- // Map normalized position back to original text using line-based matching
56
- const haystackLines = haystack.split('\n');
57
- const normHaystackLines = normHaystack.split('\n');
58
- const normNeedleLines = normNeedle.split('\n');
59
-
60
- // Find which normalized line the match starts on
61
- const needleFirstLine = normNeedleLines[0];
62
- const needleLastLine = normNeedleLines[normNeedleLines.length - 1];
63
-
64
- for (let i = 0; i <= normHaystackLines.length - normNeedleLines.length; i++) {
65
- // Check if this position matches in normalized form
66
- let matches = true;
67
- for (let j = 0; j < normNeedleLines.length; j++) {
68
- if (normHaystackLines[i + j] !== normNeedleLines[j]) {
69
- matches = false;
70
- break;
71
- }
72
- }
73
- if (matches) {
74
- // Return the original lines from haystack
75
- return haystackLines.slice(i, i + normNeedleLines.length).join('\n');
76
- }
77
- }
78
-
79
- // Fallback: single-line needle within a line
80
- if (normNeedleLines.length === 1) {
81
- for (let i = 0; i < normHaystackLines.length; i++) {
82
- const idx = normHaystackLines[i].indexOf(normNeedle);
83
- if (idx !== -1) {
84
- // Map character position back — this is approximate for single-line matches
85
- // Return the full original line's content at the approximate position
86
- return haystackLines[i];
87
- }
88
- }
89
- }
90
-
91
- return null;
92
- }
93
-
94
- /**
95
- * Sliding-window Levenshtein search to find the most similar substring.
96
- * Used for error messages when fuzzyFindText also fails.
97
- *
98
- * @param {string} content - The file content to search in
99
- * @param {string} target - The text we're looking for
100
- * @returns {{ text: string, distance: number, line: number } | null}
101
- */
102
- function findMostSimilar(content, target) {
103
- if (!content || !target) return null;
104
-
105
- const contentLines = content.split('\n');
106
- const targetLines = target.split('\n');
107
- const targetLineCount = targetLines.length;
108
-
109
- if (contentLines.length === 0 || targetLineCount === 0) return null;
110
-
111
- // For single-line targets, search line by line
112
- if (targetLineCount === 1) {
113
- return findBestSingleLineMatch(contentLines, target);
114
- }
115
-
116
- // Multi-line: sliding window search
117
- return findBestMultiLineMatch(contentLines, target, targetLineCount);
118
- }
119
-
120
- /**
121
- * Calculate sampling step size for large files
122
- * @param {number} totalItems - Total items to search
123
- * @returns {number} Step size (at least 1)
124
- */
125
- function calculateStep(totalItems) {
126
- return Math.max(1, Math.floor(totalItems / MAX_CANDIDATES));
127
- }
128
-
129
- /**
130
- * Check if distance is within acceptable threshold
131
- * @param {number} distance - Levenshtein distance
132
- * @param {number} targetLength - Length of target string
133
- * @returns {boolean}
134
- */
135
- function isWithinThreshold(distance, targetLength) {
136
- return distance <= Math.ceil(targetLength * SIMILARITY_THRESHOLD);
137
- }
138
-
139
- /**
140
- * Find best matching single line
141
- * @param {string[]} contentLines - File content split by lines
142
- * @param {string} target - Target text to find
143
- * @returns {{text: string, distance: number, line: number}|null}
144
- */
145
- function findBestSingleLineMatch(contentLines, target) {
146
- const targetTrimmed = target.trim();
147
- const step = calculateStep(contentLines.length);
148
-
149
- let best = null;
150
- let bestDist = Infinity;
151
-
152
- // Coarse search with sampling
153
- for (let i = 0; i < contentLines.length; i += step) {
154
- const line = contentLines[i];
155
- if (!line.trim()) continue;
156
-
157
- const dist = levenshtein(line.trim(), targetTrimmed);
158
- if (dist < bestDist) {
159
- bestDist = dist;
160
- best = { text: line, distance: dist, line: i + 1 };
161
- }
162
- }
163
-
164
- // Refine around best match
165
- if (best && step > 1) {
166
- const refined = refineSingleLineSearch(contentLines, targetTrimmed, best, step);
167
- best = refined || best;
168
- bestDist = best.distance;
169
- }
170
-
171
- return isWithinThreshold(bestDist, target.length) ? best : null;
172
- }
173
-
174
- /**
175
- * Refine search by checking neighbors of best match
176
- * @param {string[]} contentLines - File content lines
177
- * @param {string} targetTrimmed - Trimmed target
178
- * @param {Object} best - Current best match
179
- * @param {number} step - Sampling step
180
- * @returns {Object|null} Refined match or null
181
- */
182
- function refineSingleLineSearch(contentLines, targetTrimmed, best, step) {
183
- const center = best.line - 1;
184
- const lo = Math.max(0, center - step);
185
- const hi = Math.min(contentLines.length - 1, center + step);
186
-
187
- let bestDist = best.distance;
188
- let result = null;
189
-
190
- for (let i = lo; i <= hi; i++) {
191
- const line = contentLines[i];
192
- if (!line.trim()) continue;
193
-
194
- const dist = levenshtein(line.trim(), targetTrimmed);
195
- if (dist < bestDist) {
196
- bestDist = dist;
197
- result = { text: line, distance: dist, line: i + 1 };
198
- }
199
- }
200
-
201
- return result;
202
- }
203
-
204
- /**
205
- * Find best matching multi-line window
206
- * @param {string[]} contentLines - File content split by lines
207
- * @param {string} target - Target text to find
208
- * @param {number} windowSize - Number of lines in window
209
- * @returns {{text: string, distance: number, line: number}|null}
210
- */
211
- function findBestMultiLineMatch(contentLines, target, windowSize) {
212
- const maxWindows = contentLines.length - windowSize + 1;
213
- if (maxWindows <= 0) return null;
214
-
215
- const step = calculateStep(maxWindows);
216
-
217
- let best = null;
218
- let bestDist = Infinity;
219
-
220
- // Coarse search with sampling
221
- for (let i = 0; i < maxWindows; i += step) {
222
- const window = contentLines.slice(i, i + windowSize).join('\n');
223
- const dist = levenshtein(window, target);
224
- if (dist < bestDist) {
225
- bestDist = dist;
226
- best = { text: window, distance: dist, line: i + 1 };
227
- }
228
- }
229
-
230
- // Refine around best match
231
- if (best && step > 1) {
232
- const refined = refineMultiLineSearch(contentLines, target, best, step, windowSize, maxWindows);
233
- best = refined || best;
234
- bestDist = best.distance;
235
- }
236
-
237
- return isWithinThreshold(bestDist, target.length) ? best : null;
238
- }
239
-
240
- /**
241
- * Refine multi-line search around best match
242
- * @param {string[]} contentLines - File content lines
243
- * @param {string} target - Target text
244
- * @param {Object} best - Current best match
245
- * @param {number} step - Sampling step
246
- * @param {number} windowSize - Window size in lines
247
- * @param {number} maxWindows - Maximum window positions
248
- * @returns {Object|null} Refined match or null
249
- */
250
- function refineMultiLineSearch(contentLines, target, best, step, windowSize, maxWindows) {
251
- const center = best.line - 1;
252
- const lo = Math.max(0, center - step);
253
- const hi = Math.min(maxWindows - 1, center + step);
254
-
255
- let bestDist = best.distance;
256
- let result = null;
257
-
258
- for (let i = lo; i <= hi; i++) {
259
- const window = contentLines.slice(i, i + windowSize).join('\n');
260
- const dist = levenshtein(window, target);
261
- if (dist < bestDist) {
262
- bestDist = dist;
263
- result = { text: window, distance: dist, line: i + 1 };
264
- }
265
- }
266
-
267
- return result;
268
- }
269
-
270
- module.exports = { normalizeWhitespace, fuzzyFindText, findMostSimilar };