@trim21/personal-pi-extensions 0.0.137 → 0.0.140
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 +2 -2
- package/package.json +1 -1
- package/src/gh-readonly.ts +23 -67
- package/src/opencode-edit-engine.ts +465 -0
- package/src/opencode-edit.ts +9 -446
- package/src/workspace-guard.ts +132 -9
package/README.md
CHANGED
|
@@ -85,7 +85,7 @@ pi -e ./src/bwrap/index.ts
|
|
|
85
85
|
阻止 `write` 和 `edit` 工具写入 workspace 外部的路径。读取工具(`read`、`ls`、`find`、`grep`)不受限制。
|
|
86
86
|
|
|
87
87
|
- workspace 内或 `/tmp` 下的路径自动放行
|
|
88
|
-
-
|
|
88
|
+
- 外部路径需通过确认对话框由用户审批,对话框内以 ```diff 代码块展示将要发生的变更预览(与 opencode-edit 共享匹配引擎,能定位时显示带行号的真实 patch,否则退化为参数 diff)
|
|
89
89
|
- 无需配置
|
|
90
90
|
|
|
91
91
|
### 使用
|
|
@@ -98,7 +98,7 @@ pi -e ./src/workspace-guard.ts
|
|
|
98
98
|
|
|
99
99
|
## opencode-edit
|
|
100
100
|
|
|
101
|
-
替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode
|
|
101
|
+
替换内置 `edit` 工具,使用 [opencode](https://github.com/anomalyco/opencode) 的 schema 和模糊匹配引擎。核心 replacer 和 `replace()` 函数直接复制自 opencode,行为与原版完全一致。匹配引擎位于 `src/opencode-edit-engine.ts`,与 workspace-guard 的审批弹窗 diff 预览共享。
|
|
102
102
|
|
|
103
103
|
支持的匹配策略:
|
|
104
104
|
|
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -171,60 +171,6 @@ export class GhError extends Error {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/** How long `read-github-pr-status` waits for pending checks to resolve. */
|
|
175
|
-
const POLL_INTERVAL_MS = 30_000;
|
|
176
|
-
const POLL_TIMEOUT_MS = 30 * 60_000;
|
|
177
|
-
|
|
178
|
-
/** Sleep for `ms`, resolving early if `signal` is aborted. */
|
|
179
|
-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
180
|
-
return new Promise((resolve) => {
|
|
181
|
-
if (signal?.aborted) {
|
|
182
|
-
resolve();
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const onAbort = () => {
|
|
186
|
-
clearTimeout(timer);
|
|
187
|
-
resolve();
|
|
188
|
-
};
|
|
189
|
-
const timer = setTimeout(() => {
|
|
190
|
-
signal?.removeEventListener("abort", onAbort);
|
|
191
|
-
resolve();
|
|
192
|
-
}, ms);
|
|
193
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Poll a `gh pr checks` query until it reaches a final state.
|
|
199
|
-
*
|
|
200
|
-
* `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some pending.
|
|
201
|
-
* Pending (8) is polled every `intervalMs` until `timeoutMs` elapses, at which
|
|
202
|
-
* point the current result is returned as-is. Any other code is returned
|
|
203
|
-
* immediately; the caller decides whether it is an error.
|
|
204
|
-
*/
|
|
205
|
-
export async function pollChecksResult<R extends { code: number; stdout: string }>(
|
|
206
|
-
query: () => Promise<R>,
|
|
207
|
-
opts: { signal?: AbortSignal; intervalMs?: number; timeoutMs?: number } = {},
|
|
208
|
-
): Promise<R> {
|
|
209
|
-
const { signal, intervalMs = POLL_INTERVAL_MS, timeoutMs = POLL_TIMEOUT_MS } = opts;
|
|
210
|
-
const deadline = Date.now() + timeoutMs;
|
|
211
|
-
for (;;) {
|
|
212
|
-
if (signal?.aborted) {
|
|
213
|
-
throw new Error("read-github-pr-status aborted");
|
|
214
|
-
}
|
|
215
|
-
const result = await query();
|
|
216
|
-
if (result.code !== 8) {
|
|
217
|
-
// 0 = all passed, 1 = some failed, anything else is a real error
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
// Still pending — keep waiting unless the overall timeout expired
|
|
221
|
-
if (Date.now() >= deadline) {
|
|
222
|
-
return result;
|
|
223
|
-
}
|
|
224
|
-
await sleep(intervalMs, signal);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
174
|
/** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
|
|
229
175
|
export async function ghExec(
|
|
230
176
|
args: string[],
|
|
@@ -407,11 +353,20 @@ async function getJobLog(
|
|
|
407
353
|
// Not cached, fetch from GitHub
|
|
408
354
|
}
|
|
409
355
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
356
|
+
// `gh api` refuses to print responses that contain terminal escape
|
|
357
|
+
// sequences unless `--allow-escape-sequences` is passed. Job logs are a
|
|
358
|
+
// binary zip, so without this flag the download always fails with
|
|
359
|
+
// "the response contains terminal escape sequences; pass
|
|
360
|
+
// --allow-escape-sequences to output it anyway". The ANSI escapes are
|
|
361
|
+
// stripped later by `cleanStepOutput`, so there is no injection surface.
|
|
362
|
+
const log = await ghExec(
|
|
363
|
+
["api", "--allow-escape-sequences", `/repos/${effectiveRepo}/actions/jobs/${jobId}/logs`],
|
|
364
|
+
{
|
|
365
|
+
cwd,
|
|
366
|
+
signal,
|
|
367
|
+
input,
|
|
368
|
+
},
|
|
369
|
+
);
|
|
415
370
|
|
|
416
371
|
// Write to cache
|
|
417
372
|
await mkdir(cacheDir, { recursive: true });
|
|
@@ -1026,7 +981,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1026
981
|
pi.registerTool({
|
|
1027
982
|
name: "read-github-pr-status",
|
|
1028
983
|
label: "GitHub PR Status",
|
|
1029
|
-
description:
|
|
984
|
+
description:
|
|
985
|
+
"Get the current status checks and CI results for a GitHub pull request. Returns the current snapshot immediately; pending checks are reported as-is, not waited on. Use wait-github-pr-checks to block until checks finish.",
|
|
1030
986
|
promptSnippet: "Read GitHub PR status checks",
|
|
1031
987
|
parameters: Type.Object({
|
|
1032
988
|
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
@@ -1036,16 +992,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1036
992
|
const { number, repo } = params;
|
|
1037
993
|
const args = ["pr", "checks", String(number), ...repoArgs(repo)];
|
|
1038
994
|
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
});
|
|
995
|
+
// `gh pr checks` exit codes: 0 = all passed, 1 = some failed, 8 = some
|
|
996
|
+
// pending. All three are valid states — return the current snapshot
|
|
997
|
+
// as-is without waiting. `wait-github-pr-checks` is the blocking variant.
|
|
998
|
+
const result = await runGh(args, { cwd: ctx.cwd, signal });
|
|
1043
999
|
|
|
1044
|
-
if (
|
|
1000
|
+
if (result.code !== 0 && result.code !== 1 && result.code !== 8) {
|
|
1045
1001
|
// Anything else is a real error (cancelled, auth, network, ...)
|
|
1046
|
-
throw new GhError(args,
|
|
1002
|
+
throw new GhError(args, result, params);
|
|
1047
1003
|
}
|
|
1048
|
-
return toToolResult(
|
|
1004
|
+
return toToolResult(result.stdout, params);
|
|
1049
1005
|
},
|
|
1050
1006
|
});
|
|
1051
1007
|
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opencode edit matching engine.
|
|
3
|
+
*
|
|
4
|
+
* The core replacers and replace() function are copied directly from
|
|
5
|
+
* https://github.com/anomalyco/opencode (packages/opencode/src/tool/edit.ts)
|
|
6
|
+
* and wrapped in a pi extension so the behaviour is identical to opencode.
|
|
7
|
+
*
|
|
8
|
+
* Shared by:
|
|
9
|
+
* - opencode-edit.ts — the edit tool implementation
|
|
10
|
+
* - workspace-guard.ts — the diff preview shown in the approval dialog
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── BOM & line ending helpers ─────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export function stripBom(content: string): { bom: string; text: string } {
|
|
16
|
+
return content.startsWith("\uFEFF")
|
|
17
|
+
? { bom: "\uFEFF", text: content.slice(1) }
|
|
18
|
+
: { bom: "", text: content };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectLineEnding(content: string): "\r\n" | "\n" {
|
|
22
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeToLF(text: string): string {
|
|
26
|
+
return text.replaceAll("\r\n", "\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
|
|
30
|
+
return ending === "\r\n" ? text.replaceAll("\n", "\r\n") : text;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Strip BOM and normalize line endings to LF (what the replacers expect). */
|
|
34
|
+
export function normalizeForEdit(content: string): string {
|
|
35
|
+
const { text } = stripBom(content);
|
|
36
|
+
return normalizeToLF(text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── copied from opencode ──────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function levenshtein(a: string, b: string): number {
|
|
42
|
+
if (a === "" || b === "") {
|
|
43
|
+
return Math.max(a.length, b.length);
|
|
44
|
+
}
|
|
45
|
+
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
46
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
47
|
+
);
|
|
48
|
+
for (let i = 1; i <= a.length; i++) {
|
|
49
|
+
for (let j = 1; j <= b.length; j++) {
|
|
50
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
51
|
+
matrix[i][j] = Math.min(
|
|
52
|
+
matrix[i - 1][j] + 1,
|
|
53
|
+
matrix[i][j - 1] + 1,
|
|
54
|
+
matrix[i - 1][j - 1] + cost,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return matrix[a.length][b.length];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Replacer = (content: string, find: string) => Generator<string, void, unknown>;
|
|
62
|
+
|
|
63
|
+
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65;
|
|
64
|
+
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65;
|
|
65
|
+
|
|
66
|
+
const SimpleReplacer: Replacer = function* (_content, find) {
|
|
67
|
+
yield find;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
71
|
+
const originalLines = content.split("\n");
|
|
72
|
+
const searchLines = find.split("\n");
|
|
73
|
+
if (searchLines[searchLines.length - 1] === "") {
|
|
74
|
+
searchLines.pop();
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
77
|
+
let matches = true;
|
|
78
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
79
|
+
const originalTrimmed = originalLines[i + j].trim();
|
|
80
|
+
const searchTrimmed = searchLines[j].trim();
|
|
81
|
+
if (originalTrimmed !== searchTrimmed) {
|
|
82
|
+
matches = false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (matches) {
|
|
87
|
+
let matchStartIndex = 0;
|
|
88
|
+
for (let k = 0; k < i; k++) {
|
|
89
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
90
|
+
}
|
|
91
|
+
let matchEndIndex = matchStartIndex;
|
|
92
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
93
|
+
matchEndIndex += originalLines[i + k].length;
|
|
94
|
+
if (k < searchLines.length - 1) {
|
|
95
|
+
matchEndIndex += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
104
|
+
const originalLines = content.split("\n");
|
|
105
|
+
const searchLines = find.split("\n");
|
|
106
|
+
if (searchLines.length < 3) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (searchLines[searchLines.length - 1] === "") {
|
|
110
|
+
searchLines.pop();
|
|
111
|
+
}
|
|
112
|
+
const firstLineSearch = searchLines[0].trim();
|
|
113
|
+
const lastLineSearch = searchLines[searchLines.length - 1].trim();
|
|
114
|
+
const searchBlockSize = searchLines.length;
|
|
115
|
+
const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25));
|
|
116
|
+
|
|
117
|
+
const candidates: Array<{ startLine: number; endLine: number }> = [];
|
|
118
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
119
|
+
if (originalLines[i].trim() !== firstLineSearch) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (let j = i + 2; j < originalLines.length; j++) {
|
|
123
|
+
if (originalLines[j].trim() === lastLineSearch) {
|
|
124
|
+
const actualBlockSize = j - i + 1;
|
|
125
|
+
if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
|
|
126
|
+
candidates.push({ startLine: i, endLine: j });
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (candidates.length === 0) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (candidates.length === 1) {
|
|
137
|
+
const { startLine, endLine } = candidates[0];
|
|
138
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
139
|
+
let similarity = 0;
|
|
140
|
+
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
141
|
+
if (linesToCheck > 0) {
|
|
142
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
143
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
144
|
+
const searchLine = searchLines[j].trim();
|
|
145
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
146
|
+
if (maxLen === 0) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
150
|
+
similarity += (1 - distance / maxLen) / linesToCheck;
|
|
151
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
similarity = 1.0;
|
|
157
|
+
}
|
|
158
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
159
|
+
let matchStartIndex = 0;
|
|
160
|
+
for (let k = 0; k < startLine; k++) {
|
|
161
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
162
|
+
}
|
|
163
|
+
let matchEndIndex = matchStartIndex;
|
|
164
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
165
|
+
matchEndIndex += originalLines[k].length;
|
|
166
|
+
if (k < endLine) {
|
|
167
|
+
matchEndIndex += 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let bestMatch: { startLine: number; endLine: number } | null = null;
|
|
176
|
+
let maxSimilarity = -1;
|
|
177
|
+
for (const candidate of candidates) {
|
|
178
|
+
const { startLine, endLine } = candidate;
|
|
179
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
180
|
+
let similarity = 0;
|
|
181
|
+
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
182
|
+
if (linesToCheck > 0) {
|
|
183
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
184
|
+
const originalLine = originalLines[startLine + j].trim();
|
|
185
|
+
const searchLine = searchLines[j].trim();
|
|
186
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
187
|
+
if (maxLen === 0) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
191
|
+
similarity += 1 - distance / maxLen;
|
|
192
|
+
}
|
|
193
|
+
similarity /= linesToCheck;
|
|
194
|
+
} else {
|
|
195
|
+
similarity = 1.0;
|
|
196
|
+
}
|
|
197
|
+
if (similarity > maxSimilarity) {
|
|
198
|
+
maxSimilarity = similarity;
|
|
199
|
+
bestMatch = candidate;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
|
203
|
+
const { startLine, endLine } = bestMatch;
|
|
204
|
+
let matchStartIndex = 0;
|
|
205
|
+
for (let k = 0; k < startLine; k++) {
|
|
206
|
+
matchStartIndex += originalLines[k].length + 1;
|
|
207
|
+
}
|
|
208
|
+
let matchEndIndex = matchStartIndex;
|
|
209
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
210
|
+
matchEndIndex += originalLines[k].length;
|
|
211
|
+
if (k < endLine) {
|
|
212
|
+
matchEndIndex += 1;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
yield content.substring(matchStartIndex, matchEndIndex);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
220
|
+
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
221
|
+
const normalizedFind = normalizeWhitespace(find);
|
|
222
|
+
const lines = content.split("\n");
|
|
223
|
+
for (let i = 0; i < lines.length; i++) {
|
|
224
|
+
const line = lines[i];
|
|
225
|
+
if (normalizeWhitespace(line) === normalizedFind) {
|
|
226
|
+
yield line;
|
|
227
|
+
} else {
|
|
228
|
+
const normalizedLine = normalizeWhitespace(line);
|
|
229
|
+
if (normalizedLine.includes(normalizedFind)) {
|
|
230
|
+
const words = find.trim().split(/\s+/);
|
|
231
|
+
if (words.length > 0) {
|
|
232
|
+
const pattern = words
|
|
233
|
+
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
234
|
+
.join("\\s+");
|
|
235
|
+
try {
|
|
236
|
+
const regex = new RegExp(pattern);
|
|
237
|
+
const match = line.match(regex);
|
|
238
|
+
if (match) {
|
|
239
|
+
yield match[0];
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// Invalid regex pattern, skip
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const findLines = find.split("\n");
|
|
249
|
+
if (findLines.length > 1) {
|
|
250
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
251
|
+
const block = lines.slice(i, i + findLines.length);
|
|
252
|
+
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
|
253
|
+
yield block.join("\n");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
260
|
+
const removeIndentation = (text: string) => {
|
|
261
|
+
const lines = text.split("\n");
|
|
262
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0);
|
|
263
|
+
if (nonEmptyLines.length === 0) return text;
|
|
264
|
+
const minIndent = Math.min(
|
|
265
|
+
...nonEmptyLines.map((line) => {
|
|
266
|
+
const match = line.match(/^(\s*)/);
|
|
267
|
+
return match ? match[1].length : 0;
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
return lines
|
|
271
|
+
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
|
272
|
+
.join("\n");
|
|
273
|
+
};
|
|
274
|
+
const normalizedFind = removeIndentation(find);
|
|
275
|
+
const contentLines = content.split("\n");
|
|
276
|
+
const findLines = find.split("\n");
|
|
277
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
278
|
+
const block = contentLines.slice(i, i + findLines.length).join("\n");
|
|
279
|
+
if (removeIndentation(block) === normalizedFind) {
|
|
280
|
+
yield block;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
286
|
+
const unescapeString = (str: string): string => {
|
|
287
|
+
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (_match, capturedChar) => {
|
|
288
|
+
switch (capturedChar) {
|
|
289
|
+
case "n":
|
|
290
|
+
return "\n";
|
|
291
|
+
case "t":
|
|
292
|
+
return "\t";
|
|
293
|
+
case "r":
|
|
294
|
+
return "\r";
|
|
295
|
+
case "'":
|
|
296
|
+
return "'";
|
|
297
|
+
case '"':
|
|
298
|
+
return '"';
|
|
299
|
+
case "`":
|
|
300
|
+
return "`";
|
|
301
|
+
case "\\":
|
|
302
|
+
return "\\";
|
|
303
|
+
case "\n":
|
|
304
|
+
return "\n";
|
|
305
|
+
case "$":
|
|
306
|
+
return "$";
|
|
307
|
+
default:
|
|
308
|
+
return _match;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
const unescapedFind = unescapeString(find);
|
|
313
|
+
if (content.includes(unescapedFind)) {
|
|
314
|
+
yield unescapedFind;
|
|
315
|
+
}
|
|
316
|
+
const lines = content.split("\n");
|
|
317
|
+
const findLines = unescapedFind.split("\n");
|
|
318
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
319
|
+
const block = lines.slice(i, i + findLines.length).join("\n");
|
|
320
|
+
const unescapedBlock = unescapeString(block);
|
|
321
|
+
if (unescapedBlock === unescapedFind) {
|
|
322
|
+
yield block;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
328
|
+
let startIndex = 0;
|
|
329
|
+
while (true) {
|
|
330
|
+
const index = content.indexOf(find, startIndex);
|
|
331
|
+
if (index === -1) break;
|
|
332
|
+
yield find;
|
|
333
|
+
startIndex = index + find.length;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
338
|
+
const trimmedFind = find.trim();
|
|
339
|
+
if (trimmedFind === find) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (content.includes(trimmedFind)) {
|
|
343
|
+
yield trimmedFind;
|
|
344
|
+
}
|
|
345
|
+
const lines = content.split("\n");
|
|
346
|
+
const findLines = find.split("\n");
|
|
347
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
348
|
+
const block = lines.slice(i, i + findLines.length).join("\n");
|
|
349
|
+
if (block.trim() === trimmedFind) {
|
|
350
|
+
yield block;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
356
|
+
const findLines = find.split("\n");
|
|
357
|
+
if (findLines.length < 3) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (findLines[findLines.length - 1] === "") {
|
|
361
|
+
findLines.pop();
|
|
362
|
+
}
|
|
363
|
+
const contentLines = content.split("\n");
|
|
364
|
+
const firstLine = findLines[0].trim();
|
|
365
|
+
const lastLine = findLines[findLines.length - 1].trim();
|
|
366
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
367
|
+
if (contentLines[i].trim() !== firstLine) continue;
|
|
368
|
+
for (let j = i + 2; j < contentLines.length; j++) {
|
|
369
|
+
if (contentLines[j].trim() === lastLine) {
|
|
370
|
+
const blockLines = contentLines.slice(i, j + 1);
|
|
371
|
+
const block = blockLines.join("\n");
|
|
372
|
+
if (blockLines.length === findLines.length) {
|
|
373
|
+
let matchingLines = 0;
|
|
374
|
+
let totalNonEmptyLines = 0;
|
|
375
|
+
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
376
|
+
const blockLine = blockLines[k].trim();
|
|
377
|
+
const findLine = findLines[k].trim();
|
|
378
|
+
if (blockLine.length > 0 || findLine.length > 0) {
|
|
379
|
+
totalNonEmptyLines++;
|
|
380
|
+
if (blockLine === findLine) {
|
|
381
|
+
matchingLines++;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
|
386
|
+
yield block;
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
function isDisproportionateMatch(search: string, oldString: string) {
|
|
397
|
+
const oldLines = oldString.split("\n").length;
|
|
398
|
+
const searchLines = search.split("\n").length;
|
|
399
|
+
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true;
|
|
400
|
+
if (oldLines === 1) return false;
|
|
401
|
+
return (
|
|
402
|
+
search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Replace `oldString` with `newString` in `content`, using opencode's matching
|
|
408
|
+
* engine. Expects LF-normalized content (see `normalizeForEdit`).
|
|
409
|
+
* Throws when oldString cannot be matched or the match is ambiguous.
|
|
410
|
+
*/
|
|
411
|
+
export function replace(
|
|
412
|
+
content: string,
|
|
413
|
+
oldString: string,
|
|
414
|
+
newString: string,
|
|
415
|
+
replaceAll = false,
|
|
416
|
+
): string {
|
|
417
|
+
if (oldString === newString) {
|
|
418
|
+
throw new Error("No changes to apply: oldString and newString are identical.");
|
|
419
|
+
}
|
|
420
|
+
if (oldString === "") {
|
|
421
|
+
throw new Error(
|
|
422
|
+
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let notFound = true;
|
|
427
|
+
|
|
428
|
+
for (const replacer of [
|
|
429
|
+
SimpleReplacer,
|
|
430
|
+
LineTrimmedReplacer,
|
|
431
|
+
BlockAnchorReplacer,
|
|
432
|
+
WhitespaceNormalizedReplacer,
|
|
433
|
+
IndentationFlexibleReplacer,
|
|
434
|
+
EscapeNormalizedReplacer,
|
|
435
|
+
TrimmedBoundaryReplacer,
|
|
436
|
+
ContextAwareReplacer,
|
|
437
|
+
MultiOccurrenceReplacer,
|
|
438
|
+
]) {
|
|
439
|
+
for (const search of replacer(content, oldString)) {
|
|
440
|
+
const index = content.indexOf(search);
|
|
441
|
+
if (index === -1) continue;
|
|
442
|
+
notFound = false;
|
|
443
|
+
if (isDisproportionateMatch(search, oldString)) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
"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.",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (replaceAll) {
|
|
449
|
+
return content.replaceAll(search, newString);
|
|
450
|
+
}
|
|
451
|
+
const lastIndex = content.lastIndexOf(search);
|
|
452
|
+
if (index !== lastIndex) continue;
|
|
453
|
+
return content.substring(0, index) + newString + content.substring(index + search.length);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (notFound) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
throw new Error(
|
|
463
|
+
"Found multiple matches for oldString. Provide more surrounding context to make the match unique.",
|
|
464
|
+
);
|
|
465
|
+
}
|
package/src/opencode-edit.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Opencode Edit Extension — Replaces the built-in edit tool with opencode's
|
|
3
3
|
* schema and matching engine.
|
|
4
4
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
7
|
-
* and wrapped in a pi extension so the behaviour is identical to opencode.
|
|
5
|
+
* The matching engine (replacers + replace()) lives in opencode-edit-engine.ts
|
|
6
|
+
* and is also used by workspace-guard for the diff preview.
|
|
8
7
|
*
|
|
9
8
|
* Usage:
|
|
10
9
|
* pi -e ./opencode-edit.ts
|
|
@@ -16,6 +15,13 @@ import {
|
|
|
16
15
|
generateUnifiedPatch,
|
|
17
16
|
withFileMutationQueue,
|
|
18
17
|
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import {
|
|
19
|
+
detectLineEnding,
|
|
20
|
+
normalizeToLF,
|
|
21
|
+
replace,
|
|
22
|
+
restoreLineEndings,
|
|
23
|
+
stripBom,
|
|
24
|
+
} from "./opencode-edit-engine.js";
|
|
19
25
|
import { constants } from "fs";
|
|
20
26
|
import { access, readFile, writeFile } from "fs/promises";
|
|
21
27
|
import { isAbsolute, resolve } from "path";
|
|
@@ -34,449 +40,6 @@ const editSchema = Type.Object({
|
|
|
34
40
|
),
|
|
35
41
|
});
|
|
36
42
|
|
|
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
43
|
// ── extension ─────────────────────────────────────────────────────────────────
|
|
481
44
|
|
|
482
45
|
export default function (pi: ExtensionAPI) {
|
package/src/workspace-guard.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* File-modifying tools (write, edit) are gated:
|
|
5
5
|
* - Paths inside the workspace or /tmp are auto-allowed.
|
|
6
6
|
* - Paths outside require user approval via confirmation dialog.
|
|
7
|
+
* The dialog shows a ```diff code block preview of the pending change.
|
|
7
8
|
*
|
|
8
9
|
* Read tools (read, ls, find, grep) are unrestricted.
|
|
9
10
|
*
|
|
@@ -11,14 +12,23 @@
|
|
|
11
12
|
* pi -e workspace-guard
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import { isAbsolute, join, resolve, relative, sep } from "node:path";
|
|
15
|
+
import { basename, isAbsolute, join, resolve, relative, sep } from "node:path";
|
|
15
16
|
import { homedir } from "node:os";
|
|
16
|
-
import
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import {
|
|
19
|
+
generateUnifiedPatch,
|
|
20
|
+
type ExtensionAPI,
|
|
21
|
+
type ToolCallEvent,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
|
|
17
24
|
|
|
18
25
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
19
26
|
const ALWAYS_ALLOW = ["/tmp"];
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
/** Maximum lines of the diff preview shown in the approval dialog. */
|
|
29
|
+
const MAX_PREVIEW_LINES = 100;
|
|
30
|
+
|
|
31
|
+
export function getWriteTarget(input: ToolCallEvent["input"]): string | undefined {
|
|
22
32
|
if (typeof input !== "object" || input === null) return undefined;
|
|
23
33
|
if ("path" in input && typeof input.path === "string") return input.path;
|
|
24
34
|
if ("filePath" in input && typeof input.filePath === "string") return input.filePath;
|
|
@@ -52,6 +62,115 @@ function isPathAllowed(resolvedPath: string, cwd: string): boolean {
|
|
|
52
62
|
return false;
|
|
53
63
|
}
|
|
54
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Type guard for built-in edit entries `{ oldText, newText }`.
|
|
67
|
+
*/
|
|
68
|
+
function isEditPair(value: unknown): value is { oldText: string; newText: string } {
|
|
69
|
+
return (
|
|
70
|
+
typeof value === "object" &&
|
|
71
|
+
value !== null &&
|
|
72
|
+
"oldText" in value &&
|
|
73
|
+
typeof value.oldText === "string" &&
|
|
74
|
+
"newText" in value &&
|
|
75
|
+
typeof value.newText === "string"
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract the opencode-style edit pair `{ oldString, newString, replaceAll }`.
|
|
81
|
+
*/
|
|
82
|
+
function getOpencodeEditPair(
|
|
83
|
+
input: ToolCallEvent["input"],
|
|
84
|
+
): { oldString: string; newString: string; replaceAll: boolean } | undefined {
|
|
85
|
+
if (typeof input !== "object" || input === null) return undefined;
|
|
86
|
+
if (!("oldString" in input) || !("newString" in input)) return undefined;
|
|
87
|
+
if (typeof input.oldString !== "string" || typeof input.newString !== "string") return undefined;
|
|
88
|
+
return {
|
|
89
|
+
oldString: input.oldString,
|
|
90
|
+
newString: input.newString,
|
|
91
|
+
replaceAll: "replaceAll" in input && input.replaceAll === true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Apply the pending write/edit to `content`.
|
|
97
|
+
* Returns undefined when the change cannot be applied (missing fields or oldText not found).
|
|
98
|
+
*/
|
|
99
|
+
function applyChange(
|
|
100
|
+
toolName: string,
|
|
101
|
+
input: ToolCallEvent["input"],
|
|
102
|
+
content: string,
|
|
103
|
+
): string | undefined {
|
|
104
|
+
if (typeof input !== "object" || input === null) return undefined;
|
|
105
|
+
|
|
106
|
+
if (toolName === "write") {
|
|
107
|
+
return "content" in input && typeof input.content === "string" ? input.content : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Built-in edit: { path, edits: [{ oldText, newText }] }
|
|
111
|
+
if ("edits" in input && Array.isArray(input.edits)) {
|
|
112
|
+
let next = content;
|
|
113
|
+
for (const edit of input.edits) {
|
|
114
|
+
if (!isEditPair(edit)) return undefined;
|
|
115
|
+
if (!next.includes(edit.oldText)) return undefined;
|
|
116
|
+
next = next.replace(edit.oldText, edit.newText);
|
|
117
|
+
}
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Wrap patch text in a ```diff code block, truncating very large diffs. */
|
|
125
|
+
function wrapDiff(patch: string): string {
|
|
126
|
+
const lines = patch.split("\n");
|
|
127
|
+
if (lines.length > MAX_PREVIEW_LINES) {
|
|
128
|
+
const truncated = lines.slice(0, MAX_PREVIEW_LINES).join("\n");
|
|
129
|
+
return `\`\`\`diff\n${truncated}\n… (preview truncated to ${MAX_PREVIEW_LINES} lines)\n\`\`\``;
|
|
130
|
+
}
|
|
131
|
+
return `\`\`\`diff\n${patch}\n\`\`\``;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a ```diff code block preview of the pending change.
|
|
136
|
+
* Returns undefined when the diff cannot be computed.
|
|
137
|
+
*/
|
|
138
|
+
export async function buildDiffPreview(
|
|
139
|
+
toolName: string,
|
|
140
|
+
input: ToolCallEvent["input"],
|
|
141
|
+
resolvedPath: string,
|
|
142
|
+
): Promise<string | undefined> {
|
|
143
|
+
let oldContent = "";
|
|
144
|
+
try {
|
|
145
|
+
oldContent = await readFile(resolvedPath, "utf-8");
|
|
146
|
+
} catch {
|
|
147
|
+
// Unreadable or missing file: treat as empty so writes show as full additions.
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// opencode-edit: reuse the real matching engine to locate oldString, giving a
|
|
151
|
+
// line-numbered patch when it matches. Fall back to a parameter diff when the
|
|
152
|
+
// edit cannot be applied (oldString not found, ambiguous, or no file).
|
|
153
|
+
const pair = getOpencodeEditPair(input);
|
|
154
|
+
if (pair) {
|
|
155
|
+
try {
|
|
156
|
+
const normalized = normalizeForEdit(oldContent);
|
|
157
|
+
const newContent = replace(normalized, pair.oldString, pair.newString, pair.replaceAll);
|
|
158
|
+
// The full path is shown in the dialog title, so the patch header only
|
|
159
|
+
// carries the file name.
|
|
160
|
+
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), normalized, newContent));
|
|
161
|
+
} catch {
|
|
162
|
+
const removed = pair.oldString.split("\n").map((line) => `-${line}`);
|
|
163
|
+
const added = pair.newString.split("\n").map((line) => `+${line}`);
|
|
164
|
+
return wrapDiff([...removed, ...added].join("\n"));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const newContent = applyChange(toolName, input, oldContent);
|
|
169
|
+
if (newContent === undefined) return undefined;
|
|
170
|
+
|
|
171
|
+
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent));
|
|
172
|
+
}
|
|
173
|
+
|
|
55
174
|
export default function (pi: ExtensionAPI) {
|
|
56
175
|
pi.on("before_agent_start", (event, ctx) => {
|
|
57
176
|
const currentCwd = ctx.cwd;
|
|
@@ -83,13 +202,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
202
|
|
|
84
203
|
let choice: string | undefined;
|
|
85
204
|
while (!choice) {
|
|
86
|
-
|
|
205
|
+
const diffPreview = await buildDiffPreview(event.toolName, event.input, resolved);
|
|
206
|
+
|
|
207
|
+
const title =
|
|
87
208
|
`Model requests write access outside workspace:\n\n` +
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
209
|
+
` Tool: ${event.toolName}\n` +
|
|
210
|
+
` Path: ${rawPath}\n` +
|
|
211
|
+
` Resolved: ${resolved}\n` +
|
|
212
|
+
(diffPreview ? `\n${diffPreview}\n` : "") +
|
|
213
|
+
`\nAllow?`;
|
|
214
|
+
|
|
215
|
+
choice = await ctx.ui.select(title, ["Approve once", "Block", "Block with reason"]);
|
|
93
216
|
|
|
94
217
|
if (typeof choice === "undefined") {
|
|
95
218
|
ctx.abort();
|