@link-assistant/agent 0.0.8
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/EXAMPLES.md +383 -0
- package/LICENSE +24 -0
- package/MODELS.md +95 -0
- package/README.md +388 -0
- package/TOOLS.md +134 -0
- package/package.json +89 -0
- package/src/agent/agent.ts +150 -0
- package/src/agent/generate.txt +75 -0
- package/src/auth/index.ts +64 -0
- package/src/bun/index.ts +96 -0
- package/src/bus/global.ts +10 -0
- package/src/bus/index.ts +119 -0
- package/src/cli/bootstrap.js +41 -0
- package/src/cli/bootstrap.ts +17 -0
- package/src/cli/cmd/agent.ts +165 -0
- package/src/cli/cmd/cmd.ts +5 -0
- package/src/cli/cmd/export.ts +88 -0
- package/src/cli/cmd/mcp.ts +80 -0
- package/src/cli/cmd/models.ts +58 -0
- package/src/cli/cmd/run.ts +359 -0
- package/src/cli/cmd/stats.ts +276 -0
- package/src/cli/error.ts +27 -0
- package/src/command/index.ts +73 -0
- package/src/command/template/initialize.txt +10 -0
- package/src/config/config.ts +705 -0
- package/src/config/markdown.ts +41 -0
- package/src/file/ripgrep.ts +391 -0
- package/src/file/time.ts +38 -0
- package/src/file/watcher.ts +75 -0
- package/src/file.ts +6 -0
- package/src/flag/flag.ts +19 -0
- package/src/format/formatter.ts +248 -0
- package/src/format/index.ts +137 -0
- package/src/global/index.ts +52 -0
- package/src/id/id.ts +72 -0
- package/src/index.js +371 -0
- package/src/mcp/index.ts +289 -0
- package/src/patch/index.ts +622 -0
- package/src/project/bootstrap.ts +22 -0
- package/src/project/instance.ts +67 -0
- package/src/project/project.ts +105 -0
- package/src/project/state.ts +65 -0
- package/src/provider/models-macro.ts +11 -0
- package/src/provider/models.ts +98 -0
- package/src/provider/opencode.js +47 -0
- package/src/provider/provider.ts +636 -0
- package/src/provider/transform.ts +241 -0
- package/src/server/project.ts +48 -0
- package/src/server/server.ts +249 -0
- package/src/session/agent.js +204 -0
- package/src/session/compaction.ts +249 -0
- package/src/session/index.ts +380 -0
- package/src/session/message-v2.ts +758 -0
- package/src/session/message.ts +189 -0
- package/src/session/processor.ts +356 -0
- package/src/session/prompt/anthropic-20250930.txt +166 -0
- package/src/session/prompt/anthropic.txt +105 -0
- package/src/session/prompt/anthropic_spoof.txt +1 -0
- package/src/session/prompt/beast.txt +147 -0
- package/src/session/prompt/build-switch.txt +5 -0
- package/src/session/prompt/codex.txt +318 -0
- package/src/session/prompt/copilot-gpt-5.txt +143 -0
- package/src/session/prompt/gemini.txt +155 -0
- package/src/session/prompt/grok-code.txt +1 -0
- package/src/session/prompt/plan.txt +8 -0
- package/src/session/prompt/polaris.txt +107 -0
- package/src/session/prompt/qwen.txt +109 -0
- package/src/session/prompt/summarize-turn.txt +5 -0
- package/src/session/prompt/summarize.txt +10 -0
- package/src/session/prompt/title.txt +25 -0
- package/src/session/prompt.ts +1390 -0
- package/src/session/retry.ts +53 -0
- package/src/session/revert.ts +108 -0
- package/src/session/status.ts +75 -0
- package/src/session/summary.ts +179 -0
- package/src/session/system.ts +138 -0
- package/src/session/todo.ts +36 -0
- package/src/snapshot/index.ts +197 -0
- package/src/storage/storage.ts +226 -0
- package/src/tool/bash.ts +193 -0
- package/src/tool/bash.txt +121 -0
- package/src/tool/batch.ts +173 -0
- package/src/tool/batch.txt +28 -0
- package/src/tool/codesearch.ts +123 -0
- package/src/tool/codesearch.txt +12 -0
- package/src/tool/edit.ts +604 -0
- package/src/tool/edit.txt +10 -0
- package/src/tool/glob.ts +65 -0
- package/src/tool/glob.txt +6 -0
- package/src/tool/grep.ts +116 -0
- package/src/tool/grep.txt +8 -0
- package/src/tool/invalid.ts +17 -0
- package/src/tool/ls.ts +110 -0
- package/src/tool/ls.txt +1 -0
- package/src/tool/multiedit.ts +46 -0
- package/src/tool/multiedit.txt +41 -0
- package/src/tool/patch.ts +188 -0
- package/src/tool/patch.txt +1 -0
- package/src/tool/read.ts +201 -0
- package/src/tool/read.txt +12 -0
- package/src/tool/registry.ts +87 -0
- package/src/tool/task.ts +126 -0
- package/src/tool/task.txt +60 -0
- package/src/tool/todo.ts +39 -0
- package/src/tool/todoread.txt +14 -0
- package/src/tool/todowrite.txt +167 -0
- package/src/tool/tool.ts +66 -0
- package/src/tool/webfetch.ts +171 -0
- package/src/tool/webfetch.txt +14 -0
- package/src/tool/websearch.ts +133 -0
- package/src/tool/websearch.txt +11 -0
- package/src/tool/write.ts +33 -0
- package/src/tool/write.txt +8 -0
- package/src/util/binary.ts +41 -0
- package/src/util/context.ts +25 -0
- package/src/util/defer.ts +12 -0
- package/src/util/error.ts +54 -0
- package/src/util/eventloop.ts +20 -0
- package/src/util/filesystem.ts +69 -0
- package/src/util/fn.ts +11 -0
- package/src/util/iife.ts +3 -0
- package/src/util/keybind.ts +79 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/locale.ts +39 -0
- package/src/util/lock.ts +98 -0
- package/src/util/log.ts +177 -0
- package/src/util/queue.ts +19 -0
- package/src/util/rpc.ts +42 -0
- package/src/util/scrap.ts +10 -0
- package/src/util/signal.ts +12 -0
- package/src/util/timeout.ts +14 -0
- package/src/util/token.ts +7 -0
- package/src/util/wildcard.ts +54 -0
package/src/tool/edit.ts
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
// the approaches in this edit tool are sourced from
|
|
2
|
+
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
|
3
|
+
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
|
4
|
+
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
|
5
|
+
|
|
6
|
+
import z from "zod"
|
|
7
|
+
import * as path from "path"
|
|
8
|
+
import { Tool } from "./tool"
|
|
9
|
+
import { createTwoFilesPatch, diffLines } from "diff"
|
|
10
|
+
import DESCRIPTION from "./edit.txt"
|
|
11
|
+
import { File } from "../file"
|
|
12
|
+
import { Bus } from "../bus"
|
|
13
|
+
import { FileTime } from "../file/time"
|
|
14
|
+
import { Instance } from "../project/instance"
|
|
15
|
+
import { Snapshot } from "../snapshot"
|
|
16
|
+
|
|
17
|
+
function normalizeLineEndings(text: string): string {
|
|
18
|
+
return text.replaceAll("\r\n", "\n")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const EditTool = Tool.define("edit", {
|
|
22
|
+
description: DESCRIPTION,
|
|
23
|
+
parameters: z.object({
|
|
24
|
+
filePath: z.string().describe("The absolute path to the file to modify"),
|
|
25
|
+
oldString: z.string().describe("The text to replace"),
|
|
26
|
+
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
|
27
|
+
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
|
28
|
+
}),
|
|
29
|
+
async execute(params, ctx) {
|
|
30
|
+
if (!params.filePath) {
|
|
31
|
+
throw new Error("filePath is required")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (params.oldString === params.newString) {
|
|
35
|
+
throw new Error("oldString and newString must be different")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// No restrictions - unrestricted file editing
|
|
39
|
+
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
|
40
|
+
|
|
41
|
+
let diff = ""
|
|
42
|
+
let contentOld = ""
|
|
43
|
+
let contentNew = ""
|
|
44
|
+
await (async () => {
|
|
45
|
+
if (params.oldString === "") {
|
|
46
|
+
contentNew = params.newString
|
|
47
|
+
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
|
48
|
+
await Bun.write(filePath, params.newString)
|
|
49
|
+
await Bus.publish(File.Event.Edited, {
|
|
50
|
+
file: filePath,
|
|
51
|
+
})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const file = Bun.file(filePath)
|
|
56
|
+
const stats = await file.stat().catch(() => {})
|
|
57
|
+
if (!stats) throw new Error(`File ${filePath} not found`)
|
|
58
|
+
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
|
59
|
+
await FileTime.assert(ctx.sessionID, filePath)
|
|
60
|
+
contentOld = await file.text()
|
|
61
|
+
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
|
62
|
+
|
|
63
|
+
diff = trimDiff(
|
|
64
|
+
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
await file.write(contentNew)
|
|
68
|
+
await Bus.publish(File.Event.Edited, {
|
|
69
|
+
file: filePath,
|
|
70
|
+
})
|
|
71
|
+
contentNew = await file.text()
|
|
72
|
+
diff = trimDiff(
|
|
73
|
+
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
|
74
|
+
)
|
|
75
|
+
})()
|
|
76
|
+
|
|
77
|
+
FileTime.read(ctx.sessionID, filePath)
|
|
78
|
+
|
|
79
|
+
let output = ""
|
|
80
|
+
const diagnostics = {}
|
|
81
|
+
|
|
82
|
+
const filediff: Snapshot.FileDiff = {
|
|
83
|
+
file: filePath,
|
|
84
|
+
before: contentOld,
|
|
85
|
+
after: contentNew,
|
|
86
|
+
additions: 0,
|
|
87
|
+
deletions: 0,
|
|
88
|
+
}
|
|
89
|
+
for (const change of diffLines(contentOld, contentNew)) {
|
|
90
|
+
if (change.added) filediff.additions += change.count || 0
|
|
91
|
+
if (change.removed) filediff.deletions += change.count || 0
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
metadata: {
|
|
96
|
+
diagnostics,
|
|
97
|
+
diff,
|
|
98
|
+
filediff,
|
|
99
|
+
},
|
|
100
|
+
title: `${path.relative(Instance.worktree, filePath)}`,
|
|
101
|
+
output,
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
|
107
|
+
|
|
108
|
+
// Similarity thresholds for block anchor fallback matching
|
|
109
|
+
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
|
110
|
+
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Levenshtein distance algorithm implementation
|
|
114
|
+
*/
|
|
115
|
+
function levenshtein(a: string, b: string): number {
|
|
116
|
+
// Handle empty strings
|
|
117
|
+
if (a === "" || b === "") {
|
|
118
|
+
return Math.max(a.length, b.length)
|
|
119
|
+
}
|
|
120
|
+
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
|
121
|
+
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
for (let i = 1; i <= a.length; i++) {
|
|
125
|
+
for (let j = 1; j <= b.length; j++) {
|
|
126
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
127
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return matrix[a.length][b.length]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const SimpleReplacer: Replacer = function* (_content, find) {
|
|
134
|
+
yield find
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
|
138
|
+
const originalLines = content.split("\n")
|
|
139
|
+
const searchLines = find.split("\n")
|
|
140
|
+
|
|
141
|
+
if (searchLines[searchLines.length - 1] === "") {
|
|
142
|
+
searchLines.pop()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
|
146
|
+
let matches = true
|
|
147
|
+
|
|
148
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
149
|
+
const originalTrimmed = originalLines[i + j].trim()
|
|
150
|
+
const searchTrimmed = searchLines[j].trim()
|
|
151
|
+
|
|
152
|
+
if (originalTrimmed !== searchTrimmed) {
|
|
153
|
+
matches = false
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (matches) {
|
|
159
|
+
let matchStartIndex = 0
|
|
160
|
+
for (let k = 0; k < i; k++) {
|
|
161
|
+
matchStartIndex += originalLines[k].length + 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let matchEndIndex = matchStartIndex
|
|
165
|
+
for (let k = 0; k < searchLines.length; k++) {
|
|
166
|
+
matchEndIndex += originalLines[i + k].length
|
|
167
|
+
if (k < searchLines.length - 1) {
|
|
168
|
+
matchEndIndex += 1 // Add newline character except for the last line
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
yield content.substring(matchStartIndex, matchEndIndex)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
|
178
|
+
const originalLines = content.split("\n")
|
|
179
|
+
const searchLines = find.split("\n")
|
|
180
|
+
|
|
181
|
+
if (searchLines.length < 3) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (searchLines[searchLines.length - 1] === "") {
|
|
186
|
+
searchLines.pop()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const firstLineSearch = searchLines[0].trim()
|
|
190
|
+
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
|
191
|
+
const searchBlockSize = searchLines.length
|
|
192
|
+
|
|
193
|
+
// Collect all candidate positions where both anchors match
|
|
194
|
+
const candidates: Array<{ startLine: number; endLine: number }> = []
|
|
195
|
+
for (let i = 0; i < originalLines.length; i++) {
|
|
196
|
+
if (originalLines[i].trim() !== firstLineSearch) {
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Look for the matching last line after this first line
|
|
201
|
+
for (let j = i + 2; j < originalLines.length; j++) {
|
|
202
|
+
if (originalLines[j].trim() === lastLineSearch) {
|
|
203
|
+
candidates.push({ startLine: i, endLine: j })
|
|
204
|
+
break // Only match the first occurrence of the last line
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Return immediately if no candidates
|
|
210
|
+
if (candidates.length === 0) {
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle single candidate scenario (using relaxed threshold)
|
|
215
|
+
if (candidates.length === 1) {
|
|
216
|
+
const { startLine, endLine } = candidates[0]
|
|
217
|
+
const actualBlockSize = endLine - startLine + 1
|
|
218
|
+
|
|
219
|
+
let similarity = 0
|
|
220
|
+
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
|
221
|
+
|
|
222
|
+
if (linesToCheck > 0) {
|
|
223
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
224
|
+
const originalLine = originalLines[startLine + j].trim()
|
|
225
|
+
const searchLine = searchLines[j].trim()
|
|
226
|
+
const maxLen = Math.max(originalLine.length, searchLine.length)
|
|
227
|
+
if (maxLen === 0) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
const distance = levenshtein(originalLine, searchLine)
|
|
231
|
+
similarity += (1 - distance / maxLen) / linesToCheck
|
|
232
|
+
|
|
233
|
+
// Exit early when threshold is reached
|
|
234
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
235
|
+
break
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// No middle lines to compare, just accept based on anchors
|
|
240
|
+
similarity = 1.0
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
|
244
|
+
let matchStartIndex = 0
|
|
245
|
+
for (let k = 0; k < startLine; k++) {
|
|
246
|
+
matchStartIndex += originalLines[k].length + 1
|
|
247
|
+
}
|
|
248
|
+
let matchEndIndex = matchStartIndex
|
|
249
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
250
|
+
matchEndIndex += originalLines[k].length
|
|
251
|
+
if (k < endLine) {
|
|
252
|
+
matchEndIndex += 1 // Add newline character except for the last line
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
yield content.substring(matchStartIndex, matchEndIndex)
|
|
256
|
+
}
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Calculate similarity for multiple candidates
|
|
261
|
+
let bestMatch: { startLine: number; endLine: number } | null = null
|
|
262
|
+
let maxSimilarity = -1
|
|
263
|
+
|
|
264
|
+
for (const candidate of candidates) {
|
|
265
|
+
const { startLine, endLine } = candidate
|
|
266
|
+
const actualBlockSize = endLine - startLine + 1
|
|
267
|
+
|
|
268
|
+
let similarity = 0
|
|
269
|
+
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
|
270
|
+
|
|
271
|
+
if (linesToCheck > 0) {
|
|
272
|
+
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
|
273
|
+
const originalLine = originalLines[startLine + j].trim()
|
|
274
|
+
const searchLine = searchLines[j].trim()
|
|
275
|
+
const maxLen = Math.max(originalLine.length, searchLine.length)
|
|
276
|
+
if (maxLen === 0) {
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const distance = levenshtein(originalLine, searchLine)
|
|
280
|
+
similarity += 1 - distance / maxLen
|
|
281
|
+
}
|
|
282
|
+
similarity /= linesToCheck // Average similarity
|
|
283
|
+
} else {
|
|
284
|
+
// No middle lines to compare, just accept based on anchors
|
|
285
|
+
similarity = 1.0
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (similarity > maxSimilarity) {
|
|
289
|
+
maxSimilarity = similarity
|
|
290
|
+
bestMatch = candidate
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Threshold judgment
|
|
295
|
+
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
|
296
|
+
const { startLine, endLine } = bestMatch
|
|
297
|
+
let matchStartIndex = 0
|
|
298
|
+
for (let k = 0; k < startLine; k++) {
|
|
299
|
+
matchStartIndex += originalLines[k].length + 1
|
|
300
|
+
}
|
|
301
|
+
let matchEndIndex = matchStartIndex
|
|
302
|
+
for (let k = startLine; k <= endLine; k++) {
|
|
303
|
+
matchEndIndex += originalLines[k].length
|
|
304
|
+
if (k < endLine) {
|
|
305
|
+
matchEndIndex += 1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
yield content.substring(matchStartIndex, matchEndIndex)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
|
313
|
+
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
|
314
|
+
const normalizedFind = normalizeWhitespace(find)
|
|
315
|
+
|
|
316
|
+
// Handle single line matches
|
|
317
|
+
const lines = content.split("\n")
|
|
318
|
+
for (let i = 0; i < lines.length; i++) {
|
|
319
|
+
const line = lines[i]
|
|
320
|
+
if (normalizeWhitespace(line) === normalizedFind) {
|
|
321
|
+
yield line
|
|
322
|
+
} else {
|
|
323
|
+
// Only check for substring matches if the full line doesn't match
|
|
324
|
+
const normalizedLine = normalizeWhitespace(line)
|
|
325
|
+
if (normalizedLine.includes(normalizedFind)) {
|
|
326
|
+
// Find the actual substring in the original line that matches
|
|
327
|
+
const words = find.trim().split(/\s+/)
|
|
328
|
+
if (words.length > 0) {
|
|
329
|
+
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
|
330
|
+
try {
|
|
331
|
+
const regex = new RegExp(pattern)
|
|
332
|
+
const match = line.match(regex)
|
|
333
|
+
if (match) {
|
|
334
|
+
yield match[0]
|
|
335
|
+
}
|
|
336
|
+
} catch (e) {
|
|
337
|
+
// Invalid regex pattern, skip
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Handle multi-line matches
|
|
345
|
+
const findLines = find.split("\n")
|
|
346
|
+
if (findLines.length > 1) {
|
|
347
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
348
|
+
const block = lines.slice(i, i + findLines.length)
|
|
349
|
+
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
|
350
|
+
yield block.join("\n")
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
|
357
|
+
const removeIndentation = (text: string) => {
|
|
358
|
+
const lines = text.split("\n")
|
|
359
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
|
360
|
+
if (nonEmptyLines.length === 0) return text
|
|
361
|
+
|
|
362
|
+
const minIndent = Math.min(
|
|
363
|
+
...nonEmptyLines.map((line) => {
|
|
364
|
+
const match = line.match(/^(\s*)/)
|
|
365
|
+
return match ? match[1].length : 0
|
|
366
|
+
}),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const normalizedFind = removeIndentation(find)
|
|
373
|
+
const contentLines = content.split("\n")
|
|
374
|
+
const findLines = find.split("\n")
|
|
375
|
+
|
|
376
|
+
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
|
377
|
+
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
|
378
|
+
if (removeIndentation(block) === normalizedFind) {
|
|
379
|
+
yield block
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
|
385
|
+
const unescapeString = (str: string): string => {
|
|
386
|
+
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
|
387
|
+
switch (capturedChar) {
|
|
388
|
+
case "n":
|
|
389
|
+
return "\n"
|
|
390
|
+
case "t":
|
|
391
|
+
return "\t"
|
|
392
|
+
case "r":
|
|
393
|
+
return "\r"
|
|
394
|
+
case "'":
|
|
395
|
+
return "'"
|
|
396
|
+
case '"':
|
|
397
|
+
return '"'
|
|
398
|
+
case "`":
|
|
399
|
+
return "`"
|
|
400
|
+
case "\\":
|
|
401
|
+
return "\\"
|
|
402
|
+
case "\n":
|
|
403
|
+
return "\n"
|
|
404
|
+
case "$":
|
|
405
|
+
return "$"
|
|
406
|
+
default:
|
|
407
|
+
return match
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const unescapedFind = unescapeString(find)
|
|
413
|
+
|
|
414
|
+
// Try direct match with unescaped find string
|
|
415
|
+
if (content.includes(unescapedFind)) {
|
|
416
|
+
yield unescapedFind
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Also try finding escaped versions in content that match unescaped find
|
|
420
|
+
const lines = content.split("\n")
|
|
421
|
+
const findLines = unescapedFind.split("\n")
|
|
422
|
+
|
|
423
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
424
|
+
const block = lines.slice(i, i + findLines.length).join("\n")
|
|
425
|
+
const unescapedBlock = unescapeString(block)
|
|
426
|
+
|
|
427
|
+
if (unescapedBlock === unescapedFind) {
|
|
428
|
+
yield block
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
|
434
|
+
// This replacer yields all exact matches, allowing the replace function
|
|
435
|
+
// to handle multiple occurrences based on replaceAll parameter
|
|
436
|
+
let startIndex = 0
|
|
437
|
+
|
|
438
|
+
while (true) {
|
|
439
|
+
const index = content.indexOf(find, startIndex)
|
|
440
|
+
if (index === -1) break
|
|
441
|
+
|
|
442
|
+
yield find
|
|
443
|
+
startIndex = index + find.length
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
|
448
|
+
const trimmedFind = find.trim()
|
|
449
|
+
|
|
450
|
+
if (trimmedFind === find) {
|
|
451
|
+
// Already trimmed, no point in trying
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Try to find the trimmed version
|
|
456
|
+
if (content.includes(trimmedFind)) {
|
|
457
|
+
yield trimmedFind
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Also try finding blocks where trimmed content matches
|
|
461
|
+
const lines = content.split("\n")
|
|
462
|
+
const findLines = find.split("\n")
|
|
463
|
+
|
|
464
|
+
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
|
465
|
+
const block = lines.slice(i, i + findLines.length).join("\n")
|
|
466
|
+
|
|
467
|
+
if (block.trim() === trimmedFind) {
|
|
468
|
+
yield block
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
|
474
|
+
const findLines = find.split("\n")
|
|
475
|
+
if (findLines.length < 3) {
|
|
476
|
+
// Need at least 3 lines to have meaningful context
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Remove trailing empty line if present
|
|
481
|
+
if (findLines[findLines.length - 1] === "") {
|
|
482
|
+
findLines.pop()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const contentLines = content.split("\n")
|
|
486
|
+
|
|
487
|
+
// Extract first and last lines as context anchors
|
|
488
|
+
const firstLine = findLines[0].trim()
|
|
489
|
+
const lastLine = findLines[findLines.length - 1].trim()
|
|
490
|
+
|
|
491
|
+
// Find blocks that start and end with the context anchors
|
|
492
|
+
for (let i = 0; i < contentLines.length; i++) {
|
|
493
|
+
if (contentLines[i].trim() !== firstLine) continue
|
|
494
|
+
|
|
495
|
+
// Look for the matching last line
|
|
496
|
+
for (let j = i + 2; j < contentLines.length; j++) {
|
|
497
|
+
if (contentLines[j].trim() === lastLine) {
|
|
498
|
+
// Found a potential context block
|
|
499
|
+
const blockLines = contentLines.slice(i, j + 1)
|
|
500
|
+
const block = blockLines.join("\n")
|
|
501
|
+
|
|
502
|
+
// Check if the middle content has reasonable similarity
|
|
503
|
+
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
|
|
504
|
+
if (blockLines.length === findLines.length) {
|
|
505
|
+
let matchingLines = 0
|
|
506
|
+
let totalNonEmptyLines = 0
|
|
507
|
+
|
|
508
|
+
for (let k = 1; k < blockLines.length - 1; k++) {
|
|
509
|
+
const blockLine = blockLines[k].trim()
|
|
510
|
+
const findLine = findLines[k].trim()
|
|
511
|
+
|
|
512
|
+
if (blockLine.length > 0 || findLine.length > 0) {
|
|
513
|
+
totalNonEmptyLines++
|
|
514
|
+
if (blockLine === findLine) {
|
|
515
|
+
matchingLines++
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
|
521
|
+
yield block
|
|
522
|
+
break // Only match the first occurrence
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
break
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export function trimDiff(diff: string): string {
|
|
532
|
+
const lines = diff.split("\n")
|
|
533
|
+
const contentLines = lines.filter(
|
|
534
|
+
(line) =>
|
|
535
|
+
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
536
|
+
!line.startsWith("---") &&
|
|
537
|
+
!line.startsWith("+++"),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if (contentLines.length === 0) return diff
|
|
541
|
+
|
|
542
|
+
let min = Infinity
|
|
543
|
+
for (const line of contentLines) {
|
|
544
|
+
const content = line.slice(1)
|
|
545
|
+
if (content.trim().length > 0) {
|
|
546
|
+
const match = content.match(/^(\s*)/)
|
|
547
|
+
if (match) min = Math.min(min, match[1].length)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (min === Infinity || min === 0) return diff
|
|
551
|
+
const trimmedLines = lines.map((line) => {
|
|
552
|
+
if (
|
|
553
|
+
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
|
|
554
|
+
!line.startsWith("---") &&
|
|
555
|
+
!line.startsWith("+++")
|
|
556
|
+
) {
|
|
557
|
+
const prefix = line[0]
|
|
558
|
+
const content = line.slice(1)
|
|
559
|
+
return prefix + content.slice(min)
|
|
560
|
+
}
|
|
561
|
+
return line
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
return trimmedLines.join("\n")
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
|
568
|
+
if (oldString === newString) {
|
|
569
|
+
throw new Error("oldString and newString must be different")
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let notFound = true
|
|
573
|
+
|
|
574
|
+
for (const replacer of [
|
|
575
|
+
SimpleReplacer,
|
|
576
|
+
LineTrimmedReplacer,
|
|
577
|
+
BlockAnchorReplacer,
|
|
578
|
+
WhitespaceNormalizedReplacer,
|
|
579
|
+
IndentationFlexibleReplacer,
|
|
580
|
+
EscapeNormalizedReplacer,
|
|
581
|
+
TrimmedBoundaryReplacer,
|
|
582
|
+
ContextAwareReplacer,
|
|
583
|
+
MultiOccurrenceReplacer,
|
|
584
|
+
]) {
|
|
585
|
+
for (const search of replacer(content, oldString)) {
|
|
586
|
+
const index = content.indexOf(search)
|
|
587
|
+
if (index === -1) continue
|
|
588
|
+
notFound = false
|
|
589
|
+
if (replaceAll) {
|
|
590
|
+
return content.replaceAll(search, newString)
|
|
591
|
+
}
|
|
592
|
+
const lastIndex = content.lastIndexOf(search)
|
|
593
|
+
if (index !== lastIndex) continue
|
|
594
|
+
return content.substring(0, index) + newString + content.substring(index + search.length)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (notFound) {
|
|
599
|
+
throw new Error("oldString not found in content")
|
|
600
|
+
}
|
|
601
|
+
throw new Error(
|
|
602
|
+
"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.",
|
|
603
|
+
)
|
|
604
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Performs exact string replacements in files.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
|
5
|
+
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
|
6
|
+
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
|
7
|
+
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
|
8
|
+
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
|
9
|
+
- The edit will FAIL if `oldString` is found multiple times in the file with an error "oldString found multiple times and requires more code context to uniquely identify the intended match". Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
|
10
|
+
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
package/src/tool/glob.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import z from "zod"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { Tool } from "./tool"
|
|
4
|
+
import DESCRIPTION from "./glob.txt"
|
|
5
|
+
import { Ripgrep } from "../file/ripgrep"
|
|
6
|
+
import { Instance } from "../project/instance"
|
|
7
|
+
|
|
8
|
+
export const GlobTool = Tool.define("glob", {
|
|
9
|
+
description: DESCRIPTION,
|
|
10
|
+
parameters: z.object({
|
|
11
|
+
pattern: z.string().describe("The glob pattern to match files against"),
|
|
12
|
+
path: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
`The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.`,
|
|
17
|
+
),
|
|
18
|
+
}),
|
|
19
|
+
async execute(params) {
|
|
20
|
+
let search = params.path ?? Instance.directory
|
|
21
|
+
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
|
22
|
+
|
|
23
|
+
const limit = 100
|
|
24
|
+
const files = []
|
|
25
|
+
let truncated = false
|
|
26
|
+
for await (const file of Ripgrep.files({
|
|
27
|
+
cwd: search,
|
|
28
|
+
glob: [params.pattern],
|
|
29
|
+
})) {
|
|
30
|
+
if (files.length >= limit) {
|
|
31
|
+
truncated = true
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
const full = path.resolve(search, file)
|
|
35
|
+
const stats = await Bun.file(full)
|
|
36
|
+
.stat()
|
|
37
|
+
.then((x) => x.mtime.getTime())
|
|
38
|
+
.catch(() => 0)
|
|
39
|
+
files.push({
|
|
40
|
+
path: full,
|
|
41
|
+
mtime: stats,
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
files.sort((a, b) => b.mtime - a.mtime)
|
|
45
|
+
|
|
46
|
+
const output = []
|
|
47
|
+
if (files.length === 0) output.push("No files found")
|
|
48
|
+
if (files.length > 0) {
|
|
49
|
+
output.push(...files.map((f) => f.path))
|
|
50
|
+
if (truncated) {
|
|
51
|
+
output.push("")
|
|
52
|
+
output.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
title: path.relative(Instance.worktree, search),
|
|
58
|
+
metadata: {
|
|
59
|
+
count: files.length,
|
|
60
|
+
truncated,
|
|
61
|
+
},
|
|
62
|
+
output: output.join("\n"),
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
- Fast file pattern matching tool that works with any codebase size
|
|
2
|
+
- Supports glob patterns like "**/*.js" or "src/**/*.ts"
|
|
3
|
+
- Returns matching file paths sorted by modification time
|
|
4
|
+
- Use this tool when you need to find files by name patterns
|
|
5
|
+
- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
|
6
|
+
- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.
|