pi-crew 0.6.4 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +68 -0
  3. package/package.json +2 -1
  4. package/src/errors.ts +20 -2
  5. package/src/extension/knowledge-injection.ts +71 -0
  6. package/src/extension/pi-api.ts +47 -0
  7. package/src/extension/register.ts +19 -6
  8. package/src/extension/registration/commands.ts +40 -1
  9. package/src/extension/registration/compaction-guard.ts +154 -14
  10. package/src/extension/team-tool/handle-settings.ts +57 -0
  11. package/src/extension/team-tool/inspect.ts +4 -1
  12. package/src/extension/team-tool/plan.ts +8 -1
  13. package/src/runtime/intercom-bridge.ts +5 -1
  14. package/src/runtime/replace.ts +555 -0
  15. package/src/runtime/resilient-edit.ts +166 -0
  16. package/src/runtime/single-agent-compose.ts +87 -0
  17. package/src/runtime/task-runner/prompt-builder.ts +6 -0
  18. package/src/runtime/team-runner.ts +24 -7
  19. package/src/schema/team-tool-schema.ts +6 -0
  20. package/src/state/usage.ts +73 -0
  21. package/src/ui/card-colors.ts +126 -0
  22. package/src/ui/deploy-bundled-themes.ts +71 -0
  23. package/src/ui/render-diff.ts +37 -3
  24. package/src/ui/settings-overlay.ts +70 -7
  25. package/src/ui/syntax-highlight.ts +42 -23
  26. package/src/ui/theme-discovery.ts +188 -0
  27. package/src/ui/tool-renderers/index.ts +27 -14
  28. package/themes/crew-catppuccin-latte.json +96 -0
  29. package/themes/crew-catppuccin-mocha.json +93 -0
  30. package/themes/crew-dark.json +90 -0
  31. package/themes/crew-dracula.json +83 -0
  32. package/themes/crew-gruvbox-dark.json +83 -0
  33. package/themes/crew-gruvbox-light.json +90 -0
  34. package/themes/crew-nord.json +85 -0
  35. package/themes/crew-one-dark.json +89 -0
  36. package/themes/crew-solarized-dark.json +90 -0
  37. package/themes/crew-solarized-light.json +92 -0
  38. package/themes/crew-tokyo-night.json +85 -0
  39. package/src/extension/registration/brief-tool-overrides.ts +0 -400
  40. package/src/runtime/budget-tracker.ts +0 -354
  41. package/src/state/memory-store.ts +0 -244
@@ -4,6 +4,11 @@ import { configPatchFromConfig } from "../team-tool/config-patch.ts";
4
4
  import { result } from "../team-tool/context.ts";
5
5
  import type { PiTeamsToolResult } from "../tool-result.ts";
6
6
  import { suggestConfigKey } from "../../config/suggestions.ts";
7
+ import {
8
+ formatThemesListing,
9
+ discoverPiThemes,
10
+ setPiTheme,
11
+ } from "../../ui/theme-discovery.ts";
7
12
 
8
13
  // ---------------------------------------------------------------------------
9
14
  // Effective defaults — values used when config key is not set
@@ -244,6 +249,8 @@ const USAGE = [
244
249
  " json Show full effective config as JSON",
245
250
  " schema Show all known config keys (schema reference)",
246
251
  " paths Show config file paths (user + project)",
252
+ " themes Browse theme gallery (Pi UI themes)",
253
+ " theme <name> Switch the Pi UI theme (applies live, no restart)",
247
254
  " get <key> Get a specific config value",
248
255
  " set <key> <value> Set a config value",
249
256
  " unset <key> Remove a config value",
@@ -319,6 +326,56 @@ export function handleSettings(params: { config?: Record<string, unknown> }, ctx
319
326
  return result(lines.join("\n"), { ...OK, path: loaded.path } as never);
320
327
  }
321
328
 
329
+ // team-settings themes — browse the theme gallery
330
+ if (args === "themes" || args === "theme-gallery") {
331
+ return result(formatThemesListing(), { ...OK } as never);
332
+ }
333
+
334
+ // team-settings theme <name> — switch the Pi UI theme
335
+ if (args === "theme" || args.startsWith("theme ")) {
336
+ const name = args === "theme" ? "" : args.slice(6).trim();
337
+ if (!name) {
338
+ const available = discoverPiThemes().map((t) => t.name).join(", ");
339
+ return result(
340
+ `Usage: team-settings theme <name>\n\nAvailable Pi themes: ${available}\n\nBrowse all: team-settings themes`,
341
+ { ...ERR },
342
+ true,
343
+ );
344
+ }
345
+ const available = discoverPiThemes();
346
+ const exists = available.some((t) => t.name === name);
347
+ if (!exists) {
348
+ const hint = available.map((t) => t.name).join(", ");
349
+ return result(
350
+ `Unknown Pi theme: ${name}\n\nAvailable: ${hint}\n\nCustom themes live in ~/.pi/agent/themes/<name>.json`,
351
+ { ...ERR },
352
+ true,
353
+ );
354
+ }
355
+ try {
356
+ const savedTo = setPiTheme(name);
357
+ return result(
358
+ [
359
+ `✓ Pi theme set to '${name}'`,
360
+ ` Written to: ${savedTo}`,
361
+ ` Applied live — no restart needed.`,
362
+ ].join("\n"),
363
+ { ...OK, theme: name } as never,
364
+ );
365
+ } catch (error) {
366
+ return result(error instanceof Error ? error.message : String(error), { ...ERR }, true);
367
+ }
368
+ }
369
+
370
+ // team-settings shiki <name> — removed (Shiki highlighting dropped)
371
+ if (args === "shiki" || args === "shiki-theme" || args.startsWith("shiki ") || args.startsWith("shiki-theme ")) {
372
+ return result(
373
+ `Shiki syntax highlighting has been removed from pi-crew.\nUse 'team-settings theme <name>' to switch the Pi UI theme, which drives code-block colors.`,
374
+ { ...ERR },
375
+ true,
376
+ );
377
+ }
378
+
322
379
  // team-settings scope [user|project]
323
380
  if (args === "scope" || args.startsWith("scope ")) {
324
381
  const scopeArg = args === "scope" ? "" : args.slice(6).trim();
@@ -1,7 +1,7 @@
1
1
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
2
  import { readEvents } from "../../state/event-log.ts";
3
3
  import { loadRunManifestById } from "../../state/state-store.ts";
4
- import { aggregateUsage, formatUsage } from "../../state/usage.ts";
4
+ import { aggregateUsage, formatUsage, formatCostReport } from "../../state/usage.ts";
5
5
  import type { PiTeamsToolResult } from "../tool-result.ts";
6
6
  import { locateRunCwd } from "../team-tool.ts";
7
7
  import { result, type TeamContext } from "./context.ts";
@@ -41,6 +41,9 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
41
41
  `Workflow: ${loaded.manifest.workflow ?? "(none)"}`,
42
42
  `Goal: ${loaded.manifest.goal}`,
43
43
  `Usage: ${formatUsage(usage)}`,
44
+ "",
45
+ formatCostReport(loaded.tasks),
46
+ "",
44
47
  "Tasks:",
45
48
  ...loaded.tasks.map((task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`),
46
49
  ];
@@ -4,6 +4,7 @@ import { validateWorkflowForTeam } from "../../workflows/validate-workflow.ts";
4
4
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
5
5
  import type { PiTeamsToolResult } from "../tool-result.ts";
6
6
  import { result, type TeamContext } from "./context.ts";
7
+ import { composeSingleAgentPrompt } from "../../runtime/single-agent-compose.ts";
7
8
 
8
9
  export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
9
10
  const teamName = params.team ?? "default";
@@ -14,6 +15,12 @@ export function handlePlan(params: TeamToolParamsValue, ctx: TeamContext): PiTea
14
15
  if (!workflow) return result(`Workflow '${workflowName}' not found.`, { action: "plan", status: "error" }, true);
15
16
  const errors = validateWorkflowForTeam(workflow, team);
16
17
  if (errors.length > 0) return result([`Workflow '${workflow.name}' is not valid for team '${team.name}':`, ...errors.map((error) => `- ${error}`)].join("\n"), { action: "plan", status: "error" }, true);
17
- const lines = [`Team plan: ${team.name}`, `Workflow: ${workflow.name}`, `Goal: ${params.goal ?? params.task ?? "(not provided)"}`, "", "Steps:", ...workflow.steps.map((step, index) => `${index + 1}. ${step.id} [${step.role}]${step.dependsOn?.length ? ` after ${step.dependsOn.join(", ")}` : ""}`)];
18
+ const goal = params.goal ?? params.task ?? "(not provided)";
19
+ // ROADMAP T2.2: single-agent composition mode (cliff hedge).
20
+ if (params.singleAgent) {
21
+ const composed = composeSingleAgentPrompt(workflow, goal);
22
+ return result([`Single-agent plan for ${team.name} / ${workflow.name} (${composed.stepCount} phases composed into one sequential prompt):`, "", composed.prompt, "", "This prompt can be handed to a single agent to execute the entire workflow sequentially — pi-crew's cliff-resilient mode (survives single-agent domination)."].join("\n"), { action: "plan", status: "ok" });
23
+ }
24
+ const lines = [`Team plan: ${team.name}`, `Workflow: ${workflow.name}`, `Goal: ${goal}`, "", "Steps:", ...workflow.steps.map((step, index) => `${index + 1}. ${step.id} [${step.role}]${step.dependsOn?.length ? ` after ${step.dependsOn.join(", ")}` : ""}`)];
18
25
  return result(lines.join("\n"), { action: "plan", status: "ok" });
19
26
  }
@@ -51,6 +51,10 @@ const MAX_QUEUE_SIZE = 100;
51
51
  export class IntercomQueue {
52
52
  private pending = new Map<string, PendingMessage>();
53
53
  private queue: IntercomMessage[] = [];
54
+ /** Monotonic sequence counter — guarantees unique IDs even when many
55
+ * messages enqueue within the same millisecond (avoids Date.now()
56
+ * collision + weak Math.random birthday-paradox flakes under load). */
57
+ private seq = 0;
54
58
 
55
59
  /**
56
60
  * Enqueue a message and return a promise that resolves when the
@@ -63,7 +67,7 @@ export class IntercomQueue {
63
67
  if (firstKey) this.evict(firstKey, "queue_full");
64
68
  }
65
69
 
66
- const id = `icm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
70
+ const id = `icm-${Date.now().toString(36)}-${(this.seq++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
67
71
 
68
72
  return new Promise<IntercomResponse>((resolve) => {
69
73
  const entry: PendingMessage = { message, id, resolve };
@@ -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
+ }