ucode-agent 1.54.0 → 1.56.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/README.md +17 -6
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/package.json +64 -65
- package/src/core/loop.js +3067 -3240
- package/src/core/provider.js +1149 -1099
- package/src/core/window.js +84 -11
- package/src/tools/files.js +67 -13
- package/src/tools/fuzzy.js +295 -0
- package/src/tools/index.js +6 -45
- package/src/tools/scaffold.js +502 -528
- package/src/ui/plain.js +8 -2
- package/src/ui/screen.js +66 -16
- package/src/ui/theme.js +22 -2
package/src/core/window.js
CHANGED
|
@@ -87,10 +87,12 @@ function cutPoint(messages, budget) {
|
|
|
87
87
|
* @param {Array} messages
|
|
88
88
|
* @param {object} o
|
|
89
89
|
* @param {number} o.limit token budget
|
|
90
|
-
* @param {Function} o.summarize async (older) => string
|
|
90
|
+
* @param {Function} o.summarize async (older, previousSummary) => string
|
|
91
|
+
* @param {boolean} [o.force] fold even below the threshold — the provider
|
|
92
|
+
* has already said the request is too big
|
|
91
93
|
*/
|
|
92
|
-
export async function fold(messages, { limit, summarize }) {
|
|
93
|
-
if (!tooBig(messages, limit)) return { messages, folded: false };
|
|
94
|
+
export async function fold(messages, { limit, summarize, force = false }) {
|
|
95
|
+
if (!force && !tooBig(messages, limit)) return { messages, folded: false };
|
|
94
96
|
|
|
95
97
|
// Half the size that triggered the fold, so there is room to work before
|
|
96
98
|
// the next one. Sized off the same absolute rule, or a fold on a
|
|
@@ -103,7 +105,10 @@ export async function fold(messages, { limit, summarize }) {
|
|
|
103
105
|
// Nothing old enough to fold — the tail on its own is already oversized.
|
|
104
106
|
if (older.length === 0) return { messages, folded: false };
|
|
105
107
|
|
|
106
|
-
|
|
108
|
+
// A second fold must not summarize the first summary as if it were chat:
|
|
109
|
+
// it is handed over as the prior summary, to be merged rather than retold.
|
|
110
|
+
const previous = older.find((m) => m.folded)?.summary ?? null;
|
|
111
|
+
const summary = await summarize(older.filter((m) => !m.folded), previous);
|
|
107
112
|
|
|
108
113
|
return {
|
|
109
114
|
folded: true,
|
|
@@ -117,19 +122,87 @@ export async function fold(messages, { limit, summarize }) {
|
|
|
117
122
|
`were folded away to stay inside the context window.\n\n${summary}\n\n` +
|
|
118
123
|
'Treat all of that as settled context. Everything after this point is verbatim.',
|
|
119
124
|
folded: true,
|
|
125
|
+
summary,
|
|
120
126
|
},
|
|
121
127
|
...recent,
|
|
122
128
|
],
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
/**
|
|
133
|
+
* What the summarizer is asked to do: opencode's anchored summary (MIT, see
|
|
134
|
+
* THIRD_PARTY_NOTICES.md). Fixed sections mean nothing gets dropped because
|
|
135
|
+
* the summarizer found it dull — the next step and the files that matter
|
|
136
|
+
* always have a place — and a second fold merges into the first instead of
|
|
137
|
+
* summarizing a summary.
|
|
138
|
+
*/
|
|
139
|
+
export const SUMMARY_PROMPT = `You summarize a coding session so another coding agent can continue the work with nothing else to go on.
|
|
140
|
+
|
|
141
|
+
Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
|
142
|
+
<template>
|
|
143
|
+
## Objective
|
|
144
|
+
- [one or two brief sentences describing what the user is trying to accomplish]
|
|
145
|
+
|
|
146
|
+
## Important Details
|
|
147
|
+
- [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
|
|
148
|
+
|
|
149
|
+
## Work State
|
|
150
|
+
### Completed
|
|
151
|
+
- [finished work, verified facts, or changes made; otherwise "(none)"]
|
|
152
|
+
|
|
153
|
+
### Active
|
|
154
|
+
- [current work, partial changes, or investigation state; otherwise "(none)"]
|
|
155
|
+
|
|
156
|
+
### Blocked
|
|
157
|
+
- [blockers, failing commands, or unknowns; otherwise "(none)"]
|
|
158
|
+
|
|
159
|
+
## Next Move
|
|
160
|
+
1. [immediate concrete action, or "(none)"]
|
|
161
|
+
2. [next action if known, or "(none)"]
|
|
162
|
+
|
|
163
|
+
## Relevant Files
|
|
164
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
Rules:
|
|
168
|
+
- Keep every section, even when empty.
|
|
169
|
+
- Use terse bullets, not prose paragraphs.
|
|
170
|
+
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
|
|
171
|
+
- Do not mention the summary process or that context was compacted.`;
|
|
172
|
+
|
|
173
|
+
const MERGE = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
|
|
174
|
+
|
|
175
|
+
When combining:
|
|
176
|
+
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
|
|
177
|
+
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
|
|
178
|
+
- Add new progress, decisions, constraints, and context from the conversation.
|
|
179
|
+
- Move completed work from "Active" to "Completed".
|
|
180
|
+
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
|
|
181
|
+
- Update "Objective" and "Next Move" to reflect the current work state.`;
|
|
182
|
+
|
|
183
|
+
/** The summarizer's user message: the conversation, and the summary it extends if there is one. */
|
|
184
|
+
export function summaryRequest(conversation, previous = null) {
|
|
185
|
+
// File contents are in there too. One carrying "</conversation>" must not
|
|
186
|
+
// close the section early and speak to the summarizer as if it were us.
|
|
187
|
+
const fence = (s) => String(s).replace(/<\/?(?:conversation|prior-summary)>/gi, '');
|
|
188
|
+
conversation = fence(conversation);
|
|
189
|
+
if (previous) previous = fence(previous);
|
|
190
|
+
const parts = [`Here is the conversation so far:
|
|
191
|
+
|
|
192
|
+
<conversation>
|
|
193
|
+
${conversation}
|
|
194
|
+
</conversation>`];
|
|
195
|
+
if (previous) {
|
|
196
|
+
parts.push(`Here is the summary of the conversation before the <conversation> above:
|
|
197
|
+
|
|
198
|
+
<prior-summary>
|
|
199
|
+
${previous}
|
|
200
|
+
</prior-summary>`, MERGE);
|
|
201
|
+
} else {
|
|
202
|
+
parts.push('Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.');
|
|
203
|
+
}
|
|
204
|
+
return parts.join('\n\n');
|
|
205
|
+
}
|
|
133
206
|
|
|
134
207
|
/**
|
|
135
208
|
* Flatten the messages being folded into plain text for the summarizer.
|
package/src/tools/files.js
CHANGED
|
@@ -17,8 +17,29 @@ import {
|
|
|
17
17
|
noteFile, writeTracked, assertUnchanged,
|
|
18
18
|
} from './shared.js';
|
|
19
19
|
import { packageJsonWritten } from './shell.js';
|
|
20
|
+
import { fuzzyReplace } from './fuzzy.js';
|
|
20
21
|
import { parse as parseSource } from '@babel/parser';
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Up to three names beside a missing file that look like what was meant —
|
|
25
|
+
* "App.jsx" for "app.jsx", "index.html" for "index.htm". Named in the refusal
|
|
26
|
+
* (as opencode's read tool does), the next step is the right read instead of
|
|
27
|
+
* a list_dir to find out.
|
|
28
|
+
*/
|
|
29
|
+
async function lookalikes(target) {
|
|
30
|
+
const dir = path.dirname(target.abs);
|
|
31
|
+
const base = path.basename(target.abs).toLowerCase();
|
|
32
|
+
const shown = path.dirname(target.show);
|
|
33
|
+
try {
|
|
34
|
+
return (await fs.readdir(dir))
|
|
35
|
+
.filter((n) => n.toLowerCase().includes(base) || (n.length > 2 && base.includes(n.toLowerCase())))
|
|
36
|
+
.slice(0, 3)
|
|
37
|
+
.map((n) => (shown === '.' ? n : `${shown}/${n}`));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
23
44
|
const target = resolveIn(p, 'read_file');
|
|
24
45
|
await guard(target, `read ${target.abs}`);
|
|
@@ -28,7 +49,12 @@ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
|
28
49
|
try {
|
|
29
50
|
stat = await fs.stat(target.abs);
|
|
30
51
|
} catch (err) {
|
|
31
|
-
|
|
52
|
+
const failure = fsFailure(err, attempted, target.show);
|
|
53
|
+
// Outside the project the user said yes to one path, not to a listing of
|
|
54
|
+
// the folder around it — so no lookalikes there.
|
|
55
|
+
const near = err?.code === 'ENOENT' && target.inside ? await lookalikes(target) : [];
|
|
56
|
+
if (near.length) failure.fix = `Did you mean ${near.join(', ')}? ${failure.fix}`;
|
|
57
|
+
throw failure;
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
if (stat.isDirectory()) {
|
|
@@ -444,7 +470,7 @@ function explainMiss(original, oldString, show) {
|
|
|
444
470
|
}
|
|
445
471
|
|
|
446
472
|
/** Apply one replacement to a string, or explain precisely why it cannot. */
|
|
447
|
-
function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
|
|
473
|
+
function replaceOnce(text, { old_string, new_string, replace_all }, { show, attempted, label = '' }) {
|
|
448
474
|
const prefix = label ? `${label}: ` : '';
|
|
449
475
|
|
|
450
476
|
if (typeof old_string !== 'string' || typeof new_string !== 'string') {
|
|
@@ -467,7 +493,7 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
467
493
|
// for exactly this loop. Saying "already done" ends it in one step.
|
|
468
494
|
if (old_string === new_string) {
|
|
469
495
|
const found = text.indexOf(old_string);
|
|
470
|
-
return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length,
|
|
496
|
+
return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, how: '', count: 0 };
|
|
471
497
|
}
|
|
472
498
|
|
|
473
499
|
// Models write \n. A file checked out on Windows is often \r\n, and then an
|
|
@@ -486,21 +512,45 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
486
512
|
detail: { hits },
|
|
487
513
|
});
|
|
488
514
|
|
|
515
|
+
// replace_all is for renames: every copy changes, and many copies is the point.
|
|
516
|
+
const all = replace_all === true || replace_all === 'true';
|
|
489
517
|
const hits = text.split(oldText).length - 1;
|
|
490
|
-
if (hits > 1) throw ambiguous(hits);
|
|
491
|
-
if (hits
|
|
518
|
+
if (hits > 1 && !all) throw ambiguous(hits);
|
|
519
|
+
if (hits >= 1) {
|
|
492
520
|
const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
|
|
493
|
-
|
|
521
|
+
const out = all ? text.split(oldText).join(newText) : text.replace(oldText, () => newText);
|
|
522
|
+
return { text: out, at, how: '', count: hits };
|
|
494
523
|
}
|
|
495
524
|
|
|
496
525
|
// No exact match. The commonest reason by far is whitespace — tabs against
|
|
497
526
|
// spaces, a different indent depth, trailing spaces — with every word right.
|
|
498
527
|
// Match line by line ignoring that, and re-indent the replacement to fit.
|
|
499
528
|
// Still unique or nothing: a loose match found twice is refused like any other.
|
|
500
|
-
const loose = looseReplace(text, old_string, new_string);
|
|
501
|
-
if (loose?.count === 1)
|
|
529
|
+
const loose = all ? null : looseReplace(text, old_string, new_string);
|
|
530
|
+
if (loose?.count === 1) {
|
|
531
|
+
return { text: loose.text, at: loose.at, how: 'ignoring whitespace and re-indented to fit', count: 1 };
|
|
532
|
+
}
|
|
502
533
|
if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
|
|
503
534
|
|
|
535
|
+
// Still nothing. The remaining slips — a middle line remembered slightly
|
|
536
|
+
// wrong, escapes written out, a blank line at either end — each have a
|
|
537
|
+
// matcher of their own (fuzzy.js). They are given the edit in the file's
|
|
538
|
+
// own line endings, and only the matched span changes: the rest of a file
|
|
539
|
+
// with mixed endings keeps every one it had.
|
|
540
|
+
const fuzzy = fuzzyReplace(text, oldText, newText, { all });
|
|
541
|
+
if (fuzzy?.ambiguous) throw ambiguous('several', ' once matched loosely');
|
|
542
|
+
if (fuzzy?.wide) {
|
|
543
|
+
throw new ToolFailure({
|
|
544
|
+
kind: 'no_match', attempted,
|
|
545
|
+
failed: `${prefix}old_string only matches ${show} loosely, across far more text than it contains. Refusing to replace that much.`,
|
|
546
|
+
fix: `Read ${show} again and copy the exact text you mean to replace.`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (fuzzy) {
|
|
550
|
+
const at = text.slice(0, fuzzy.index).split('\n').length;
|
|
551
|
+
return { text: fuzzy.text, at, how: fuzzy.how, count: fuzzy.count };
|
|
552
|
+
}
|
|
553
|
+
|
|
504
554
|
const { failed, fix } = explainMiss(text, old_string, show);
|
|
505
555
|
throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
|
|
506
556
|
}
|
|
@@ -548,7 +598,7 @@ function looseReplace(text, oldString, newString) {
|
|
|
548
598
|
return { count: 1, text: out.join(eol), at: start + 1 };
|
|
549
599
|
}
|
|
550
600
|
|
|
551
|
-
export async function editFile({ path: p, old_string, new_string }) {
|
|
601
|
+
export async function editFile({ path: p, old_string, new_string, replace_all }) {
|
|
552
602
|
const target = resolveIn(p, 'edit_file');
|
|
553
603
|
const attempted = `editing ${target.show}`;
|
|
554
604
|
await guard(target, `edit ${target.abs}`);
|
|
@@ -560,7 +610,7 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
560
610
|
throw fsFailure(err, attempted, target.show);
|
|
561
611
|
}
|
|
562
612
|
|
|
563
|
-
const { text, at,
|
|
613
|
+
const { text, at, how, count } = replaceOnce(original, { old_string, new_string, replace_all }, {
|
|
564
614
|
show: target.show, attempted,
|
|
565
615
|
});
|
|
566
616
|
|
|
@@ -574,14 +624,18 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
574
624
|
|
|
575
625
|
const delta = toLines(text).length - toLines(original).length;
|
|
576
626
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
577
|
-
const
|
|
627
|
+
const where = count > 1
|
|
628
|
+
? `${count} occurrences in ${target.show}, the first at line ${at}`
|
|
629
|
+
: `one occurrence in ${target.show} at line ${at}`;
|
|
630
|
+
const matched = how ? `, matched ${how}` : '';
|
|
578
631
|
|
|
579
632
|
const span = toLines(new_string).length;
|
|
580
633
|
const out = result(
|
|
581
|
-
`Replaced
|
|
634
|
+
`Replaced ${where} (${change}${matched}).` +
|
|
582
635
|
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
583
636
|
nowReads(target.show, text, at, span),
|
|
584
|
-
|
|
637
|
+
`${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
|
|
638
|
+
`${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
|
|
585
639
|
MAX_FILE_OUTPUT
|
|
586
640
|
);
|
|
587
641
|
// The replacement is diffed on its own and offset to where it landed, so
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fuzzy.js — finding the text an edit meant when it is not in the file as written.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from opencode's edit tool (MIT License, Copyright (c) 2025 opencode;
|
|
5
|
+
* see THIRD_PARTY_NOTICES.md).
|
|
6
|
+
*
|
|
7
|
+
* An old_string that misses costs a whole round trip: the refusal goes back,
|
|
8
|
+
* the model re-reads the file, and sends the edit again. Most misses are the
|
|
9
|
+
* same handful of slips — a line of the middle remembered slightly wrong,
|
|
10
|
+
* whitespace collapsed, the indent shifted, "\n" written out as an escape,
|
|
11
|
+
* a stray blank line at either end. Each matcher below yields the spans of the
|
|
12
|
+
* file that old_string plausibly means; the first span found exactly once is
|
|
13
|
+
* the one replaced. Found in two places is never guessed at.
|
|
14
|
+
*
|
|
15
|
+
* files.js tries an exact match and its own whitespace-tolerant, re-indenting
|
|
16
|
+
* match first; this is only reached when both have missed.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** How alike the middle lines of a block must be for block anchoring to accept it. */
|
|
20
|
+
const SIMILARITY = 0.65;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Past this, the file is not searched loosely at all. The matchers are plain
|
|
24
|
+
* scans and they run on the main thread, where a stale edit against a huge
|
|
25
|
+
* lockfile would freeze the session — Esc included — for minutes.
|
|
26
|
+
* ponytail: a flat cap, not a budget; move the chain to a worker if loose
|
|
27
|
+
* edits on files this big ever matter.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_LINES = 20_000;
|
|
30
|
+
const MAX_CHARS = 1_000_000;
|
|
31
|
+
|
|
32
|
+
function levenshtein(a, b) {
|
|
33
|
+
if (a === '' || b === '') return Math.max(a.length, b.length);
|
|
34
|
+
// Two long minified lines would be a hundred million steps. Lines that long
|
|
35
|
+
// are not what a remembered-slightly-wrong edit is about: call them unlike.
|
|
36
|
+
if (a.length * b.length > 1_000_000) return a === b ? 0 : Math.max(a.length, b.length);
|
|
37
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
38
|
+
for (let i = 1; i <= a.length; i++) {
|
|
39
|
+
const cur = [i];
|
|
40
|
+
for (let j = 1; j <= b.length; j++) {
|
|
41
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
42
|
+
}
|
|
43
|
+
prev = cur;
|
|
44
|
+
}
|
|
45
|
+
return prev[b.length];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The lines of `find`, without the empty string a trailing newline leaves. */
|
|
49
|
+
function wantedLines(find) {
|
|
50
|
+
const lines = find.split('\n');
|
|
51
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lines joined into windows before a loose search gives up and calls it a miss. */
|
|
56
|
+
const WINDOW_BUDGET = 200_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every run of `count` consecutive lines whose first line `could` start a
|
|
60
|
+
* match, joined back into text. The check is cheap and rules out nearly every
|
|
61
|
+
* window before it is built; the budget bounds the rest.
|
|
62
|
+
*/
|
|
63
|
+
function* windows(lines, count, could = () => true) {
|
|
64
|
+
let spent = 0;
|
|
65
|
+
for (let i = 0; i + count <= lines.length; i++) {
|
|
66
|
+
if (!could(lines[i])) continue;
|
|
67
|
+
if ((spent += count) > WINDOW_BUDGET) return;
|
|
68
|
+
yield lines.slice(i, i + count).join('\n');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* First and last lines exact, the middle close enough. Catches an edit whose
|
|
74
|
+
* middle was remembered with a changed word or two — the commonest miss on a
|
|
75
|
+
* block the model read many steps ago.
|
|
76
|
+
*/
|
|
77
|
+
function* blockAnchor(content, find) {
|
|
78
|
+
if (find.split('\n').length < 3) return;
|
|
79
|
+
const want = wantedLines(find);
|
|
80
|
+
const lines = content.split('\n');
|
|
81
|
+
const first = want[0].trim();
|
|
82
|
+
const last = want[want.length - 1].trim();
|
|
83
|
+
const slack = Math.max(1, Math.floor(want.length * 0.25));
|
|
84
|
+
|
|
85
|
+
// Only the first closing line after an opening one counts, and a block is
|
|
86
|
+
// accepted within `slack` of the wanted length — so there is no point
|
|
87
|
+
// looking further than that. Scanning on to the end of the file made a
|
|
88
|
+
// blank first and last line quadratic in the file's length.
|
|
89
|
+
const blocks = [];
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
if (lines[i].trim() !== first) continue;
|
|
92
|
+
const end = Math.min(lines.length, i + want.length + slack);
|
|
93
|
+
for (let j = i + 2; j < end; j++) {
|
|
94
|
+
if (lines[j].trim() !== last) continue;
|
|
95
|
+
if (Math.abs(j - i + 1 - want.length) <= slack) blocks.push([i, j]);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const similarity = ([i, j]) => {
|
|
101
|
+
const inner = Math.min(want.length - 2, j - i - 1);
|
|
102
|
+
if (inner <= 0) return 1;
|
|
103
|
+
let total = 0;
|
|
104
|
+
for (let k = 1; k < want.length - 1 && k < j - i; k++) {
|
|
105
|
+
const a = lines[i + k].trim();
|
|
106
|
+
const b = want[k].trim();
|
|
107
|
+
const longest = Math.max(a.length, b.length);
|
|
108
|
+
if (longest) total += 1 - levenshtein(a, b) / longest;
|
|
109
|
+
}
|
|
110
|
+
return total / inner;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// opencode takes the best-scoring block. Every block close enough is
|
|
114
|
+
// offered instead, so two look-alike handlers read as ambiguous and are
|
|
115
|
+
// refused rather than the first one quietly edited.
|
|
116
|
+
for (const block of blocks) {
|
|
117
|
+
if (similarity(block) >= SIMILARITY) yield lines.slice(block[0], block[1] + 1).join('\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
blockAnchor.how = 'by its first and last lines, the middle nearly the same';
|
|
121
|
+
|
|
122
|
+
/** Every run of whitespace treated as one space. */
|
|
123
|
+
function* whitespaceNormalized(content, find) {
|
|
124
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
125
|
+
const want = flat(find);
|
|
126
|
+
if (!want) return;
|
|
127
|
+
const lines = content.split('\n');
|
|
128
|
+
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const flatLine = flat(line);
|
|
131
|
+
if (flatLine === want) {
|
|
132
|
+
yield line;
|
|
133
|
+
} else if (flatLine.includes(want)) {
|
|
134
|
+
const pattern = find.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+');
|
|
135
|
+
const hit = line.match(new RegExp(pattern));
|
|
136
|
+
if (hit) yield hit[0];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const count = find.split('\n').length;
|
|
141
|
+
if (count > 1) {
|
|
142
|
+
for (const block of windows(lines, count, (l) => want.startsWith(flat(l)))) if (flat(block) === want) yield block;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
whitespaceNormalized.how = 'with runs of whitespace collapsed';
|
|
146
|
+
|
|
147
|
+
/** The same block at a different indent depth. */
|
|
148
|
+
function* indentationFlexible(content, find) {
|
|
149
|
+
const dedent = (s) => {
|
|
150
|
+
const lines = s.split('\n');
|
|
151
|
+
const filled = lines.filter((l) => l.trim());
|
|
152
|
+
if (!filled.length) return s;
|
|
153
|
+
const least = Math.min(...filled.map((l) => /^\s*/.exec(l)[0].length));
|
|
154
|
+
return lines.map((l) => (l.trim() ? l.slice(least) : l)).join('\n');
|
|
155
|
+
};
|
|
156
|
+
const want = dedent(find);
|
|
157
|
+
// Dedenting keeps every line's text, so the first lines must match trimmed.
|
|
158
|
+
const head = find.split('\n')[0].trim();
|
|
159
|
+
for (const block of windows(content.split('\n'), find.split('\n').length, (l) => l.trim() === head)) {
|
|
160
|
+
if (dedent(block) === want) yield block;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
indentationFlexible.how = 'ignoring indentation';
|
|
164
|
+
|
|
165
|
+
const ESCAPES = { n: '\n', t: '\t', r: '\r', "'": "'", '"': '"', '`': '`', '\\': '\\', '\n': '\n', $: '$' };
|
|
166
|
+
const unescape = (s) => s.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_, c) => ESCAPES[c]);
|
|
167
|
+
|
|
168
|
+
/** "\n" and "\"" written out as escapes, where the file has the real characters. */
|
|
169
|
+
function* escapeNormalized(content, find) {
|
|
170
|
+
const want = unescape(find);
|
|
171
|
+
if (content.includes(want)) yield want;
|
|
172
|
+
// A trailing backslash may be escaping the line break, so it is left off.
|
|
173
|
+
const could = (l) => want.startsWith(unescape(l).replace(/\\$/, ''));
|
|
174
|
+
for (const block of windows(content.split('\n'), want.split('\n').length, could)) {
|
|
175
|
+
if (unescape(block) === want) yield block;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
escapeNormalized.how = 'after undoing escaped characters';
|
|
179
|
+
// A model that wrote old_string with "\n" for its line breaks wrote
|
|
180
|
+
// new_string the same way; spliced in as is, the file would get a literal
|
|
181
|
+
// backslash-n where each line break should be. A new_string with real line
|
|
182
|
+
// breaks is already in the file's terms and is left alone.
|
|
183
|
+
escapeNormalized.adapt = (replacement, find) =>
|
|
184
|
+
unescape(find) !== find && !replacement.includes('\n') ? unescape(replacement) : replacement;
|
|
185
|
+
|
|
186
|
+
/** Blank lines or spaces at either end that the file does not have there. */
|
|
187
|
+
function* trimmedBoundary(content, find) {
|
|
188
|
+
const want = find.trim();
|
|
189
|
+
if (want === find || !want) return;
|
|
190
|
+
if (content.includes(want)) yield want;
|
|
191
|
+
for (const block of windows(content.split('\n'), find.split('\n').length, (l) => want.startsWith(l.trimStart()))) {
|
|
192
|
+
if (block.trim() === want) yield block;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
trimmedBoundary.how = 'ignoring leading and trailing whitespace';
|
|
196
|
+
|
|
197
|
+
/** First and last lines exact, the same length, and at least half the middle identical. */
|
|
198
|
+
function* contextAware(content, find) {
|
|
199
|
+
if (find.split('\n').length < 3) return;
|
|
200
|
+
const want = wantedLines(find);
|
|
201
|
+
const lines = content.split('\n');
|
|
202
|
+
const first = want[0].trim();
|
|
203
|
+
const last = want[want.length - 1].trim();
|
|
204
|
+
|
|
205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
206
|
+
if (lines[i].trim() !== first) continue;
|
|
207
|
+
// Only a block exactly as long as the one wanted is accepted.
|
|
208
|
+
const end = Math.min(lines.length, i + want.length);
|
|
209
|
+
for (let j = i + 2; j < end; j++) {
|
|
210
|
+
if (lines[j].trim() !== last) continue;
|
|
211
|
+
const block = lines.slice(i, j + 1);
|
|
212
|
+
if (block.length === want.length) {
|
|
213
|
+
let same = 0;
|
|
214
|
+
let counted = 0;
|
|
215
|
+
for (let k = 1; k < block.length - 1; k++) {
|
|
216
|
+
const a = block[k].trim();
|
|
217
|
+
const b = want[k].trim();
|
|
218
|
+
if (a || b) {
|
|
219
|
+
counted++;
|
|
220
|
+
if (a === b) same++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (counted === 0 || same / counted >= 0.5) yield block.join('\n');
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
contextAware.how = 'by its first and last lines';
|
|
230
|
+
|
|
231
|
+
const MATCHERS = [blockAnchor, whitespaceNormalized, indentationFlexible, escapeNormalized, trimmedBoundary, contextAware];
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* A match far bigger than what was asked for is a matcher reaching, not a
|
|
235
|
+
* find. Replacing it would delete code the model never meant to touch.
|
|
236
|
+
*/
|
|
237
|
+
function tooWide(match, find) {
|
|
238
|
+
const lines = find.split('\n').length;
|
|
239
|
+
if (match.split('\n').length >= Math.max(lines + 3, lines * 2)) return true;
|
|
240
|
+
if (lines === 1) return false;
|
|
241
|
+
return match.trim().length > Math.max(find.trim().length + 500, find.trim().length * 4);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** How many separate places a set of [start, end) spans covers, overlaps counted once. */
|
|
245
|
+
function places(spans) {
|
|
246
|
+
let count = 0;
|
|
247
|
+
let reach = -1;
|
|
248
|
+
for (const [start, end] of spans.sort((a, b) => a[0] - b[0])) {
|
|
249
|
+
if (start >= reach) count++;
|
|
250
|
+
reach = Math.max(reach, end);
|
|
251
|
+
}
|
|
252
|
+
return count;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Replace what `find` loosely means in `content`.
|
|
257
|
+
*
|
|
258
|
+
* `find` and `replacement` should already use the file's line endings; the
|
|
259
|
+
* file itself is never re-ended, so a mixed file keeps every line it had.
|
|
260
|
+
* Returns { text, index, count, how } on success, { ambiguous: true } when the
|
|
261
|
+
* first matcher to find anything finds it in more than one place,
|
|
262
|
+
* { wide: true } when the match is far bigger than `find`, and null when
|
|
263
|
+
* nothing matched at all. With `all`, every copy of the matched span changes.
|
|
264
|
+
*
|
|
265
|
+
* Unlike opencode, a matcher that finds two places is the end of it: a looser
|
|
266
|
+
* matcher further down picking one of them would be a guess.
|
|
267
|
+
*/
|
|
268
|
+
export function fuzzyReplace(content, find, replacement, { all = false } = {}) {
|
|
269
|
+
if (!find || content.length > MAX_CHARS || find.length > MAX_CHARS) return null;
|
|
270
|
+
if (content.split('\n', MAX_LINES + 1).length > MAX_LINES) return null;
|
|
271
|
+
for (const matcher of MATCHERS) {
|
|
272
|
+
// Spans built from whole lines of a \r\n file end on the last line's \r.
|
|
273
|
+
// That \r belongs to the line break after the match, which stays.
|
|
274
|
+
const own = (m) => (m.endsWith('\r') && !find.endsWith('\r') ? m.slice(0, -1) : m);
|
|
275
|
+
const hits = [...new Set([...matcher(content, find)].map(own))].filter((m) => m && content.includes(m));
|
|
276
|
+
if (!hits.length) continue;
|
|
277
|
+
|
|
278
|
+
const pick = hits[0];
|
|
279
|
+
if (tooWide(pick, find)) return { wide: true };
|
|
280
|
+
const text = matcher.adapt ? matcher.adapt(replacement, find) : replacement;
|
|
281
|
+
const index = content.indexOf(pick);
|
|
282
|
+
if (all) {
|
|
283
|
+
const copies = content.split(pick);
|
|
284
|
+
return { text: copies.join(text), index, count: copies.length - 1, how: matcher.how };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const spans = [];
|
|
288
|
+
for (const m of hits) {
|
|
289
|
+
for (let i = content.indexOf(m); i !== -1; i = content.indexOf(m, i + 1)) spans.push([i, i + m.length]);
|
|
290
|
+
}
|
|
291
|
+
if (places(spans) > 1) return { ambiguous: true };
|
|
292
|
+
return { text: content.slice(0, index) + text + content.slice(index + pick.length), index, count: 1, how: matcher.how };
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* line the user reads while each one runs.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { jsonrepair } from 'jsonrepair';
|
|
7
6
|
import { ToolFailure } from '../core/failure.js';
|
|
8
7
|
import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFiles } from './files.js';
|
|
9
8
|
import { listDir, glob, grep } from './search.js';
|
|
@@ -231,15 +230,16 @@ export const tools = [
|
|
|
231
230
|
description:
|
|
232
231
|
'Replace one exact piece of text in a file. old_string must match the file ' +
|
|
233
232
|
'character for character, including indentation, and must occur exactly once — ' +
|
|
234
|
-
'the edit is refused on zero matches and on two.
|
|
235
|
-
'
|
|
236
|
-
'read it again afterwards.',
|
|
233
|
+
'the edit is refused on zero matches and on two. Set replace_all to change every ' +
|
|
234
|
+
'copy, for a rename. This is the normal way to change existing code. The result ' +
|
|
235
|
+
'shows the file as it now stands, so do not read it again afterwards.',
|
|
237
236
|
parameters: {
|
|
238
237
|
type: 'object',
|
|
239
238
|
properties: {
|
|
240
239
|
path: str('File path, relative to the project root.'),
|
|
241
|
-
old_string: str('The exact text to replace. Must be unique in the file.'),
|
|
240
|
+
old_string: str('The exact text to replace. Must be unique in the file unless replace_all is set.'),
|
|
242
241
|
new_string: str('What to put there instead.'),
|
|
242
|
+
replace_all: bool('Replace every occurrence instead of exactly one. Default false.'),
|
|
243
243
|
},
|
|
244
244
|
required: ['path', 'old_string', 'new_string'],
|
|
245
245
|
},
|
|
@@ -574,13 +574,6 @@ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'm
|
|
|
574
574
|
*/
|
|
575
575
|
const LENIENT = new Set(['create_app.files']);
|
|
576
576
|
|
|
577
|
-
/** Names models use for an argument that the schema calls something else. */
|
|
578
|
-
const ALIASES = {
|
|
579
|
-
path: ['file', 'filename', 'file_path', 'filepath'],
|
|
580
|
-
content: ['contents', 'text', 'code', 'body'],
|
|
581
|
-
paths: ['files'],
|
|
582
|
-
};
|
|
583
|
-
|
|
584
577
|
function check(name, args) {
|
|
585
578
|
const schema = tools.find((t) => t.name === name).parameters;
|
|
586
579
|
const problems = [];
|
|
@@ -589,30 +582,6 @@ function check(name, args) {
|
|
|
589
582
|
return ['the arguments must be a JSON object'];
|
|
590
583
|
}
|
|
591
584
|
|
|
592
|
-
// One file named as "path" where the tool takes a list called "paths".
|
|
593
|
-
// DeepSeek did it to four read_files at once, a whole round trip spent being
|
|
594
|
-
// told a plural it could simply have been given.
|
|
595
|
-
// The same argument under a neighbouring name: write_file sent "file"
|
|
596
|
-
// instead of "path" cost DeepSeek two and a half minutes to be told so.
|
|
597
|
-
for (const [key, others] of Object.entries(ALIASES)) {
|
|
598
|
-
if (!schema.properties[key] || args[key] != null) continue;
|
|
599
|
-
const other = others.find((o) => args[o] != null && !schema.properties[o]);
|
|
600
|
-
if (other) { args[key] = args[other]; delete args[other]; }
|
|
601
|
-
}
|
|
602
|
-
// read_files sent as files: [{ path }], the shape batch_write takes.
|
|
603
|
-
if (Array.isArray(args.paths)) {
|
|
604
|
-
args.paths = args.paths.map((p) => (typeof p?.path === 'string' ? p.path : p));
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
for (const key of schema.required ?? []) {
|
|
608
|
-
const one = key.endsWith('s') ? key.slice(0, -1) : '';
|
|
609
|
-
if (args[key] == null && one && !schema.properties[one] && args[one] != null
|
|
610
|
-
&& schema.properties[key]?.type === 'array') {
|
|
611
|
-
args[key] = Array.isArray(args[one]) ? args[one] : [args[one]];
|
|
612
|
-
delete args[one];
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
585
|
for (const key of schema.required ?? []) {
|
|
617
586
|
if (args[key] === undefined || args[key] === null) problems.push(`"${key}" is required and missing`);
|
|
618
587
|
}
|
|
@@ -639,15 +608,7 @@ function check(name, args) {
|
|
|
639
608
|
const parsed = JSON.parse(value);
|
|
640
609
|
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
641
610
|
if (kind === wanted || LENIENT.has(`${name}.${key}`)) { args[key] = parsed; actual = kind; }
|
|
642
|
-
} catch {
|
|
643
|
-
// Nearly JSON: DeepSeek sent a whole app's files this way and was
|
|
644
|
-
// told only that a string is not an array. Repair it before refusing.
|
|
645
|
-
try {
|
|
646
|
-
const parsed = JSON.parse(jsonrepair(value));
|
|
647
|
-
const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
|
|
648
|
-
if (kind === wanted) { args[key] = parsed; actual = kind; }
|
|
649
|
-
} catch { /* not JSON either — the message below is the right answer */ }
|
|
650
|
-
}
|
|
611
|
+
} catch { /* not JSON either — the message below is the right answer */ }
|
|
651
612
|
}
|
|
652
613
|
|
|
653
614
|
// A list of files written as a { path: contents } map. The tool reads it
|