@c4ccz/zero 1.0.0 → 1.0.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/package.json +10 -7
- package/packages/core/src/agent/config-loader.ts +174 -0
- package/packages/core/src/agent/engine.ts +266 -0
- package/packages/core/src/agent/registry-tools.ts +58 -0
- package/packages/core/src/agent/registry.ts +213 -0
- package/packages/core/src/commands/index.ts +116 -0
- package/packages/core/src/config/index.ts +139 -0
- package/packages/core/src/confirmation/message-bus.ts +206 -0
- package/packages/core/src/diff/index.ts +232 -0
- package/packages/core/src/diff-detect/index.ts +207 -0
- package/packages/core/src/edit-coder/index.ts +194 -0
- package/packages/core/src/events/index.ts +151 -0
- package/packages/core/src/file-tracker/index.ts +183 -0
- package/packages/core/src/git/index.ts +305 -0
- package/packages/core/src/history/index.ts +236 -0
- package/packages/core/src/image/index.ts +131 -0
- package/packages/core/src/index.ts +131 -0
- package/packages/core/src/lsp/index.ts +251 -0
- package/packages/core/src/mcp/index.ts +186 -0
- package/packages/core/src/memory/index.ts +141 -0
- package/packages/core/src/memory/prompts.ts +52 -0
- package/packages/core/src/orchestrator/protocol.ts +140 -0
- package/packages/core/src/orchestrator/server.ts +319 -0
- package/packages/core/src/output/index.ts +164 -0
- package/packages/core/src/permissions/index.ts +126 -0
- package/packages/core/src/prompts/engine.ts +188 -0
- package/packages/core/src/providers/extended.ts +8 -0
- package/packages/core/src/providers/registry.ts +687 -0
- package/packages/core/src/routing/model-router.ts +160 -0
- package/packages/core/src/server/index.ts +232 -0
- package/packages/core/src/session/index.ts +174 -0
- package/packages/core/src/session/service.ts +322 -0
- package/packages/core/src/skills/index.ts +205 -0
- package/packages/core/src/streaming/index.ts +166 -0
- package/packages/core/src/sub-agent/index.ts +179 -0
- package/packages/core/src/tools/builtin.ts +568 -0
- package/packages/core/src/tools/linter.ts +26 -0
- package/packages/core/src/tools/repo-map.ts +11 -0
- package/packages/core/src/types.ts +356 -0
- package/packages/sdk/src/index.ts +247 -0
- package/packages/tui/src/index.ts +141 -0
- package/bun.lock +0 -35
- package/bunfig.toml +0 -6
- package/packages/cli/package.json +0 -23
- package/packages/core/package.json +0 -41
- package/packages/sdk/package.json +0 -16
- package/packages/tui/package.json +0 -19
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO Diff System
|
|
3
|
+
* Smart file editing with diff generation and application
|
|
4
|
+
*
|
|
5
|
+
* Sources:
|
|
6
|
+
* - aider: search/replace diff editing, unified diff
|
|
7
|
+
* - cline: block-based editing
|
|
8
|
+
* - kilocode: precise file modification
|
|
9
|
+
* - crush: diff detection and application
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface DiffBlock {
|
|
13
|
+
type: "context" | "add" | "remove";
|
|
14
|
+
lineNumber: number;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FileDiff {
|
|
19
|
+
filePath: string;
|
|
20
|
+
oldContent: string;
|
|
21
|
+
newContent: string;
|
|
22
|
+
blocks: DiffBlock[];
|
|
23
|
+
stats: { added: number; removed: number; unchanged: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a unified diff between two strings
|
|
28
|
+
*/
|
|
29
|
+
export function generateDiff(oldContent: string, newContent: string, filePath = "file"): FileDiff {
|
|
30
|
+
const oldLines = oldContent.split("\n");
|
|
31
|
+
const newLines = newContent.split("\n");
|
|
32
|
+
const blocks: DiffBlock[] = [];
|
|
33
|
+
|
|
34
|
+
let added = 0;
|
|
35
|
+
let removed = 0;
|
|
36
|
+
let unchanged = 0;
|
|
37
|
+
|
|
38
|
+
// Simple LCS-based diff
|
|
39
|
+
const lcs = computeLCS(oldLines, newLines);
|
|
40
|
+
let i = 0;
|
|
41
|
+
let j = 0;
|
|
42
|
+
let k = 0;
|
|
43
|
+
|
|
44
|
+
while (i < oldLines.length || j < newLines.length) {
|
|
45
|
+
if (k < lcs.length && i < oldLines.length && oldLines[i] === lcs[k] && j < newLines.length && newLines[j] === lcs[k]) {
|
|
46
|
+
blocks.push({ type: "context", lineNumber: i + 1, content: oldLines[i] });
|
|
47
|
+
unchanged++;
|
|
48
|
+
i++;
|
|
49
|
+
j++;
|
|
50
|
+
k++;
|
|
51
|
+
} else if (j < newLines.length && (k >= lcs.length || newLines[j] !== lcs[k])) {
|
|
52
|
+
blocks.push({ type: "add", lineNumber: j + 1, content: newLines[j] });
|
|
53
|
+
added++;
|
|
54
|
+
j++;
|
|
55
|
+
} else if (i < oldLines.length && (k >= lcs.length || oldLines[i] !== lcs[k])) {
|
|
56
|
+
blocks.push({ type: "remove", lineNumber: i + 1, content: oldLines[i] });
|
|
57
|
+
removed++;
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
filePath,
|
|
64
|
+
oldContent,
|
|
65
|
+
newContent,
|
|
66
|
+
blocks,
|
|
67
|
+
stats: { added, removed, unchanged },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format diff as unified diff string
|
|
73
|
+
*/
|
|
74
|
+
export function formatUnifiedDiff(diff: FileDiff): string {
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
lines.push(`--- a/${diff.filePath}`);
|
|
77
|
+
lines.push(`+++ b/${diff.filePath}`);
|
|
78
|
+
lines.push(`@@ -1,${diff.oldContent.split("\n").length} +1,${diff.newContent.split("\n").length} @@`);
|
|
79
|
+
|
|
80
|
+
for (const block of diff.blocks) {
|
|
81
|
+
switch (block.type) {
|
|
82
|
+
case "context":
|
|
83
|
+
lines.push(` ${block.content}`);
|
|
84
|
+
break;
|
|
85
|
+
case "add":
|
|
86
|
+
lines.push(`+${block.content}`);
|
|
87
|
+
break;
|
|
88
|
+
case "remove":
|
|
89
|
+
lines.push(`-${block.content}`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Apply a search/replace edit (aider-style)
|
|
99
|
+
*/
|
|
100
|
+
export function applySearchReplace(
|
|
101
|
+
content: string,
|
|
102
|
+
search: string,
|
|
103
|
+
replace: string,
|
|
104
|
+
): { success: boolean; result: string; error?: string } {
|
|
105
|
+
const count = content.split(search).length - 1;
|
|
106
|
+
|
|
107
|
+
if (count === 0) {
|
|
108
|
+
return { success: false, result: content, error: "Search text not found in file" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (count > 1) {
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
result: content,
|
|
115
|
+
error: `Search text found ${count} times. Provide more context for unique match.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
success: true,
|
|
121
|
+
result: content.replace(search, replace),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Apply multiple search/replace blocks (aider-style multi-edit)
|
|
127
|
+
*/
|
|
128
|
+
export function applyMultiEdit(
|
|
129
|
+
content: string,
|
|
130
|
+
edits: Array<{ search: string; replace: string }>,
|
|
131
|
+
): { success: boolean; result: string; errors: string[] } {
|
|
132
|
+
let result = content;
|
|
133
|
+
const errors: string[] = [];
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < edits.length; i++) {
|
|
136
|
+
const { search, replace } = edits[i];
|
|
137
|
+
const applied = applySearchReplace(result, search, replace);
|
|
138
|
+
|
|
139
|
+
if (!applied.success) {
|
|
140
|
+
errors.push(`Edit ${i + 1}: ${applied.error}`);
|
|
141
|
+
} else {
|
|
142
|
+
result = applied.result;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { success: errors.length === 0, result, errors };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Smart merge: apply changes while preserving untouched sections
|
|
151
|
+
*/
|
|
152
|
+
export function smartMerge(
|
|
153
|
+
original: string,
|
|
154
|
+
modified: string,
|
|
155
|
+
base: string,
|
|
156
|
+
): { success: boolean; result: string; conflicts: string[] } {
|
|
157
|
+
// Simple three-way merge
|
|
158
|
+
const origLines = original.split("\n");
|
|
159
|
+
const modLines = modified.split("\n");
|
|
160
|
+
const baseLines = base.split("\n");
|
|
161
|
+
|
|
162
|
+
const result: string[] = [];
|
|
163
|
+
const conflicts: string[] = [];
|
|
164
|
+
|
|
165
|
+
const maxLen = Math.max(origLines.length, modLines.length, baseLines.length);
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < maxLen; i++) {
|
|
168
|
+
const origLine = origLines[i] ?? "";
|
|
169
|
+
const modLine = modLines[i] ?? "";
|
|
170
|
+
const baseLine = baseLines[i] ?? "";
|
|
171
|
+
|
|
172
|
+
if (modLine === baseLine) {
|
|
173
|
+
// No change from base -> use original
|
|
174
|
+
result.push(origLine);
|
|
175
|
+
} else if (origLine === baseLine) {
|
|
176
|
+
// Change from base in modified -> use modified
|
|
177
|
+
result.push(modLine);
|
|
178
|
+
} else if (origLine === modLine) {
|
|
179
|
+
// Same change in both -> use either
|
|
180
|
+
result.push(origLine);
|
|
181
|
+
} else {
|
|
182
|
+
// Conflict
|
|
183
|
+
conflicts.push(`Line ${i + 1}: original="${origLine}" vs modified="${modLine}"`);
|
|
184
|
+
result.push(`<<<<<<< ORIGINAL`);
|
|
185
|
+
result.push(origLine);
|
|
186
|
+
result.push(`=======`);
|
|
187
|
+
result.push(modLine);
|
|
188
|
+
result.push(`>>>>>>> MODIFIED`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
success: conflicts.length === 0,
|
|
194
|
+
result: result.join("\n"),
|
|
195
|
+
conflicts,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Helper: Longest Common Subsequence
|
|
200
|
+
function computeLCS(a: string[], b: string[]): string[] {
|
|
201
|
+
const m = a.length;
|
|
202
|
+
const n = b.length;
|
|
203
|
+
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
|
204
|
+
|
|
205
|
+
for (let i = 1; i <= m; i++) {
|
|
206
|
+
for (let j = 1; j <= n; j++) {
|
|
207
|
+
if (a[i - 1] === b[j - 1]) {
|
|
208
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
209
|
+
} else {
|
|
210
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Backtrack to find LCS
|
|
216
|
+
const lcs: string[] = [];
|
|
217
|
+
let i = m;
|
|
218
|
+
let j = n;
|
|
219
|
+
while (i > 0 && j > 0) {
|
|
220
|
+
if (a[i - 1] === b[j - 1]) {
|
|
221
|
+
lcs.unshift(a[i - 1]);
|
|
222
|
+
i--;
|
|
223
|
+
j--;
|
|
224
|
+
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
225
|
+
i--;
|
|
226
|
+
} else {
|
|
227
|
+
j--;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return lcs;
|
|
232
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO Diff Detect Service
|
|
3
|
+
* Adapted from: crush (Charm) - MIT
|
|
4
|
+
*
|
|
5
|
+
* Detects diffs between file versions and provides
|
|
6
|
+
* intelligent change analysis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
|
|
11
|
+
export interface DiffStats {
|
|
12
|
+
additions: number;
|
|
13
|
+
deletions: number;
|
|
14
|
+
modifications: number;
|
|
15
|
+
totalChanges: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DiffHunk {
|
|
19
|
+
oldStart: number;
|
|
20
|
+
oldLines: number;
|
|
21
|
+
newStart: number;
|
|
22
|
+
newLines: number;
|
|
23
|
+
lines: DiffLine[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DiffLine {
|
|
27
|
+
type: "context" | "add" | "remove";
|
|
28
|
+
content: string;
|
|
29
|
+
oldLineNumber?: number;
|
|
30
|
+
newLineNumber?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FileDiff {
|
|
34
|
+
filePath: string;
|
|
35
|
+
stats: DiffStats;
|
|
36
|
+
hunks: DiffHunk[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Detect diff between two strings
|
|
41
|
+
*/
|
|
42
|
+
export function detectDiff(before: string, after: string, filePath = "file"): FileDiff {
|
|
43
|
+
const beforeLines = before.split("\n");
|
|
44
|
+
const afterLines = after.split("\n");
|
|
45
|
+
|
|
46
|
+
const hunks: DiffHunk[] = [];
|
|
47
|
+
let additions = 0;
|
|
48
|
+
let deletions = 0;
|
|
49
|
+
let modifications = 0;
|
|
50
|
+
|
|
51
|
+
// Simple line-by-line diff using LCS
|
|
52
|
+
const lcs = computeLCS(beforeLines, afterLines);
|
|
53
|
+
|
|
54
|
+
let i = 0;
|
|
55
|
+
let j = 0;
|
|
56
|
+
let k = 0;
|
|
57
|
+
const lines: DiffLine[] = [];
|
|
58
|
+
|
|
59
|
+
while (i < beforeLines.length || j < afterLines.length) {
|
|
60
|
+
if (k < lcs.length && i < beforeLines.length && beforeLines[i] === lcs[k] && j < afterLines.length && afterLines[j] === lcs[k]) {
|
|
61
|
+
lines.push({
|
|
62
|
+
type: "context",
|
|
63
|
+
content: beforeLines[i],
|
|
64
|
+
oldLineNumber: i + 1,
|
|
65
|
+
newLineNumber: j + 1,
|
|
66
|
+
});
|
|
67
|
+
i++;
|
|
68
|
+
j++;
|
|
69
|
+
k++;
|
|
70
|
+
} else if (j < afterLines.length && (k >= lcs.length || afterLines[j] !== lcs[k])) {
|
|
71
|
+
lines.push({
|
|
72
|
+
type: "add",
|
|
73
|
+
content: afterLines[j],
|
|
74
|
+
newLineNumber: j + 1,
|
|
75
|
+
});
|
|
76
|
+
additions++;
|
|
77
|
+
j++;
|
|
78
|
+
} else if (i < beforeLines.length && (k >= lcs.length || beforeLines[i] !== lcs[k])) {
|
|
79
|
+
lines.push({
|
|
80
|
+
type: "remove",
|
|
81
|
+
content: beforeLines[i],
|
|
82
|
+
oldLineNumber: i + 1,
|
|
83
|
+
});
|
|
84
|
+
deletions++;
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Group lines into hunks
|
|
90
|
+
if (lines.length > 0) {
|
|
91
|
+
hunks.push({
|
|
92
|
+
oldStart: 1,
|
|
93
|
+
oldLines: beforeLines.length,
|
|
94
|
+
newStart: 1,
|
|
95
|
+
newLines: afterLines.length,
|
|
96
|
+
lines,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Detect modifications (paired add/remove)
|
|
101
|
+
const minAddDel = Math.min(additions, deletions);
|
|
102
|
+
modifications = minAddDel;
|
|
103
|
+
additions -= minAddDel;
|
|
104
|
+
deletions -= minAddDel;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
filePath,
|
|
108
|
+
stats: {
|
|
109
|
+
additions,
|
|
110
|
+
deletions,
|
|
111
|
+
modifications,
|
|
112
|
+
totalChanges: additions + deletions + modifications,
|
|
113
|
+
},
|
|
114
|
+
hunks,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detect diff between two files
|
|
120
|
+
*/
|
|
121
|
+
export async function detectFileDiff(beforePath: string, afterPath: string): Promise<FileDiff> {
|
|
122
|
+
const [before, after] = await Promise.all([
|
|
123
|
+
fs.readFile(beforePath, "utf-8").catch(() => ""),
|
|
124
|
+
fs.readFile(afterPath, "utf-8").catch(() => ""),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
return detectDiff(before, after, afterPath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Format diff as unified diff string
|
|
132
|
+
*/
|
|
133
|
+
export function formatDiff(diff: FileDiff): string {
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
lines.push(`--- a/${diff.filePath}`);
|
|
136
|
+
lines.push(`+++ b/${diff.filePath}`);
|
|
137
|
+
|
|
138
|
+
for (const hunk of diff.hunks) {
|
|
139
|
+
lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
|
|
140
|
+
|
|
141
|
+
for (const line of hunk.lines) {
|
|
142
|
+
switch (line.type) {
|
|
143
|
+
case "context":
|
|
144
|
+
lines.push(` ${line.content}`);
|
|
145
|
+
break;
|
|
146
|
+
case "add":
|
|
147
|
+
lines.push(`+${line.content}`);
|
|
148
|
+
break;
|
|
149
|
+
case "remove":
|
|
150
|
+
lines.push(`-${line.content}`);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get summary of changes
|
|
161
|
+
*/
|
|
162
|
+
export function summarizeDiff(diff: FileDiff): string {
|
|
163
|
+
const { additions, deletions, modifications } = diff.stats;
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
|
|
166
|
+
if (additions > 0) parts.push(`+${additions} added`);
|
|
167
|
+
if (deletions > 0) parts.push(`-${deletions} removed`);
|
|
168
|
+
if (modifications > 0) parts.push(`~${modifications} modified`);
|
|
169
|
+
|
|
170
|
+
return parts.length > 0
|
|
171
|
+
? `${diff.filePath}: ${parts.join(", ")}`
|
|
172
|
+
: `${diff.filePath}: no changes`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// LCS helper
|
|
176
|
+
function computeLCS(a: string[], b: string[]): string[] {
|
|
177
|
+
const m = a.length;
|
|
178
|
+
const n = b.length;
|
|
179
|
+
const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
|
|
180
|
+
|
|
181
|
+
for (let i = 1; i <= m; i++) {
|
|
182
|
+
for (let j = 1; j <= n; j++) {
|
|
183
|
+
if (a[i - 1] === b[j - 1]) {
|
|
184
|
+
dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
185
|
+
} else {
|
|
186
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lcs: string[] = [];
|
|
192
|
+
let i = m;
|
|
193
|
+
let j = n;
|
|
194
|
+
while (i > 0 && j > 0) {
|
|
195
|
+
if (a[i - 1] === b[j - 1]) {
|
|
196
|
+
lcs.unshift(a[i - 1]);
|
|
197
|
+
i--;
|
|
198
|
+
j--;
|
|
199
|
+
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
|
200
|
+
i--;
|
|
201
|
+
} else {
|
|
202
|
+
j--;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return lcs;
|
|
207
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO EditBlock Coder
|
|
3
|
+
* Adapted from: aider (Python) - Apache 2.0
|
|
4
|
+
*
|
|
5
|
+
* Aider-style edit block system that uses SEARCH/REPLACE blocks
|
|
6
|
+
* for precise, multi-location file editing.
|
|
7
|
+
*
|
|
8
|
+
* Format:
|
|
9
|
+
* ```
|
|
10
|
+
* <<<<<<< SEARCH
|
|
11
|
+
* exact text to find
|
|
12
|
+
* =======
|
|
13
|
+
* replacement text
|
|
14
|
+
* >>>>>>> REPLACE
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import type { ToolDefinition, ToolResult } from "../types.js";
|
|
21
|
+
|
|
22
|
+
export interface EditBlock {
|
|
23
|
+
search: string;
|
|
24
|
+
replace: string;
|
|
25
|
+
lineNumber?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EditBlockResult {
|
|
29
|
+
filePath: string;
|
|
30
|
+
applied: number;
|
|
31
|
+
failed: number;
|
|
32
|
+
errors: string[];
|
|
33
|
+
diff: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse edit blocks from LLM output
|
|
38
|
+
*/
|
|
39
|
+
export function parseEditBlocks(content: string): EditBlock[] {
|
|
40
|
+
const blocks: EditBlock[] = [];
|
|
41
|
+
const regex = /<<<<<<< SEARCH\n([\s\S]*?)=======\n([\s\S]*?)>>>>>>> REPLACE/g;
|
|
42
|
+
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = regex.exec(content)) !== null) {
|
|
45
|
+
blocks.push({
|
|
46
|
+
search: match[1],
|
|
47
|
+
replace: match[2],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return blocks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Apply edit blocks to file content
|
|
56
|
+
*/
|
|
57
|
+
export function applyEditBlocks(content: string, blocks: EditBlock[]): { result: string; applied: number; errors: string[] } {
|
|
58
|
+
let result = content;
|
|
59
|
+
let applied = 0;
|
|
60
|
+
const errors: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
63
|
+
const block = blocks[i];
|
|
64
|
+
const searchStr = block.search;
|
|
65
|
+
|
|
66
|
+
// Find the search text
|
|
67
|
+
const idx = result.indexOf(searchStr);
|
|
68
|
+
if (idx === -1) {
|
|
69
|
+
// Try fuzzy match (trim whitespace differences)
|
|
70
|
+
const trimmedSearch = searchStr.trim();
|
|
71
|
+
const lines = result.split("\n");
|
|
72
|
+
let found = false;
|
|
73
|
+
|
|
74
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
75
|
+
const windowSize = trimmedSearch.split("\n").length;
|
|
76
|
+
const window = lines.slice(lineIdx, lineIdx + windowSize).join("\n").trim();
|
|
77
|
+
|
|
78
|
+
if (window === trimmedSearch) {
|
|
79
|
+
// Found a fuzzy match
|
|
80
|
+
const beforeLines = lines.slice(0, lineIdx).join("\n");
|
|
81
|
+
const afterLines = lines.slice(lineIdx + windowSize).join("\n");
|
|
82
|
+
result = (beforeLines ? beforeLines + "\n" : "") + block.replace.trimEnd() + (afterLines ? "\n" + afterLines : "");
|
|
83
|
+
applied++;
|
|
84
|
+
found = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!found) {
|
|
90
|
+
errors.push(`Block ${i + 1}: Search text not found`);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Exact match found
|
|
94
|
+
const count = result.split(searchStr).length - 1;
|
|
95
|
+
if (count > 1) {
|
|
96
|
+
errors.push(`Block ${i + 1}: Search text found ${count} times (need unique match)`);
|
|
97
|
+
} else {
|
|
98
|
+
result = result.replace(searchStr, block.replace);
|
|
99
|
+
applied++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { result, applied, errors };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Apply edit blocks to a file
|
|
109
|
+
*/
|
|
110
|
+
export async function applyEditBlocksToFile(filePath: string, blocks: EditBlock[], workingDir: string): Promise<EditBlockResult> {
|
|
111
|
+
const fullPath = path.resolve(workingDir, filePath);
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
115
|
+
const { result, applied, errors } = applyEditBlocks(content, blocks);
|
|
116
|
+
|
|
117
|
+
if (applied > 0) {
|
|
118
|
+
await fs.writeFile(fullPath, result, "utf-8");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Generate simple diff
|
|
122
|
+
const diffLines: string[] = [`--- a/${filePath}`, `+++ b/${filePath}`];
|
|
123
|
+
for (const block of blocks.slice(0, applied)) {
|
|
124
|
+
diffLines.push(`-${block.search.split("\n")[0]}`);
|
|
125
|
+
diffLines.push(`+${block.replace.split("\n")[0]}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
filePath,
|
|
130
|
+
applied,
|
|
131
|
+
failed: blocks.length - applied,
|
|
132
|
+
errors,
|
|
133
|
+
diff: diffLines.join("\n"),
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
filePath,
|
|
138
|
+
applied: 0,
|
|
139
|
+
failed: blocks.length,
|
|
140
|
+
errors: [`Failed to read file: ${error instanceof Error ? error.message : String(error)}`],
|
|
141
|
+
diff: "",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate edit block format for LLM
|
|
148
|
+
*/
|
|
149
|
+
export function formatEditBlock(search: string, replace: string): string {
|
|
150
|
+
return `<<<<<<< SEARCH\n${search}=======\n${replace}>>>>>>> REPLACE`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* EditBlock tool definition
|
|
155
|
+
*/
|
|
156
|
+
export const editBlockTool: ToolDefinition = {
|
|
157
|
+
name: "edit_block",
|
|
158
|
+
description: `Apply SEARCH/REPLACE edit blocks to a file. This is the most precise way to edit files.
|
|
159
|
+
|
|
160
|
+
Format your edits as:
|
|
161
|
+
<<<<<<< SEARCH
|
|
162
|
+
exact text to find
|
|
163
|
+
=======
|
|
164
|
+
replacement text
|
|
165
|
+
>>>>>>> REPLACE
|
|
166
|
+
|
|
167
|
+
Multiple edit blocks can be applied in a single call. Each SEARCH block must match exactly once in the file.`,
|
|
168
|
+
parameters: {
|
|
169
|
+
path: { type: "string", description: "Path to the file to edit", required: true },
|
|
170
|
+
edits: { type: "string", description: "Edit blocks in SEARCH/REPLACE format", required: true },
|
|
171
|
+
},
|
|
172
|
+
category: "file",
|
|
173
|
+
requiresApproval: true,
|
|
174
|
+
handler: async (args, context): Promise<ToolResult> => {
|
|
175
|
+
const blocks = parseEditBlocks(args.edits as string);
|
|
176
|
+
|
|
177
|
+
if (blocks.length === 0) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
output: "",
|
|
181
|
+
error: "No valid edit blocks found. Use <<<<<<< SEARCH / ======= / >>>>>>> REPLACE format.",
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = await applyEditBlocksToFile(args.path as string, blocks, context.workingDirectory);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
success: result.applied > 0,
|
|
189
|
+
output: `Applied ${result.applied}/${blocks.length} edit blocks to ${args.path}${result.errors.length > 0 ? "\nErrors:\n" + result.errors.join("\n") : ""}`,
|
|
190
|
+
data: result,
|
|
191
|
+
error: result.applied === 0 ? result.errors.join("; ") : undefined,
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
};
|