@hncode/hncode 0.5.2 → 0.5.5
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 +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/src/tools/edit.js
CHANGED
|
@@ -1,591 +1,741 @@
|
|
|
1
|
-
// Edit tool — mirrors the hncode Edit tool schema & behavior.
|
|
2
|
-
// Snippet-based exact replacement (old_string -> new_string), NOT a whole-file rewrite,
|
|
3
|
-
// so edits stay token-cheap (unlike kimi-code-cli's whole-file Edit).
|
|
4
|
-
|
|
5
|
-
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import { resolvePath, ensureDir, checkpointBeforeWrite } from './utils.js';
|
|
8
|
-
import { hashStr, normalizeText } from './read.js';
|
|
9
|
-
|
|
10
|
-
// Typographic quotes -> straight quotes. A model almost always emits ASCII
|
|
11
|
-
// quotes, but docs/blog/markdown often use curly ones; without this the edit
|
|
12
|
-
// fails with "old_string not found" even though the text is visibly there.
|
|
13
|
-
// Mirrors Claude Code's normalizeQuotes.
|
|
14
|
-
const CURLY = [
|
|
15
|
-
['\u2018', "'"], ['\u2019', "'"], // ‘ ’
|
|
16
|
-
['\u201c', '"'], ['\u201d', '"'], // “ ”
|
|
17
|
-
];
|
|
18
|
-
export function normalizeQuotes(s) {
|
|
19
|
-
let out = String(s);
|
|
20
|
-
for (const [from, to] of CURLY) out = out.split(from).join(to);
|
|
21
|
-
return out;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Re-exported from read.js so the staleness guard has ONE definition of the
|
|
25
|
-
// region hash. Previously read.js and edit.js each had their own copy; any
|
|
26
|
-
// drift between them would silently disable staleness detection.
|
|
27
|
-
import { hashRegion as hashLines } from './read.js';
|
|
28
|
-
|
|
29
|
-
export const spec = {
|
|
30
|
-
name: 'Edit',
|
|
31
|
-
description: 'Edit a file. THREE shapes:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use when the exact text is known and a line range is not convenient.\n- RANGED SUBSTRING mode: path + start_line + end_line + old_string + new_string. Searches for old_string ONLY inside [start_line, end_line], and replaces only what it finds there. Use this when the same snippet appears several times and you need exactly one of them: replace_all changes every copy, while plain substring mode refuses an ambiguous match. The range says WHERE to look, old_string says WHAT to replace.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG — nothing between the quote and `return`, so the line lands at column 0 inside an indented block. Look at the SHAPE as well: the quote touches the first character, and spaces only turn up after the first newline.\nnew_string: "return total;\n total += 1;"\nRIGHT — four spaces, then `return`. The opening quote is followed by the indent as well, so every line carries its own indentation INCLUDING the first:\nnew_string: " return total;\n total += 1;"\nSame call, same words. The only difference is the spaces at the START of the first line, and that is the whole point — in the RIGHT form the indent sits between the quote and the code, exactly where it sits in the file you copied it from.\nThe same applies to tabs, and to a line whose indentation changes partway through.\nRANGE HYGIENE —
|
|
32
|
-
parameters: {
|
|
33
|
-
type: 'object',
|
|
34
|
-
properties: {
|
|
35
|
-
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
36
|
-
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit for pure line-range mode. COMBINE it with start_line/end_line to replace only the occurrence inside that range.' },
|
|
37
|
-
new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
|
|
38
|
-
|
|
39
|
-
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
{ required: ['path', '
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
: ''
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// old_string
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// the
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
let
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
if (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
// the
|
|
1
|
+
// Edit tool — mirrors the hncode Edit tool schema & behavior.
|
|
2
|
+
// Snippet-based exact replacement (old_string -> new_string), NOT a whole-file rewrite,
|
|
3
|
+
// so edits stay token-cheap (unlike kimi-code-cli's whole-file Edit).
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { resolvePath, ensureDir, checkpointBeforeWrite } from './utils.js';
|
|
8
|
+
import { hashStr, normalizeText } from './read.js';
|
|
9
|
+
|
|
10
|
+
// Typographic quotes -> straight quotes. A model almost always emits ASCII
|
|
11
|
+
// quotes, but docs/blog/markdown often use curly ones; without this the edit
|
|
12
|
+
// fails with "old_string not found" even though the text is visibly there.
|
|
13
|
+
// Mirrors Claude Code's normalizeQuotes.
|
|
14
|
+
const CURLY = [
|
|
15
|
+
['\u2018', "'"], ['\u2019', "'"], // ‘ ’
|
|
16
|
+
['\u201c', '"'], ['\u201d', '"'], // “ ”
|
|
17
|
+
];
|
|
18
|
+
export function normalizeQuotes(s) {
|
|
19
|
+
let out = String(s);
|
|
20
|
+
for (const [from, to] of CURLY) out = out.split(from).join(to);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Re-exported from read.js so the staleness guard has ONE definition of the
|
|
25
|
+
// region hash. Previously read.js and edit.js each had their own copy; any
|
|
26
|
+
// drift between them would silently disable staleness detection.
|
|
27
|
+
import { hashRegion as hashLines } from './read.js';
|
|
28
|
+
|
|
29
|
+
export const spec = {
|
|
30
|
+
name: 'Edit',
|
|
31
|
+
description: 'Edit a file. THREE shapes:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use when the exact text is known and a line range is not convenient.\n- RANGED SUBSTRING mode: path + start_line + end_line + old_string + new_string. Searches for old_string ONLY inside [start_line, end_line], and replaces only what it finds there. Use this when the same snippet appears several times and you need exactly one of them: replace_all changes every copy, while plain substring mode refuses an ambiguous match. The range says WHERE to look, old_string says WHAT to replace.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG — nothing between the quote and `return`, so the line lands at column 0 inside an indented block. Look at the SHAPE as well: the quote touches the first character, and spaces only turn up after the first newline.\nnew_string: "return total;\n total += 1;"\nRIGHT — four spaces, then `return`. The opening quote is followed by the indent as well, so every line carries its own indentation INCLUDING the first:\nnew_string: " return total;\n total += 1;"\nSame call, same words. The only difference is the spaces at the START of the first line, and that is the whole point — in the RIGHT form the indent sits between the quote and the code, exactly where it sits in the file you copied it from.\nThe same applies to tabs, and to a line whose indentation changes partway through.\nRANGE HYGIENE — COUNT THE LINES BEFORE YOU SEND. Lines [start_line, end_line] are deleted and new_string is inserted in their place. The rule that follows from that is arithmetic, not judgement:\n new_string must have EXACTLY (end_line - start_line + 1) lines.\nCount them. If you wrote three lines for a two-line range, one of them already exists past end_line — it was not deleted, so it is now duplicated. Do not reason about which line "looks related"; count. A range that should grow is a WRONG RANGE, so widen end_line rather than adding the extra line by hand.\nThe mistake looks like this — end_line: 3 for lines 2-3 is a TWO-line range, given three lines:\nnew_string: "const a = 1;\nconst b = 20;\nconst c = 3;" (3 lines for a 2-line range: c is now duplicated)\nAnd correct — two lines for a two-line range:\nnew_string: "const a = 1;\nconst b = 20;"\nTo replace line 4 as well, set end_line: 4 — then three lines are correct and the count agrees.\nIN A DIFF, A DUPLICATE SHOWS AS THE SAME LINE NUMBER TWICE: a "-" row and a "+" row numbered alike, or the same number on two rows. That is the signature of this mistake, not of a tool fault. Read the diff before continuing; it costs one look.\nA DUPLICATED SEAM IS REMOVED FOR YOU. If the last line of new_string is the same text as the line at\nend_line + 1 — the line just past the range, which is never deleted — that last line is dropped, because\nit would otherwise appear twice. The reply says what was dropped. Pass allow_dup: true when two\nidentical adjacent lines are what you actually want.\nIn SUBSTRING and RANGED SUBSTRING modes old_string is matched EXACTLY, including the indentation of its own first line.\nOmitting new_string is an error, not an empty replacement.\nMis-indented lines often still parse, so a green test run is not proof that an edit landed correctly — re-Read the region before editing it again.\',',
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
path: { type: 'string', description: 'Path to the file to edit.' },
|
|
36
|
+
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit for pure line-range mode. COMBINE it with start_line/end_line to replace only the occurrence inside that range.' },
|
|
37
|
+
new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
|
|
38
|
+
|
|
39
|
+
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
40
|
+
allow_dup: { type: 'boolean', default: false, description: 'Default OFF, and when off duplicate lines are removed automatically: if new_string repeats a line that already exists next to it, that copy is not inserted. The file\'s own lines are never touched. Set true to KEEP the repetition — needed only when two identical adjacent lines are the intended result.' },
|
|
41
|
+
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). With old_string too, it is the first line to SEARCH for it in.' },
|
|
42
|
+
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line). With old_string too, it is the last line to search in.' },
|
|
43
|
+
},
|
|
44
|
+
// Three accepted shapes:
|
|
45
|
+
// line-range start_line + end_line + new_string
|
|
46
|
+
// exact-substring old_string + new_string
|
|
47
|
+
// rangedToWindow-substring start_line + end_line + old_string + new_string
|
|
48
|
+
// Line-range is the preferred route; the third exists for "one of several copies".
|
|
49
|
+
anyOf: [
|
|
50
|
+
{ required: ['path', 'start_line', 'end_line', 'new_string'] },
|
|
51
|
+
{ required: ['path', 'old_string', 'new_string'] },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
async execute(args, ctx) {
|
|
55
|
+
// Validate BEFORE resolving the path. `resolvePath(undefined)` resolves to the
|
|
56
|
+
// workspace root rather than failing, so a truncated tool call (agent.js falls
|
|
57
|
+
// back to `{ raw, _rawLen }` when the streamed JSON will not parse) could
|
|
58
|
+
// target a DIRECTORY and produce a confusing EISDIR/ENOENT instead of "retry
|
|
59
|
+
// the call".
|
|
60
|
+
if (typeof args.path !== 'string' || !args.path.trim()) {
|
|
61
|
+
const truncated = args && args.raw
|
|
62
|
+
? ` The arguments did not parse as JSON${args._rawLen ? ` (${args._rawLen} characters received)` : ''} — the call was most likely truncated. Retry, and keep \`old_string\` / \`new_string\` small (edit in several passes rather than one large replacement).`
|
|
63
|
+
: '';
|
|
64
|
+
return `Error: \`path\` is required and must be a non-empty string.${truncated}`;
|
|
65
|
+
}
|
|
66
|
+
let p;
|
|
67
|
+
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
68
|
+
// A Jupyter notebook is JSON, not text: a snippet edit corrupts it. Point the
|
|
69
|
+
// model at the notebook-aware tool instead (mirrors Claude Code errorCode 5).
|
|
70
|
+
if (String(p).toLowerCase().endsWith('.ipynb')) {
|
|
71
|
+
return `Error: ${args.path} is a Jupyter notebook, and its .ipynb is JSON — a text replacement would corrupt it. Use NotebookEdit instead: \`{ path: ${JSON.stringify(args.path)}, action: "list" }\` to see the cells, then action "replace" with the cell index.`;
|
|
72
|
+
}
|
|
73
|
+
let content;
|
|
74
|
+
try {
|
|
75
|
+
content = fs.readFileSync(p, 'utf8');
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (e && e.code === 'ENOENT') {
|
|
78
|
+
return `Error reading ${args.path}: file does not exist.${similarFileHint(p)}`;
|
|
79
|
+
}
|
|
80
|
+
return `Error reading ${args.path}: ${e.message}`;
|
|
81
|
+
}
|
|
82
|
+
const { start_line, end_line } = args;
|
|
83
|
+
// THREE shapes, not two.
|
|
84
|
+
//
|
|
85
|
+
// line-range start_line + end_line + new_string
|
|
86
|
+
// substring old_string + new_string
|
|
87
|
+
// RANGED SUB start_line + end_line + old_string + new_string
|
|
88
|
+
//
|
|
89
|
+
// The third is what the first two cannot express: replace ONE occurrence of a snippet that
|
|
90
|
+
// appears several times, without `replace_all` (which would hit every copy) and without
|
|
91
|
+
// counting lines by hand. The range narrows WHERE to look; `old_string` says WHAT to replace
|
|
92
|
+
// inside it. Rejecting the combination sent callers to `replace_all` as their only option,
|
|
93
|
+
// which is how the wrong copy gets edited.
|
|
94
|
+
//
|
|
95
|
+
// `old_string` alongside a range is therefore not a mistake — but a range with NEITHER
|
|
96
|
+
// old_string nor new_string still is, and is reported below.
|
|
97
|
+
const hasRangeKeys = args.start_line != null || args.end_line != null;
|
|
98
|
+
const hasSubKeys = args.old_string != null;
|
|
99
|
+
const hasNewString = (args.new_string != null) || (args.new_content != null);
|
|
100
|
+
if (hasRangeKeys && hasSubKeys && !hasNewString) {
|
|
101
|
+
return 'Error: a rangedToWindow edit needs new_string as well as old_string.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- RANGED SUBSTRING ---------------------------------------------------
|
|
105
|
+
// A range AND an old_string: search for the snippet only INSIDE the range. Handled here,
|
|
106
|
+
// before the pure line-range path, because that path replaces whole lines and would ignore
|
|
107
|
+
// the snippet entirely — the very ambiguity the old mutual-exclusion check was avoiding.
|
|
108
|
+
//
|
|
109
|
+
// The range is validated, then the substring path below does the matching with the search
|
|
110
|
+
// window clamped to it. Splitting the work this way keeps ONE implementation of matching
|
|
111
|
+
// (quote-normalized retry, staleness, whitespace tolerance) instead of a second, weaker copy.
|
|
112
|
+
// A HALF-GIVEN RANGE with an old_string is not a reason to refuse: the snippet locates the
|
|
113
|
+
// edit by itself, and the single bound is a hint that failed to pair. Callers add `start_line`
|
|
114
|
+
// from the schema's own wording ("1-based first line to replace") expecting it to help, so
|
|
115
|
+
// refusing rejected a request that was already complete.
|
|
116
|
+
//
|
|
117
|
+
// `rangedToWindow` is set even though no window is applied. It means "the range question has been
|
|
118
|
+
// settled", which is what the line-range guard below tests — leaving it false made that guard
|
|
119
|
+
// see the same unpaired bound and reject the call one block later.
|
|
120
|
+
let searchFrom = 0;
|
|
121
|
+
let searchTo = 0; // 0,0 = the whole file
|
|
122
|
+
// A real window was validated, so the search below is confined to it. Distinct from
|
|
123
|
+
// `rangeHandled`: this one means "clamp", and setting it for a snippet with no window made
|
|
124
|
+
// every search run inside 0..0 and find nothing.
|
|
125
|
+
let rangedToWindow = false;
|
|
126
|
+
// The range question is answered — either a window was applied, or the caller gave an
|
|
127
|
+
// `old_string` that locates the edit on its own and a half-given bound was ignored.
|
|
128
|
+
let rangeHandled = hasSubKeys;
|
|
129
|
+
if (hasRangeKeys && hasSubKeys && hasNewString) {
|
|
130
|
+
if ((start_line != null) !== (end_line != null)) {
|
|
131
|
+
// Ignore the unpaired bound; `searchFrom`/`searchTo` stay at the whole file and `rangedToWindow`
|
|
132
|
+
// was already set above.
|
|
133
|
+
searchFrom = 0;
|
|
134
|
+
searchTo = 0;
|
|
135
|
+
} else {
|
|
136
|
+
// The range was given in full: validate it, then confine the search to it.
|
|
137
|
+
const start = Number(start_line) | 0, end = Number(end_line) | 0;
|
|
138
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 1 || end < start) {
|
|
139
|
+
return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
|
|
140
|
+
}
|
|
141
|
+
{
|
|
142
|
+
const all = content.split(/\r\n|\r|\n/);
|
|
143
|
+
if (end > all.length) {
|
|
144
|
+
return `Error: end_line ${end_line} exceeds file length (${all.length} lines).`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
rangedToWindow = true;
|
|
148
|
+
rangeHandled = true;
|
|
149
|
+
searchFrom = start;
|
|
150
|
+
searchTo = end;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- LINE-RANGE mode ---------------------------------------------------
|
|
155
|
+
// `start_line`..`end_line` replace the lines at those 1-based positions,
|
|
156
|
+
// with NO old_string involved at all. This is the path for "change line 12"
|
|
157
|
+
// where spelling out the exact current text is awkward.
|
|
158
|
+
if (!rangeHandled && (start_line != null) !== (end_line != null)) {
|
|
159
|
+
return 'Error: line-range mode needs BOTH start_line and end_line.';
|
|
160
|
+
}
|
|
161
|
+
if (!rangeHandled && start_line != null && end_line != null) {
|
|
162
|
+
// Accept both spellings so old new_content callers keep working.
|
|
163
|
+
const new_string = args.new_string != null ? args.new_string : args.new_content;
|
|
164
|
+
// new_string is required — a missing one would put the literal string
|
|
165
|
+
// "undefined" in the file.
|
|
166
|
+
if (new_string == null) return `Error: new_string is required for line-range mode (pass an empty string to delete the lines).`;
|
|
167
|
+
if (typeof new_string !== 'string') {
|
|
168
|
+
return `Error: new_string must be a string (got ${new_string === null ? 'null' : typeof new_string}).`;
|
|
169
|
+
}
|
|
170
|
+
const start = Number(start_line) | 0, end = Number(end_line) | 0;
|
|
171
|
+
if (start < 1 || end < start) {
|
|
172
|
+
return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
|
|
173
|
+
}
|
|
174
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
175
|
+
const lines = content.split(/\r\n|\r|\n/);
|
|
176
|
+
if (end > lines.length) {
|
|
177
|
+
return `Error: end_line ${end_line} exceeds file length (${lines.length} lines).`;
|
|
178
|
+
}
|
|
179
|
+
// STALENESS: the range must sit inside a region this turn actually Read and
|
|
180
|
+
// that still matches. Same guarantee as the substring path, checked here
|
|
181
|
+
// directly (no old_string to search for).
|
|
182
|
+
const stale = checkStaleRange(p, lines, start, end, ctx);
|
|
183
|
+
if (stale) return stale;
|
|
184
|
+
|
|
185
|
+
const inserted = normalizeText(new_string).split('\n');
|
|
186
|
+
// The lines being REPLACED, captured before the splice. The TUI renders an
|
|
187
|
+
// Edit's diff from the tool ARGUMENTS, and line-range mode has no
|
|
188
|
+
// `old_string` to diff against — so without reporting them here the edit
|
|
189
|
+
// drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
|
|
190
|
+
// the caller diff the replaced region -> `new_string`.
|
|
191
|
+
const replacedText = lines.slice(start - 1, end).join('\n');
|
|
192
|
+
|
|
193
|
+
// AUTO-DEDUPE THE SEAM — unless `allow_dup` says the duplicate is wanted.
|
|
194
|
+
//
|
|
195
|
+
// The recurring mistake: `new_string`'s last line is the same text as the line at `end_line + 1`.
|
|
196
|
+
// That line is OUTSIDE the replaced range, so it is never deleted — the copy inside `new_string`
|
|
197
|
+
// lands directly above it and the file holds the text twice. Every line number below then shifts.
|
|
198
|
+
//
|
|
199
|
+
// REFUSING was the first attempt and it was wrong twice over. It rejected legitimate edits — a
|
|
200
|
+
// one-line `new_string` against a six-line range matched the following line by coincidence and the
|
|
201
|
+
// edit was blocked — and it put the burden on the caller to work out whose line it was.
|
|
202
|
+
//
|
|
203
|
+
// DROPPING THE DUPLICATE is what the caller meant either way. If they restated a line they did not
|
|
204
|
+
// intend to replace, removing the restatement produces exactly the file they described; if they DID
|
|
205
|
+
// want it changed, the range was wrong — but silently producing a duplicate helps no one, so the
|
|
206
|
+
// reply says what was dropped and how to keep it.
|
|
207
|
+
//
|
|
208
|
+
// `allow_dup: true` keeps the duplicate, for the real case where two identical lines are the point.
|
|
209
|
+
// REMOVE ADJACENT DUPLICATES FROM THE RESULT — the file the caller will open.
|
|
206
210
|
//
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// normalize the FILE content to LF for matching, then restore the file's own
|
|
229
|
-
// line ending style when writing back, so we never corrupt or mix endings.
|
|
230
|
-
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
231
|
-
const normContent = content.split(/\r\n|\r|\n/).join('\n');
|
|
232
|
-
const normOld = normalizeText(old_string);
|
|
233
|
-
const normNew = normalizeText(new_string);
|
|
234
|
-
|
|
235
|
-
// A RANGED edit searches only inside the given lines. `windowStart` is the character offset of
|
|
236
|
-
// the range's first line in `normContent`, and `windowEnd` the end of its last line; the whole
|
|
237
|
-
// file is the window when no range was given. Every match, count and replacement below uses
|
|
238
|
-
// this window, so a snippet that appears elsewhere in the file is neither found nor touched —
|
|
239
|
-
// which is the entire point, and what `replace_all` cannot express.
|
|
240
|
-
let windowStart = 0;
|
|
241
|
-
let windowEnd = normContent.length;
|
|
242
|
-
if (rangedToWindow) {
|
|
243
|
-
const allLines = normContent.split('\n');
|
|
244
|
-
// Offset of line N's first character: the sum of the lengths of lines 1..N-1 plus their
|
|
245
|
-
// newlines. Computed by walking, because a single `indexOf` would find the wrong copy of a
|
|
246
|
-
// line that also occurs earlier in the file.
|
|
247
|
-
const offsetOfLine = (n) => {
|
|
248
|
-
let at = 0;
|
|
249
|
-
for (let i = 0; i < n - 1 && i < allLines.length; i++) at += allLines[i].length + 1;
|
|
250
|
-
return at;
|
|
251
|
-
};
|
|
252
|
-
windowStart = offsetOfLine(searchFrom);
|
|
253
|
-
const lastLineStart = offsetOfLine(searchTo);
|
|
254
|
-
windowEnd = Math.min(normContent.length, lastLineStart + (allLines[searchTo - 1] || '').length);
|
|
255
|
-
}
|
|
256
|
-
/** Occurrences of `needle` inside the window. */
|
|
257
|
-
const countInWindow = (needle) => {
|
|
258
|
-
if (!needle) return 0;
|
|
259
|
-
const seg = normContent.slice(windowStart, windowEnd);
|
|
260
|
-
return seg.split(needle).length - 1;
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
let matchOld = normOld; // the string that will actually be matched/replaced
|
|
264
|
-
let matchNew = normNew;
|
|
265
|
-
let count = countInWindow(matchOld);
|
|
266
|
-
if (count === 0) {
|
|
267
|
-
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
268
|
-
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
269
|
-
// substring found in the file so typography is preserved. normalizeQuotes is
|
|
270
|
-
// 1 char -> 1 char, so indices line up between the two strings.
|
|
271
|
-
const normFileQ = normalizeQuotes(normContent);
|
|
272
|
-
if (normFileQ !== normContent) {
|
|
273
|
-
const normOldQ = normalizeQuotes(normOld);
|
|
274
|
-
// Search the NORMALIZED window for the normalized snippet, then take the real text at
|
|
275
|
-
// that place so the replacement keeps the file's own typography.
|
|
276
|
-
const idx = normFileQ.indexOf(normOldQ, windowStart);
|
|
277
|
-
if (idx >= 0 && idx + normOldQ.length <= windowEnd) {
|
|
278
|
-
matchOld = normContent.slice(idx, idx + normOldQ.length);
|
|
279
|
-
matchNew = normNew; // keep the model's new_string as written
|
|
280
|
-
count = countInWindow(matchOld);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
if (count === 0) {
|
|
285
|
-
if (rangedToWindow) {
|
|
286
|
-
// Say WHERE it was looked for, so a snippet that exists elsewhere is not reported as
|
|
287
|
-
// missing outright — that sends the caller hunting for a typo that is not there.
|
|
288
|
-
const elsewhere = normContent.split(normOld).length - 1;
|
|
289
|
-
const hint = elsewhere
|
|
290
|
-
? ` It appears ${elsewhere} time(s) elsewhere in the file, outside lines ${searchFrom}-${searchTo}. Broaden the range, or correct the range.`
|
|
291
|
-
: '';
|
|
292
|
-
return `Error: old_string not found within lines ${searchFrom}-${searchTo} of ${args.path}.${hint}`;
|
|
293
|
-
}
|
|
294
|
-
const near = nearestLineHint(normContent, normOld);
|
|
295
|
-
return `Error: old_string not found in ${args.path}.${near}`;
|
|
296
|
-
}
|
|
297
|
-
if (count > 1 && !replace_all) {
|
|
298
|
-
if (rangedToWindow) {
|
|
299
|
-
return `Error: old_string matched ${count} occurrences within lines ${searchFrom}-${searchTo} of ${args.path}. Narrow the range, or use replace_all=true to change all of them.`;
|
|
300
|
-
}
|
|
301
|
-
return `Error: old_string matched ${count} occurrences in ${args.path}. Use replace_all=true or make old_string unique.`;
|
|
302
|
-
}
|
|
303
|
-
// --- Staleness guard: the AI must edit against content it actually Read.
|
|
304
|
-
// We stored per-region + whole-file hashes in ctx.readPool when Read ran.
|
|
305
|
-
// If the file changed on disk since (external edit, stream, Ctrl-C side
|
|
306
|
-
// effect), reject so a snippet edit cannot silently corrupt the file. ---
|
|
307
|
-
const stale = checkStale(p, normContent, matchOld, ctx);
|
|
308
|
-
if (stale) return stale;
|
|
309
|
-
|
|
310
|
-
// Replace INSIDE the window only. A rangedToWindow edit must not touch an identical snippet sitting
|
|
311
|
-
// before or after the range — replacing across the whole file is the failure the range exists
|
|
312
|
-
// to prevent. With no range the window IS the whole file, so non-rangedToWindow callers are unchanged.
|
|
313
|
-
const normUpdated = (() => {
|
|
314
|
-
const head = normContent.slice(0, windowStart);
|
|
315
|
-
const body = normContent.slice(windowStart, windowEnd);
|
|
316
|
-
const tail = normContent.slice(windowEnd);
|
|
317
|
-
return head + (replace_all ? body.replaceAll(matchOld, matchNew) : body.replace(matchOld, matchNew)) + tail;
|
|
318
|
-
})();
|
|
319
|
-
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
320
|
-
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
321
|
-
checkpointBeforeWrite(p, ctx);
|
|
322
|
-
try {
|
|
323
|
-
ensureDir(p);
|
|
324
|
-
fs.writeFileSync(p, updated, 'utf8');
|
|
325
|
-
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
326
|
-
// Silently re-arm the Read snapshot for the edited region so the AI can keep
|
|
327
|
-
// editing on top of its own change WITHOUT another Read: we recompute the
|
|
328
|
-
// affected line range + hash from the NEW content and refresh the pool. We
|
|
329
|
-
// also drop any previously-recorded region whose content no longer matches
|
|
330
|
-
// (those are genuinely stale now). Nothing here is told to the model.
|
|
331
|
-
if (ctx && ctx.readPool) {
|
|
332
|
-
const entry = ctx.readPool.get(p);
|
|
333
|
-
if (entry) {
|
|
334
|
-
const newLines = normUpdated.split('\n');
|
|
335
|
-
entry.fileHash = hashStr(normUpdated);
|
|
336
|
-
entry.lines = newLines.length;
|
|
337
|
-
// Locate the edit site in the NEW content: the first line of the new
|
|
338
|
-
// text if present, else fall back to the old target's location.
|
|
339
|
-
const anchor = String(matchNew).split('\n')[0];
|
|
340
|
-
let at = newLines.indexOf(anchor);
|
|
341
|
-
if (at < 0) at = newLines.indexOf(String(matchOld).split('\n')[0]);
|
|
342
|
-
if (at >= 0) {
|
|
343
|
-
const newSpan = String(matchNew).split('\n').length;
|
|
344
|
-
const start = at + 1;
|
|
345
|
-
const end = Math.min(newLines.length, at + Math.max(1, newSpan));
|
|
346
|
-
// Re-hash every surviving old region against the new content, and add
|
|
347
|
-
// the freshly-edited span. Regions that no longer match are dropped.
|
|
348
|
-
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
349
|
-
kept.push({ start, end, hash: hashLines(newLines, start, end) });
|
|
350
|
-
entry.regions = kept;
|
|
351
|
-
} else {
|
|
352
|
-
// Could not locate the site: drop stale regions so nothing false stays.
|
|
353
|
-
entry.regions = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
211
|
+
// Nothing here tries to work out WHICH line the caller meant; that guessing is what made every
|
|
212
|
+
// previous version leak. The rule is the one a reader would apply: if the edit leaves two identical
|
|
213
|
+
// lines next to each other, one of them is redundant, so it goes.
|
|
214
|
+
//
|
|
215
|
+
// `new_string` is what gets adjusted, because the file's own lines were already there — editing
|
|
216
|
+
// them would delete content the caller never asked to touch.
|
|
217
|
+
//
|
|
218
|
+
// A blank or whitespace-only line between two identical lines counts as adjacent: the blank is
|
|
219
|
+
// almost always the tail of a pasted block, and leaving it strands a gap where the duplicate was.
|
|
220
|
+
//
|
|
221
|
+
// `allow_dup: true` skips all of this and writes exactly what was asked for.
|
|
222
|
+
let insertedFinal = inserted;
|
|
223
|
+
const drops = [];
|
|
224
|
+
if (args.allow_dup !== true) {
|
|
225
|
+
// One pass per candidate line: each removal can expose another pair, so the scan repeats until
|
|
226
|
+
// it finds nothing. Bounded by the block length, so it always terminates.
|
|
227
|
+
for (let guard = 0; guard <= insertedFinal.length; guard++) {
|
|
228
|
+
const hit = findAdjacentDuplicate(insertedFinal) || findSeamDuplicate(lines, insertedFinal, start, end);
|
|
229
|
+
if (!hit) break;
|
|
230
|
+
drops.push(hit);
|
|
231
|
+
insertedFinal = [...insertedFinal.slice(0, hit.index), ...insertedFinal.slice(hit.index + 1)];
|
|
354
232
|
}
|
|
355
|
-
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
356
233
|
}
|
|
357
|
-
|
|
358
|
-
const occurrences = replace_all ? count : 1;
|
|
359
|
-
// Publish the diff for the UI (same contract as the line-range path above).
|
|
360
|
-
if (ctx) {
|
|
361
|
-
const at = normContent.split('\n').indexOf(String(normOld).split('\n')[0]);
|
|
362
|
-
const startLine = at >= 0 ? at + 1 : 1;
|
|
363
|
-
ctx.lastEditDiff = buildEditDiff({
|
|
364
|
-
fileLines: normContent.split('\n'),
|
|
365
|
-
oldText: old_string,
|
|
366
|
-
newText: new_string,
|
|
367
|
-
startLine,
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
|
|
371
|
-
},
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
/** How many unchanged lines to show on each side of a changed region. */
|
|
375
|
-
|
|
376
|
-
/** How many unchanged lines to show on each side of a changed region. */
|
|
377
|
-
const DIFF_CONTEXT = 5;
|
|
234
|
+
const seamDrop = drops.length ? drops[0] : null;
|
|
378
235
|
|
|
236
|
+
const out = [...lines.slice(0, start - 1), ...insertedFinal, ...lines.slice(end)];
|
|
237
|
+
const normUpdated = out.join('\n');
|
|
238
|
+
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
239
|
+
|
|
240
|
+
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
241
|
+
checkpointBeforeWrite(p, ctx);
|
|
242
|
+
try {
|
|
243
|
+
ensureDir(p);
|
|
244
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
245
|
+
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
246
|
+
refreshReadPool(p, normUpdated, { start, end: start + insertedFinal.length - 1 }, ctx);
|
|
247
|
+
const removed = end - start + 1;
|
|
248
|
+
// Publish the diff for the UI. The Edit line is drawn from these, so a
|
|
249
|
+
// line-range edit shows the same +/- rows and +N/-N counts as a substring
|
|
250
|
+
// edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
|
|
251
|
+
// the agent loop right after this tool returns.
|
|
252
|
+
//
|
|
253
|
+
// `lines` is the pre-edit content, which is what `start` indexes into.
|
|
254
|
+
// The diff's NEW text is `insertedFinal` — what the file now holds — not the
|
|
255
|
+
// raw `new_string`: when the seam-drop removed a line, the raw text showed a
|
|
256
|
+
// `+` row that is not in the file and shifted every after-context line
|
|
257
|
+
// number by the dropped count.
|
|
258
|
+
if (ctx) ctx.lastEditDiff = buildEditDiff({ fileLines: lines, oldText: replacedText, newText: insertedFinal.join('\n'), startLine: start });
|
|
259
|
+
// Say what the seam did, whichever way it went: a silent drop and a silent keep are both
|
|
260
|
+
// indistinguishable from an edit that never had the ambiguity. The dropped line is named
|
|
261
|
+
// with its ACTUAL pair (the two result lines that would have been identical) — it is not
|
|
262
|
+
// always "the last line of new_string against end_line + 1": a block can carry the repeat
|
|
263
|
+
// inside itself, with blank lines between the copies.
|
|
264
|
+
// THE NOTE HAS TO SAY WHICH COPY WENT, or it reads as "the tool is eating my code".
|
|
265
|
+
//
|
|
266
|
+
// It used to read `Dropped a duplicated line: "}" ... Pass allow_dup: true to keep it`, which a
|
|
267
|
+
// caller interpreted as the tool silently deleting a line it had written on purpose — so it stopped
|
|
268
|
+
// trusting the tool and never reached for the flag. What was actually removed is the copy inside
|
|
269
|
+
// new_string; every line already in the file is untouched.
|
|
270
|
+
const seamNote = seamDrop
|
|
271
|
+
? ` One line from new_string was NOT inserted: "${seamDrop.text}" already sits at result line`
|
|
272
|
+
+ ` ${seamDrop.a} (it would have been adjacent to the copy at line ${seamDrop.b}). The file's own`
|
|
273
|
+
+ ' lines were not touched. If that repetition was intended, resend with allow_dup: true.'
|
|
274
|
+
: (args.allow_dup === true ? ' allow_dup: true — the repeated line was kept as asked.' : '');
|
|
275
|
+
return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${insertedFinal.length} line${insertedFinal.length === 1 ? '' : 's'}.${seamNote}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---- SUBSTRING mode (old_string -> new_string) --------------------------
|
|
279
|
+
// `new_content` is accepted as an alias of `new_string` — the validation above
|
|
280
|
+
// already counts it, so refusing it here sent a confusing "new_string is
|
|
281
|
+
// required" for an argument the mode's own checks accepted.
|
|
282
|
+
let { old_string, new_string, replace_all = false } = args;
|
|
283
|
+
if (new_string == null && args.new_content != null) new_string = args.new_content;
|
|
284
|
+
// Reaching here means neither mode was usable: line-range needs
|
|
285
|
+
// start_line+end_line (handled above) and substring needs old_string. When
|
|
286
|
+
// BOTH are absent, say so — "old_string must not be empty" sends the model
|
|
287
|
+
// hunting for a stray empty string it never sent.
|
|
288
|
+
if (old_string == null && new_string == null) {
|
|
289
|
+
return 'Error: no edit mode given. Pass EITHER (start_line, end_line, new_string) OR (old_string, new_string).';
|
|
290
|
+
}
|
|
291
|
+
if (old_string === '' || old_string == null) return `Error: old_string must not be empty.`;
|
|
292
|
+
if (new_string == null) return `Error: new_string is required for substring mode.`;
|
|
293
|
+
|
|
294
|
+
// --- Newline normalization (Windows CRLF) ---
|
|
295
|
+
// The Read tool normalizes \r\n -> \n, so an agent builds old_string/new_string
|
|
296
|
+
// with LF. But the file on disk may be CRLF, making a raw LF match fail. We
|
|
297
|
+
// normalize the FILE content to LF for matching, then restore the file's own
|
|
298
|
+
// line ending style when writing back, so we never corrupt or mix endings.
|
|
299
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
300
|
+
const normContent = content.split(/\r\n|\r|\n/).join('\n');
|
|
301
|
+
const normOld = normalizeText(old_string);
|
|
302
|
+
const normNew = normalizeText(new_string);
|
|
303
|
+
|
|
304
|
+
// A RANGED edit searches only inside the given lines. `windowStart` is the character offset of
|
|
305
|
+
// the range's first line in `normContent`, and `windowEnd` the end of its last line; the whole
|
|
306
|
+
// file is the window when no range was given. Every match, count and replacement below uses
|
|
307
|
+
// this window, so a snippet that appears elsewhere in the file is neither found nor touched —
|
|
308
|
+
// which is the entire point, and what `replace_all` cannot express.
|
|
309
|
+
let windowStart = 0;
|
|
310
|
+
let windowEnd = normContent.length;
|
|
311
|
+
if (rangedToWindow) {
|
|
312
|
+
const allLines = normContent.split('\n');
|
|
313
|
+
// Offset of line N's first character: the sum of the lengths of lines 1..N-1 plus their
|
|
314
|
+
// newlines. Computed by walking, because a single `indexOf` would find the wrong copy of a
|
|
315
|
+
// line that also occurs earlier in the file.
|
|
316
|
+
const offsetOfLine = (n) => {
|
|
317
|
+
let at = 0;
|
|
318
|
+
for (let i = 0; i < n - 1 && i < allLines.length; i++) at += allLines[i].length + 1;
|
|
319
|
+
return at;
|
|
320
|
+
};
|
|
321
|
+
windowStart = offsetOfLine(searchFrom);
|
|
322
|
+
const lastLineStart = offsetOfLine(searchTo);
|
|
323
|
+
windowEnd = Math.min(normContent.length, lastLineStart + (allLines[searchTo - 1] || '').length);
|
|
324
|
+
}
|
|
325
|
+
/** Occurrences of `needle` inside the window. */
|
|
326
|
+
const countInWindow = (needle) => {
|
|
327
|
+
if (!needle) return 0;
|
|
328
|
+
const seg = normContent.slice(windowStart, windowEnd);
|
|
329
|
+
return seg.split(needle).length - 1;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let matchOld = normOld; // the string that will actually be matched/replaced
|
|
333
|
+
let matchNew = normNew;
|
|
334
|
+
let count = countInWindow(matchOld);
|
|
335
|
+
if (count === 0) {
|
|
336
|
+
// Quote-normalized retry: the file may use curly quotes while the model sent
|
|
337
|
+
// straight ones. Match on the normalized forms, then replace the ACTUAL
|
|
338
|
+
// substring found in the file so typography is preserved. normalizeQuotes is
|
|
339
|
+
// 1 char -> 1 char, so indices line up between the two strings.
|
|
340
|
+
const normFileQ = normalizeQuotes(normContent);
|
|
341
|
+
if (normFileQ !== normContent) {
|
|
342
|
+
const normOldQ = normalizeQuotes(normOld);
|
|
343
|
+
// Search the NORMALIZED window for the normalized snippet, then take the real text at
|
|
344
|
+
// that place so the replacement keeps the file's own typography.
|
|
345
|
+
const idx = normFileQ.indexOf(normOldQ, windowStart);
|
|
346
|
+
if (idx >= 0 && idx + normOldQ.length <= windowEnd) {
|
|
347
|
+
matchOld = normContent.slice(idx, idx + normOldQ.length);
|
|
348
|
+
matchNew = normNew; // keep the model's new_string as written
|
|
349
|
+
count = countInWindow(matchOld);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (count === 0) {
|
|
354
|
+
if (rangedToWindow) {
|
|
355
|
+
// Say WHERE it was looked for, so a snippet that exists elsewhere is not reported as
|
|
356
|
+
// missing outright — that sends the caller hunting for a typo that is not there.
|
|
357
|
+
const elsewhere = normContent.split(normOld).length - 1;
|
|
358
|
+
const hint = elsewhere
|
|
359
|
+
? ` It appears ${elsewhere} time(s) elsewhere in the file, outside lines ${searchFrom}-${searchTo}. Broaden the range, or correct the range.`
|
|
360
|
+
: '';
|
|
361
|
+
return `Error: old_string not found within lines ${searchFrom}-${searchTo} of ${args.path}.${hint}`;
|
|
362
|
+
}
|
|
363
|
+
const near = nearestLineHint(normContent, normOld);
|
|
364
|
+
return `Error: old_string not found in ${args.path}.${near}`;
|
|
365
|
+
}
|
|
366
|
+
if (count > 1 && !replace_all) {
|
|
367
|
+
if (rangedToWindow) {
|
|
368
|
+
return `Error: old_string matched ${count} occurrences within lines ${searchFrom}-${searchTo} of ${args.path}. Narrow the range, or use replace_all=true to change all of them.`;
|
|
369
|
+
}
|
|
370
|
+
return `Error: old_string matched ${count} occurrences in ${args.path}. Use replace_all=true or make old_string unique.`;
|
|
371
|
+
}
|
|
372
|
+
// --- Staleness guard: the AI must edit against content it actually Read.
|
|
373
|
+
// We stored per-region + whole-file hashes in ctx.readPool when Read ran.
|
|
374
|
+
// If the file changed on disk since (external edit, stream, Ctrl-C side
|
|
375
|
+
// effect), reject so a snippet edit cannot silently corrupt the file. ---
|
|
376
|
+
const stale = checkStale(p, normContent, matchOld, ctx);
|
|
377
|
+
if (stale) return stale;
|
|
378
|
+
|
|
379
|
+
// Replace INSIDE the window only. A rangedToWindow edit must not touch an identical snippet sitting
|
|
380
|
+
// before or after the range — replacing across the whole file is the failure the range exists
|
|
381
|
+
// to prevent. With no range the window IS the whole file, so non-rangedToWindow callers are unchanged.
|
|
382
|
+
//
|
|
383
|
+
// The replacement is passed as a FUNCTION: a string second argument makes JS interpret
|
|
384
|
+
// `$&`, `` $` ``, `$'` and `$$` in the NEW text, so an edit whose new_string contains them
|
|
385
|
+
// (regex examples, Makefile `$$`, shell code) silently wrote the wrong file.
|
|
386
|
+
const normUpdated = (() => {
|
|
387
|
+
const head = normContent.slice(0, windowStart);
|
|
388
|
+
const body = normContent.slice(windowStart, windowEnd);
|
|
389
|
+
const tail = normContent.slice(windowEnd);
|
|
390
|
+
const literal = () => matchNew;
|
|
391
|
+
return head + (replace_all ? body.replaceAll(matchOld, literal) : body.replace(matchOld, literal)) + tail;
|
|
392
|
+
})();
|
|
393
|
+
// The replaced text's actual character offset in normContent (first occurrence
|
|
394
|
+
// inside the window — guaranteed >= 0, the count above found it). Everything
|
|
395
|
+
// before the match is unchanged, so this one offset anchors BOTH the diff and
|
|
396
|
+
// the Read-pool re-arm below to the real edit site. Locating the site by
|
|
397
|
+
// matching its first LINE (the old approach) collapsed to line 1 when
|
|
398
|
+
// old_string starts mid-line, and pointed at an earlier copy when the file
|
|
399
|
+
// repeats that line — the diff then drew context from the top of the file.
|
|
400
|
+
const matchAt = normContent.indexOf(matchOld, windowStart);
|
|
401
|
+
const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
|
|
402
|
+
// Checkpoint the pre-edit content so /undo can put it back (file-history.js).
|
|
403
|
+
checkpointBeforeWrite(p, ctx);
|
|
404
|
+
try {
|
|
405
|
+
ensureDir(p);
|
|
406
|
+
fs.writeFileSync(p, updated, 'utf8');
|
|
407
|
+
} catch (e) { return `Error writing ${args.path}: ${e.message}`; }
|
|
408
|
+
// Silently re-arm the Read snapshot for the edited region so the AI can keep
|
|
409
|
+
// editing on top of its own change WITHOUT another Read: we recompute the
|
|
410
|
+
// affected line range + hash from the NEW content and refresh the pool. We
|
|
411
|
+
// also drop any previously-recorded region whose content no longer matches
|
|
412
|
+
// (those are genuinely stale now). Nothing here is told to the model.
|
|
413
|
+
if (ctx && ctx.readPool) {
|
|
414
|
+
const entry = ctx.readPool.get(p);
|
|
415
|
+
if (entry) {
|
|
416
|
+
const newLines = normUpdated.split('\n');
|
|
417
|
+
entry.fileHash = hashStr(normUpdated);
|
|
418
|
+
entry.lines = newLines.length;
|
|
419
|
+
// Locate the edit site in the NEW content: same character offset as the
|
|
420
|
+
// match in the old one, because the text before the match is untouched.
|
|
421
|
+
// An anchor taken from the NEW text's first line found the first copy of
|
|
422
|
+
// that line ANYWHERE in the file (and `indexOf('')` returned 0 for a
|
|
423
|
+
// deletion), recording the re-armed region at a line the model never edited.
|
|
424
|
+
if (matchAt >= 0) {
|
|
425
|
+
const at = normUpdated.slice(0, matchAt).split('\n').length - 1; // 0-based
|
|
426
|
+
const newSpan = Math.max(1, String(matchNew).split('\n').length);
|
|
427
|
+
const start = at + 1;
|
|
428
|
+
const end = Math.min(newLines.length, at + newSpan);
|
|
429
|
+
// Re-hash every surviving old region against the new content, and add
|
|
430
|
+
// the freshly-edited span. Regions that no longer match are dropped.
|
|
431
|
+
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
432
|
+
kept.push({ start, end, hash: hashLines(newLines, start, end) });
|
|
433
|
+
entry.regions = kept;
|
|
434
|
+
} else {
|
|
435
|
+
// Could not locate the site: drop stale regions so nothing false stays.
|
|
436
|
+
entry.regions = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
437
|
+
}
|
|
438
|
+
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
const occurrences = replace_all ? count : 1;
|
|
442
|
+
// Publish the diff for the UI (same contract as the line-range path above).
|
|
443
|
+
// `startLine` comes from the actual match offset, so a snippet that starts
|
|
444
|
+
// mid-line or a file with repeated lines still anchors the context rows at
|
|
445
|
+
// the place that was really edited.
|
|
446
|
+
if (ctx) {
|
|
447
|
+
const startLine = matchAt >= 0 ? normContent.slice(0, matchAt).split('\n').length : 1;
|
|
448
|
+
ctx.lastEditDiff = buildEditDiff({
|
|
449
|
+
fileLines: normContent.split('\n'),
|
|
450
|
+
oldText: old_string,
|
|
451
|
+
newText: new_string,
|
|
452
|
+
startLine,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/** How many unchanged lines to show on each side of a changed region. */
|
|
460
|
+
const DIFF_CONTEXT = 5;
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
/** The line's leading whitespace, for the same-indent closer rule. */
|
|
465
|
+
function leadingWs(s) {
|
|
466
|
+
const m = /^\s*/.exec(String(s == null ? '' : s));
|
|
467
|
+
return m ? m[0] : '';
|
|
468
|
+
}
|
|
469
|
+
|
|
379
470
|
/**
|
|
380
|
-
*
|
|
471
|
+
* The first repeated line in `inserted`, and where to remove one copy.
|
|
472
|
+
*
|
|
473
|
+
* ONE RULE: a line that repeats the one above it is one too many, so it goes. Nothing here looks at what
|
|
474
|
+
* the line contains — a brace, a comment closer, a string of spaces are all just text, and asking which
|
|
475
|
+
* of them "may legitimately repeat" is a question this tool has no business answering.
|
|
381
476
|
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* could tell a correct edit from one applied at the right text in the wrong place — and with
|
|
385
|
-
* repeated code (several similar blocks, two `}` in a row) the same text appears in many places.
|
|
386
|
-
* `context` carries the file's own lines so the renderer can draw them unmarked, above and below.
|
|
477
|
+
* A blank line between the two does not break the pair: a pasted block usually ends with one, and leaving
|
|
478
|
+
* it behind strands a gap where the duplicate was.
|
|
387
479
|
*
|
|
388
|
-
* `
|
|
389
|
-
*
|
|
480
|
+
* Only lines FROM `new_string` are ever removed. The file's own lines were already there, and deleting one
|
|
481
|
+
* would remove content the caller never asked to touch — that is the whole reason to look at `inserted`
|
|
482
|
+
* rather than at the result.
|
|
390
483
|
*/
|
|
391
|
-
function
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
for (let n = lastReplaced + 1; n <= lastReplaced + DIFF_CONTEXT; n++) {
|
|
402
|
-
if (n >= 1 && n <= lines.length) after.push({ n, text: lines[n - 1] });
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
old: normalizeText(oldText),
|
|
406
|
-
new: normalizeText(newText),
|
|
407
|
-
startLine,
|
|
408
|
-
context: { before, after },
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Suggest a near-miss filename when a path does not exist: same directory, an
|
|
413
|
-
// exact case-insensitive match, same stem with a different extension, or a
|
|
414
|
-
// prefix match. Saves the model a round-trip of guessing (Claude Code's
|
|
415
|
-
// "Did you mean X?").
|
|
416
|
-
function similarFileHint(missingPath) {
|
|
417
|
-
const dir = path.dirname(missingPath);
|
|
418
|
-
const base = path.basename(missingPath);
|
|
419
|
-
const stem = base.replace(/\.[^.]*$/, '').toLowerCase();
|
|
420
|
-
let entries;
|
|
421
|
-
try { entries = fs.readdirSync(dir); } catch { return ''; }
|
|
422
|
-
const lower = base.toLowerCase();
|
|
423
|
-
// Levenshtein distance, capped — enough to catch a typo (confg -> config).
|
|
424
|
-
const lev = (a, b) => {
|
|
425
|
-
const m = a.length, n = b.length;
|
|
426
|
-
if (Math.abs(m - n) > 2) return 3;
|
|
427
|
-
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
428
|
-
for (let i = 1; i <= m; i++) {
|
|
429
|
-
const cur = [i];
|
|
430
|
-
for (let j = 1; j <= n; j++) {
|
|
431
|
-
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
432
|
-
}
|
|
433
|
-
prev = cur;
|
|
434
|
-
}
|
|
435
|
-
return prev[n];
|
|
436
|
-
};
|
|
437
|
-
let cand = entries.find((e) => e.toLowerCase() === lower);
|
|
438
|
-
if (!cand) {
|
|
439
|
-
let best = null, bestD = 3;
|
|
440
|
-
for (const e of entries) {
|
|
441
|
-
const d = lev(stem, e.replace(/\.[^.]*$/, '').toLowerCase());
|
|
442
|
-
if (d < bestD) { bestD = d; best = e; }
|
|
443
|
-
}
|
|
444
|
-
cand = best;
|
|
445
|
-
}
|
|
446
|
-
if (cand && cand !== base) return ` Did you mean "${path.join(dir, cand)}"?`;
|
|
447
|
-
return '';
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// When old_string is not found, look for the closest line in the file and name
|
|
451
|
-
// it, so the model can see what the text actually is instead of guessing.
|
|
452
|
-
function nearestLineHint(content, oldStr) {
|
|
453
|
-
const want = String(oldStr).split('\n')[0].trim();
|
|
454
|
-
if (!want || want.length < 3) return '';
|
|
455
|
-
const lines = String(content).split('\n');
|
|
456
|
-
// Character bigrams: robust to small differences and cheap on long lines.
|
|
457
|
-
const grams = (s) => {
|
|
458
|
-
const set = new Set();
|
|
459
|
-
for (let i = 0; i + 1 < s.length; i++) set.add(s.slice(i, i + 2));
|
|
460
|
-
return set;
|
|
461
|
-
};
|
|
462
|
-
const wantGrams = grams(want);
|
|
463
|
-
if (!wantGrams.size) return '';
|
|
464
|
-
let best = -1, bestScore = 0;
|
|
465
|
-
for (let i = 0; i < lines.length; i++) {
|
|
466
|
-
const g = grams(lines[i].trim());
|
|
467
|
-
if (!g.size) continue;
|
|
468
|
-
let hit = 0;
|
|
469
|
-
for (const t of wantGrams) if (g.has(t)) hit++;
|
|
470
|
-
const score = hit / wantGrams.size;
|
|
471
|
-
if (score > bestScore) { bestScore = score; best = i; }
|
|
472
|
-
}
|
|
473
|
-
if (best >= 0 && bestScore >= 0.5) {
|
|
474
|
-
return ` The closest line is line ${best + 1}: ${JSON.stringify(lines[best].trim().slice(0, 120))}`;
|
|
475
|
-
}
|
|
476
|
-
return '';
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
480
|
-
function checkStale(p, normContent, normOld, ctx) {
|
|
481
|
-
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
482
|
-
const entry = ctx.readPool.get(p);
|
|
483
|
-
if (!entry) return null; // this file was never Read this turn; allow
|
|
484
|
-
|
|
485
|
-
const curLines = normContent.split('\n');
|
|
486
|
-
const curFileHash = hashStr(normContent);
|
|
487
|
-
|
|
488
|
-
// Locate the edit target's first line in the CURRENT file (1-based).
|
|
489
|
-
//
|
|
490
|
-
// An EXACT line match first, then a line that merely CONTAINS it. The exact match alone
|
|
491
|
-
// missed every edit whose `old_string` begins mid-line — `old_string: "return 1;"` against
|
|
492
|
-
// a line ` return 1;` found nothing, so `editLine` was -1 and step (1) below was skipped
|
|
493
|
-
// entirely: the edit was then reported as stale (branch 2) or, with the whole-file hash
|
|
494
|
-
// also differing, rejected as though the file had changed. Two-line `old_string`s are the
|
|
495
|
-
// common case for a range edit, and their first line usually IS whole, which is why this
|
|
496
|
-
// only showed up on single-line and indented-prefix edits.
|
|
497
|
-
const firstLine = String(normOld).split('\n')[0];
|
|
498
|
-
let idx = curLines.indexOf(firstLine);
|
|
499
|
-
if (idx < 0 && firstLine) idx = curLines.findIndex((l) => l.includes(firstLine));
|
|
500
|
-
const editLine = idx >= 0 ? idx + 1 : -1;
|
|
501
|
-
|
|
502
|
-
// (1) The edit target must sit inside a region the AI actually Read, and that
|
|
503
|
-
// region's content must still match what was Read. Reading the WHOLE file
|
|
504
|
-
// records a region covering every line, so this also covers "read full,
|
|
505
|
-
// edit a part". This is what rejects "read lines 1-3, edit line 7".
|
|
506
|
-
if (editLine > 0) {
|
|
507
|
-
const coveredByIntactRegion = entry.regions.some(
|
|
508
|
-
(r) => editLine >= r.start && editLine <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
|
|
509
|
-
);
|
|
510
|
-
if (coveredByIntactRegion) return null;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// (2) Whole file unchanged since Read, but the edit target was never Read
|
|
514
|
-
// (no region covers it). Name the exact lines so the model reads the right
|
|
515
|
-
// window instead of guessing.
|
|
516
|
-
if (curFileHash === entry.fileHash) {
|
|
517
|
-
const span = String(normOld).split('\n').length;
|
|
518
|
-
const at = editLine > 0 ? editLine : 1;
|
|
519
|
-
return `Edit rejected: you have not read the lines you are editing in ${p}. The target is around line ${at} (${span} line${span === 1 ? '' : 's'}). Read it first — ${args_readHint(p, at, span)} — then Edit.`;
|
|
484
|
+
function findAdjacentDuplicate(inserted) {
|
|
485
|
+
let prev = null;
|
|
486
|
+
let prevAt = -1;
|
|
487
|
+
for (let i = 0; i < inserted.length; i++) {
|
|
488
|
+
const t = String(inserted[i] == null ? '' : inserted[i]).trim();
|
|
489
|
+
if (!t) continue;
|
|
490
|
+
if (prev !== null && t === prev) return { a: prevAt + 1, b: i + 1, text: t, index: i };
|
|
491
|
+
prev = t;
|
|
492
|
+
prevAt = i;
|
|
520
493
|
}
|
|
521
|
-
|
|
522
|
-
// (3) The file changed on disk since it was Read and the edit target is not in
|
|
523
|
-
// an intact Read region. Reject so a stale snippet cannot corrupt the file.
|
|
524
|
-
const at3 = editLine > 0 ? ` (target line ${editLine})` : '';
|
|
525
|
-
return `Edit rejected: ${p} changed since it was Read${at3}, and the edited region is not in an intact Read snapshot. Re-run ${args_readHint(p, editLine > 0 ? editLine : 1, String(normOld).split('\n').length)} to get the current content, then Edit.`;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// A ready-to-use Read call for the hint. Keeps the model from guessing line ranges.
|
|
529
|
-
function args_readHint(p, at, span) {
|
|
530
|
-
const from = Math.max(1, at - 3);
|
|
531
|
-
const n = Math.max(span + 6, 10);
|
|
532
|
-
return `Read { path: ${JSON.stringify(p)}, line_offset: ${from}, n_lines: ${n} }`;
|
|
494
|
+
return null;
|
|
533
495
|
}
|
|
534
|
-
// ---- helpers for the line-range path ---------------------------------------
|
|
535
|
-
|
|
536
|
-
// Same guarantee as checkStale, but for an explicit [start, end] line range:
|
|
537
|
-
// every line in the range must sit inside a region that was Read this turn and
|
|
538
|
-
// whose content still matches. Returns an error string, or null when safe.
|
|
539
|
-
function checkStaleRange(p, curLines, start, end, ctx) {
|
|
540
|
-
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
541
|
-
const entry = ctx.readPool.get(p);
|
|
542
|
-
if (!entry) return null; // never Read this turn; allow
|
|
543
496
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
//
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const intact = entry.regions
|
|
558
|
-
.filter((r) => hashLines(curLines, r.start, r.end) === r.hash)
|
|
559
|
-
.sort((a, b) => a.start - b.start);
|
|
560
|
-
let reach = start - 1; // highest line known to be covered
|
|
561
|
-
for (const r of intact) {
|
|
562
|
-
if (r.end < start) continue; // entirely before the target
|
|
563
|
-
if (r.start > reach + 1) break; // a gap opens before this region: stop
|
|
564
|
-
if (r.end > reach) reach = r.end;
|
|
565
|
-
if (reach >= end) return null; // reached the end: fully covered
|
|
497
|
+
/**
|
|
498
|
+
* The pair at the SEAM: the last line of `inserted` against the line at `end_line + 1`, which the edit
|
|
499
|
+
* never replaced.
|
|
500
|
+
*
|
|
501
|
+
* Same rule, same answer — the inserted copy goes, the file's own line stays.
|
|
502
|
+
*/
|
|
503
|
+
function findSeamDuplicate(lines, inserted, start, end) {
|
|
504
|
+
if (!inserted.length) return null;
|
|
505
|
+
const norm = (v) => String(v == null ? '' : v).trim();
|
|
506
|
+
// THE END: new_string's last line against the first line the edit did not replace.
|
|
507
|
+
const last = norm(inserted[inserted.length - 1]);
|
|
508
|
+
if (last && norm(lines[end]) === last) {
|
|
509
|
+
return { a: inserted.length, b: end + 1, text: last, index: inserted.length - 1 };
|
|
566
510
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
// of re-reading the target lines.
|
|
572
|
-
return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read them first — ${args_readHint(p, start, end - start + 1)} — then Edit.`;
|
|
511
|
+
// THE START: new_string's first line against the line just above the range.
|
|
512
|
+
const first = norm(inserted[0]);
|
|
513
|
+
if (first && norm(lines[start - 2]) === first) {
|
|
514
|
+
return { a: start - 1, b: start, text: first, index: 0 };
|
|
573
515
|
}
|
|
574
|
-
return
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// Refresh the region cache after a successful edit: drop regions that no longer
|
|
578
|
-
// match the NEW content, and record the freshly-written span. Mirrors the
|
|
579
|
-
// re-arm block used by the substring path.
|
|
580
|
-
function refreshReadPool(p, normUpdated, written, ctx) {
|
|
581
|
-
if (!ctx || !ctx.readPool) return;
|
|
582
|
-
const entry = ctx.readPool.get(p);
|
|
583
|
-
if (!entry) return;
|
|
584
|
-
const newLines = normUpdated.split('\n');
|
|
585
|
-
entry.fileHash = hashStr(normUpdated);
|
|
586
|
-
entry.lines = newLines.length;
|
|
587
|
-
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
588
|
-
kept.push({ start: written.start, end: Math.min(newLines.length, Math.max(written.start, written.end)), hash: hashLines(newLines, written.start, Math.min(newLines.length, Math.max(written.start, written.end))) });
|
|
589
|
-
entry.regions = kept;
|
|
590
|
-
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
516
|
+
return null;
|
|
591
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Build the diff payload, including the unchanged lines around the change.
|
|
520
|
+
*
|
|
521
|
+
* The replaced lines alone do not tell a reader WHERE the edit landed: a four-line block quoted with no
|
|
522
|
+
* surroundings could be anywhere in a 500-line file, so neither the model nor the user could tell a
|
|
523
|
+
* correct edit from one applied at the right text in the wrong place — and with repeated code (several
|
|
524
|
+
* similar blocks, two `}` in a row) the same text appears in many places.
|
|
525
|
+
*
|
|
526
|
+
* `context` carries the file's own lines so the renderer can draw them unmarked, above and below.
|
|
527
|
+
*
|
|
528
|
+
* `fileLines` is the content BEFORE the edit, for the replaced region's START LINE to be meaningful:
|
|
529
|
+
* `startLine` is a line number in that same file.
|
|
530
|
+
*/
|
|
531
|
+
function buildEditDiff({ fileLines, oldText, newText, startLine }) {
|
|
532
|
+
const lines = Array.isArray(fileLines) ? fileLines : [];
|
|
533
|
+
const before = [];
|
|
534
|
+
const after = [];
|
|
535
|
+
const replacedCount = normalizeText(oldText).split('\n').length;
|
|
536
|
+
// `startLine` is 1-based; the replaced block occupies [startLine, startLine + n - 1].
|
|
537
|
+
const lastReplaced = startLine + replacedCount - 1;
|
|
538
|
+
for (let n = startLine - DIFF_CONTEXT; n < startLine; n++) {
|
|
539
|
+
if (n >= 1 && n <= lines.length) before.push({ n, text: lines[n - 1] });
|
|
540
|
+
}
|
|
541
|
+
// The AFTER context is drawn from the file BEFORE the edit, but its line numbers must describe the file
|
|
542
|
+
// AFTER it — that is the file a reader has open when they read the diff.
|
|
543
|
+
//
|
|
544
|
+
// Using the pre-edit numbers looked right for a same-size replacement and drifted by one per added line
|
|
545
|
+
// otherwise: an edit that grew the file by three shifted every context row, so the diff showed line
|
|
546
|
+
// numbers that did not exist where it claimed, or repeated ones that did.
|
|
547
|
+
//
|
|
548
|
+
// `net` is the number of lines the edit added (negative when it shrank the file), so every line below
|
|
549
|
+
// the replaced block moves by exactly that much.
|
|
550
|
+
const net = normalizeText(newText).split('\n').length - replacedCount;
|
|
551
|
+
for (let n = lastReplaced + 1; n <= lastReplaced + DIFF_CONTEXT; n++) {
|
|
552
|
+
if (n >= 1 && n <= lines.length) after.push({ n: n + net, text: lines[n - 1] });
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
old: normalizeText(oldText),
|
|
556
|
+
new: normalizeText(newText),
|
|
557
|
+
startLine,
|
|
558
|
+
context: { before, after },
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Suggest a near-miss filename when a path does not exist: same directory, an
|
|
563
|
+
// exact case-insensitive match, same stem with a different extension, or a
|
|
564
|
+
// prefix match. Saves the model a round-trip of guessing (Claude Code's
|
|
565
|
+
// "Did you mean X?").
|
|
566
|
+
function similarFileHint(missingPath) {
|
|
567
|
+
const dir = path.dirname(missingPath);
|
|
568
|
+
const base = path.basename(missingPath);
|
|
569
|
+
const stem = base.replace(/\.[^.]*$/, '').toLowerCase();
|
|
570
|
+
let entries;
|
|
571
|
+
try { entries = fs.readdirSync(dir); } catch { return ''; }
|
|
572
|
+
const lower = base.toLowerCase();
|
|
573
|
+
// Levenshtein distance, capped — enough to catch a typo (confg -> config).
|
|
574
|
+
const lev = (a, b) => {
|
|
575
|
+
const m = a.length, n = b.length;
|
|
576
|
+
if (Math.abs(m - n) > 2) return 3;
|
|
577
|
+
let prev = Array.from({ length: n + 1 }, (_, j) => j);
|
|
578
|
+
for (let i = 1; i <= m; i++) {
|
|
579
|
+
const cur = [i];
|
|
580
|
+
for (let j = 1; j <= n; j++) {
|
|
581
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
582
|
+
}
|
|
583
|
+
prev = cur;
|
|
584
|
+
}
|
|
585
|
+
return prev[n];
|
|
586
|
+
};
|
|
587
|
+
let cand = entries.find((e) => e.toLowerCase() === lower);
|
|
588
|
+
if (!cand) {
|
|
589
|
+
let best = null, bestD = 3;
|
|
590
|
+
for (const e of entries) {
|
|
591
|
+
const d = lev(stem, e.replace(/\.[^.]*$/, '').toLowerCase());
|
|
592
|
+
if (d < bestD) { bestD = d; best = e; }
|
|
593
|
+
}
|
|
594
|
+
cand = best;
|
|
595
|
+
}
|
|
596
|
+
if (cand && cand !== base) return ` Did you mean "${path.join(dir, cand)}"?`;
|
|
597
|
+
return '';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// When old_string is not found, look for the closest line in the file and name
|
|
601
|
+
// it, so the model can see what the text actually is instead of guessing.
|
|
602
|
+
function nearestLineHint(content, oldStr) {
|
|
603
|
+
const want = String(oldStr).split('\n')[0].trim();
|
|
604
|
+
if (!want || want.length < 3) return '';
|
|
605
|
+
const lines = String(content).split('\n');
|
|
606
|
+
// Character bigrams: robust to small differences and cheap on long lines.
|
|
607
|
+
const grams = (s) => {
|
|
608
|
+
const set = new Set();
|
|
609
|
+
for (let i = 0; i + 1 < s.length; i++) set.add(s.slice(i, i + 2));
|
|
610
|
+
return set;
|
|
611
|
+
};
|
|
612
|
+
const wantGrams = grams(want);
|
|
613
|
+
if (!wantGrams.size) return '';
|
|
614
|
+
let best = -1, bestScore = 0;
|
|
615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
616
|
+
const g = grams(lines[i].trim());
|
|
617
|
+
if (!g.size) continue;
|
|
618
|
+
let hit = 0;
|
|
619
|
+
for (const t of wantGrams) if (g.has(t)) hit++;
|
|
620
|
+
const score = hit / wantGrams.size;
|
|
621
|
+
if (score > bestScore) { bestScore = score; best = i; }
|
|
622
|
+
}
|
|
623
|
+
if (best >= 0 && bestScore >= 0.5) {
|
|
624
|
+
return ` The closest line is line ${best + 1}: ${JSON.stringify(lines[best].trim().slice(0, 120))}`;
|
|
625
|
+
}
|
|
626
|
+
return '';
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
630
|
+
function checkStale(p, normContent, normOld, ctx) {
|
|
631
|
+
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
632
|
+
const entry = ctx.readPool.get(p);
|
|
633
|
+
if (!entry) return null; // this file was never Read this turn; allow
|
|
634
|
+
|
|
635
|
+
const curLines = normContent.split('\n');
|
|
636
|
+
const curFileHash = hashStr(normContent);
|
|
637
|
+
|
|
638
|
+
// Locate the edit target's first line in the CURRENT file (1-based).
|
|
639
|
+
//
|
|
640
|
+
// An EXACT line match first, then a line that merely CONTAINS it. The exact match alone
|
|
641
|
+
// missed every edit whose `old_string` begins mid-line — `old_string: "return 1;"` against
|
|
642
|
+
// a line ` return 1;` found nothing, so `editLine` was -1 and step (1) below was skipped
|
|
643
|
+
// entirely: the edit was then reported as stale (branch 2) or, with the whole-file hash
|
|
644
|
+
// also differing, rejected as though the file had changed. Two-line `old_string`s are the
|
|
645
|
+
// common case for a range edit, and their first line usually IS whole, which is why this
|
|
646
|
+
// only showed up on single-line and indented-prefix edits.
|
|
647
|
+
const firstLine = String(normOld).split('\n')[0];
|
|
648
|
+
let idx = curLines.indexOf(firstLine);
|
|
649
|
+
if (idx < 0 && firstLine) idx = curLines.findIndex((l) => l.includes(firstLine));
|
|
650
|
+
const editLine = idx >= 0 ? idx + 1 : -1;
|
|
651
|
+
|
|
652
|
+
// (1) The edit target must sit inside a region the AI actually Read, and that
|
|
653
|
+
// region's content must still match what was Read. Reading the WHOLE file
|
|
654
|
+
// records a region covering every line, so this also covers "read full,
|
|
655
|
+
// edit a part". This is what rejects "read lines 1-3, edit line 7".
|
|
656
|
+
if (editLine > 0) {
|
|
657
|
+
const coveredByIntactRegion = entry.regions.some(
|
|
658
|
+
(r) => editLine >= r.start && editLine <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
|
|
659
|
+
);
|
|
660
|
+
if (coveredByIntactRegion) return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// (2) Whole file unchanged since Read, but the edit target was never Read
|
|
664
|
+
// (no region covers it). Name the exact lines so the model reads the right
|
|
665
|
+
// window instead of guessing.
|
|
666
|
+
if (curFileHash === entry.fileHash) {
|
|
667
|
+
const span = String(normOld).split('\n').length;
|
|
668
|
+
const at = editLine > 0 ? editLine : 1;
|
|
669
|
+
return `Edit rejected: you have not read the lines you are editing in ${p}. The target is around line ${at} (${span} line${span === 1 ? '' : 's'}). Read it first — ${args_readHint(p, at, span)} — then Edit.`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// (3) The file changed on disk since it was Read and the edit target is not in
|
|
673
|
+
// an intact Read region. Reject so a stale snippet cannot corrupt the file.
|
|
674
|
+
const at3 = editLine > 0 ? ` (target line ${editLine})` : '';
|
|
675
|
+
return `Edit rejected: ${p} changed since it was Read${at3}, and the edited region is not in an intact Read snapshot. Re-run ${args_readHint(p, editLine > 0 ? editLine : 1, String(normOld).split('\n').length)} to get the current content, then Edit.`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// A ready-to-use Read call for the hint. Keeps the model from guessing line ranges.
|
|
679
|
+
function args_readHint(p, at, span) {
|
|
680
|
+
const from = Math.max(1, at - 3);
|
|
681
|
+
const n = Math.max(span + 6, 10);
|
|
682
|
+
return `Read { path: ${JSON.stringify(p)}, line_offset: ${from}, n_lines: ${n} }`;
|
|
683
|
+
}
|
|
684
|
+
// ---- helpers for the line-range path ---------------------------------------
|
|
685
|
+
|
|
686
|
+
// Same guarantee as checkStale, but for an explicit [start, end] line range:
|
|
687
|
+
// every line in the range must sit inside a region that was Read this turn and
|
|
688
|
+
// whose content still matches. Returns an error string, or null when safe.
|
|
689
|
+
function checkStaleRange(p, curLines, start, end, ctx) {
|
|
690
|
+
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
|
691
|
+
const entry = ctx.readPool.get(p);
|
|
692
|
+
if (!entry) return null; // never Read this turn; allow
|
|
693
|
+
|
|
694
|
+
const normContent = curLines.join('\n');
|
|
695
|
+
const curFileHash = hashStr(normContent);
|
|
696
|
+
|
|
697
|
+
// A range is "read" when the regions the model has SEEN cover it — not only when
|
|
698
|
+
// a single region spans it. A file read in two passes (5-10 then 11-16) must
|
|
699
|
+
// allow editing 5-16: the two slices are contiguous, both still intact, so the
|
|
700
|
+
// written range is fully known. Requiring one enclosing region rejected that
|
|
701
|
+
// perfectly safe edit and forced a third Read.
|
|
702
|
+
//
|
|
703
|
+
// Only regions whose hash STILL matches count: a region whose text changed is no
|
|
704
|
+
// longer what the model saw, so it cannot vouch for anything. The cover must also
|
|
705
|
+
// be SEAMLESS — a gap (5-10 and 12-16 with 11 unread) leaves 11 unknown, so it
|
|
706
|
+
// does NOT count, and the edit is correctly refused.
|
|
707
|
+
const intact = entry.regions
|
|
708
|
+
.filter((r) => hashLines(curLines, r.start, r.end) === r.hash)
|
|
709
|
+
.sort((a, b) => a.start - b.start);
|
|
710
|
+
let reach = start - 1; // highest line known to be covered
|
|
711
|
+
for (const r of intact) {
|
|
712
|
+
if (r.end < start) continue; // entirely before the target
|
|
713
|
+
if (r.start > reach + 1) break; // a gap opens before this region: stop
|
|
714
|
+
if (r.end > reach) reach = r.end;
|
|
715
|
+
if (reach >= end) return null; // reached the end: fully covered
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (curFileHash === entry.fileHash) {
|
|
719
|
+
// No "(read so far: …)" listing: the range is what the model must act on, and
|
|
720
|
+
// echoing the regions it HAS read only invited it to reason about them instead
|
|
721
|
+
// of re-reading the target lines.
|
|
722
|
+
return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read them first — ${args_readHint(p, start, end - start + 1)} — then Edit.`;
|
|
723
|
+
}
|
|
724
|
+
return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run ${args_readHint(p, start, end - start + 1)} to get the current content, then Edit.`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Refresh the region cache after a successful edit: drop regions that no longer
|
|
728
|
+
// match the NEW content, and record the freshly-written span. Mirrors the
|
|
729
|
+
// re-arm block used by the substring path.
|
|
730
|
+
function refreshReadPool(p, normUpdated, written, ctx) {
|
|
731
|
+
if (!ctx || !ctx.readPool) return;
|
|
732
|
+
const entry = ctx.readPool.get(p);
|
|
733
|
+
if (!entry) return;
|
|
734
|
+
const newLines = normUpdated.split('\n');
|
|
735
|
+
entry.fileHash = hashStr(normUpdated);
|
|
736
|
+
entry.lines = newLines.length;
|
|
737
|
+
const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
|
|
738
|
+
kept.push({ start: written.start, end: Math.min(newLines.length, Math.max(written.start, written.end)), hash: hashLines(newLines, written.start, Math.min(newLines.length, Math.max(written.start, written.end))) });
|
|
739
|
+
entry.regions = kept;
|
|
740
|
+
try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
|
|
741
|
+
}
|