mini-coder 0.5.1 → 0.5.3
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/bun.lock +39 -40
- package/package.json +10 -11
- package/src/agent.ts +38 -15
- package/src/errors.ts +15 -0
- package/src/git.ts +95 -19
- package/src/index.ts +125 -6
- package/src/prompt.ts +7 -4
- package/src/settings.ts +66 -4
- package/src/submit.ts +4 -6
- package/src/tools.ts +671 -6
- package/src/ui/agent.ts +1 -4
- package/src/ui/commands.test.ts +3 -0
- package/src/ui/commands.ts +1 -4
- package/src/ui/conversation.test.ts +214 -15
- package/src/ui/conversation.ts +269 -37
- package/src/ui/input.test.ts +5 -1
- package/src/ui/input.ts +7 -1
- package/src/ui/status.test.ts +3 -0
- package/src/ui.ts +6 -0
- package/src/version.ts +48 -0
package/src/tools.ts
CHANGED
|
@@ -57,6 +57,320 @@ function normalizeLineEndings(
|
|
|
57
57
|
return content.replace(/\r\n/g, "\n");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const MAX_EDIT_ERROR_MATCHES = 3;
|
|
61
|
+
const MAX_EDIT_ERROR_SNIPPET_LINES = 8;
|
|
62
|
+
const MAX_EDIT_ERROR_SNIPPET_LINE_CHARS = 160;
|
|
63
|
+
const MIN_EDIT_SIMILARITY_SCORE = 0.45;
|
|
64
|
+
|
|
65
|
+
interface EditSnippet {
|
|
66
|
+
startLine: number;
|
|
67
|
+
endLine: number;
|
|
68
|
+
lines: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function splitDisplayLines(content: string): string[] {
|
|
72
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
73
|
+
if (lines.at(-1) === "") {
|
|
74
|
+
lines.pop();
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function countDisplayLines(content: string): number {
|
|
80
|
+
return Math.max(splitDisplayLines(content).length, 1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatLineRange(startLine: number, endLine: number): string {
|
|
84
|
+
return startLine === endLine
|
|
85
|
+
? `line ${startLine}`
|
|
86
|
+
: `lines ${startLine}-${endLine}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function truncateSnippetLine(line: string): string {
|
|
90
|
+
if (line.length <= MAX_EDIT_ERROR_SNIPPET_LINE_CHARS) {
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
return `${line.slice(0, MAX_EDIT_ERROR_SNIPPET_LINE_CHARS - 1)}…`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatSnippetLines(lines: readonly string[]): string {
|
|
97
|
+
const visibleLines = lines.slice(0, MAX_EDIT_ERROR_SNIPPET_LINES);
|
|
98
|
+
const formatted = visibleLines
|
|
99
|
+
.map((line) => ` ${truncateSnippetLine(line)}`)
|
|
100
|
+
.join("\n");
|
|
101
|
+
const hiddenLineCount = lines.length - visibleLines.length;
|
|
102
|
+
if (hiddenLineCount <= 0) {
|
|
103
|
+
return formatted;
|
|
104
|
+
}
|
|
105
|
+
return `${formatted}\n … ${hiddenLineCount} more lines`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function commonPrefixLength(a: string, b: string): number {
|
|
109
|
+
let index = 0;
|
|
110
|
+
const maxLength = Math.min(a.length, b.length);
|
|
111
|
+
while (index < maxLength && a[index] === b[index]) {
|
|
112
|
+
index++;
|
|
113
|
+
}
|
|
114
|
+
return index;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function commonSuffixLength(
|
|
118
|
+
a: string,
|
|
119
|
+
b: string,
|
|
120
|
+
prefixLength: number,
|
|
121
|
+
): number {
|
|
122
|
+
let index = 0;
|
|
123
|
+
const maxLength = Math.min(a.length, b.length) - prefixLength;
|
|
124
|
+
while (
|
|
125
|
+
index < maxLength &&
|
|
126
|
+
a[a.length - 1 - index] === b[b.length - 1 - index]
|
|
127
|
+
) {
|
|
128
|
+
index++;
|
|
129
|
+
}
|
|
130
|
+
return index;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function scoreSimilarLine(oldLine: string, candidateLine: string): number {
|
|
134
|
+
if (oldLine === candidateLine) {
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const normalizedOldLine = oldLine.trim();
|
|
139
|
+
const normalizedCandidateLine = candidateLine.trim();
|
|
140
|
+
if (normalizedOldLine === normalizedCandidateLine) {
|
|
141
|
+
return normalizedOldLine === "" ? 1 : 0.98;
|
|
142
|
+
}
|
|
143
|
+
if (normalizedOldLine === "" || normalizedCandidateLine === "") {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const prefixLength = commonPrefixLength(
|
|
148
|
+
normalizedOldLine,
|
|
149
|
+
normalizedCandidateLine,
|
|
150
|
+
);
|
|
151
|
+
const suffixLength = commonSuffixLength(
|
|
152
|
+
normalizedOldLine,
|
|
153
|
+
normalizedCandidateLine,
|
|
154
|
+
prefixLength,
|
|
155
|
+
);
|
|
156
|
+
const overlapLength = Math.min(
|
|
157
|
+
normalizedOldLine.length,
|
|
158
|
+
prefixLength + suffixLength,
|
|
159
|
+
);
|
|
160
|
+
const maxLength = Math.max(
|
|
161
|
+
normalizedOldLine.length,
|
|
162
|
+
normalizedCandidateLine.length,
|
|
163
|
+
);
|
|
164
|
+
const structuralScore = overlapLength / maxLength;
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
normalizedOldLine.includes(normalizedCandidateLine) ||
|
|
168
|
+
normalizedCandidateLine.includes(normalizedOldLine)
|
|
169
|
+
) {
|
|
170
|
+
const sharedLength = Math.min(
|
|
171
|
+
normalizedOldLine.length,
|
|
172
|
+
normalizedCandidateLine.length,
|
|
173
|
+
);
|
|
174
|
+
return Math.max(structuralScore, sharedLength / maxLength);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return structuralScore;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scoreLineWindow(
|
|
181
|
+
oldLines: readonly string[],
|
|
182
|
+
candidateLines: readonly string[],
|
|
183
|
+
): number {
|
|
184
|
+
const maxLineCount = Math.max(oldLines.length, candidateLines.length);
|
|
185
|
+
let weightedScore = 0;
|
|
186
|
+
let totalWeight = 0;
|
|
187
|
+
|
|
188
|
+
for (let index = 0; index < maxLineCount; index++) {
|
|
189
|
+
const oldLine = oldLines[index] ?? "";
|
|
190
|
+
const candidateLine = candidateLines[index] ?? "";
|
|
191
|
+
const weight = Math.max(
|
|
192
|
+
oldLine.trim().length,
|
|
193
|
+
candidateLine.trim().length,
|
|
194
|
+
1,
|
|
195
|
+
);
|
|
196
|
+
weightedScore += scoreSimilarLine(oldLine, candidateLine) * weight;
|
|
197
|
+
totalWeight += weight;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return totalWeight === 0 ? 0 : weightedScore / totalWeight;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findClosestEditSnippets(
|
|
204
|
+
oldText: string,
|
|
205
|
+
content: string,
|
|
206
|
+
): EditSnippet[] {
|
|
207
|
+
const oldLines = splitDisplayLines(oldText);
|
|
208
|
+
const fileLines = splitDisplayLines(content);
|
|
209
|
+
if (fileLines.length === 0) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const windowSizes = Array.from(
|
|
214
|
+
new Set([
|
|
215
|
+
Math.max(1, oldLines.length - 1),
|
|
216
|
+
Math.max(1, oldLines.length),
|
|
217
|
+
Math.min(fileLines.length, oldLines.length + 1),
|
|
218
|
+
]),
|
|
219
|
+
);
|
|
220
|
+
const candidates: (EditSnippet & { score: number })[] = [];
|
|
221
|
+
|
|
222
|
+
for (const windowSize of windowSizes) {
|
|
223
|
+
if (windowSize > fileLines.length) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (
|
|
228
|
+
let startLineIndex = 0;
|
|
229
|
+
startLineIndex <= fileLines.length - windowSize;
|
|
230
|
+
startLineIndex++
|
|
231
|
+
) {
|
|
232
|
+
const lines = fileLines.slice(
|
|
233
|
+
startLineIndex,
|
|
234
|
+
startLineIndex + windowSize,
|
|
235
|
+
);
|
|
236
|
+
candidates.push({
|
|
237
|
+
startLine: startLineIndex + 1,
|
|
238
|
+
endLine: startLineIndex + windowSize,
|
|
239
|
+
lines,
|
|
240
|
+
score: scoreLineWindow(oldLines, lines),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
candidates.sort((a, b) => {
|
|
246
|
+
const scoreDelta = b.score - a.score;
|
|
247
|
+
if (scoreDelta !== 0) {
|
|
248
|
+
return scoreDelta;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lineSpanDelta =
|
|
252
|
+
Math.abs(a.lines.length - oldLines.length) -
|
|
253
|
+
Math.abs(b.lines.length - oldLines.length);
|
|
254
|
+
if (lineSpanDelta !== 0) {
|
|
255
|
+
return lineSpanDelta;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return a.startLine - b.startLine;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const snippets: EditSnippet[] = [];
|
|
262
|
+
const seen = new Set<string>();
|
|
263
|
+
for (const candidate of candidates) {
|
|
264
|
+
if (candidate.score < MIN_EDIT_SIMILARITY_SCORE) {
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const key = `${candidate.startLine}:${candidate.endLine}`;
|
|
269
|
+
if (seen.has(key)) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
snippets.push({
|
|
274
|
+
startLine: candidate.startLine,
|
|
275
|
+
endLine: candidate.endLine,
|
|
276
|
+
lines: candidate.lines,
|
|
277
|
+
});
|
|
278
|
+
seen.add(key);
|
|
279
|
+
|
|
280
|
+
if (snippets.length === MAX_EDIT_ERROR_MATCHES) {
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return snippets;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function buildLineStarts(content: string): number[] {
|
|
289
|
+
const lineStarts = [0];
|
|
290
|
+
for (let index = 0; index < content.length; index++) {
|
|
291
|
+
if (content[index] === "\n") {
|
|
292
|
+
lineStarts.push(index + 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return lineStarts;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function findLineNumber(lineStarts: readonly number[], index: number): number {
|
|
299
|
+
let low = 0;
|
|
300
|
+
let high = lineStarts.length - 1;
|
|
301
|
+
|
|
302
|
+
while (low <= high) {
|
|
303
|
+
const mid = Math.floor((low + high) / 2);
|
|
304
|
+
const lineStart = lineStarts[mid];
|
|
305
|
+
if (lineStart === undefined) {
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (lineStart <= index) {
|
|
309
|
+
low = mid + 1;
|
|
310
|
+
} else {
|
|
311
|
+
high = mid - 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return high + 1;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function formatEditNotFoundError(
|
|
319
|
+
path: string,
|
|
320
|
+
oldText: string,
|
|
321
|
+
content: string,
|
|
322
|
+
): string {
|
|
323
|
+
const snippets = findClosestEditSnippets(oldText, content);
|
|
324
|
+
if (snippets.length === 0) {
|
|
325
|
+
return `Old text not found in ${path}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
`Old text not found in ${path}`,
|
|
330
|
+
"Closest matches:",
|
|
331
|
+
...snippets.map(
|
|
332
|
+
(snippet) =>
|
|
333
|
+
`- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
|
|
334
|
+
),
|
|
335
|
+
].join("\n");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function formatEditMultipleMatchesError(
|
|
339
|
+
path: string,
|
|
340
|
+
oldText: string,
|
|
341
|
+
content: string,
|
|
342
|
+
matchIndices: readonly number[],
|
|
343
|
+
totalMatches: number,
|
|
344
|
+
): string {
|
|
345
|
+
const lineStarts = buildLineStarts(content);
|
|
346
|
+
const fileLines = splitDisplayLines(content);
|
|
347
|
+
const matchLineCount = countDisplayLines(oldText);
|
|
348
|
+
const snippets = matchIndices.map((matchIndex) => {
|
|
349
|
+
const startLine = findLineNumber(lineStarts, matchIndex);
|
|
350
|
+
const endLine = startLine + matchLineCount - 1;
|
|
351
|
+
return {
|
|
352
|
+
startLine,
|
|
353
|
+
endLine,
|
|
354
|
+
lines: fileLines.slice(startLine - 1, endLine),
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const lines = [
|
|
359
|
+
`Old text matches multiple locations (${totalMatches}) in ${path}`,
|
|
360
|
+
"Matches:",
|
|
361
|
+
...snippets.map(
|
|
362
|
+
(snippet) =>
|
|
363
|
+
`- ${formatLineRange(snippet.startLine, snippet.endLine)}\n${formatSnippetLines(snippet.lines)}`,
|
|
364
|
+
),
|
|
365
|
+
];
|
|
366
|
+
const hiddenMatchCount = totalMatches - matchIndices.length;
|
|
367
|
+
if (hiddenMatchCount > 0) {
|
|
368
|
+
lines.push(`- … ${hiddenMatchCount} more matches`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return lines.join("\n");
|
|
372
|
+
}
|
|
373
|
+
|
|
60
374
|
// ---------------------------------------------------------------------------
|
|
61
375
|
// edit
|
|
62
376
|
// ---------------------------------------------------------------------------
|
|
@@ -106,20 +420,33 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
|
106
420
|
|
|
107
421
|
// Count occurrences
|
|
108
422
|
let count = 0;
|
|
423
|
+
const matchIndices: number[] = [];
|
|
109
424
|
let idx = 0;
|
|
110
425
|
while (true) {
|
|
111
426
|
idx = content.indexOf(args.oldText, idx);
|
|
112
427
|
if (idx === -1) break;
|
|
113
428
|
count++;
|
|
429
|
+
if (matchIndices.length < MAX_EDIT_ERROR_MATCHES) {
|
|
430
|
+
matchIndices.push(idx);
|
|
431
|
+
}
|
|
114
432
|
idx += args.oldText.length;
|
|
115
433
|
}
|
|
116
434
|
|
|
117
435
|
if (count === 0) {
|
|
118
|
-
return textResult(
|
|
436
|
+
return textResult(
|
|
437
|
+
formatEditNotFoundError(args.path, args.oldText, content),
|
|
438
|
+
true,
|
|
439
|
+
);
|
|
119
440
|
}
|
|
120
441
|
if (count > 1) {
|
|
121
442
|
return textResult(
|
|
122
|
-
|
|
443
|
+
formatEditMultipleMatchesError(
|
|
444
|
+
args.path,
|
|
445
|
+
args.oldText,
|
|
446
|
+
content,
|
|
447
|
+
matchIndices,
|
|
448
|
+
count,
|
|
449
|
+
),
|
|
123
450
|
true,
|
|
124
451
|
);
|
|
125
452
|
}
|
|
@@ -129,7 +456,14 @@ export function executeEdit(args: EditArgs, cwd: string): ToolExecResult {
|
|
|
129
456
|
const newText = lineEnding
|
|
130
457
|
? normalizeLineEndings(args.newText, lineEnding)
|
|
131
458
|
: args.newText;
|
|
132
|
-
const
|
|
459
|
+
const matchIndex = matchIndices[0];
|
|
460
|
+
if (matchIndex === undefined) {
|
|
461
|
+
return textResult(`Old text not found in ${args.path}`, true);
|
|
462
|
+
}
|
|
463
|
+
const updated =
|
|
464
|
+
content.slice(0, matchIndex) +
|
|
465
|
+
newText +
|
|
466
|
+
content.slice(matchIndex + args.oldText.length);
|
|
133
467
|
writeFileSync(filePath, updated, "utf-8");
|
|
134
468
|
return textResult(`Edited ${args.path}`, false);
|
|
135
469
|
}
|
|
@@ -174,6 +508,335 @@ function formatShellOutput(stdout: string, stderr: string): string {
|
|
|
174
508
|
return "";
|
|
175
509
|
}
|
|
176
510
|
|
|
511
|
+
interface ShellCommandLines {
|
|
512
|
+
lines: string[];
|
|
513
|
+
lineEnding: "\n" | "\r\n";
|
|
514
|
+
hasTrailingLineEnding: boolean;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
interface PendingHeredoc {
|
|
518
|
+
startLineIndex: number;
|
|
519
|
+
delimiter: string;
|
|
520
|
+
stripLeadingTabs: boolean;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface ShellQuoteState {
|
|
524
|
+
quote: "'" | '"' | null;
|
|
525
|
+
escaped: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function splitShellCommandLines(command: string): ShellCommandLines {
|
|
529
|
+
const lineEnding = detectLineEnding(command) ?? "\n";
|
|
530
|
+
const normalized = normalizeLineEndings(command, "\n");
|
|
531
|
+
const hasTrailingLineEnding = normalized.endsWith("\n");
|
|
532
|
+
const lines = normalized.split("\n");
|
|
533
|
+
if (hasTrailingLineEnding) {
|
|
534
|
+
lines.pop();
|
|
535
|
+
}
|
|
536
|
+
return { lines, lineEnding, hasTrailingLineEnding };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function joinShellCommandLines(parts: ShellCommandLines): string {
|
|
540
|
+
const joined = parts.lines.join(parts.lineEnding);
|
|
541
|
+
if (parts.hasTrailingLineEnding) {
|
|
542
|
+
return joined + parts.lineEnding;
|
|
543
|
+
}
|
|
544
|
+
return joined;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function advanceShellQuoteState(char: string, state: ShellQuoteState): boolean {
|
|
548
|
+
if (state.quote === "'") {
|
|
549
|
+
if (char === "'") {
|
|
550
|
+
state.quote = null;
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (state.quote === '"') {
|
|
556
|
+
if (state.escaped) {
|
|
557
|
+
state.escaped = false;
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
if (char === "\\") {
|
|
561
|
+
state.escaped = true;
|
|
562
|
+
return true;
|
|
563
|
+
}
|
|
564
|
+
if (char === '"') {
|
|
565
|
+
state.quote = null;
|
|
566
|
+
}
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (char === "'") {
|
|
571
|
+
state.quote = "'";
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
if (char === '"') {
|
|
575
|
+
state.quote = '"';
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function isHeredocPrefixCharacter(char: string): boolean {
|
|
583
|
+
return (
|
|
584
|
+
char === "" ||
|
|
585
|
+
char === " " ||
|
|
586
|
+
char === "\t" ||
|
|
587
|
+
char === ";" ||
|
|
588
|
+
char === "(" ||
|
|
589
|
+
char === "&" ||
|
|
590
|
+
char === "|"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function getHeredocStartAt(
|
|
595
|
+
line: string,
|
|
596
|
+
index: number,
|
|
597
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
598
|
+
if (line[index] !== "<" || line[index + 1] !== "<") {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const previousChar = index === 0 ? "" : (line[index - 1] ?? "");
|
|
603
|
+
if (!isHeredocPrefixCharacter(previousChar)) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
index,
|
|
609
|
+
stripLeadingTabs: line[index + 2] === "-",
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function findUnquotedHeredocStart(
|
|
614
|
+
line: string,
|
|
615
|
+
): { index: number; stripLeadingTabs: boolean } | null {
|
|
616
|
+
const quoteState: ShellQuoteState = { quote: null, escaped: false };
|
|
617
|
+
let heredocStart: { index: number; stripLeadingTabs: boolean } | null = null;
|
|
618
|
+
|
|
619
|
+
for (let index = 0; index < line.length - 1; index++) {
|
|
620
|
+
const char = line[index];
|
|
621
|
+
if (char === undefined || advanceShellQuoteState(char, quoteState)) {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const nextHeredocStart = getHeredocStartAt(line, index);
|
|
626
|
+
if (!nextHeredocStart) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (heredocStart) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
heredocStart = nextHeredocStart;
|
|
634
|
+
index += heredocStart.stripLeadingTabs ? 2 : 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return heredocStart;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function skipHeredocDelimiterWhitespace(line: string, cursor: number): number {
|
|
641
|
+
let nextCursor = cursor;
|
|
642
|
+
while (line[nextCursor] === " " || line[nextCursor] === "\t") {
|
|
643
|
+
nextCursor++;
|
|
644
|
+
}
|
|
645
|
+
return nextCursor;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function readQuotedHeredocDelimiter(
|
|
649
|
+
line: string,
|
|
650
|
+
cursor: number,
|
|
651
|
+
): string | null {
|
|
652
|
+
const quote = line[cursor];
|
|
653
|
+
if (quote !== "'" && quote !== '"') {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const endQuoteIndex = line.indexOf(quote, cursor + 1);
|
|
658
|
+
if (endQuoteIndex === -1) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
return line.slice(cursor + 1, endQuoteIndex);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function isHeredocDelimiterStopCharacter(char: string): boolean {
|
|
665
|
+
return (
|
|
666
|
+
char === " " ||
|
|
667
|
+
char === "\t" ||
|
|
668
|
+
char === "<" ||
|
|
669
|
+
char === ">" ||
|
|
670
|
+
char === "&" ||
|
|
671
|
+
char === "|" ||
|
|
672
|
+
char === ";" ||
|
|
673
|
+
char === "(" ||
|
|
674
|
+
char === ")"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function readBareHeredocDelimiter(line: string, cursor: number): string | null {
|
|
679
|
+
const startChar = line[cursor];
|
|
680
|
+
if (startChar === undefined || !/[A-Za-z_]/.test(startChar)) {
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let endIndex = cursor;
|
|
685
|
+
while (endIndex < line.length) {
|
|
686
|
+
const currentChar = line[endIndex];
|
|
687
|
+
if (
|
|
688
|
+
currentChar === undefined ||
|
|
689
|
+
isHeredocDelimiterStopCharacter(currentChar)
|
|
690
|
+
) {
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
endIndex++;
|
|
694
|
+
}
|
|
695
|
+
return line.slice(cursor, endIndex);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function findUnquotedHeredoc(
|
|
699
|
+
line: string,
|
|
700
|
+
startLineIndex: number,
|
|
701
|
+
): PendingHeredoc | null {
|
|
702
|
+
const heredocStart = findUnquotedHeredocStart(line);
|
|
703
|
+
if (!heredocStart) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const cursor = skipHeredocDelimiterWhitespace(
|
|
708
|
+
line,
|
|
709
|
+
heredocStart.index + 2 + (heredocStart.stripLeadingTabs ? 1 : 0),
|
|
710
|
+
);
|
|
711
|
+
const delimiter =
|
|
712
|
+
readQuotedHeredocDelimiter(line, cursor) ??
|
|
713
|
+
readBareHeredocDelimiter(line, cursor);
|
|
714
|
+
if (!delimiter) {
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return {
|
|
719
|
+
startLineIndex,
|
|
720
|
+
delimiter,
|
|
721
|
+
stripLeadingTabs: heredocStart.stripLeadingTabs,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function getHeredocLineBody(line: string, stripLeadingTabs: boolean): string {
|
|
726
|
+
if (!stripLeadingTabs) {
|
|
727
|
+
return line;
|
|
728
|
+
}
|
|
729
|
+
return line.replace(/^\t+/, "");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function getSupportedHeredocTrailer(rest: string): string | null {
|
|
733
|
+
const trimmedRest = rest.trimStart();
|
|
734
|
+
if (!trimmedRest) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
if (trimmedRest.startsWith("&&")) {
|
|
738
|
+
return trimmedRest.slice(2).trim() ? rest : null;
|
|
739
|
+
}
|
|
740
|
+
if (trimmedRest.startsWith("||")) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
if (trimmedRest.startsWith("|")) {
|
|
744
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
745
|
+
}
|
|
746
|
+
if (trimmedRest.startsWith(">")) {
|
|
747
|
+
return trimmedRest.slice(1).trim() ? rest : null;
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function rewritePendingHeredocTrailer(
|
|
753
|
+
parts: ShellCommandLines,
|
|
754
|
+
line: string,
|
|
755
|
+
lineIndex: number,
|
|
756
|
+
pendingHeredoc: PendingHeredoc,
|
|
757
|
+
): PendingHeredoc | null {
|
|
758
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
759
|
+
if (body === pendingHeredoc.delimiter) {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
if (!body.startsWith(pendingHeredoc.delimiter)) {
|
|
763
|
+
return pendingHeredoc;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const trailer = getSupportedHeredocTrailer(
|
|
767
|
+
body.slice(pendingHeredoc.delimiter.length),
|
|
768
|
+
);
|
|
769
|
+
if (!trailer) {
|
|
770
|
+
return pendingHeredoc;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const startLine = parts.lines[pendingHeredoc.startLineIndex];
|
|
774
|
+
if (startLine === undefined) {
|
|
775
|
+
return pendingHeredoc;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
parts.lines[pendingHeredoc.startLineIndex] = startLine + trailer;
|
|
779
|
+
const leadingTabs = pendingHeredoc.stripLeadingTabs
|
|
780
|
+
? (line.match(/^\t*/) ?? [""])[0]
|
|
781
|
+
: "";
|
|
782
|
+
parts.lines[lineIndex] = `${leadingTabs}${pendingHeredoc.delimiter}`;
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function normalizeHeredocTrailingContinuations(command: string): string {
|
|
787
|
+
const parts = splitShellCommandLines(command);
|
|
788
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
789
|
+
|
|
790
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
791
|
+
if (pendingHeredoc) {
|
|
792
|
+
pendingHeredoc = rewritePendingHeredocTrailer(
|
|
793
|
+
parts,
|
|
794
|
+
line,
|
|
795
|
+
index,
|
|
796
|
+
pendingHeredoc,
|
|
797
|
+
);
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
pendingHeredoc = findUnquotedHeredoc(line, index);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return joinShellCommandLines(parts);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function normalizeLeadingDashPrintf(command: string): string {
|
|
808
|
+
const parts = splitShellCommandLines(command);
|
|
809
|
+
let pendingHeredoc: PendingHeredoc | null = null;
|
|
810
|
+
|
|
811
|
+
for (const [index, line] of parts.lines.entries()) {
|
|
812
|
+
if (pendingHeredoc) {
|
|
813
|
+
const body = getHeredocLineBody(line, pendingHeredoc.stripLeadingTabs);
|
|
814
|
+
if (body === pendingHeredoc.delimiter) {
|
|
815
|
+
pendingHeredoc = null;
|
|
816
|
+
}
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
parts.lines[index] = line.replace(
|
|
821
|
+
/^(\s*)printf(\s+)(['"])-/,
|
|
822
|
+
"$1printf$2-- $3-",
|
|
823
|
+
);
|
|
824
|
+
pendingHeredoc = findUnquotedHeredoc(parts.lines[index] || "", index);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return joinShellCommandLines(parts);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function normalizeShellCommand(command: string): string {
|
|
831
|
+
try {
|
|
832
|
+
return normalizeLeadingDashPrintf(
|
|
833
|
+
normalizeHeredocTrailingContinuations(command),
|
|
834
|
+
);
|
|
835
|
+
} catch {
|
|
836
|
+
return command;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
177
840
|
/** Read a spawned shell stream into a string, reporting progressive updates. */
|
|
178
841
|
async function consumeShellStream(
|
|
179
842
|
stream: ReadableStream<Uint8Array>,
|
|
@@ -225,7 +888,8 @@ export async function executeShell(
|
|
|
225
888
|
stderr: "pipe",
|
|
226
889
|
};
|
|
227
890
|
if (opts?.signal) spawnOpts.signal = opts.signal;
|
|
228
|
-
const
|
|
891
|
+
const command = normalizeShellCommand(args.command);
|
|
892
|
+
const proc = Bun.spawn([shell, "-c", command], spawnOpts);
|
|
229
893
|
|
|
230
894
|
let stdoutBuf = "";
|
|
231
895
|
let stderrBuf = "";
|
|
@@ -532,7 +1196,8 @@ export const editTool: Tool = {
|
|
|
532
1196
|
"Make an exact-text replacement in a single file. " +
|
|
533
1197
|
"Provide the file path, the exact text to find, and the replacement text. " +
|
|
534
1198
|
"The old text must match exactly one location in the file. " +
|
|
535
|
-
"To create a new file, use an empty old text and the full file content as new text."
|
|
1199
|
+
"To create a new file, use an empty old text and the full file content as new text. " +
|
|
1200
|
+
"Use this to write the exact final file content the task requires.",
|
|
536
1201
|
parameters: Type.Object({
|
|
537
1202
|
path: Type.String({
|
|
538
1203
|
description: "File path (absolute or relative to cwd)",
|
|
@@ -552,7 +1217,7 @@ export const shellTool: Tool = {
|
|
|
552
1217
|
name: "shell",
|
|
553
1218
|
description:
|
|
554
1219
|
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
555
|
-
"Use
|
|
1220
|
+
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands.",
|
|
556
1221
|
parameters: Type.Object({
|
|
557
1222
|
command: Type.String({ description: "The shell command to execute" }),
|
|
558
1223
|
}),
|