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.
- package/README.md +34 -12
- package/dist/bundle.js +505 -0
- package/dist/nex-code.js +485 -0
- package/package.json +8 -6
- package/bin/nex-code.js +0 -99
- package/cli/agent.js +0 -835
- package/cli/compactor.js +0 -85
- package/cli/context-engine.js +0 -507
- package/cli/context.js +0 -98
- package/cli/costs.js +0 -290
- package/cli/diff.js +0 -366
- package/cli/file-history.js +0 -94
- package/cli/format.js +0 -211
- package/cli/fuzzy-match.js +0 -270
- package/cli/git.js +0 -211
- package/cli/hooks.js +0 -173
- package/cli/index.js +0 -1289
- package/cli/mcp.js +0 -284
- package/cli/memory.js +0 -170
- package/cli/ollama.js +0 -130
- package/cli/permissions.js +0 -124
- package/cli/picker.js +0 -201
- package/cli/planner.js +0 -282
- package/cli/providers/anthropic.js +0 -333
- package/cli/providers/base.js +0 -116
- package/cli/providers/gemini.js +0 -239
- package/cli/providers/local.js +0 -249
- package/cli/providers/ollama.js +0 -228
- package/cli/providers/openai.js +0 -237
- package/cli/providers/registry.js +0 -454
- package/cli/render.js +0 -495
- package/cli/safety.js +0 -241
- package/cli/session.js +0 -133
- package/cli/skills.js +0 -412
- package/cli/spinner.js +0 -371
- package/cli/sub-agent.js +0 -425
- package/cli/tasks.js +0 -179
- package/cli/tool-tiers.js +0 -164
- package/cli/tool-validator.js +0 -138
- package/cli/tools.js +0 -1050
- package/cli/ui.js +0 -93
package/cli/file-history.js
DELETED
|
@@ -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 };
|
package/cli/fuzzy-match.js
DELETED
|
@@ -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 };
|