acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/edit.mjs
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ SURGICAL EDITS — REPLACE A SPAN, NOT THE WHOLE FILE ───────────────────
|
|
3
|
+
*
|
|
4
|
+
* `write_file` is the only way this CLI could change existing code, which means
|
|
5
|
+
* changing one line of a 2,000-line file required the model to re-emit all 2,000.
|
|
6
|
+
* That is bad three separate ways, and the third is the one that actually hurts:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Cost.** ~8,000 output tokens instead of ~50. At scale that is the whole
|
|
9
|
+
* pricing advantage handed back.
|
|
10
|
+
* 2. **Truncation.** Long rewrites hit `max_tokens` and arrive half-finished.
|
|
11
|
+
* 3. ⚠️ **SILENT DELETION.** Anything the model does not think to re-emit is
|
|
12
|
+
* gone — a comment, an import, an unrelated function. The file still
|
|
13
|
+
* parses, the tests may still pass, and nobody notices until the thing that
|
|
14
|
+
* vanished was load-bearing. A whole-file rewrite is a destructive
|
|
15
|
+
* operation wearing the costume of an edit.
|
|
16
|
+
*
|
|
17
|
+
* So: `edit_file(path, old_string, new_string)`. The model quotes the span it
|
|
18
|
+
* means and gets exactly that span replaced.
|
|
19
|
+
*
|
|
20
|
+
* ── ⚠️ UNIQUENESS IS THE SAFETY PROPERTY, NOT A CONVENIENCE ─────────────────
|
|
21
|
+
* If `old_string` appears more than once the edit is REFUSED, not applied to the
|
|
22
|
+
* first hit. "Replace the first `return null`" is almost never what anyone means
|
|
23
|
+
* in a file with nine of them, and picking one silently is how an agent corrupts
|
|
24
|
+
* code in a way that looks deliberate. The refusal tells the model how many
|
|
25
|
+
* matches there were so it can quote more surrounding context — which is the
|
|
26
|
+
* behaviour we want anyway.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, writeFileSync, statSync } from 'node:fs';
|
|
30
|
+
import { resolveInWorkspace } from './workspace.mjs';
|
|
31
|
+
|
|
32
|
+
/** Same ceiling the read path uses — an edit target must be readable as text. */
|
|
33
|
+
const MAX_EDIT_BYTES = 512 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Count non-overlapping occurrences. `split().length - 1` rather than a regex,
|
|
37
|
+
* because `old_string` is arbitrary code and must never be treated as a pattern.
|
|
38
|
+
*/
|
|
39
|
+
export function countOccurrences(haystack, needle) {
|
|
40
|
+
if (!needle) return 0;
|
|
41
|
+
return haystack.split(needle).length - 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ── ⚠️⚠️ CRLF: THE DEFECT THAT ROUTED THE AGENT INTO THE HAZARD ─────────────
|
|
46
|
+
*
|
|
47
|
+
* Everything below exists for one reason. A model writes multi-line `old_string`
|
|
48
|
+
* with `\n`. A Windows file holds `\r\n`. The literal match count is 0, the edit
|
|
49
|
+
* is refused, and the model's only remaining move is `write_file` — A WHOLE-FILE
|
|
50
|
+
* REWRITE FROM MEMORY.
|
|
51
|
+
*
|
|
52
|
+
* ⭐ That is precisely the operation `edit_file` was built to prevent. So this
|
|
53
|
+
* was not a blocked feature, it was a FUNNEL INTO THE HAZARD, and it fired on
|
|
54
|
+
* essentially every multi-line edit on the platform the owner develops on. It
|
|
55
|
+
* cost twice over: the file silently converts to LF (a diff touching every
|
|
56
|
+
* line) AND anything the model did not think to re-emit is gone.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️⚠️ AND THE OBVIOUS FIX IS THE SAME BUG WEARING A HAT. "Normalise the file to
|
|
59
|
+
* \n, match, write it back" matches beautifully and rewrites every line ending
|
|
60
|
+
* in the file — the identical damage, more quietly, with `ok: true` on top. So
|
|
61
|
+
* the rule this code is written to satisfy is:
|
|
62
|
+
*
|
|
63
|
+
* THE BYTES OUTSIDE THE REPLACED SPAN MUST BE IDENTICAL.
|
|
64
|
+
*
|
|
65
|
+
* Which means we may normalise only a THROWAWAY COPY, and must map the hit back
|
|
66
|
+
* to an index in the ORIGINAL string. `content.slice(0, start)` and
|
|
67
|
+
* `content.slice(end)` then carry the untouched bytes through verbatim, by
|
|
68
|
+
* construction rather than by care.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** The three endings that exist in the wild. `\r\n` first — order matters. */
|
|
72
|
+
const EOL_RE = /\r\n|\r|\n/g;
|
|
73
|
+
|
|
74
|
+
/** Collapse every ending to `\n`. Used ONLY on throwaway copies. */
|
|
75
|
+
function toLf(text) {
|
|
76
|
+
return text.replace(/\r\n|\r/g, '\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalise, and remember where every surviving character CAME FROM.
|
|
81
|
+
*
|
|
82
|
+
* ⭐ `map[i]` is the index in `source` of the character at `out[i]`, and the
|
|
83
|
+
* array carries one extra entry (`source.length`) so that an end-exclusive
|
|
84
|
+
* index maps too — without that sentinel a span reaching EOF maps to
|
|
85
|
+
* `undefined` and the slice silently becomes "to the end of the string", which
|
|
86
|
+
* is right by accident until the day it is not.
|
|
87
|
+
*/
|
|
88
|
+
function normaliseWithIndexMap(source) {
|
|
89
|
+
let out = '';
|
|
90
|
+
const map = [];
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < source.length) {
|
|
93
|
+
const ch = source[i];
|
|
94
|
+
map.push(i);
|
|
95
|
+
if (ch === '\r') {
|
|
96
|
+
out += '\n';
|
|
97
|
+
i += source[i + 1] === '\n' ? 2 : 1;
|
|
98
|
+
} else {
|
|
99
|
+
out += ch;
|
|
100
|
+
i += 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
map.push(source.length);
|
|
104
|
+
return { out, map };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every line ending in `text`, in order, as literal strings. */
|
|
108
|
+
function lineEndingsOf(text) {
|
|
109
|
+
return text.match(EOL_RE) ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The ending a file mostly uses. Ties go to whichever appeared first. */
|
|
113
|
+
function prevailingEnding(text) {
|
|
114
|
+
const found = lineEndingsOf(text);
|
|
115
|
+
if (found.length === 0) return null;
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
for (const e of found) counts.set(e, (counts.get(e) ?? 0) + 1);
|
|
118
|
+
let best = found[0];
|
|
119
|
+
for (const e of found) if ((counts.get(e) ?? 0) > (counts.get(best) ?? 0)) best = e;
|
|
120
|
+
return best;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Re-emit `text` using the endings the original span actually had.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ POSITIONALLY, not "the file's prevailing ending". A span can straddle a
|
|
127
|
+
* CRLF line and an LF line — real repos are mixed, usually because someone's
|
|
128
|
+
* editor disagreed with someone's git config — and flattening the span to one
|
|
129
|
+
* ending rewrites a line nobody asked to touch. The k-th newline of the
|
|
130
|
+
* replacement gets the k-th ending the span had; if the replacement adds lines,
|
|
131
|
+
* the last observed ending continues; if the span had none, the file's
|
|
132
|
+
* prevailing ending, and only then `\n`.
|
|
133
|
+
*/
|
|
134
|
+
function reflowToEndings(text, spanEndings, fallback) {
|
|
135
|
+
const lines = toLf(text).split('\n');
|
|
136
|
+
let out = lines[0];
|
|
137
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
138
|
+
const eol = spanEndings[i - 1] ?? spanEndings[spanEndings.length - 1] ?? fallback;
|
|
139
|
+
out += eol + lines[i];
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Non-overlapping match positions — the same left-to-right walk `split` does. */
|
|
145
|
+
function matchPositions(haystack, needle) {
|
|
146
|
+
const at = [];
|
|
147
|
+
let i = haystack.indexOf(needle);
|
|
148
|
+
while (i !== -1) {
|
|
149
|
+
at.push(i);
|
|
150
|
+
i = haystack.indexOf(needle, i + needle.length);
|
|
151
|
+
}
|
|
152
|
+
return at;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The one retry: match `oldString` against `content` IGNORING line-ending style.
|
|
157
|
+
*
|
|
158
|
+
* Returns `null` when this path cannot possibly apply, so the caller falls
|
|
159
|
+
* through to its ordinary not-found refusal and the message stays the one it
|
|
160
|
+
* always was.
|
|
161
|
+
*/
|
|
162
|
+
function matchAcrossLineEndings(content, oldString, newString) {
|
|
163
|
+
/**
|
|
164
|
+
* ⚠️ TWO CHEAP EXITS, AND THEY ARE WHAT KEEPS THIS ADDITIVE.
|
|
165
|
+
*
|
|
166
|
+
* 1. A needle with no line break at all cannot gain a match by normalising —
|
|
167
|
+
* normalising only ever rewrites `\r`, and a needle containing no `\r` and
|
|
168
|
+
* no `\n` can only match a run of non-newline characters, which is
|
|
169
|
+
* byte-identical before and after. So SINGLE-LINE EDITS NEVER ENTER HERE,
|
|
170
|
+
* provably, not just in practice.
|
|
171
|
+
* 2. If neither side contains a `\r`, normalising is the identity function on
|
|
172
|
+
* both, so the second search would repeat the first one's answer.
|
|
173
|
+
*/
|
|
174
|
+
if (!/[\r\n]/.test(oldString)) return null;
|
|
175
|
+
if (!content.includes('\r') && !oldString.includes('\r')) return null;
|
|
176
|
+
|
|
177
|
+
const { out: haystack, map } = normaliseWithIndexMap(content);
|
|
178
|
+
const needle = toLf(oldString);
|
|
179
|
+
const at = matchPositions(haystack, needle);
|
|
180
|
+
|
|
181
|
+
if (at.length === 0) return null;
|
|
182
|
+
if (at.length > 1) {
|
|
183
|
+
/**
|
|
184
|
+
* ⚠️ AMBIGUITY IS A REFUSAL, NOT A GUESS. Uniqueness is a SAFETY property —
|
|
185
|
+
* it is the rule that stops an agent corrupting code in a way that looks
|
|
186
|
+
* deliberate — and a tolerant matcher is not allowed to relax it just
|
|
187
|
+
* because it was the one that found the matches. The message names the
|
|
188
|
+
* cause, because "it appears twice" is baffling to a model looking at a
|
|
189
|
+
* file where, literally, it appears zero times.
|
|
190
|
+
*/
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
error:
|
|
194
|
+
`old_string is not in the file literally, but ignoring line-ending style (CRLF vs LF) it ` +
|
|
195
|
+
`matches ${at.length} times, so it is ambiguous and nothing was changed. Quote more ` +
|
|
196
|
+
'surrounding context until the span is unique.',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const start = map[at[0]];
|
|
201
|
+
const end = map[at[0] + needle.length];
|
|
202
|
+
const span = content.slice(start, end);
|
|
203
|
+
const replacement = reflowToEndings(newString, lineEndingsOf(span), prevailingEnding(content) ?? '\n');
|
|
204
|
+
const next = content.slice(0, start) + replacement + content.slice(end);
|
|
205
|
+
|
|
206
|
+
if (next === content) {
|
|
207
|
+
// ⚠️ old_string and new_string differed only in ending STYLE. Writing this
|
|
208
|
+
// produces the same bytes, and a success report on a no-op is a lie the
|
|
209
|
+
// model will build its next step on.
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error:
|
|
213
|
+
'old_string and new_string differ only in line-ending style, so there is nothing to do — ' +
|
|
214
|
+
'the file already contains exactly what you asked for.',
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { ok: true, content: next, newlineNormalised: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Replace exactly one occurrence of `oldString` with `newString`.
|
|
223
|
+
*
|
|
224
|
+
* Pure so it is testable without a filesystem: the caller owns the I/O, this
|
|
225
|
+
* owns the decision.
|
|
226
|
+
*/
|
|
227
|
+
export function applyEdit(content, oldString, newString) {
|
|
228
|
+
if (typeof oldString !== 'string' || oldString.length === 0) {
|
|
229
|
+
return { ok: false, error: 'old_string is required — quote the exact text to replace' };
|
|
230
|
+
}
|
|
231
|
+
if (typeof newString !== 'string') {
|
|
232
|
+
return { ok: false, error: 'new_string is required (use "" to delete the span)' };
|
|
233
|
+
}
|
|
234
|
+
if (oldString === newString) {
|
|
235
|
+
// ⚠️ A no-op edit reported as success is a lie the model will build on: it
|
|
236
|
+
// moves to the next step believing something changed.
|
|
237
|
+
return { ok: false, error: 'old_string and new_string are identical — nothing to do' };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const hits = countOccurrences(content, oldString);
|
|
241
|
+
if (hits === 0) {
|
|
242
|
+
// ⭐ ONE retry, ignoring line-ending style only. Returns null when the path
|
|
243
|
+
// cannot apply, so the refusal below is still the refusal it always was.
|
|
244
|
+
const tolerant = matchAcrossLineEndings(content, oldString, newString);
|
|
245
|
+
if (tolerant) return tolerant;
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error:
|
|
249
|
+
'old_string was not found. It must match the file EXACTLY, including indentation and ' +
|
|
250
|
+
'line breaks — read the file first and copy the span verbatim.',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (hits > 1) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error:
|
|
257
|
+
`old_string appears ${hits} times, so it is ambiguous. Quote more surrounding ` +
|
|
258
|
+
'context until the span is unique. (Editing the first match silently is how an agent ' +
|
|
259
|
+
'corrupts code in a way that looks deliberate.)',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
return { ok: true, content: content.replace(oldString, newString) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read, edit, write — with the workspace's own path safety. */
|
|
266
|
+
export function editFile(root, rawPath, oldString, newString, { dryRun = false } = {}) {
|
|
267
|
+
const target = resolveInWorkspace(root, rawPath, 'write');
|
|
268
|
+
if (!target.ok) return { ok: false, error: target.reason };
|
|
269
|
+
|
|
270
|
+
let stat;
|
|
271
|
+
try {
|
|
272
|
+
stat = statSync(target.absolute);
|
|
273
|
+
} catch {
|
|
274
|
+
return { ok: false, error: `no such file: ${target.relative} — use write_file to create it` };
|
|
275
|
+
}
|
|
276
|
+
if (stat.isDirectory()) return { ok: false, error: `${target.relative} is a directory` };
|
|
277
|
+
if (stat.size > MAX_EDIT_BYTES) {
|
|
278
|
+
return { ok: false, error: `${target.relative} is ${stat.size} bytes, over the ${MAX_EDIT_BYTES}-byte edit limit` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* ⚠️ READ THE BYTES, NOT A DECODED STRING — THE DECODE IS THE DESTRUCTION.
|
|
283
|
+
*
|
|
284
|
+
* This used to be `readFileSync(path, 'utf8')`, and that single argument was a
|
|
285
|
+
* silent data-loss bug. `'utf8'` does not fail on invalid input: every byte it
|
|
286
|
+
* cannot make sense of becomes U+FFFD, and the write-back turns each one into
|
|
287
|
+
* `ef bf bd`. So a latin-1 / cp1252 / Shift-JIS file — legitimate text, just
|
|
288
|
+
* not our encoding — came back permanently mangled while the function returned
|
|
289
|
+
* `ok: true` and a plausible byte count.
|
|
290
|
+
*
|
|
291
|
+
* REPRODUCED 2026-08-10: 17 bytes `48656c6c6f20fffee9e80a534543524554`, one
|
|
292
|
+
* `edit_file` replacing SECRET→PUBLIC, and `ff fe e9 e8` were gone forever —
|
|
293
|
+
* four bytes the model never asked to touch, with a success report on top of
|
|
294
|
+
* them. Nothing about the request was risky; the read was.
|
|
295
|
+
*/
|
|
296
|
+
const raw = readFileSync(target.absolute);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* ⚠️ NUL AS AN ESCAPE, NEVER A RAW BYTE — I WROTE THIS BUG TWICE TODAY.
|
|
300
|
+
* A literal control character in source survives as a plain SPACE through some
|
|
301
|
+
* tooling, which turns this into `includes(' ')` — refusing every file that
|
|
302
|
+
* contains a space, i.e. all of them. Edit would have reported every file as
|
|
303
|
+
* binary and refused to touch anything.
|
|
304
|
+
*
|
|
305
|
+
* The check itself matches readFile's, so read / search / edit all agree about
|
|
306
|
+
* what counts as text; editing a binary as UTF-8 corrupts it silently.
|
|
307
|
+
* It stays FIRST because "looks binary" is the more useful sentence for a .png
|
|
308
|
+
* than "not valid UTF-8", and a real binary is almost always both.
|
|
309
|
+
*/
|
|
310
|
+
if (raw.includes(0)) {
|
|
311
|
+
return { ok: false, error: `${target.relative} looks binary — refusing to edit it as text` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* ⭐ `fatal: true` is the whole fix — it THROWS on exactly the bytes that
|
|
316
|
+
* `'utf8'` would have silently replaced.
|
|
317
|
+
*
|
|
318
|
+
* ⚠️ A round-trip comparison (`raw.equals(Buffer.from(str, 'utf8'))`) looks
|
|
319
|
+
* equivalent and is weaker: a lone surrogate encodes to three bytes and U+FFFD
|
|
320
|
+
* encodes to three bytes, so the lengths agree and only the strict decoder
|
|
321
|
+
* notices. Use the decoder.
|
|
322
|
+
*
|
|
323
|
+
* ⚠️ `ignoreBOM: true` IS LOAD-BEARING, AND IT IS NAMED BACKWARDS. It means
|
|
324
|
+
* "do not treat a leading U+FEFF as a marker to swallow" — i.e. KEEP the BOM
|
|
325
|
+
* as an ordinary character. The default (`false`) strips it, which would have
|
|
326
|
+
* quietly deleted three bytes from the front of every BOM'd file on every
|
|
327
|
+
* edit: a new silent-data-loss bug shipped inside the fix for silent data
|
|
328
|
+
* loss. My own regression test caught it, which is the only reason it is not
|
|
329
|
+
* in this file right now.
|
|
330
|
+
*
|
|
331
|
+
* ⚠️ The refusal must not say "try again" — retrying is the one thing that
|
|
332
|
+
* cannot help, because the file will still be in the same encoding next time.
|
|
333
|
+
* Name the two moves that actually get the caller unstuck instead.
|
|
334
|
+
*/
|
|
335
|
+
let before;
|
|
336
|
+
try {
|
|
337
|
+
before = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
|
|
338
|
+
} catch {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error:
|
|
342
|
+
`${target.relative} is not valid UTF-8 — refusing to edit it as text. ` +
|
|
343
|
+
'Editing it would replace every undecodable byte with U+FFFD and destroy them permanently. ' +
|
|
344
|
+
'Convert the file to UTF-8 first, or change it with a tool that preserves its encoding.',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const result = applyEdit(before, oldString, newString);
|
|
349
|
+
if (!result.ok) return { ok: false, error: `${target.relative}: ${result.error}` };
|
|
350
|
+
|
|
351
|
+
if (!dryRun) writeFileSync(target.absolute, result.content, 'utf8');
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
path: target.relative,
|
|
356
|
+
bytes: Buffer.byteLength(result.content, 'utf8'),
|
|
357
|
+
// ⭐ The saving, reported so it is visible rather than theoretical: how much
|
|
358
|
+
// of the file we did NOT have to re-emit.
|
|
359
|
+
replacedChars: oldString.length,
|
|
360
|
+
fileChars: before.length,
|
|
361
|
+
/**
|
|
362
|
+
* ⚠️ `created: false` and `previousBytes` are shaped to match write_file's
|
|
363
|
+
* result, because the summary renders both through one line. An edit that
|
|
364
|
+
* omitted them printed "created undefined bytes, was undefined" — a result
|
|
365
|
+
* object that is ALMOST the expected shape is worse than an obviously wrong
|
|
366
|
+
* one, because it renders instead of throwing.
|
|
367
|
+
*
|
|
368
|
+
* ⚠️ `previousBytes` is `raw.length`, NOT `Buffer.byteLength(before)`. The
|
|
369
|
+
* latter measures the string we decoded, which is a claim about our own
|
|
370
|
+
* variable rather than about the file — and it lied the moment the decode
|
|
371
|
+
* was lossy: a 17-byte file was reported as `previousBytes: 25`, because
|
|
372
|
+
* four bad bytes had already become four 3-byte U+FFFDs. The strict decode
|
|
373
|
+
* above closes that gap, but the honest source for "what was on disk" is
|
|
374
|
+
* still the bytes we read off the disk.
|
|
375
|
+
*/
|
|
376
|
+
created: false,
|
|
377
|
+
previousBytes: raw.length,
|
|
378
|
+
dryRun,
|
|
379
|
+
/**
|
|
380
|
+
* ⭐ Reported, not hidden: the span matched only after line-ending style was
|
|
381
|
+
* ignored. A tolerance that is invisible is indistinguishable from a bug, and
|
|
382
|
+
* the day this maps a hit to the wrong place, this flag is the thread to pull.
|
|
383
|
+
*/
|
|
384
|
+
newlineNormalised: result.newlineNormalised === true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* ── ⭐ THE EXECUTOR-GENERIC EDIT — what makes ONE loop serve TWO clients ─────
|
|
390
|
+
*
|
|
391
|
+
* `editFile` above reaches straight for `fs` via a root path. That is correct
|
|
392
|
+
* for the CLI and impossible for the browser builder, whose workspace is a Map
|
|
393
|
+
* with no disk behind it.
|
|
394
|
+
*
|
|
395
|
+
* ⚠️ The tempting fix is a branch in the dispatcher — "if memory executor, do
|
|
396
|
+
* this instead" — and that is how one loop quietly becomes two, which is the
|
|
397
|
+
* exact failure this whole refactor exists to undo (measured: the CLI and
|
|
398
|
+
* console registries had ZERO overlap while both claiming to share a design).
|
|
399
|
+
*
|
|
400
|
+
* ⭐ So the EDIT LOGIC never knew about the filesystem in the first place:
|
|
401
|
+
* `applyEdit` is already pure. All this needs is read and write, which every
|
|
402
|
+
* executor has. One implementation, both clients, no branch.
|
|
403
|
+
*/
|
|
404
|
+
export function editThroughExecutor(executor, rawPath, oldString, newString) {
|
|
405
|
+
const before = executor.readFile(rawPath);
|
|
406
|
+
if (!before.ok) {
|
|
407
|
+
return { ok: false, error: `${before.error} — use write_file to create it` };
|
|
408
|
+
}
|
|
409
|
+
const result = applyEdit(before.content, oldString, newString);
|
|
410
|
+
if (!result.ok) return { ok: false, error: `${before.path}: ${result.error}` };
|
|
411
|
+
|
|
412
|
+
const written = executor.writeFile(before.path, result.content);
|
|
413
|
+
if (!written.ok) return { ok: false, error: written.error };
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
ok: true,
|
|
417
|
+
path: before.path,
|
|
418
|
+
bytes: written.bytes,
|
|
419
|
+
replacedChars: oldString.length,
|
|
420
|
+
fileChars: before.content.length,
|
|
421
|
+
// Shaped to match write_file's result — the summary renders both through
|
|
422
|
+
// one line, and an ALMOST-matching object renders "was undefined".
|
|
423
|
+
created: false,
|
|
424
|
+
previousBytes: before.bytes,
|
|
425
|
+
dryRun: written.dryRun === true,
|
|
426
|
+
// Same flag as editFile's — the two doors must not describe the same edit
|
|
427
|
+
// differently, which is how a shared engine quietly becomes two.
|
|
428
|
+
newlineNormalised: result.newlineNormalised === true,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function editToolSchema() {
|
|
433
|
+
return {
|
|
434
|
+
type: 'function',
|
|
435
|
+
function: {
|
|
436
|
+
name: 'edit_file',
|
|
437
|
+
description: [
|
|
438
|
+
'Change PART of an existing file by replacing an exact span of text.',
|
|
439
|
+
'Prefer this over write_file for any file that already exists — write_file replaces the',
|
|
440
|
+
'WHOLE file, so anything you do not re-emit is deleted.',
|
|
441
|
+
'old_string must match the file exactly, indentation included, and must be UNIQUE — if it',
|
|
442
|
+
'appears more than once the edit is refused, so quote enough surrounding context to be',
|
|
443
|
+
'unambiguous. Read the file first and copy the span verbatim.',
|
|
444
|
+
'Line-ending style is the ONE thing you need not get right: plain \\n in old_string matches a',
|
|
445
|
+
'CRLF (Windows) or CR file, and the file keeps its own endings. Never rewrite a whole file just',
|
|
446
|
+
'to change its line breaks.',
|
|
447
|
+
'Use an empty new_string to delete the span.',
|
|
448
|
+
].join(' '),
|
|
449
|
+
parameters: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: {
|
|
452
|
+
path: { type: 'string', description: 'Workspace-relative path to an EXISTING file.' },
|
|
453
|
+
old_string: { type: 'string', description: 'The exact text to replace. Must be unique in the file.' },
|
|
454
|
+
new_string: { type: 'string', description: 'What to replace it with. "" deletes the span.' },
|
|
455
|
+
},
|
|
456
|
+
required: ['path', 'old_string', 'new_string'],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|