@aaroncql/pim-agent 0.0.1

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 (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,285 @@
1
+ import {
2
+ chmod,
3
+ link,
4
+ mkdtemp,
5
+ readFile,
6
+ rm,
7
+ stat,
8
+ symlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterAll, describe, expect, test } from "bun:test";
14
+ import { editFile, formatEditSummary } from "./edit";
15
+ import type { RawEdit } from "./schema";
16
+
17
+ const tempRoots: string[] = [];
18
+
19
+ const tempRoot = async (): Promise<string> => {
20
+ const root = await mkdtemp(join(tmpdir(), "pim-edit-tool-"));
21
+ tempRoots.push(root);
22
+ return root;
23
+ };
24
+
25
+ afterAll(async () => {
26
+ await Promise.all(
27
+ tempRoots.map((root) => rm(root, { force: true, recursive: true }))
28
+ );
29
+ });
30
+
31
+ describe("editFile", () => {
32
+ test("applies exact single edit", async () => {
33
+ const root = await tempRoot();
34
+ const path = join(root, "notes.txt");
35
+ await writeFile(path, "alpha\nbeta\ngamma", "utf8");
36
+
37
+ const outcome = await editFile(path, [
38
+ { oldString: "beta", newString: "delta" },
39
+ ]);
40
+
41
+ expect(await readFile(path, "utf8")).toBe("alpha\ndelta\ngamma");
42
+ expect(outcome.editCount).toBe(1);
43
+ expect(outcome.noops).toEqual([]);
44
+ expect(outcome.resolvedEdits[0]?.strategy).toBe("simple");
45
+ expect(formatEditSummary(path, outcome)).toBe(
46
+ `1 edit made to ${path}: lines 2.`
47
+ );
48
+ });
49
+
50
+ test("applies multi-edit batch against pre-batch content", async () => {
51
+ const root = await tempRoot();
52
+ const path = join(root, "notes.txt");
53
+ await writeFile(path, "alpha\nbeta\ngamma\ndelta", "utf8");
54
+
55
+ await editFile(path, [
56
+ { oldString: "beta", newString: "BETA" },
57
+ { oldString: "delta", newString: "DELTA" },
58
+ ]);
59
+
60
+ expect(await readFile(path, "utf8")).toBe("alpha\nBETA\ngamma\nDELTA");
61
+ });
62
+
63
+ test("rejects sequential transformations in one batch", async () => {
64
+ const root = await tempRoot();
65
+ const path = join(root, "notes.txt");
66
+ await writeFile(path, "alpha\nbeta", "utf8");
67
+
68
+ await expect(
69
+ editFile(path, [
70
+ { oldString: "beta", newString: "delta" },
71
+ { oldString: "delta", newString: "omega" },
72
+ ])
73
+ ).rejects.toThrow(/oldString was not found/);
74
+ expect(await readFile(path, "utf8")).toBe("alpha\nbeta");
75
+ });
76
+
77
+ test("rejects overlapping resolved ranges", async () => {
78
+ const root = await tempRoot();
79
+ const path = join(root, "notes.txt");
80
+ await writeFile(path, "alpha\nbeta\ngamma", "utf8");
81
+
82
+ await expect(
83
+ editFile(path, [
84
+ { oldString: "beta\ngamma", newString: "merged" },
85
+ { oldString: "gamma", newString: "changed" },
86
+ ])
87
+ ).rejects.toThrow(/target overlapping byte ranges/);
88
+ expect(await readFile(path, "utf8")).toBe("alpha\nbeta\ngamma");
89
+ });
90
+
91
+ test("replaceAll replaces all occurrences and lists every range", async () => {
92
+ const root = await tempRoot();
93
+ const path = join(root, "notes.txt");
94
+ await writeFile(path, "foo\nbar\nfoo", "utf8");
95
+
96
+ const outcome = await editFile(path, [
97
+ { oldString: "foo", newString: "baz", replaceAll: true },
98
+ ]);
99
+
100
+ expect(await readFile(path, "utf8")).toBe("baz\nbar\nbaz");
101
+ expect(outcome.ranges).toEqual(["1", "3"]);
102
+ expect(formatEditSummary(path, outcome)).toBe(
103
+ `1 edit made to ${path}: lines 1 and 3.`
104
+ );
105
+ });
106
+
107
+ test("replaceAll overlap with another edit is rejected", async () => {
108
+ const root = await tempRoot();
109
+ const path = join(root, "notes.txt");
110
+ await writeFile(path, "foo\nbar\nfoo", "utf8");
111
+
112
+ await expect(
113
+ editFile(path, [
114
+ { oldString: "foo", newString: "baz", replaceAll: true },
115
+ { oldString: "foo\nbar", newString: "merged" },
116
+ ])
117
+ ).rejects.toThrow(/target overlapping byte ranges/);
118
+ });
119
+
120
+ test("rejects duplicate edits", async () => {
121
+ const root = await tempRoot();
122
+ const path = join(root, "notes.txt");
123
+ await writeFile(path, "alpha\nbeta", "utf8");
124
+
125
+ const edit: RawEdit = { oldString: "beta", newString: "delta" };
126
+
127
+ await expect(editFile(path, [edit, edit])).rejects.toThrow(
128
+ /Edits 0 and 1 are identical/
129
+ );
130
+ });
131
+
132
+ test("rejects all-noop fuzzy batches and tracks partial noops", async () => {
133
+ const root = await tempRoot();
134
+ const noopPath = join(root, "noop.txt");
135
+ const partialPath = join(root, "partial.txt");
136
+ await writeFile(noopPath, " beta", "utf8");
137
+ await writeFile(partialPath, "alpha\n beta", "utf8");
138
+
139
+ await expect(
140
+ editFile(noopPath, [{ oldString: "beta ", newString: " beta" }])
141
+ ).rejects.toThrow(/All edits were no-ops/);
142
+
143
+ const outcome = await editFile(partialPath, [
144
+ { oldString: "alpha", newString: "ALPHA" },
145
+ { oldString: "beta ", newString: " beta" },
146
+ ]);
147
+
148
+ expect(await readFile(partialPath, "utf8")).toBe("ALPHA\n beta");
149
+ expect(outcome.noops).toEqual([{ index: 1, range: "2" }]);
150
+ });
151
+
152
+ test("reports closest regions when oldString is not found", async () => {
153
+ const root = await tempRoot();
154
+ const path = join(root, "notes.txt");
155
+ await writeFile(path, "alpha\nbeta\ngamma", "utf8");
156
+
157
+ await expect(
158
+ editFile(path, [{ oldString: "betx", newString: "delta" }])
159
+ ).rejects.toThrow(/oldString was not found[\s\S]*lines 2/);
160
+ });
161
+
162
+ test("reports bare not found when nothing is close", async () => {
163
+ const root = await tempRoot();
164
+ const path = join(root, "notes.txt");
165
+ await writeFile(path, "aaaa\nbbbb", "utf8");
166
+
167
+ await expect(
168
+ editFile(path, [{ oldString: "zzzz", newString: "delta" }])
169
+ ).rejects.toThrow("oldString was not found in the file.");
170
+ });
171
+
172
+ test("rejects multiple matches without replaceAll", async () => {
173
+ const root = await tempRoot();
174
+ const path = join(root, "notes.txt");
175
+ await writeFile(path, "foo\nbar\nfoo", "utf8");
176
+
177
+ await expect(
178
+ editFile(path, [{ oldString: "foo", newString: "baz" }])
179
+ ).rejects.toThrow(/matched multiple regions/);
180
+ });
181
+
182
+ test("rejects escape drift after fuzzy match", async () => {
183
+ const root = await tempRoot();
184
+ const path = join(root, "notes.txt");
185
+ await writeFile(path, " beta", "utf8");
186
+
187
+ await expect(
188
+ editFile(path, [{ oldString: "beta ", newString: "new\\nvalue" }])
189
+ ).rejects.toThrow(/newString contains literal escape text/);
190
+ });
191
+
192
+ test("preserves UTF-8 BOM when editing", async () => {
193
+ const root = await tempRoot();
194
+ const path = join(root, "bom.txt");
195
+ await writeFile(path, "\uFEFFalpha\nbeta", "utf8");
196
+
197
+ await editFile(path, [{ oldString: "beta", newString: "delta" }]);
198
+
199
+ const bytes = await Bun.file(path).bytes();
200
+ expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
201
+ expect(await readFile(path, "utf8")).toBe("\uFEFFalpha\ndelta");
202
+ });
203
+
204
+ test("preserves CRLF line endings when editing", async () => {
205
+ const root = await tempRoot();
206
+ const path = join(root, "crlf.txt");
207
+ await writeFile(path, "alpha\r\nbeta\r\n", "utf8");
208
+
209
+ await editFile(path, [{ oldString: "beta", newString: "delta" }]);
210
+
211
+ expect(await readFile(path, "utf8")).toBe("alpha\r\ndelta\r\n");
212
+ });
213
+
214
+ test("updates symlink targets and preserves hard-link inodes plus mode", async () => {
215
+ const root = await tempRoot();
216
+ const target = join(root, "target.txt");
217
+ const linked = join(root, "linked.txt");
218
+ const alias = join(root, "alias.txt");
219
+
220
+ await writeFile(target, "alpha\nbeta", "utf8");
221
+ await chmod(target, 0o640);
222
+ await link(target, linked);
223
+ await symlink(target, alias);
224
+
225
+ const before = await stat(target);
226
+
227
+ await editFile(alias, [{ oldString: "beta", newString: "delta" }]);
228
+
229
+ const after = await stat(target);
230
+ expect(await readFile(target, "utf8")).toBe("alpha\ndelta");
231
+ expect(await readFile(linked, "utf8")).toBe("alpha\ndelta");
232
+ expect(after.ino).toBe(before.ino);
233
+ expect(Number(after.mode) & 0o777).toBe(0o640);
234
+ });
235
+
236
+ test("serializes concurrent edits on the same path", async () => {
237
+ const root = await tempRoot();
238
+ const path = join(root, "notes.txt");
239
+ await writeFile(path, "0\n", "utf8");
240
+
241
+ const concurrent = await Promise.all([
242
+ editFile(path, [{ oldString: "0", newString: "0\n1" }]),
243
+ editFile(path, [{ oldString: "0", newString: "0\n2" }]),
244
+ editFile(path, [{ oldString: "0", newString: "0\n3" }]),
245
+ ]);
246
+
247
+ const final = await readFile(path, "utf8");
248
+ expect(final).toContain("0");
249
+ expect(concurrent).toHaveLength(3);
250
+ });
251
+
252
+ test("buildDiff splits distant edits into separate hunks", async () => {
253
+ const root = await tempRoot();
254
+ const path = join(root, "notes.txt");
255
+ const lines = Array.from(
256
+ { length: 200 },
257
+ (_, index) => `line ${index + 1}`
258
+ );
259
+ await writeFile(path, lines.join("\n"), "utf8");
260
+
261
+ const outcome = await editFile(path, [
262
+ { oldString: "line 1\nline 2", newString: "changed 1\nline 2" },
263
+ { oldString: "line 199\nline 200", newString: "line 199\nchanged 200" },
264
+ ]);
265
+
266
+ expect(outcome.diff?.hunks).toHaveLength(2);
267
+ expect(formatEditSummary(path, outcome)).toBe(
268
+ `2 edits made to ${path}: lines 1-2 and 199-200.`
269
+ );
270
+ });
271
+
272
+ test("rejects directories and binary files", async () => {
273
+ const root = await tempRoot();
274
+ const binary = join(root, "bin.dat");
275
+ await Bun.write(binary, new Uint8Array([0, 1, 2, 0, 4]));
276
+
277
+ await expect(
278
+ editFile(root, [{ oldString: "x", newString: "y" }])
279
+ ).rejects.toThrow(/Path is a directory/);
280
+
281
+ await expect(
282
+ editFile(binary, [{ oldString: "x", newString: "y" }])
283
+ ).rejects.toThrow(/binary file/);
284
+ });
285
+ });
@@ -0,0 +1,382 @@
1
+ import type { Stats } from "node:fs";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import {
4
+ EditMatcher,
5
+ type EditMatchStrategy,
6
+ type EditRange,
7
+ } from "../../shared/EditMatcher";
8
+ import { DiffLines, type ToolDiff } from "../../shared/DiffLines";
9
+ import { Fs } from "../../shared/Fs";
10
+ import { FsErrors } from "../../shared/FsErrors";
11
+ import { Lines } from "../../shared/Lines";
12
+ import type { RawEdit } from "./schema";
13
+
14
+ export type NoopEdit = {
15
+ readonly index: number;
16
+ readonly range: string;
17
+ };
18
+
19
+ export type ResolvedEditMetadata = {
20
+ readonly index: number;
21
+ readonly ranges: readonly string[];
22
+ readonly strategy: EditMatchStrategy;
23
+ readonly matchCount: number;
24
+ readonly replaceAll: boolean;
25
+ };
26
+
27
+ export type EditOutcome = {
28
+ readonly editCount: number;
29
+ readonly warnings: readonly string[];
30
+ readonly noops: readonly NoopEdit[];
31
+ readonly ranges: readonly string[];
32
+ readonly resolvedEdits: readonly ResolvedEditMetadata[];
33
+ readonly diff?: ToolDiff;
34
+ };
35
+
36
+ type ParsedEdit = {
37
+ readonly index: number;
38
+ readonly oldString: string;
39
+ readonly newString: string;
40
+ readonly replaceAll: boolean;
41
+ };
42
+
43
+ type ResolvedEdit = {
44
+ readonly index: number;
45
+ readonly ranges: readonly EditRange[];
46
+ readonly newString: string;
47
+ readonly strategy: EditMatchStrategy;
48
+ readonly matchCount: number;
49
+ readonly replaceAll: boolean;
50
+ };
51
+
52
+ type Mutation = {
53
+ readonly index: number;
54
+ readonly range: EditRange;
55
+ readonly newString: string;
56
+ };
57
+
58
+ const CONTEXT_LINES = 2;
59
+ const MAX_EDIT_BYTES = 8 * 1024 * 1024;
60
+
61
+ const editQueues = new Map<string, Promise<void>>();
62
+
63
+ export async function editFile(
64
+ absolutePath: string,
65
+ rawEdits: readonly RawEdit[]
66
+ ): Promise<EditOutcome> {
67
+ let canonicalPath: string;
68
+ try {
69
+ canonicalPath = await realpath(absolutePath);
70
+ } catch (error) {
71
+ if (FsErrors.code(error) === "ENOENT") {
72
+ throw new Error(await FsErrors.renderMissing(absolutePath));
73
+ }
74
+ throw error;
75
+ }
76
+
77
+ return enqueue(canonicalPath, () =>
78
+ performEdit(absolutePath, canonicalPath, rawEdits)
79
+ );
80
+ }
81
+
82
+ async function performEdit(
83
+ displayPath: string,
84
+ canonicalPath: string,
85
+ rawEdits: readonly RawEdit[]
86
+ ): Promise<EditOutcome> {
87
+ const edits = parseEdits(rawEdits);
88
+ const metadata = await stat(canonicalPath);
89
+
90
+ if (metadata.isDirectory()) {
91
+ throw new Error(`Path is a directory: ${displayPath}.`);
92
+ }
93
+
94
+ if (metadata.size > MAX_EDIT_BYTES) {
95
+ throw new Error(
96
+ `Path exceeds the ${MAX_EDIT_BYTES}-byte edit cap (${metadata.size} bytes): ${displayPath}.`
97
+ );
98
+ }
99
+
100
+ assertNoDuplicateEdits(edits);
101
+
102
+ const file = Bun.file(canonicalPath);
103
+ const bytes = await file.bytes();
104
+
105
+ if (bytes.subarray(0, 8192).includes(0)) {
106
+ throw new Error(
107
+ `Path is a binary file: ${displayPath}. Edit only supports UTF-8 text files.`
108
+ );
109
+ }
110
+
111
+ const hadBom = Lines.hasUtf8Bom(bytes);
112
+ const originalContent = Lines.stripUtf8Bom(
113
+ new TextDecoder("utf-8").decode(bytes)
114
+ );
115
+ const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n";
116
+ const normalizedEdits = edits.map((edit) => ({
117
+ ...edit,
118
+ oldString: normalizeEditString(edit.oldString, lineEnding),
119
+ newString: normalizeEditString(edit.newString, lineEnding),
120
+ }));
121
+ const resolved = normalizedEdits.map((edit) =>
122
+ resolveEdit(originalContent, edit)
123
+ );
124
+ const allMutations = resolved.flatMap((edit) =>
125
+ edit.ranges.map((range) => ({
126
+ index: edit.index,
127
+ range,
128
+ newString: edit.newString,
129
+ }))
130
+ );
131
+
132
+ const sortedMutations = sortMutations(allMutations);
133
+ assertNoOverlaps(sortedMutations);
134
+
135
+ const isNoop = (mutation: Mutation) =>
136
+ originalContent.slice(mutation.range[0], mutation.range[1]) ===
137
+ mutation.newString;
138
+
139
+ const lineRangeCache = new Map<string, string>();
140
+ const lineRange = (range: EditRange): string => {
141
+ const key = `${range[0]}:${range[1]}`;
142
+ let cached = lineRangeCache.get(key);
143
+ if (cached === undefined) {
144
+ cached = EditMatcher.lineRangeFor(originalContent, range);
145
+ lineRangeCache.set(key, cached);
146
+ }
147
+ return cached;
148
+ };
149
+
150
+ const noops: NoopEdit[] = [];
151
+ const effectiveMutations: Mutation[] = [];
152
+ for (const mutation of sortedMutations) {
153
+ if (isNoop(mutation)) {
154
+ noops.push({ index: mutation.index, range: lineRange(mutation.range) });
155
+ } else {
156
+ effectiveMutations.push(mutation);
157
+ }
158
+ }
159
+
160
+ if (effectiveMutations.length === 0) {
161
+ throw new Error(renderAllNoopError(noops));
162
+ }
163
+
164
+ const nextContent = EditMatcher.applyAll(originalContent, effectiveMutations);
165
+
166
+ await writeFileAtomic(
167
+ canonicalPath,
168
+ hadBom ? `${Lines.utf8Bom}${nextContent}` : nextContent,
169
+ metadata
170
+ );
171
+
172
+ const original = Lines.splitWithTrailingNewline(originalContent);
173
+ const next = Lines.splitWithTrailingNewline(nextContent);
174
+ const diff = DiffLines.buildToolDiff(
175
+ displayPath,
176
+ original,
177
+ next,
178
+ CONTEXT_LINES
179
+ );
180
+
181
+ const resolvedEdits = resolved.map((edit) => ({
182
+ index: edit.index,
183
+ ranges: edit.ranges.map((range) => lineRange(range)),
184
+ strategy: edit.strategy,
185
+ matchCount: edit.matchCount,
186
+ replaceAll: edit.replaceAll,
187
+ }));
188
+
189
+ return {
190
+ editCount: edits.length,
191
+ warnings: [],
192
+ noops,
193
+ ranges: effectiveMutations.map((mutation) => lineRange(mutation.range)),
194
+ resolvedEdits,
195
+ ...(diff === undefined ? {} : { diff }),
196
+ };
197
+ }
198
+
199
+ function parseEdits(rawEdits: readonly RawEdit[]): readonly ParsedEdit[] {
200
+ if (rawEdits.length === 0) {
201
+ throw new Error("Expected non-empty edits array.");
202
+ }
203
+
204
+ return rawEdits.map((raw, index) => {
205
+ if (raw.oldString === raw.newString) {
206
+ throw new Error(`Edit ${index}: oldString and newString are identical.`);
207
+ }
208
+
209
+ return {
210
+ index,
211
+ oldString: raw.oldString,
212
+ newString: raw.newString,
213
+ replaceAll: raw.replaceAll ?? false,
214
+ };
215
+ });
216
+ }
217
+
218
+ function normalizeEditString(text: string, lineEnding: "\n" | "\r\n"): string {
219
+ const normalized = Lines.normalize(text);
220
+ return lineEnding === "\n" ? normalized : normalized.replaceAll("\n", "\r\n");
221
+ }
222
+
223
+ function resolveEdit(content: string, edit: ParsedEdit): ResolvedEdit {
224
+ try {
225
+ const outcome = EditMatcher.resolve(
226
+ content,
227
+ edit.oldString,
228
+ edit.replaceAll
229
+ );
230
+ const ranges = "ranges" in outcome ? outcome.ranges : [outcome.range];
231
+
232
+ for (const range of ranges) {
233
+ EditMatcher.assertNoEscapeDrift(
234
+ outcome.strategy,
235
+ edit.newString,
236
+ content.slice(range[0], range[1])
237
+ );
238
+ }
239
+
240
+ return {
241
+ index: edit.index,
242
+ ranges,
243
+ newString: edit.newString,
244
+ strategy: outcome.strategy,
245
+ matchCount: outcome.matchCount,
246
+ replaceAll: edit.replaceAll,
247
+ };
248
+ } catch (error) {
249
+ if (error instanceof EditMatcher.NotFoundError) {
250
+ throw new Error(EditMatcher.renderNotFound(error));
251
+ }
252
+
253
+ throw error;
254
+ }
255
+ }
256
+
257
+ function assertNoDuplicateEdits(edits: readonly ParsedEdit[]): void {
258
+ const seen = new Map<string, number>();
259
+
260
+ for (const [index, edit] of edits.entries()) {
261
+ const key = duplicateKey(edit);
262
+ const previous = seen.get(key);
263
+
264
+ if (previous !== undefined) {
265
+ throw new Error(
266
+ `Edits ${previous} and ${index} are identical. Remove one.`
267
+ );
268
+ }
269
+
270
+ seen.set(key, index);
271
+ }
272
+ }
273
+
274
+ function duplicateKey(edit: ParsedEdit): string {
275
+ return JSON.stringify({
276
+ oldString: edit.oldString,
277
+ newString: edit.newString,
278
+ replaceAll: edit.replaceAll,
279
+ });
280
+ }
281
+
282
+ function sortMutations(mutations: readonly Mutation[]): readonly Mutation[] {
283
+ return [...mutations].sort((left, right) => {
284
+ if (left.range[0] !== right.range[0]) {
285
+ return left.range[0] - right.range[0];
286
+ }
287
+
288
+ return left.range[1] - right.range[1];
289
+ });
290
+ }
291
+
292
+ function assertNoOverlaps(sorted: readonly Mutation[]): void {
293
+ for (let index = 1; index < sorted.length; index += 1) {
294
+ const previous = sorted[index - 1]!;
295
+ const current = sorted[index]!;
296
+
297
+ if (previous.range[1] > current.range[0]) {
298
+ throw new Error(renderOverlapError(previous, current));
299
+ }
300
+ }
301
+ }
302
+
303
+ function renderOverlapError(left: Mutation, right: Mutation): string {
304
+ return [
305
+ `Edits ${left.index} and ${right.index} target overlapping byte ranges.`,
306
+ `- Edit ${left.index}: range ${left.range[0]}-${left.range[1]}`,
307
+ `- Edit ${right.index}: range ${right.range[0]}-${right.range[1]}`,
308
+ "Combine into a single edit, or drop one.",
309
+ ].join("\n");
310
+ }
311
+
312
+ function renderAllNoopError(noops: readonly NoopEdit[]): string {
313
+ return [
314
+ "All edits were no-ops. The file already contains the requested replacement content.",
315
+ "",
316
+ ...noops.map(
317
+ (noop) =>
318
+ `- Edit ${noop.index}: matched range ${noop.range} already equals newString.`
319
+ ),
320
+ "",
321
+ "Re-read the file and widen oldString if you meant to replace adjacent duplicated content.",
322
+ ].join("\n");
323
+ }
324
+
325
+ async function writeFileAtomic(
326
+ canonicalPath: string,
327
+ content: string,
328
+ metadata: Stats
329
+ ): Promise<void> {
330
+ if (metadata.nlink > 1) {
331
+ await Bun.write(canonicalPath, content);
332
+ return;
333
+ }
334
+ await Fs.writeAtomic(canonicalPath, content, Number(metadata.mode));
335
+ }
336
+
337
+ async function enqueue<T>(key: string, task: () => Promise<T>): Promise<T> {
338
+ const previous = editQueues.get(key) ?? Promise.resolve();
339
+ let release!: () => void;
340
+ const current = new Promise<void>((resolve) => {
341
+ release = resolve;
342
+ });
343
+
344
+ const queued = previous.then(
345
+ () => current,
346
+ () => current
347
+ );
348
+
349
+ editQueues.set(key, queued);
350
+
351
+ await previous.catch(() => undefined);
352
+
353
+ try {
354
+ return await task();
355
+ } finally {
356
+ release();
357
+
358
+ if (editQueues.get(key) === queued) {
359
+ editQueues.delete(key);
360
+ }
361
+ }
362
+ }
363
+
364
+ export function formatEditSummary(
365
+ displayPath: string,
366
+ outcome: EditOutcome
367
+ ): string {
368
+ const noun = outcome.editCount === 1 ? "edit" : "edits";
369
+ return `${outcome.editCount} ${noun} made to ${displayPath}: lines ${joinHuman(outcome.ranges)}.`;
370
+ }
371
+
372
+ function joinHuman(items: readonly string[]): string {
373
+ if (items.length === 0) {
374
+ return "unknown";
375
+ }
376
+
377
+ if (items.length === 1) {
378
+ return items[0] ?? "unknown";
379
+ }
380
+
381
+ return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
382
+ }