@aaroncql/pim-agent 0.0.1 → 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.
Files changed (84) hide show
  1. package/README.md +94 -66
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/apply-patch/coordinator.ts +49 -0
  6. package/src/extensions/apply-patch/executor.ts +566 -0
  7. package/src/extensions/apply-patch/index.ts +74 -0
  8. package/src/extensions/apply-patch/matcher.ts +66 -0
  9. package/src/extensions/apply-patch/model.ts +34 -0
  10. package/src/extensions/apply-patch/parser.ts +381 -0
  11. package/src/extensions/apply-patch/render.ts +261 -0
  12. package/src/extensions/apply-patch/schema.ts +43 -0
  13. package/src/extensions/apply-patch/types.ts +30 -0
  14. package/src/extensions/bash/index.ts +3 -3
  15. package/src/extensions/edit/index.ts +2 -1
  16. package/src/extensions/glob/index.ts +3 -1
  17. package/src/extensions/glob/schema.ts +2 -1
  18. package/src/extensions/grep/index.ts +3 -1
  19. package/src/extensions/grep/render.ts +18 -4
  20. package/src/extensions/grep/schema.ts +1 -1
  21. package/src/extensions/read/index.ts +36 -9
  22. package/src/extensions/read/render.ts +31 -3
  23. package/src/extensions/subagent/index.ts +4 -1
  24. package/src/extensions/todo/index.ts +4 -3
  25. package/src/extensions/web-search/index.ts +2 -1
  26. package/src/extensions/write/index.ts +2 -1
  27. package/src/shared/PatchSummary.ts +82 -0
  28. package/src/telegram/Renderer.ts +190 -4
  29. package/src/extensions/bash/capture.test.ts +0 -126
  30. package/src/extensions/bash/format.test.ts +0 -240
  31. package/src/extensions/bash/run.test.ts +0 -262
  32. package/src/extensions/command-picker/ranker.test.ts +0 -46
  33. package/src/extensions/edit/edit.test.ts +0 -285
  34. package/src/extensions/file-picker/catalog.test.ts +0 -263
  35. package/src/extensions/file-picker/index.test.ts +0 -168
  36. package/src/extensions/file-picker/ranker.test.ts +0 -94
  37. package/src/extensions/footer/git.test.ts +0 -76
  38. package/src/extensions/footer/index.test.ts +0 -161
  39. package/src/extensions/footer/segments.test.ts +0 -164
  40. package/src/extensions/glob/glob.test.ts +0 -171
  41. package/src/extensions/glob/index.test.ts +0 -68
  42. package/src/extensions/glob/render.test.ts +0 -126
  43. package/src/extensions/grep/grep.test.ts +0 -387
  44. package/src/extensions/grep/index.test.ts +0 -68
  45. package/src/extensions/grep/render.test.ts +0 -269
  46. package/src/extensions/read/read.test.ts +0 -177
  47. package/src/extensions/read/render.test.ts +0 -61
  48. package/src/extensions/subagent/index.test.ts +0 -44
  49. package/src/extensions/subagent/render.test.ts +0 -292
  50. package/src/extensions/subagent/subagent.test.ts +0 -315
  51. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  52. package/src/extensions/todo/index.test.ts +0 -244
  53. package/src/extensions/todo/render.test.ts +0 -180
  54. package/src/extensions/todo/todo.test.ts +0 -222
  55. package/src/extensions/tps/index.test.ts +0 -254
  56. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  57. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  58. package/src/extensions/web-fetch/render.test.ts +0 -56
  59. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  60. package/src/extensions/web-search/render.test.ts +0 -21
  61. package/src/extensions/web-search/search.test.ts +0 -53
  62. package/src/extensions/working-indicator/index.test.ts +0 -21
  63. package/src/extensions/write/render.test.ts +0 -64
  64. package/src/extensions/write/write.test.ts +0 -108
  65. package/src/shared/DiffLines.test.ts +0 -193
  66. package/src/shared/DiffRenderer.test.ts +0 -206
  67. package/src/shared/EditMatcher.test.ts +0 -123
  68. package/src/shared/FileScanner.test.ts +0 -158
  69. package/src/shared/FuzzyMatcher.test.ts +0 -114
  70. package/src/shared/GitignoreFilter.test.ts +0 -64
  71. package/src/shared/Lines.test.ts +0 -25
  72. package/src/shared/McpClient.test.ts +0 -235
  73. package/src/shared/OutputBudget.test.ts +0 -99
  74. package/src/shared/Paths.test.ts +0 -51
  75. package/src/shared/PimSettings.test.ts +0 -90
  76. package/src/shared/Renderer.test.ts +0 -190
  77. package/src/shared/SpillCache.test.ts +0 -94
  78. package/src/shared/Tools.test.ts +0 -392
  79. package/src/telegram/Config.test.ts +0 -275
  80. package/src/telegram/Markdown.test.ts +0 -143
  81. package/src/telegram/Renderer.test.ts +0 -216
  82. package/src/telegram/SessionRegistry.test.ts +0 -89
  83. package/src/telegram/TaskScheduler.test.ts +0 -278
  84. package/src/telegram/TaskTool.test.ts +0 -179
@@ -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,74 @@
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 next = computeActiveTools(active, isGpt);
62
+ if (next !== active) {
63
+ pi.setActiveTools([...next]);
64
+ }
65
+ };
66
+
67
+ pi.on("session_start", (_event, ctx) => {
68
+ reconcile(isGptModel(ctx.model));
69
+ });
70
+
71
+ pi.on("model_select", (event) => {
72
+ reconcile(isGptModel(event.model));
73
+ });
74
+ }
@@ -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
+ }