@trim21/personal-pi-extensions 0.0.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.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Opencode Edit Extension — Replaces the built-in edit tool with opencode's
3
+ * schema and matching engine.
4
+ *
5
+ * The core replacers and replace() function are copied directly from
6
+ * https://github.com/anomalyco/opencode (packages/opencode/src/tool/edit.ts)
7
+ * and wrapped in a pi extension so the behaviour is identical to opencode.
8
+ *
9
+ * Usage:
10
+ * pi -e ./opencode-edit.ts
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ generateDiffString,
16
+ generateUnifiedPatch,
17
+ withFileMutationQueue,
18
+ } from "@earendil-works/pi-coding-agent";
19
+ import { constants } from "fs";
20
+ import { access, readFile, writeFile } from "fs/promises";
21
+ import { isAbsolute, resolve } from "path";
22
+ import { Type } from "typebox";
23
+
24
+ // ── schema ────────────────────────────────────────────────────────────────────
25
+
26
+ const editSchema = Type.Object({
27
+ filePath: Type.String({ description: "The path to the file to modify (relative or absolute)" }),
28
+ oldString: Type.String({ description: "The text to replace" }),
29
+ newString: Type.String({
30
+ description: "The text to replace it with (must be different from oldString)",
31
+ }),
32
+ replaceAll: Type.Optional(
33
+ Type.Boolean({ description: "Replace all occurrences of oldString (default false)" }),
34
+ ),
35
+ });
36
+
37
+ // ── BOM & line ending helpers ─────────────────────────────────────────────────
38
+
39
+ function stripBom(content: string): { bom: string; text: string } {
40
+ return content.startsWith("\uFEFF")
41
+ ? { bom: "\uFEFF", text: content.slice(1) }
42
+ : { bom: "", text: content };
43
+ }
44
+
45
+ function detectLineEnding(content: string): "\r\n" | "\n" {
46
+ return content.includes("\r\n") ? "\r\n" : "\n";
47
+ }
48
+
49
+ function normalizeToLF(text: string): string {
50
+ return text.replaceAll("\r\n", "\n");
51
+ }
52
+
53
+ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
54
+ return ending === "\r\n" ? text.replaceAll("\n", "\r\n") : text;
55
+ }
56
+
57
+ // ── copied from opencode ──────────────────────────────────────────────────────
58
+
59
+ function levenshtein(a: string, b: string): number {
60
+ if (a === "" || b === "") {
61
+ return Math.max(a.length, b.length);
62
+ }
63
+ const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
64
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
65
+ );
66
+ for (let i = 1; i <= a.length; i++) {
67
+ for (let j = 1; j <= b.length; j++) {
68
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
69
+ matrix[i][j] = Math.min(
70
+ matrix[i - 1][j] + 1,
71
+ matrix[i][j - 1] + 1,
72
+ matrix[i - 1][j - 1] + cost,
73
+ );
74
+ }
75
+ }
76
+ return matrix[a.length][b.length];
77
+ }
78
+
79
+ type Replacer = (content: string, find: string) => Generator<string, void, unknown>;
80
+
81
+ const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
82
+ const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
83
+
84
+ const SimpleReplacer: Replacer = function* (_content, find) {
85
+ yield find;
86
+ };
87
+
88
+ const LineTrimmedReplacer: Replacer = function* (content, find) {
89
+ const originalLines = content.split("\n");
90
+ const searchLines = find.split("\n");
91
+ if (searchLines[searchLines.length - 1] === "") {
92
+ searchLines.pop();
93
+ }
94
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
95
+ let matches = true;
96
+ for (let j = 0; j < searchLines.length; j++) {
97
+ const originalTrimmed = originalLines[i + j].trim();
98
+ const searchTrimmed = searchLines[j].trim();
99
+ if (originalTrimmed !== searchTrimmed) {
100
+ matches = false;
101
+ break;
102
+ }
103
+ }
104
+ if (matches) {
105
+ let matchStartIndex = 0;
106
+ for (let k = 0; k < i; k++) {
107
+ matchStartIndex += originalLines[k].length + 1;
108
+ }
109
+ let matchEndIndex = matchStartIndex;
110
+ for (let k = 0; k < searchLines.length; k++) {
111
+ matchEndIndex += originalLines[i + k].length;
112
+ if (k < searchLines.length - 1) {
113
+ matchEndIndex += 1;
114
+ }
115
+ }
116
+ yield content.substring(matchStartIndex, matchEndIndex);
117
+ }
118
+ }
119
+ };
120
+
121
+ const BlockAnchorReplacer: Replacer = function* (content, find) {
122
+ const originalLines = content.split("\n");
123
+ const searchLines = find.split("\n");
124
+ if (searchLines.length < 3) {
125
+ return;
126
+ }
127
+ if (searchLines[searchLines.length - 1] === "") {
128
+ searchLines.pop();
129
+ }
130
+ const firstLineSearch = searchLines[0].trim();
131
+ const lastLineSearch = searchLines[searchLines.length - 1].trim();
132
+ const searchBlockSize = searchLines.length;
133
+ const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25));
134
+
135
+ const candidates: Array<{ startLine: number; endLine: number }> = [];
136
+ for (let i = 0; i < originalLines.length; i++) {
137
+ if (originalLines[i].trim() !== firstLineSearch) {
138
+ continue;
139
+ }
140
+ for (let j = i + 2; j < originalLines.length; j++) {
141
+ if (originalLines[j].trim() === lastLineSearch) {
142
+ const actualBlockSize = j - i + 1;
143
+ if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
144
+ candidates.push({ startLine: i, endLine: j });
145
+ }
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ if (candidates.length === 0) {
151
+ return;
152
+ }
153
+
154
+ if (candidates.length === 1) {
155
+ const { startLine, endLine } = candidates[0];
156
+ const actualBlockSize = endLine - startLine + 1;
157
+ let similarity = 0;
158
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
159
+ if (linesToCheck > 0) {
160
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
161
+ const originalLine = originalLines[startLine + j].trim();
162
+ const searchLine = searchLines[j].trim();
163
+ const maxLen = Math.max(originalLine.length, searchLine.length);
164
+ if (maxLen === 0) {
165
+ continue;
166
+ }
167
+ const distance = levenshtein(originalLine, searchLine);
168
+ similarity += (1 - distance / maxLen) / linesToCheck;
169
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
170
+ break;
171
+ }
172
+ }
173
+ } else {
174
+ similarity = 1.0;
175
+ }
176
+ if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
177
+ let matchStartIndex = 0;
178
+ for (let k = 0; k < startLine; k++) {
179
+ matchStartIndex += originalLines[k].length + 1;
180
+ }
181
+ let matchEndIndex = matchStartIndex;
182
+ for (let k = startLine; k <= endLine; k++) {
183
+ matchEndIndex += originalLines[k].length;
184
+ if (k < endLine) {
185
+ matchEndIndex += 1;
186
+ }
187
+ }
188
+ yield content.substring(matchStartIndex, matchEndIndex);
189
+ }
190
+ return;
191
+ }
192
+
193
+ let bestMatch: { startLine: number; endLine: number } | null = null;
194
+ let maxSimilarity = -1;
195
+ for (const candidate of candidates) {
196
+ const { startLine, endLine } = candidate;
197
+ const actualBlockSize = endLine - startLine + 1;
198
+ let similarity = 0;
199
+ const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
200
+ if (linesToCheck > 0) {
201
+ for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
202
+ const originalLine = originalLines[startLine + j].trim();
203
+ const searchLine = searchLines[j].trim();
204
+ const maxLen = Math.max(originalLine.length, searchLine.length);
205
+ if (maxLen === 0) {
206
+ continue;
207
+ }
208
+ const distance = levenshtein(originalLine, searchLine);
209
+ similarity += 1 - distance / maxLen;
210
+ }
211
+ similarity /= linesToCheck;
212
+ } else {
213
+ similarity = 1.0;
214
+ }
215
+ if (similarity > maxSimilarity) {
216
+ maxSimilarity = similarity;
217
+ bestMatch = candidate;
218
+ }
219
+ }
220
+ if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
221
+ const { startLine, endLine } = bestMatch;
222
+ let matchStartIndex = 0;
223
+ for (let k = 0; k < startLine; k++) {
224
+ matchStartIndex += originalLines[k].length + 1;
225
+ }
226
+ let matchEndIndex = matchStartIndex;
227
+ for (let k = startLine; k <= endLine; k++) {
228
+ matchEndIndex += originalLines[k].length;
229
+ if (k < endLine) {
230
+ matchEndIndex += 1;
231
+ }
232
+ }
233
+ yield content.substring(matchStartIndex, matchEndIndex);
234
+ }
235
+ };
236
+
237
+ const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
238
+ const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
239
+ const normalizedFind = normalizeWhitespace(find);
240
+ const lines = content.split("\n");
241
+ for (let i = 0; i < lines.length; i++) {
242
+ const line = lines[i];
243
+ if (normalizeWhitespace(line) === normalizedFind) {
244
+ yield line;
245
+ } else {
246
+ const normalizedLine = normalizeWhitespace(line);
247
+ if (normalizedLine.includes(normalizedFind)) {
248
+ const words = find.trim().split(/\s+/);
249
+ if (words.length > 0) {
250
+ const pattern = words
251
+ .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
252
+ .join("\\s+");
253
+ try {
254
+ const regex = new RegExp(pattern);
255
+ const match = line.match(regex);
256
+ if (match) {
257
+ yield match[0];
258
+ }
259
+ } catch {
260
+ // Invalid regex pattern, skip
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+ const findLines = find.split("\n");
267
+ if (findLines.length > 1) {
268
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
269
+ const block = lines.slice(i, i + findLines.length);
270
+ if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
271
+ yield block.join("\n");
272
+ }
273
+ }
274
+ }
275
+ };
276
+
277
+ const IndentationFlexibleReplacer: Replacer = function* (content, find) {
278
+ const removeIndentation = (text: string) => {
279
+ const lines = text.split("\n");
280
+ const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
281
+ if (nonEmptyLines.length === 0) return text;
282
+ const minIndent = Math.min(
283
+ ...nonEmptyLines.map((line) => {
284
+ const match = line.match(/^(\s*)/);
285
+ return match ? match[1].length : 0;
286
+ }),
287
+ );
288
+ return lines
289
+ .map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
290
+ .join("\n");
291
+ };
292
+ const normalizedFind = removeIndentation(find);
293
+ const contentLines = content.split("\n");
294
+ const findLines = find.split("\n");
295
+ for (let i = 0; i <= contentLines.length - findLines.length; i++) {
296
+ const block = contentLines.slice(i, i + findLines.length).join("\n");
297
+ if (removeIndentation(block) === normalizedFind) {
298
+ yield block;
299
+ }
300
+ }
301
+ };
302
+
303
+ const EscapeNormalizedReplacer: Replacer = function* (content, find) {
304
+ const unescapeString = (str: string): string => {
305
+ return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
306
+ switch (capturedChar) {
307
+ case "n":
308
+ return "\n";
309
+ case "t":
310
+ return "\t";
311
+ case "r":
312
+ return "\r";
313
+ case "'":
314
+ return "'";
315
+ case '"':
316
+ return '"';
317
+ case "`":
318
+ return "`";
319
+ case "\\":
320
+ return "\\";
321
+ case "\n":
322
+ return "\n";
323
+ case "$":
324
+ return "$";
325
+ default:
326
+ return _match;
327
+ }
328
+ });
329
+ };
330
+ const unescapedFind = unescapeString(find);
331
+ if (content.includes(unescapedFind)) {
332
+ yield unescapedFind;
333
+ }
334
+ const lines = content.split("\n");
335
+ const findLines = unescapedFind.split("\n");
336
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
337
+ const block = lines.slice(i, i + findLines.length).join("\n");
338
+ const unescapedBlock = unescapeString(block);
339
+ if (unescapedBlock === unescapedFind) {
340
+ yield block;
341
+ }
342
+ }
343
+ };
344
+
345
+ const MultiOccurrenceReplacer: Replacer = function* (content, find) {
346
+ let startIndex = 0;
347
+ while (true) {
348
+ const index = content.indexOf(find, startIndex);
349
+ if (index === -1) break;
350
+ yield find;
351
+ startIndex = index + find.length;
352
+ }
353
+ };
354
+
355
+ const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
356
+ const trimmedFind = find.trim();
357
+ if (trimmedFind === find) {
358
+ return;
359
+ }
360
+ if (content.includes(trimmedFind)) {
361
+ yield trimmedFind;
362
+ }
363
+ const lines = content.split("\n");
364
+ const findLines = find.split("\n");
365
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
366
+ const block = lines.slice(i, i + findLines.length).join("\n");
367
+ if (block.trim() === trimmedFind) {
368
+ yield block;
369
+ }
370
+ }
371
+ };
372
+
373
+ const ContextAwareReplacer: Replacer = function* (content, find) {
374
+ const findLines = find.split("\n");
375
+ if (findLines.length < 3) {
376
+ return;
377
+ }
378
+ if (findLines[findLines.length - 1] === "") {
379
+ findLines.pop();
380
+ }
381
+ const contentLines = content.split("\n");
382
+ const firstLine = findLines[0].trim();
383
+ const lastLine = findLines[findLines.length - 1].trim();
384
+ for (let i = 0; i < contentLines.length; i++) {
385
+ if (contentLines[i].trim() !== firstLine) continue;
386
+ for (let j = i + 2; j < contentLines.length; j++) {
387
+ if (contentLines[j].trim() === lastLine) {
388
+ const blockLines = contentLines.slice(i, j + 1);
389
+ const block = blockLines.join("\n");
390
+ if (blockLines.length === findLines.length) {
391
+ let matchingLines = 0;
392
+ let totalNonEmptyLines = 0;
393
+ for (let k = 1; k < blockLines.length - 1; k++) {
394
+ const blockLine = blockLines[k].trim();
395
+ const findLine = findLines[k].trim();
396
+ if (blockLine.length > 0 || findLine.length > 0) {
397
+ totalNonEmptyLines++;
398
+ if (blockLine === findLine) {
399
+ matchingLines++;
400
+ }
401
+ }
402
+ }
403
+ if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
404
+ yield block;
405
+ break;
406
+ }
407
+ }
408
+ break;
409
+ }
410
+ }
411
+ }
412
+ };
413
+
414
+ function isDisproportionateMatch(search: string, oldString: string) {
415
+ const oldLines = oldString.split("\n").length;
416
+ const searchLines = search.split("\n").length;
417
+ if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
418
+ if (oldLines === 1) return false;
419
+ return (
420
+ search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
421
+ );
422
+ }
423
+
424
+ function replace(
425
+ content: string,
426
+ oldString: string,
427
+ newString: string,
428
+ replaceAll = false,
429
+ ): string {
430
+ if (oldString === newString) {
431
+ throw new Error("No changes to apply: oldString and newString are identical.");
432
+ }
433
+ if (oldString === "") {
434
+ throw new Error(
435
+ "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
436
+ );
437
+ }
438
+
439
+ let notFound = true;
440
+
441
+ for (const replacer of [
442
+ SimpleReplacer,
443
+ LineTrimmedReplacer,
444
+ BlockAnchorReplacer,
445
+ WhitespaceNormalizedReplacer,
446
+ IndentationFlexibleReplacer,
447
+ EscapeNormalizedReplacer,
448
+ TrimmedBoundaryReplacer,
449
+ ContextAwareReplacer,
450
+ MultiOccurrenceReplacer,
451
+ ]) {
452
+ for (const search of replacer(content, oldString)) {
453
+ const index = content.indexOf(search);
454
+ if (index === -1) continue;
455
+ notFound = false;
456
+ if (isDisproportionateMatch(search, oldString)) {
457
+ throw new Error(
458
+ "Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
459
+ );
460
+ }
461
+ if (replaceAll) {
462
+ return content.replaceAll(search, newString);
463
+ }
464
+ const lastIndex = content.lastIndexOf(search);
465
+ if (index !== lastIndex) continue;
466
+ return content.substring(0, index) + newString + content.substring(index + search.length);
467
+ }
468
+ }
469
+
470
+ if (notFound) {
471
+ throw new Error(
472
+ "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
473
+ );
474
+ }
475
+ throw new Error(
476
+ "Found multiple matches for oldString. Provide more surrounding context to make the match unique.",
477
+ );
478
+ }
479
+
480
+ // ── extension ─────────────────────────────────────────────────────────────────
481
+
482
+ export default function (pi: ExtensionAPI) {
483
+ pi.registerTool({
484
+ name: "edit",
485
+ label: "edit",
486
+ description:
487
+ "Performs exact string replacements in an existing file.\n" +
488
+ "The edit will FAIL if oldString is not unique in the file.\n" +
489
+ " * Either provide a larger string with more surrounding context to make it unique, or use replaceAll to change every instance of oldString.",
490
+ promptSnippet:
491
+ "Make targeted string replacements in files using exact oldString/newString matching",
492
+ promptGuidelines: [
493
+ "Prefer editing existing files. Never write new files unless explicitly required.",
494
+ "Use the edit tool for targeted changes. Use oldString/newString with exact matching content.",
495
+ "Keep oldString as small as possible while still being unique in the file. Do not pad with large unchanged regions.",
496
+ "The edit will FAIL if oldString is not found or is found multiple times. Provide more context to make it unique or use replaceAll.",
497
+ "Use replaceAll for renaming variables or replacing all instances of a string.",
498
+ ],
499
+ parameters: editSchema,
500
+
501
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
502
+ const filePath = params.filePath;
503
+ const oldString = params.oldString;
504
+ const newString = params.newString;
505
+ const replaceAll = params.replaceAll ?? false;
506
+
507
+ const absolutePath = isAbsolute(filePath) ? filePath : resolve(ctx.cwd, filePath);
508
+
509
+ return withFileMutationQueue(absolutePath, async () => {
510
+ const throwIfAborted = (): void => {
511
+ if (signal?.aborted) throw new Error("Operation aborted");
512
+ };
513
+ throwIfAborted();
514
+
515
+ try {
516
+ await access(absolutePath, constants.R_OK | constants.W_OK);
517
+ } catch (error: unknown) {
518
+ throwIfAborted();
519
+ const msg =
520
+ error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
521
+ throw new Error(`Could not edit file: ${filePath}. ${msg}.`);
522
+ }
523
+ throwIfAborted();
524
+
525
+ const buffer = await readFile(absolutePath);
526
+ const rawContent = buffer.toString("utf-8");
527
+ throwIfAborted();
528
+
529
+ // Strip BOM then normalize line endings to LF.
530
+ // The opencode replacers split on \n and expect only LF.
531
+ const { bom, text: content } = stripBom(rawContent);
532
+ const originalEnding = detectLineEnding(content);
533
+ const normalizedContent = normalizeToLF(content);
534
+
535
+ const newContent = replace(normalizedContent, oldString, newString, replaceAll);
536
+ throwIfAborted();
537
+
538
+ const finalContent = bom + restoreLineEndings(newContent, originalEnding);
539
+ await writeFile(absolutePath, finalContent, "utf-8");
540
+ throwIfAborted();
541
+
542
+ const diffResult = generateDiffString(normalizedContent, newContent);
543
+ const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
544
+ return {
545
+ content: [
546
+ {
547
+ type: "text" as const,
548
+ text: `Successfully edited ${filePath}`,
549
+ },
550
+ ],
551
+ details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
552
+ };
553
+ });
554
+ },
555
+ });
556
+ }