pi-crew 0.6.3 → 0.7.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.
- package/CHANGELOG.md +84 -0
- package/README.md +80 -1
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -1
- package/src/extension/knowledge-injection.ts +71 -0
- package/src/extension/pi-api.ts +47 -0
- package/src/extension/register.ts +32 -1
- package/src/extension/registration/commands.ts +65 -1
- package/src/extension/registration/compaction-guard.ts +154 -14
- package/src/extension/registration/subagent-tools.ts +8 -3
- package/src/extension/registration/team-tool.ts +18 -11
- package/src/extension/team-tool/handle-settings.ts +57 -0
- package/src/extension/team-tool/inspect.ts +4 -1
- package/src/extension/team-tool/plan.ts +8 -1
- package/src/extension/team-tool/run.ts +4 -3
- package/src/extension/team-tool-types.ts +2 -0
- package/src/runtime/intercom-bridge.ts +5 -1
- package/src/runtime/replace.ts +555 -0
- package/src/runtime/resilient-edit.ts +166 -0
- package/src/runtime/single-agent-compose.ts +87 -0
- package/src/runtime/team-runner.ts +23 -6
- package/src/schema/team-tool-schema.ts +6 -0
- package/src/state/session-state-map.ts +51 -0
- package/src/state/usage.ts +73 -0
- package/src/ui/card-colors.ts +126 -0
- package/src/ui/deploy-bundled-themes.ts +71 -0
- package/src/ui/powerbar-publisher.ts +1 -1
- package/src/ui/render-diff.ts +37 -3
- package/src/ui/settings-overlay.ts +70 -7
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/syntax-highlight.ts +42 -23
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/theme-discovery.ts +188 -0
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +640 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/guards.ts +110 -0
- package/themes/crew-catppuccin-latte.json +96 -0
- package/themes/crew-catppuccin-mocha.json +93 -0
- package/themes/crew-dark.json +90 -0
- package/themes/crew-dracula.json +83 -0
- package/themes/crew-gruvbox-dark.json +83 -0
- package/themes/crew-gruvbox-light.json +90 -0
- package/themes/crew-nord.json +85 -0
- package/themes/crew-one-dark.json +89 -0
- package/themes/crew-solarized-dark.json +90 -0
- package/themes/crew-solarized-light.json +92 -0
- package/themes/crew-tokyo-night.json +85 -0
- package/src/runtime/budget-tracker.ts +0 -354
- package/src/state/memory-store.ts +0 -244
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascading text replacement engine ported from pi-diff.
|
|
3
|
+
*
|
|
4
|
+
* Tries exact → escape-normalized → line-trimmed → block-anchor →
|
|
5
|
+
* whitespace-normalized → trimmed-boundary matching to be lenient when
|
|
6
|
+
* AI agents provide slightly-wrong oldText (indentation drift, whitespace
|
|
7
|
+
* differences, escaped characters).
|
|
8
|
+
*
|
|
9
|
+
* When an LLM calls an edit tool with oldString + newString, the oldString
|
|
10
|
+
* often doesn't match exactly due to:
|
|
11
|
+
* - Whitespace differences (indentation, trailing spaces)
|
|
12
|
+
* - Escape sequences (LLMs escaping \n, \t, quotes in tool call params)
|
|
13
|
+
* - Minor formatting drift (tabs vs spaces, trimmed lines)
|
|
14
|
+
*
|
|
15
|
+
* This module provides a cascade of replacer strategies, each progressively
|
|
16
|
+
* more lenient. The first strategy that finds exactly one match wins.
|
|
17
|
+
* If multiple candidates exist for a fuzzy strategy, we reject (safety first).
|
|
18
|
+
*
|
|
19
|
+
* Design inspired by OpenCode's edit tool (anomalyco/opencode) and
|
|
20
|
+
* Cline's diff-apply evals, but restructured for independent use.
|
|
21
|
+
*
|
|
22
|
+
* Standalone module: no pi-diff or pi-specific dependencies.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export interface ReplaceResult {
|
|
30
|
+
/** The resulting content after replacement (unchanged if no match). */
|
|
31
|
+
content: string;
|
|
32
|
+
/** Whether a replacement was made. */
|
|
33
|
+
changed: boolean;
|
|
34
|
+
/** Name of the replacer strategy that matched, or "none". */
|
|
35
|
+
strategy: string;
|
|
36
|
+
/** Number of occurrences replaced (only when changed=true). */
|
|
37
|
+
count: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A replacer yields candidate substrings from `content` that match `find`.
|
|
42
|
+
* Each yielded string is an actual substring of `content` that the caller
|
|
43
|
+
* should replace. Yields nothing when no match is found.
|
|
44
|
+
*/
|
|
45
|
+
type Replacer = (content: string, find: string) => Generator<string, void, undefined>;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function countOccurrences(content: string, substring: string): number {
|
|
52
|
+
if (substring.length === 0) return 0;
|
|
53
|
+
let count = 0;
|
|
54
|
+
let pos = 0;
|
|
55
|
+
while (true) {
|
|
56
|
+
pos = content.indexOf(substring, pos);
|
|
57
|
+
if (pos === -1) break;
|
|
58
|
+
count++;
|
|
59
|
+
pos += substring.length;
|
|
60
|
+
}
|
|
61
|
+
return count;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Levenshtein distance for block anchor similarity comparison. */
|
|
65
|
+
function levenshtein(a: string, b: string): number {
|
|
66
|
+
if (a === "" || b === "") return Math.max(a.length, b.length);
|
|
67
|
+
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
68
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
69
|
+
);
|
|
70
|
+
for (let i = 1; i <= a.length; i++) {
|
|
71
|
+
for (let j = 1; j <= b.length; j++) {
|
|
72
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
73
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return matrix[a.length][b.length];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Replacers (in priority order)
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 1. Simple exact match.
|
|
85
|
+
*/
|
|
86
|
+
const SimpleReplacer: Replacer = function* (content, find) {
|
|
87
|
+
if (content.includes(find)) yield find;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 2. Escape-normalized match — unescapes common escape sequences
|
|
92
|
+
* in the find string before matching. Handles LLMs that escape
|
|
93
|
+
* tool call parameters (\\n, \\t, \\", etc.).
|
|
94
|
+
*
|
|
95
|
+
* Runs early (after Simple) so unescaped content flows through
|
|
96
|
+
* line-level strategies (LineTrimmed, BlockAnchor) correctly.
|
|
97
|
+
* Inspired by OpenCode's EscapeNormalizedReplacer.
|
|
98
|
+
*/
|
|
99
|
+
const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
100
|
+
const unescapeStr = (str: string): string => {
|
|
101
|
+
return str.replace(/\\([nrt'"`\\$])/g, (_match: string, char: string) => {
|
|
102
|
+
switch (char) {
|
|
103
|
+
case "n":
|
|
104
|
+
return "\n";
|
|
105
|
+
case "t":
|
|
106
|
+
return "\t";
|
|
107
|
+
case "r":
|
|
108
|
+
return "\r";
|
|
109
|
+
case "'":
|
|
110
|
+
return "'";
|
|
111
|
+
case '"':
|
|
112
|
+
return '"';
|
|
113
|
+
case "`":
|
|
114
|
+
return "`";
|
|
115
|
+
case "\\":
|
|
116
|
+
return "\\";
|
|
117
|
+
case "$":
|
|
118
|
+
return "$";
|
|
119
|
+
default:
|
|
120
|
+
return char;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const unescaped = unescapeStr(find);
|
|
126
|
+
if (unescaped === find) return; // nothing was escaped, skip
|
|
127
|
+
if (unescaped.length === 0) return;
|
|
128
|
+
|
|
129
|
+
// Yield the unescaped string if found directly in content
|
|
130
|
+
if (content.includes(unescaped)) {
|
|
131
|
+
yield unescaped;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fallback: find matching blocks in content
|
|
136
|
+
const contentLines = content.split("\n");
|
|
137
|
+
const findLines = unescaped.split("\n");
|
|
138
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
139
|
+
const block = contentLines.slice(i, i + findLines.length).join("\n");
|
|
140
|
+
if (block === unescaped || block.trim() === unescaped.trim()) {
|
|
141
|
+
yield block;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 3. Line-trimmed match — compares lines after trimming whitespace.
|
|
149
|
+
* Handles cases where indentation or trailing whitespace differs.
|
|
150
|
+
*/
|
|
151
|
+
const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
152
|
+
const contentLines = content.split("\n");
|
|
153
|
+
const findLines = find.split("\n");
|
|
154
|
+
|
|
155
|
+
// Remove trailing empty line from find if present
|
|
156
|
+
if (findLines.length > 1 && findLines[findLines.length - 1] === "") {
|
|
157
|
+
findLines.pop();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (findLines.length > contentLines.length) return;
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
163
|
+
let matches = true;
|
|
164
|
+
for (let j = 0; j < findLines.length; j++) {
|
|
165
|
+
if (contentLines[i + j].trim() !== findLines[j].trim()) {
|
|
166
|
+
matches = false;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (matches) {
|
|
171
|
+
// Compute the actual substring in the original content
|
|
172
|
+
let startPos = 0;
|
|
173
|
+
for (let k = 0; k < i; k++) {
|
|
174
|
+
startPos += contentLines[k].length + 1;
|
|
175
|
+
}
|
|
176
|
+
let endPos = startPos;
|
|
177
|
+
for (let k = 0; k < findLines.length; k++) {
|
|
178
|
+
endPos += contentLines[i + k].length;
|
|
179
|
+
if (k < findLines.length - 1) endPos += 1;
|
|
180
|
+
}
|
|
181
|
+
yield content.slice(startPos, endPos);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 4. Block anchor match — uses first and last lines as anchors,
|
|
188
|
+
* then compares middle lines with Levenshtein similarity.
|
|
189
|
+
* Requires at least 3 lines in the find string.
|
|
190
|
+
*
|
|
191
|
+
* Dual threshold: single candidates get a lower bar (anchors alone
|
|
192
|
+
* are strong evidence), multiple candidates need higher similarity
|
|
193
|
+
* to disambiguate. Inspired by OpenCode's BlockAnchorReplacer.
|
|
194
|
+
*/
|
|
195
|
+
const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
196
|
+
const contentLines = content.split("\n");
|
|
197
|
+
const findLines = find.split("\n");
|
|
198
|
+
|
|
199
|
+
// Need at least 3 lines for meaningful anchor matching
|
|
200
|
+
if (findLines.length < 3) return;
|
|
201
|
+
|
|
202
|
+
if (findLines[findLines.length - 1] === "") findLines.pop();
|
|
203
|
+
if (findLines.length < 3) return;
|
|
204
|
+
|
|
205
|
+
const firstAnchor = findLines[0].trim();
|
|
206
|
+
const lastAnchor = findLines[findLines.length - 1].trim();
|
|
207
|
+
const searchBlockSize = findLines.length;
|
|
208
|
+
|
|
209
|
+
const SINGLE_CANDIDATE_THRESHOLD = 0.25;
|
|
210
|
+
const MULTIPLE_CANDIDATES_THRESHOLD = 0.4;
|
|
211
|
+
|
|
212
|
+
// Collect candidate positions where both anchors match
|
|
213
|
+
const candidates: Array<{ startLine: number; endLine: number }> = [];
|
|
214
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
215
|
+
if (contentLines[i].trim() !== firstAnchor) continue;
|
|
216
|
+
// Look for matching last line after this first line
|
|
217
|
+
for (let j = i + 2; j < contentLines.length; j++) {
|
|
218
|
+
if (contentLines[j].trim() === lastAnchor) {
|
|
219
|
+
candidates.push({ startLine: i, endLine: j });
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (candidates.length === 0) return;
|
|
226
|
+
|
|
227
|
+
// Score each candidate by Levenshtein similarity
|
|
228
|
+
// Single candidate: relaxed threshold (anchors provide strong signal)
|
|
229
|
+
// Multiple candidates: require higher similarity to disambiguate
|
|
230
|
+
const isSingleCandidate = candidates.length === 1;
|
|
231
|
+
const threshold = isSingleCandidate ? SINGLE_CANDIDATE_THRESHOLD : MULTIPLE_CANDIDATES_THRESHOLD;
|
|
232
|
+
|
|
233
|
+
if (isSingleCandidate) {
|
|
234
|
+
const { startLine, endLine } = candidates[0];
|
|
235
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
236
|
+
const middleCount = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
237
|
+
let similarity = 0;
|
|
238
|
+
if (middleCount > 0) {
|
|
239
|
+
for (let j = 1; j <= middleCount; j++) {
|
|
240
|
+
const originalLine = contentLines[startLine + j].trim();
|
|
241
|
+
const searchLine = findLines[j].trim();
|
|
242
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
243
|
+
if (maxLen === 0) continue;
|
|
244
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
245
|
+
similarity += 1 - distance / maxLen;
|
|
246
|
+
}
|
|
247
|
+
similarity /= middleCount;
|
|
248
|
+
} else {
|
|
249
|
+
// No middle lines — anchors alone suffice
|
|
250
|
+
similarity = 1;
|
|
251
|
+
}
|
|
252
|
+
if (similarity >= threshold) {
|
|
253
|
+
let startPos = 0;
|
|
254
|
+
for (let k = 0; k < startLine; k++) startPos += contentLines[k].length + 1;
|
|
255
|
+
let endPos = startPos;
|
|
256
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
257
|
+
endPos += contentLines[k].length;
|
|
258
|
+
if (k < endLine) endPos += 1;
|
|
259
|
+
}
|
|
260
|
+
yield content.slice(startPos, endPos);
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Multiple candidates: pick best match above higher threshold
|
|
266
|
+
let bestMatch: { startLine: number; endLine: number } | null = null;
|
|
267
|
+
let bestSimilarity = -1;
|
|
268
|
+
for (const candidate of candidates) {
|
|
269
|
+
const { startLine, endLine } = candidate;
|
|
270
|
+
const actualBlockSize = endLine - startLine + 1;
|
|
271
|
+
const middleCount = Math.min(searchBlockSize - 2, actualBlockSize - 2);
|
|
272
|
+
let similarity = 0;
|
|
273
|
+
if (middleCount > 0) {
|
|
274
|
+
for (let j = 1; j <= middleCount; j++) {
|
|
275
|
+
const originalLine = contentLines[startLine + j].trim();
|
|
276
|
+
const searchLine = findLines[j].trim();
|
|
277
|
+
const maxLen = Math.max(originalLine.length, searchLine.length);
|
|
278
|
+
if (maxLen === 0) continue;
|
|
279
|
+
const distance = levenshtein(originalLine, searchLine);
|
|
280
|
+
similarity += 1 - distance / maxLen;
|
|
281
|
+
}
|
|
282
|
+
similarity /= middleCount;
|
|
283
|
+
} else {
|
|
284
|
+
similarity = 1;
|
|
285
|
+
}
|
|
286
|
+
if (similarity > bestSimilarity) {
|
|
287
|
+
bestSimilarity = similarity;
|
|
288
|
+
bestMatch = candidate;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (!bestMatch || bestSimilarity < threshold) return;
|
|
292
|
+
|
|
293
|
+
// Yield the actual content substring
|
|
294
|
+
const { startLine, endLine } = bestMatch;
|
|
295
|
+
let startPos = 0;
|
|
296
|
+
for (let k = 0; k < startLine; k++) {
|
|
297
|
+
startPos += contentLines[k].length + 1;
|
|
298
|
+
}
|
|
299
|
+
let endPos = startPos;
|
|
300
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
301
|
+
endPos += contentLines[k].length;
|
|
302
|
+
if (k < endLine) endPos += 1;
|
|
303
|
+
}
|
|
304
|
+
yield content.slice(startPos, endPos);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 5. Whitespace-normalized match — collapses all whitespace runs
|
|
309
|
+
* to single spaces and trims. Handles any whitespace differences.
|
|
310
|
+
*/
|
|
311
|
+
const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
312
|
+
const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
|
|
313
|
+
const normalizedFind = normalize(find);
|
|
314
|
+
|
|
315
|
+
if (normalizedFind.length === 0) return;
|
|
316
|
+
const contentLines = content.split("\n");
|
|
317
|
+
const findLines = find.split("\n");
|
|
318
|
+
|
|
319
|
+
// Single-line: find by normalized line content
|
|
320
|
+
if (findLines.length <= 1 || (findLines.length === 2 && findLines[1] === "")) {
|
|
321
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
322
|
+
if (normalize(contentLines[i]) === normalizedFind) {
|
|
323
|
+
yield contentLines[i];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Multi-line: find blocks where normalized content matches
|
|
330
|
+
const effectiveFindLines = findLines[findLines.length - 1] === "" ? findLines.slice(0, -1) : findLines;
|
|
331
|
+
for (let i = 0; i <= contentLines.length - effectiveFindLines.length; i++) {
|
|
332
|
+
const block = contentLines.slice(i, i + effectiveFindLines.length);
|
|
333
|
+
if (normalize(block.join("\n")) === normalizedFind) {
|
|
334
|
+
yield block.join("\n");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 6. Indentation-flexible match — strips common leading indentation
|
|
341
|
+
* before comparing. Handles blocks that shifted indent level.
|
|
342
|
+
* NOTE: Excluded from the REPLACERS cascade — LineTrimmedReplacer's
|
|
343
|
+
* per-line trim() is a superset. Kept here as documentation artifact.
|
|
344
|
+
*/
|
|
345
|
+
const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
346
|
+
const removeIndent = (text: string): string => {
|
|
347
|
+
const lines = text.split("\n");
|
|
348
|
+
const nonEmpty = lines.filter((l) => l.trim().length > 0);
|
|
349
|
+
if (nonEmpty.length === 0) return text;
|
|
350
|
+
const minIndent = Math.min(
|
|
351
|
+
...nonEmpty.map((l) => {
|
|
352
|
+
const m = l.match(/^(\s*)/);
|
|
353
|
+
return m ? m[1].length : 0;
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
return lines.map((l) => (l.trim().length === 0 ? l : l.slice(minIndent))).join("\n");
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const normalizedFind = removeIndent(find);
|
|
360
|
+
if (normalizedFind.length === 0) return;
|
|
361
|
+
|
|
362
|
+
const contentLines = content.split("\n");
|
|
363
|
+
const findLines = find.split("\n");
|
|
364
|
+
const effectiveFindLines = findLines[findLines.length - 1] === "" ? findLines.slice(0, -1) : findLines;
|
|
365
|
+
|
|
366
|
+
for (let i = 0; i <= contentLines.length - effectiveFindLines.length; i++) {
|
|
367
|
+
const block = contentLines.slice(i, i + effectiveFindLines.length).join("\n");
|
|
368
|
+
if (removeIndent(block) === normalizedFind) {
|
|
369
|
+
yield block;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 7. Trimmed-boundary match — trims leading/trailing whitespace
|
|
376
|
+
* from the find string before matching. Handles accidental
|
|
377
|
+
* whitespace at boundaries.
|
|
378
|
+
*/
|
|
379
|
+
const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
380
|
+
const trimmed = find.trim();
|
|
381
|
+
if (trimmed === find || trimmed.length === 0) return;
|
|
382
|
+
|
|
383
|
+
if (content.includes(trimmed)) {
|
|
384
|
+
yield trimmed;
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Fallback: find blocks where the trimmed version matches
|
|
389
|
+
const contentLines = content.split("\n");
|
|
390
|
+
const findLines = find.split("\n");
|
|
391
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
392
|
+
const block = contentLines.slice(i, i + findLines.length).join("\n");
|
|
393
|
+
if (block.trim() === trimmed) {
|
|
394
|
+
yield block;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 8. Multi-occurrence replacer — yields ALL exact matches.
|
|
402
|
+
* NOTE: Excluded from the REPLACERS cascade because replaceAll is
|
|
403
|
+
* handled directly in the public `replace()` fast path for exact
|
|
404
|
+
* matches. Fuzzy-strategy replaceAll would be unsafe (ambiguous).
|
|
405
|
+
* Kept here as documentation artifact.
|
|
406
|
+
*/
|
|
407
|
+
const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
408
|
+
if (find.length === 0) return;
|
|
409
|
+
|
|
410
|
+
let pos = 0;
|
|
411
|
+
while (true) {
|
|
412
|
+
const idx = content.indexOf(find, pos);
|
|
413
|
+
if (idx === -1) break;
|
|
414
|
+
yield find;
|
|
415
|
+
pos = idx + find.length;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 9. Context-aware match — uses first and last lines as context
|
|
421
|
+
* anchors, then checks trimmed-line similarity (50%) for middle
|
|
422
|
+
* lines. Simpler than BlockAnchor (no Levenshtein).
|
|
423
|
+
* NOTE: Excluded from the REPLACERS cascade — BlockAnchorReplacer's
|
|
424
|
+
* Levenshtein-based scoring subsumes this. Kept as documentation.
|
|
425
|
+
*/
|
|
426
|
+
const ContextAwareReplacer: Replacer = function* (_content, _find) {
|
|
427
|
+
// Reference implementation in OpenCode:
|
|
428
|
+
// https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/edit.ts
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// All replacers in priority order (active members)
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
const REPLACERS: Replacer[] = [
|
|
436
|
+
SimpleReplacer,
|
|
437
|
+
EscapeNormalizedReplacer,
|
|
438
|
+
LineTrimmedReplacer,
|
|
439
|
+
BlockAnchorReplacer,
|
|
440
|
+
WhitespaceNormalizedReplacer,
|
|
441
|
+
TrimmedBoundaryReplacer,
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Public API
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Replace oldString with newString in content using a cascade of matching
|
|
450
|
+
* strategies. Tries exact match first, then progressively relaxes matching
|
|
451
|
+
* rules. If no strategy finds a match, returns unchanged content.
|
|
452
|
+
*
|
|
453
|
+
* Safety: if a fuzzy strategy finds multiple candidates, it is skipped
|
|
454
|
+
* (we never auto-pick among ambiguous matches). Only exact matches
|
|
455
|
+
* (SimpleReplacer) are allowed to match multiple occurrences, and only
|
|
456
|
+
* when replaceAll=true.
|
|
457
|
+
*
|
|
458
|
+
* @param content - The full file content to edit.
|
|
459
|
+
* @param oldString - The text to find and replace.
|
|
460
|
+
* @param newString - The replacement text.
|
|
461
|
+
* @param options.replaceAll - When true, replace ALL non-overlapping
|
|
462
|
+
* occurrences. Only safe for exact matches (simple replacer).
|
|
463
|
+
* @returns ReplaceResult with the new content and match strategy info.
|
|
464
|
+
*/
|
|
465
|
+
export function replace(
|
|
466
|
+
content: string,
|
|
467
|
+
oldString: string,
|
|
468
|
+
newString: string,
|
|
469
|
+
options?: { replaceAll?: boolean },
|
|
470
|
+
): ReplaceResult {
|
|
471
|
+
if (oldString.length === 0) {
|
|
472
|
+
return { content, changed: false, strategy: "none", count: 0 };
|
|
473
|
+
}
|
|
474
|
+
if (oldString === newString) {
|
|
475
|
+
return { content, changed: false, strategy: "none", count: 0 };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const replaceAll = options?.replaceAll ?? false;
|
|
479
|
+
|
|
480
|
+
// Fast path: simple exact match with replaceAll
|
|
481
|
+
if (replaceAll && content.includes(oldString)) {
|
|
482
|
+
const result = content.replaceAll(oldString, newString);
|
|
483
|
+
if (result !== content) {
|
|
484
|
+
const count = countOccurrences(content, oldString);
|
|
485
|
+
return { content: result, changed: true, strategy: "simple-replaceAll", count };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Fast path: single exact match
|
|
490
|
+
if (!replaceAll) {
|
|
491
|
+
const idx = content.indexOf(oldString);
|
|
492
|
+
if (idx !== -1) {
|
|
493
|
+
const lastIdx = content.lastIndexOf(oldString);
|
|
494
|
+
if (idx === lastIdx) {
|
|
495
|
+
// Exactly one occurrence
|
|
496
|
+
const result = content.slice(0, idx) + newString + content.slice(idx + oldString.length);
|
|
497
|
+
return { content: result, changed: true, strategy: "simple", count: 1 };
|
|
498
|
+
}
|
|
499
|
+
// Multiple occurrences — fall through to fuzzy strategies
|
|
500
|
+
// (we never auto-pick among duplicates)
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Run through each replacer strategy in priority order
|
|
505
|
+
for (const replacer of REPLACERS) {
|
|
506
|
+
// Skip SimpleReplacer — already handled above
|
|
507
|
+
if (replacer === SimpleReplacer) continue;
|
|
508
|
+
|
|
509
|
+
const candidates: string[] = [];
|
|
510
|
+
for (const candidate of replacer(content, oldString)) {
|
|
511
|
+
candidates.push(candidate);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (candidates.length === 0) continue;
|
|
515
|
+
|
|
516
|
+
if (replaceAll) {
|
|
517
|
+
// For replaceAll, use all candidates
|
|
518
|
+
let result = content;
|
|
519
|
+
let totalCount = 0;
|
|
520
|
+
for (const candidate of candidates) {
|
|
521
|
+
const count = countOccurrences(result, candidate);
|
|
522
|
+
if (count > 0) {
|
|
523
|
+
result = result.replaceAll(candidate, newString);
|
|
524
|
+
totalCount += count;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (totalCount > 0) {
|
|
528
|
+
const strategyName = replacer.name
|
|
529
|
+
.replace("Replacer", "")
|
|
530
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
531
|
+
.toLowerCase();
|
|
532
|
+
return { content: result, changed: true, strategy: `${strategyName}-replaceAll`, count: totalCount };
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Single replacement: must have exactly one candidate
|
|
538
|
+
if (candidates.length === 1) {
|
|
539
|
+
const candidate = candidates[0];
|
|
540
|
+
const idx = content.indexOf(candidate);
|
|
541
|
+
if (idx !== -1) {
|
|
542
|
+
const result = content.slice(0, idx) + newString + content.slice(idx + candidate.length);
|
|
543
|
+
const strategyName = replacer.name
|
|
544
|
+
.replace("Replacer", "")
|
|
545
|
+
.replace(/([a-z])([A-Z])/g, "$1-$2")
|
|
546
|
+
.toLowerCase();
|
|
547
|
+
return { content: result, changed: true, strategy: strategyName, count: 1 };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Multiple candidates — skip this strategy (safety first)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// No match found
|
|
554
|
+
return { content, changed: false, strategy: "none", count: 0 };
|
|
555
|
+
}
|