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.
- package/CHANGELOG.md +62 -0
- package/README.md +68 -0
- package/package.json +2 -1
- package/src/errors.ts +20 -2
- package/src/extension/knowledge-injection.ts +71 -0
- package/src/extension/pi-api.ts +47 -0
- package/src/extension/register.ts +19 -6
- package/src/extension/registration/commands.ts +40 -1
- package/src/extension/registration/compaction-guard.ts +154 -14
- 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/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/task-runner/prompt-builder.ts +6 -0
- package/src/runtime/team-runner.ts +24 -7
- package/src/schema/team-tool-schema.ts +6 -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/render-diff.ts +37 -3
- package/src/ui/settings-overlay.ts +70 -7
- package/src/ui/syntax-highlight.ts +42 -23
- package/src/ui/theme-discovery.ts +188 -0
- package/src/ui/tool-renderers/index.ts +27 -14
- 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/extension/registration/brief-tool-overrides.ts +0 -400
- package/src/runtime/budget-tracker.ts +0 -354
- 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
|
|
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,
|
|
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
|
+
}
|