@aaroncql/pim-agent 0.1.0 → 0.3.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 +92 -65
- package/package.json +6 -6
- package/src/extensions/apply-patch/coordinator.ts +67 -0
- package/src/extensions/apply-patch/executor.ts +566 -0
- package/src/extensions/apply-patch/index.ts +75 -0
- package/src/extensions/apply-patch/matcher.ts +66 -0
- package/src/extensions/apply-patch/model.ts +34 -0
- package/src/extensions/apply-patch/parser.ts +381 -0
- package/src/extensions/apply-patch/render.ts +261 -0
- package/src/extensions/apply-patch/schema.ts +43 -0
- package/src/extensions/apply-patch/types.ts +30 -0
- package/src/extensions/bash/index.ts +3 -3
- package/src/extensions/edit/index.ts +2 -1
- package/src/extensions/file-picker/FilePickerSuggestionEngine.ts +14 -0
- package/src/extensions/file-picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/src/extensions/file-picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/src/extensions/file-picker/catalog.ts +38 -33
- package/src/extensions/file-picker/filePickerWorker.ts +72 -0
- package/src/extensions/file-picker/filePickerWorkerMessages.ts +39 -0
- package/src/extensions/file-picker/index.ts +138 -83
- package/src/extensions/file-picker/ranker.ts +180 -12
- package/src/extensions/glob/index.ts +3 -1
- package/src/extensions/glob/schema.ts +2 -1
- package/src/extensions/grep/grep.ts +45 -2
- package/src/extensions/grep/index.ts +3 -1
- package/src/extensions/grep/render.ts +18 -4
- package/src/extensions/grep/schema.ts +1 -1
- package/src/extensions/read/index.ts +36 -9
- package/src/extensions/read/render.ts +31 -3
- package/src/extensions/subagent/index.ts +4 -1
- package/src/extensions/todo/index.ts +4 -3
- package/src/extensions/web-search/index.ts +2 -1
- package/src/extensions/write/index.ts +2 -1
- package/src/shared/FileEnumerator.ts +492 -0
- package/src/shared/FileScanner.ts +15 -17
- package/src/shared/PatchSummary.ts +82 -0
- package/src/telegram/Renderer.ts +190 -4
- package/src/shared/GitignoreFilter.ts +0 -142
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
import { realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { DiffLines, type ToolDiff } from "../../shared/DiffLines";
|
|
5
|
+
import { Fs } from "../../shared/Fs";
|
|
6
|
+
import { FsErrors } from "../../shared/FsErrors";
|
|
7
|
+
import { Lines } from "../../shared/Lines";
|
|
8
|
+
import { Paths } from "../../shared/Paths";
|
|
9
|
+
import { seekSequenceMatches } from "./matcher";
|
|
10
|
+
import type { Hunk, Patch, UpdateChunk } from "./types";
|
|
11
|
+
|
|
12
|
+
const CONTEXT_LINES = 3;
|
|
13
|
+
const MAX_UPDATE_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
const EMPTY_PATCH_MESSAGE =
|
|
15
|
+
"No files were modified. Provide at least one *** Add File, *** Delete File, or *** Update File hunk.";
|
|
16
|
+
const NOOP_PATCH_MESSAGE =
|
|
17
|
+
"No files were modified. The patch matched the current file contents but did not change them.";
|
|
18
|
+
|
|
19
|
+
export type ApplyAction = {
|
|
20
|
+
readonly kind: "add" | "update" | "delete" | "move";
|
|
21
|
+
readonly path: string;
|
|
22
|
+
readonly movePath?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// One rendered unit per file operation, pairing the action with its content
|
|
26
|
+
// diff (undefined for a delete or a pure rename, which render as title only).
|
|
27
|
+
export type ApplyEntry = {
|
|
28
|
+
readonly action: ApplyAction;
|
|
29
|
+
readonly diff: ToolDiff | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ApplyOutcome = {
|
|
33
|
+
readonly entries: readonly ApplyEntry[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type FileWrite = {
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly displayPath: string;
|
|
39
|
+
readonly cwd: string;
|
|
40
|
+
readonly content: string;
|
|
41
|
+
readonly mode?: number;
|
|
42
|
+
readonly nlink: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type PlannedAction = {
|
|
46
|
+
readonly action: ApplyAction;
|
|
47
|
+
readonly diff: ToolDiff | undefined;
|
|
48
|
+
readonly write?: FileWrite;
|
|
49
|
+
readonly deletePath?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Only a content-less Update is a true no-op. Add/Delete/Move always change the
|
|
53
|
+
// filesystem even when their content diff is empty (e.g. creating an empty file
|
|
54
|
+
// or a pure rename), so they must not count toward the net-no-op rejection.
|
|
55
|
+
function isNoOpUpdate(entry: PlannedAction): boolean {
|
|
56
|
+
return entry.action.kind === "update" && entry.diff === undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function applyPatch(
|
|
60
|
+
patch: Patch,
|
|
61
|
+
cwd: string
|
|
62
|
+
): Promise<ApplyOutcome> {
|
|
63
|
+
if (patch.hunks.length === 0) {
|
|
64
|
+
throw new Error(EMPTY_PATCH_MESSAGE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const planned: PlannedAction[] = [];
|
|
68
|
+
for (const hunk of patch.hunks) {
|
|
69
|
+
planned.push(await planHunk(hunk, cwd));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (planned.every(isNoOpUpdate)) {
|
|
73
|
+
throw new Error(NOOP_PATCH_MESSAGE);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const writes = planned.flatMap((entry) =>
|
|
77
|
+
entry.write === undefined ? [] : [entry.write]
|
|
78
|
+
);
|
|
79
|
+
const deletes = planned.flatMap((entry) =>
|
|
80
|
+
entry.deletePath === undefined ? [] : [entry.deletePath]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
for (const write of writes) {
|
|
84
|
+
await writeFile(write);
|
|
85
|
+
}
|
|
86
|
+
for (const path of deletes) {
|
|
87
|
+
await Bun.file(path).delete();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
entries: planned.map((entry) => ({
|
|
92
|
+
action: entry.action,
|
|
93
|
+
diff: entry.diff,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function planHunk(hunk: Hunk, cwd: string): Promise<PlannedAction> {
|
|
99
|
+
if (hunk.kind === "add") {
|
|
100
|
+
return planAdd(hunk.path, hunk.contents, cwd);
|
|
101
|
+
}
|
|
102
|
+
if (hunk.kind === "delete") {
|
|
103
|
+
return planDelete(hunk.path, cwd);
|
|
104
|
+
}
|
|
105
|
+
return planUpdate(hunk, cwd);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function planAdd(
|
|
109
|
+
rawPath: string,
|
|
110
|
+
contents: string,
|
|
111
|
+
cwd: string
|
|
112
|
+
): Promise<PlannedAction> {
|
|
113
|
+
const absolutePath = Paths.resolve(rawPath, cwd);
|
|
114
|
+
|
|
115
|
+
if (await Bun.file(absolutePath).exists()) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Cannot add ${rawPath}: file already exists. Use *** Update File to modify an existing file.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await ensureWritableParentPath(absolutePath, rawPath, cwd);
|
|
122
|
+
|
|
123
|
+
// Each `+` line contributes its content plus a newline, so `contents` already
|
|
124
|
+
// ends with a trailing newline (matching Codex's added-file output). Write it
|
|
125
|
+
// as-is so the file is properly terminated and matches the rendered diff.
|
|
126
|
+
const newSide = DiffLines.fromText(contents);
|
|
127
|
+
const diff = DiffLines.buildToolDiff(
|
|
128
|
+
rawPath,
|
|
129
|
+
{ lines: [], hasTrailingNewline: false },
|
|
130
|
+
newSide,
|
|
131
|
+
CONTEXT_LINES
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
action: { kind: "add", path: rawPath },
|
|
136
|
+
diff,
|
|
137
|
+
write: {
|
|
138
|
+
path: absolutePath,
|
|
139
|
+
displayPath: rawPath,
|
|
140
|
+
cwd,
|
|
141
|
+
content: contents,
|
|
142
|
+
nlink: 1,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function planDelete(
|
|
148
|
+
rawPath: string,
|
|
149
|
+
cwd: string
|
|
150
|
+
): Promise<PlannedAction> {
|
|
151
|
+
const absolutePath = Paths.resolve(rawPath, cwd);
|
|
152
|
+
await statPatchTarget(absolutePath, rawPath, "delete");
|
|
153
|
+
|
|
154
|
+
const original = await readTextFile(absolutePath, rawPath, "delete");
|
|
155
|
+
const diff = DiffLines.buildToolDiff(
|
|
156
|
+
rawPath,
|
|
157
|
+
DiffLines.fromText(original.content),
|
|
158
|
+
{ lines: [], hasTrailingNewline: false },
|
|
159
|
+
CONTEXT_LINES
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
action: { kind: "delete", path: rawPath },
|
|
164
|
+
diff,
|
|
165
|
+
deletePath: absolutePath,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function planUpdate(
|
|
170
|
+
hunk: Extract<Hunk, { kind: "update" }>,
|
|
171
|
+
cwd: string
|
|
172
|
+
): Promise<PlannedAction> {
|
|
173
|
+
const absoluteSource = Paths.resolve(hunk.path, cwd);
|
|
174
|
+
const metadata = await statPatchTarget(absoluteSource, hunk.path, "update");
|
|
175
|
+
const canonicalSource = await realpathPatchTarget(
|
|
176
|
+
absoluteSource,
|
|
177
|
+
hunk.path,
|
|
178
|
+
"update"
|
|
179
|
+
);
|
|
180
|
+
if (metadata.size > MAX_UPDATE_BYTES) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Cannot update ${hunk.path}: file is too large (${metadata.size} bytes, max ${MAX_UPDATE_BYTES}). Use bash or another purpose-built tool for large-file edits.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const original = await readTextFile(canonicalSource, hunk.path, "update");
|
|
187
|
+
const split = Lines.splitWithTrailingNewline(original.content);
|
|
188
|
+
const newLines = applyChunks(split.lines, hunk.chunks, hunk.path);
|
|
189
|
+
|
|
190
|
+
const destPath = hunk.movePath ?? hunk.path;
|
|
191
|
+
const absoluteDest = Paths.resolve(destPath, cwd);
|
|
192
|
+
const isMove = hunk.movePath !== undefined && absoluteDest !== absoluteSource;
|
|
193
|
+
|
|
194
|
+
if (isMove && (await Bun.file(absoluteDest).exists())) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Cannot move ${hunk.path} to ${destPath}: destination already exists. Delete or rename the destination first, or choose a different path.`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const writePath = isMove ? absoluteDest : canonicalSource;
|
|
201
|
+
|
|
202
|
+
await ensureWritableParentPath(writePath, destPath, cwd);
|
|
203
|
+
|
|
204
|
+
const oldSide = {
|
|
205
|
+
lines: split.lines,
|
|
206
|
+
hasTrailingNewline: split.hasTrailingNewline,
|
|
207
|
+
};
|
|
208
|
+
const newSide = {
|
|
209
|
+
lines: newLines,
|
|
210
|
+
hasTrailingNewline: split.hasTrailingNewline,
|
|
211
|
+
};
|
|
212
|
+
const diff = DiffLines.buildToolDiff(
|
|
213
|
+
destPath,
|
|
214
|
+
oldSide,
|
|
215
|
+
newSide,
|
|
216
|
+
CONTEXT_LINES
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const content = joinLines(
|
|
220
|
+
newLines,
|
|
221
|
+
original.lineEnding,
|
|
222
|
+
split.hasTrailingNewline
|
|
223
|
+
);
|
|
224
|
+
const restored = original.hadBom ? `${Lines.utf8Bom}${content}` : content;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
action: isMove
|
|
228
|
+
? { kind: "move", path: hunk.path, movePath: destPath }
|
|
229
|
+
: { kind: "update", path: hunk.path },
|
|
230
|
+
diff,
|
|
231
|
+
write: {
|
|
232
|
+
path: writePath,
|
|
233
|
+
displayPath: destPath,
|
|
234
|
+
cwd,
|
|
235
|
+
content: restored,
|
|
236
|
+
mode: Number(metadata.mode),
|
|
237
|
+
nlink: metadata.nlink,
|
|
238
|
+
},
|
|
239
|
+
...(isMove ? { deletePath: absoluteSource } : {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function statPatchTarget(
|
|
244
|
+
absolutePath: string,
|
|
245
|
+
displayPath: string,
|
|
246
|
+
operation: "delete" | "update"
|
|
247
|
+
): Promise<Stats> {
|
|
248
|
+
let metadata: Stats;
|
|
249
|
+
try {
|
|
250
|
+
metadata = await stat(absolutePath);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(formatStatFailure(displayPath, operation, error));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (metadata.isDirectory()) {
|
|
256
|
+
const guidance =
|
|
257
|
+
operation === "delete"
|
|
258
|
+
? "Delete file hunks can only remove files."
|
|
259
|
+
: "Target a UTF-8 text file instead.";
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Cannot ${operation} ${displayPath}: path is a directory. ${guidance}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return metadata;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function realpathPatchTarget(
|
|
269
|
+
absolutePath: string,
|
|
270
|
+
displayPath: string,
|
|
271
|
+
operation: "delete" | "update"
|
|
272
|
+
): Promise<string> {
|
|
273
|
+
try {
|
|
274
|
+
return await realpath(absolutePath);
|
|
275
|
+
} catch (error) {
|
|
276
|
+
throw new Error(formatStatFailure(displayPath, operation, error));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatStatFailure(
|
|
281
|
+
displayPath: string,
|
|
282
|
+
operation: "delete" | "update",
|
|
283
|
+
error: unknown
|
|
284
|
+
): string {
|
|
285
|
+
const code = FsErrors.code(error);
|
|
286
|
+
if (operation === "delete") {
|
|
287
|
+
if (code === "ENOENT") {
|
|
288
|
+
return `Failed to delete file ${displayPath}: file does not exist. Use glob to locate the file, or omit this delete hunk.`;
|
|
289
|
+
}
|
|
290
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
291
|
+
return `Failed to delete file ${displayPath}: permission denied.`;
|
|
292
|
+
}
|
|
293
|
+
return `Failed to delete file ${displayPath}: ${errorDetail(error)}.`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (code === "ENOENT") {
|
|
297
|
+
return `Failed to read file to update ${displayPath}: file does not exist. Use *** Add File to create a new file, or use glob to locate the existing file.`;
|
|
298
|
+
}
|
|
299
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
300
|
+
return `Failed to read file to update ${displayPath}: permission denied.`;
|
|
301
|
+
}
|
|
302
|
+
return `Failed to read file to update ${displayPath}: ${errorDetail(error)}.`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function errorDetail(error: unknown): string {
|
|
306
|
+
return (
|
|
307
|
+
FsErrors.code(error) ??
|
|
308
|
+
(error instanceof Error ? error.message : "unknown error")
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function ensureWritableParentPath(
|
|
313
|
+
absolutePath: string,
|
|
314
|
+
displayPath: string,
|
|
315
|
+
cwd: string
|
|
316
|
+
): Promise<void> {
|
|
317
|
+
const nonDirectoryParent = await findNonDirectoryParent(absolutePath);
|
|
318
|
+
if (nonDirectoryParent === undefined) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
throw new Error(
|
|
323
|
+
`Cannot create parent directory ${Paths.displayRelative(
|
|
324
|
+
nonDirectoryParent,
|
|
325
|
+
cwd
|
|
326
|
+
)} for ${displayPath}: a file already exists at that path.`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function findNonDirectoryParent(
|
|
331
|
+
absolutePath: string
|
|
332
|
+
): Promise<string | undefined> {
|
|
333
|
+
let current = dirname(absolutePath);
|
|
334
|
+
|
|
335
|
+
while (true) {
|
|
336
|
+
try {
|
|
337
|
+
const metadata = await stat(current);
|
|
338
|
+
return metadata.isDirectory() ? undefined : current;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
const code = FsErrors.code(error);
|
|
341
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const next = dirname(current);
|
|
347
|
+
if (next === current) {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
current = next;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Port of Codex `compute_replacements` + `apply_replacements`, operating on
|
|
356
|
+
* logical lines (no trailing-newline sentinel). A sequential cursor advances
|
|
357
|
+
* through the file; multi-chunk hunks are disambiguated by order.
|
|
358
|
+
*/
|
|
359
|
+
function applyChunks(
|
|
360
|
+
originalLines: readonly string[],
|
|
361
|
+
chunks: readonly UpdateChunk[],
|
|
362
|
+
path: string
|
|
363
|
+
): string[] {
|
|
364
|
+
const replacements: Array<{
|
|
365
|
+
readonly start: number;
|
|
366
|
+
readonly oldLen: number;
|
|
367
|
+
readonly newLines: readonly string[];
|
|
368
|
+
}> = [];
|
|
369
|
+
let lineIndex = 0;
|
|
370
|
+
|
|
371
|
+
for (const chunk of chunks) {
|
|
372
|
+
if (chunk.changeContext !== undefined) {
|
|
373
|
+
const idx = findUniqueSequence(
|
|
374
|
+
originalLines,
|
|
375
|
+
[chunk.changeContext],
|
|
376
|
+
lineIndex,
|
|
377
|
+
false,
|
|
378
|
+
path
|
|
379
|
+
);
|
|
380
|
+
if (idx.length === 0) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Failed to find context '${chunk.changeContext}' in ${path}`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
lineIndex = idx[0]! + 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (chunk.oldLines.length === 0) {
|
|
389
|
+
const insertionIdx = originalLines.length;
|
|
390
|
+
replacements.push({
|
|
391
|
+
start: insertionIdx,
|
|
392
|
+
oldLen: 0,
|
|
393
|
+
newLines: chunk.newLines,
|
|
394
|
+
});
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let pattern = chunk.oldLines;
|
|
399
|
+
let newSlice = chunk.newLines;
|
|
400
|
+
let found = findUniqueSequence(
|
|
401
|
+
originalLines,
|
|
402
|
+
pattern,
|
|
403
|
+
lineIndex,
|
|
404
|
+
chunk.isEndOfFile,
|
|
405
|
+
path
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (found.length === 0 && pattern.at(-1) === "") {
|
|
409
|
+
pattern = pattern.slice(0, -1);
|
|
410
|
+
if (newSlice.at(-1) === "") {
|
|
411
|
+
newSlice = newSlice.slice(0, -1);
|
|
412
|
+
}
|
|
413
|
+
found = findUniqueSequence(
|
|
414
|
+
originalLines,
|
|
415
|
+
pattern,
|
|
416
|
+
lineIndex,
|
|
417
|
+
chunk.isEndOfFile,
|
|
418
|
+
path
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (found.length === 0) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
replacements.push({
|
|
429
|
+
start: found[0]!,
|
|
430
|
+
oldLen: pattern.length,
|
|
431
|
+
newLines: newSlice,
|
|
432
|
+
});
|
|
433
|
+
lineIndex = found[0]! + pattern.length;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
replacements.sort((a, b) => a.start - b.start);
|
|
437
|
+
|
|
438
|
+
const lines = [...originalLines];
|
|
439
|
+
for (const { start, oldLen, newLines } of [...replacements].reverse()) {
|
|
440
|
+
lines.splice(start, oldLen, ...newLines);
|
|
441
|
+
}
|
|
442
|
+
return lines;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function findUniqueSequence(
|
|
446
|
+
lines: readonly string[],
|
|
447
|
+
pattern: readonly string[],
|
|
448
|
+
start: number,
|
|
449
|
+
eof: boolean,
|
|
450
|
+
path: string
|
|
451
|
+
): readonly number[] {
|
|
452
|
+
const matches = seekSequenceMatches(lines, pattern, start, eof);
|
|
453
|
+
if (matches.length <= 1) {
|
|
454
|
+
return matches;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
throw new Error(renderAmbiguousMatch(path, matches));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function renderAmbiguousMatch(
|
|
461
|
+
path: string,
|
|
462
|
+
matches: readonly number[]
|
|
463
|
+
): string {
|
|
464
|
+
const lineStarts = matches.map((match) => match + 1).join(", ");
|
|
465
|
+
return `Patch matched multiple regions in ${path} (lines ${lineStarts}). Use enough context to make it unique.`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function joinLines(
|
|
469
|
+
lines: readonly string[],
|
|
470
|
+
lineEnding: "\n" | "\r\n",
|
|
471
|
+
hasTrailingNewline: boolean
|
|
472
|
+
): string {
|
|
473
|
+
if (lines.length === 0) {
|
|
474
|
+
return "";
|
|
475
|
+
}
|
|
476
|
+
const body = lines.join(lineEnding);
|
|
477
|
+
return hasTrailingNewline ? `${body}${lineEnding}` : body;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
type ReadFile = {
|
|
481
|
+
readonly content: string;
|
|
482
|
+
readonly lines: readonly string[];
|
|
483
|
+
readonly hadBom: boolean;
|
|
484
|
+
readonly lineEnding: "\n" | "\r\n";
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
async function readTextFile(
|
|
488
|
+
absolutePath: string,
|
|
489
|
+
displayPath: string,
|
|
490
|
+
operation: "delete" | "update"
|
|
491
|
+
): Promise<ReadFile> {
|
|
492
|
+
const bytes = await Bun.file(absolutePath).bytes();
|
|
493
|
+
|
|
494
|
+
if (bytes.subarray(0, 8192).includes(0)) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Cannot ${operation} ${displayPath}: binary file. apply_patch only supports UTF-8 text files.`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const hadBom = Lines.hasUtf8Bom(bytes);
|
|
501
|
+
const decoded = Lines.stripUtf8Bom(new TextDecoder("utf-8").decode(bytes));
|
|
502
|
+
const content = decoded.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
503
|
+
const lineEnding = decoded.includes("\r\n") ? "\r\n" : "\n";
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
content,
|
|
507
|
+
lines: Lines.splitWithTrailingNewline(content).lines,
|
|
508
|
+
hadBom,
|
|
509
|
+
lineEnding,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function writeFile(write: FileWrite): Promise<void> {
|
|
514
|
+
try {
|
|
515
|
+
if (write.nlink > 1) {
|
|
516
|
+
await Bun.write(write.path, write.content);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
await Fs.writeAtomic(write.path, write.content, write.mode);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
throw new Error(formatWriteFailure(write, error));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function formatWriteFailure(write: FileWrite, error: unknown): string {
|
|
526
|
+
const code = FsErrors.code(error);
|
|
527
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
528
|
+
return `Cannot write ${write.displayPath}: permission denied.`;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const failedPath = errorPath(error);
|
|
532
|
+
if (code === "EEXIST") {
|
|
533
|
+
const parentPath = failedPath
|
|
534
|
+
? Paths.displayRelative(failedPath, write.cwd)
|
|
535
|
+
: "the parent directory";
|
|
536
|
+
return `Cannot create parent directory ${parentPath} for ${write.displayPath}: a file already exists at that path.`;
|
|
537
|
+
}
|
|
538
|
+
if (code === "ENOTDIR") {
|
|
539
|
+
return `Cannot write ${write.displayPath}: a parent path is not a directory.`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return `Failed to write ${write.displayPath}: ${errorDetail(error)}.`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function errorPath(error: unknown): string | undefined {
|
|
546
|
+
return typeof error === "object" && error !== null && "path" in error
|
|
547
|
+
? String((error as { readonly path: unknown }).path)
|
|
548
|
+
: undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function formatApplySummary(outcome: ApplyOutcome): string {
|
|
552
|
+
const parts = outcome.entries.map(({ action }) => {
|
|
553
|
+
switch (action.kind) {
|
|
554
|
+
case "add":
|
|
555
|
+
return `added ${action.path}`;
|
|
556
|
+
case "delete":
|
|
557
|
+
return `deleted ${action.path}`;
|
|
558
|
+
case "move":
|
|
559
|
+
return `moved ${action.path} to ${action.movePath}`;
|
|
560
|
+
default:
|
|
561
|
+
return `updated ${action.path}`;
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
const noun = parts.length === 1 ? "change" : "changes";
|
|
565
|
+
return `Applied ${parts.length} ${noun}: ${parts.join(", ")}.`;
|
|
566
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { DiffRenderState } from "../../shared/DiffView";
|
|
3
|
+
import { Tools } from "../../shared/Tools";
|
|
4
|
+
import { computeActiveTools } from "./coordinator";
|
|
5
|
+
import { applyPatch, formatApplySummary } from "./executor";
|
|
6
|
+
import { isGptModel } from "./model";
|
|
7
|
+
import { parsePatch } from "./parser";
|
|
8
|
+
import { renderApplyPatchCall, renderApplyPatchResult } from "./render";
|
|
9
|
+
import {
|
|
10
|
+
type ApplyPatchInput,
|
|
11
|
+
applyPatchSchema,
|
|
12
|
+
prepareApplyPatchArguments,
|
|
13
|
+
} from "./schema";
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI): void {
|
|
16
|
+
Tools.register(pi, {
|
|
17
|
+
name: "apply_patch",
|
|
18
|
+
label: "Edit",
|
|
19
|
+
description:
|
|
20
|
+
"Apply a V4A patch to create, edit, delete, or move UTF-8 text files. " +
|
|
21
|
+
"Edits must be unique; include enough surrounding context for uniqueness. " +
|
|
22
|
+
"Prefer apply_patch over write for changes to existing files.",
|
|
23
|
+
parameters: applyPatchSchema,
|
|
24
|
+
prepareArguments: prepareApplyPatchArguments,
|
|
25
|
+
renderShell: "self",
|
|
26
|
+
executionMode: "sequential",
|
|
27
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
28
|
+
const { input } = params as ApplyPatchInput;
|
|
29
|
+
|
|
30
|
+
if (signal?.aborted) {
|
|
31
|
+
throw new Error("apply_patch aborted before execution.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const patch = parsePatch(input);
|
|
35
|
+
const outcome = await applyPatch(patch, ctx.cwd);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: formatApplySummary(outcome) }],
|
|
39
|
+
details: { entries: outcome.entries },
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
renderCall(args, theme, context) {
|
|
43
|
+
return renderApplyPatchCall(
|
|
44
|
+
args as Record<string, unknown> | undefined,
|
|
45
|
+
theme,
|
|
46
|
+
context as typeof context & { state: DiffRenderState }
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
renderResult(result, options, theme, context) {
|
|
50
|
+
return renderApplyPatchResult(
|
|
51
|
+
result,
|
|
52
|
+
options,
|
|
53
|
+
theme,
|
|
54
|
+
context as typeof context & { state: DiffRenderState }
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const reconcile = (isGpt: boolean): void => {
|
|
60
|
+
const active = pi.getActiveTools();
|
|
61
|
+
const available = pi.getAllTools().map((tool) => tool.name);
|
|
62
|
+
const next = computeActiveTools(available, active, isGpt);
|
|
63
|
+
if (next !== active) {
|
|
64
|
+
pi.setActiveTools([...next]);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
pi.on("session_start", (_event, ctx) => {
|
|
69
|
+
reconcile(isGptModel(ctx.model));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
pi.on("model_select", (event) => {
|
|
73
|
+
reconcile(isGptModel(event.model));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Faithful port of Codex's `seek_sequence`. Locates `pattern` within `lines`
|
|
3
|
+
* at or after `start`, advancing strictness in three passes: exact, then
|
|
4
|
+
* ignore trailing whitespace, then ignore leading + trailing whitespace.
|
|
5
|
+
* When `eof` is true the search begins at the position where the pattern
|
|
6
|
+
* would land flush against the end of the file.
|
|
7
|
+
*
|
|
8
|
+
* Special cases (matching Codex):
|
|
9
|
+
* - empty `pattern` -> returns `start` (no-op match).
|
|
10
|
+
* - `pattern.length > lines.length` -> returns `undefined`.
|
|
11
|
+
*/
|
|
12
|
+
export function seekSequence(
|
|
13
|
+
lines: readonly string[],
|
|
14
|
+
pattern: readonly string[],
|
|
15
|
+
start: number,
|
|
16
|
+
eof: boolean
|
|
17
|
+
): number | undefined {
|
|
18
|
+
return seekSequenceMatches(lines, pattern, start, eof)[0];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function seekSequenceMatches(
|
|
22
|
+
lines: readonly string[],
|
|
23
|
+
pattern: readonly string[],
|
|
24
|
+
start: number,
|
|
25
|
+
eof: boolean
|
|
26
|
+
): readonly number[] {
|
|
27
|
+
if (pattern.length === 0) {
|
|
28
|
+
return [start];
|
|
29
|
+
}
|
|
30
|
+
if (pattern.length > lines.length) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const searchStart =
|
|
35
|
+
eof && lines.length >= pattern.length
|
|
36
|
+
? lines.length - pattern.length
|
|
37
|
+
: start;
|
|
38
|
+
const last = lines.length - pattern.length;
|
|
39
|
+
|
|
40
|
+
const matchers: ReadonlyArray<(a: string, b: string) => boolean> = [
|
|
41
|
+
(a, b) => a === b,
|
|
42
|
+
(a, b) => a.trimEnd() === b.trimEnd(),
|
|
43
|
+
(a, b) => a.trim() === b.trim(),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
for (const eq of matchers) {
|
|
47
|
+
const matches: number[] = [];
|
|
48
|
+
for (let i = searchStart; i <= last; i += 1) {
|
|
49
|
+
let ok = true;
|
|
50
|
+
for (let p = 0; p < pattern.length; p += 1) {
|
|
51
|
+
if (!eq(lines[i + p]!, pattern[p]!)) {
|
|
52
|
+
ok = false;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (ok) {
|
|
57
|
+
matches.push(i);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (matches.length > 0) {
|
|
61
|
+
return matches;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return [];
|
|
66
|
+
}
|