mirror of
https://github.com/EthanMarti/infio-copilot.git
synced 2026-05-10 09:07:47 +00:00
update apply diff
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import type { DiffStrategy } from "./types"
|
||||
import { UnifiedDiffStrategy } from "./strategies/unified"
|
||||
import { SearchReplaceDiffStrategy } from "./strategies/search-replace"
|
||||
import { App } from "obsidian"
|
||||
|
||||
import { MultiSearchReplaceDiffStrategy } from "./strategies/multi-search-replace"
|
||||
import { NewUnifiedDiffStrategy } from "./strategies/new-unified"
|
||||
import { SearchReplaceDiffStrategy } from "./strategies/search-replace"
|
||||
import { UnifiedDiffStrategy } from "./strategies/unified"
|
||||
import type { DiffStrategy } from "./types"
|
||||
/**
|
||||
* Get the appropriate diff strategy for the given model
|
||||
* @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus')
|
||||
@@ -9,14 +12,22 @@ import { NewUnifiedDiffStrategy } from "./strategies/new-unified"
|
||||
*/
|
||||
export function getDiffStrategy(
|
||||
model: string,
|
||||
app: App,
|
||||
fuzzyMatchThreshold?: number,
|
||||
experimentalDiffStrategy: boolean = false,
|
||||
multiSearchReplaceDiffStrategy: boolean = false,
|
||||
): DiffStrategy {
|
||||
if (experimentalDiffStrategy) {
|
||||
return new NewUnifiedDiffStrategy(fuzzyMatchThreshold)
|
||||
}
|
||||
return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
|
||||
// if (experimentalDiffStrategy) {
|
||||
// return new NewUnifiedDiffStrategy(app, fuzzyMatchThreshold)
|
||||
// }
|
||||
|
||||
// if (multiSearchReplaceDiffStrategy) {
|
||||
// return new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold)
|
||||
// } else {
|
||||
// return new SearchReplaceDiffStrategy(fuzzyMatchThreshold)
|
||||
// }
|
||||
return new MultiSearchReplaceDiffStrategy(0.9)
|
||||
}
|
||||
|
||||
export { SearchReplaceDiffStrategy, UnifiedDiffStrategy }
|
||||
export type { DiffStrategy }
|
||||
export { UnifiedDiffStrategy, SearchReplaceDiffStrategy }
|
||||
|
||||
1566
src/core/diff/strategies/__tests__/multi-search-replace.test.ts
Normal file
1566
src/core/diff/strategies/__tests__/multi-search-replace.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
395
src/core/diff/strategies/multi-search-replace.ts
Normal file
395
src/core/diff/strategies/multi-search-replace.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { distance } from "fastest-levenshtein"
|
||||
|
||||
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text"
|
||||
// import { ToolProgressStatus } from "../../../shared/ExtensionMessage"
|
||||
// import { ToolUse } from "../../assistant-message"
|
||||
import { DiffResult, DiffStrategy } from "../types"
|
||||
|
||||
const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
|
||||
|
||||
function getSimilarity(original: string, search: string): number {
|
||||
if (search === "") {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Normalize strings by removing extra whitespace but preserve case
|
||||
const normalizeStr = (str: string) => str.replace(/\s+/g, " ").trim()
|
||||
|
||||
const normalizedOriginal = normalizeStr(original)
|
||||
const normalizedSearch = normalizeStr(search)
|
||||
|
||||
if (normalizedOriginal === normalizedSearch) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance using fastest-levenshtein's distance function
|
||||
const dist = distance(normalizedOriginal, normalizedSearch)
|
||||
|
||||
// Calculate similarity ratio (0 to 1, where 1 is an exact match)
|
||||
const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length)
|
||||
return 1 - dist / maxLength
|
||||
}
|
||||
|
||||
export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
|
||||
private fuzzyThreshold: number
|
||||
private bufferLines: number
|
||||
|
||||
getName(): string {
|
||||
return "MultiSearchReplace"
|
||||
}
|
||||
|
||||
constructor(fuzzyThreshold?: number, bufferLines?: number) {
|
||||
// Use provided threshold or default to exact matching (1.0)
|
||||
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
|
||||
// so we use it directly here
|
||||
this.fuzzyThreshold = fuzzyThreshold ?? 1.0
|
||||
this.bufferLines = bufferLines ?? BUFFER_LINES
|
||||
}
|
||||
|
||||
getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
|
||||
return `## apply_diff
|
||||
Description: Request to replace existing content in Markdown documents using a search and replace block.
|
||||
This tool allows for precise modifications to Markdown files by specifying exactly what content to search for and what to replace it with.
|
||||
The tool will maintain proper formatting while making changes to your Markdown documents.
|
||||
Only a single operation is allowed per tool use.
|
||||
The SEARCH section must exactly match existing content including whitespace and indentation.
|
||||
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
|
||||
When applying changes to Markdown, be careful about maintaining list structures, heading levels, and other Markdown formatting.
|
||||
ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks
|
||||
|
||||
Parameters:
|
||||
- path: (required) The path of the file to modify (relative to the current working directory ${args.cwd})
|
||||
- diff: (required) The search/replace block defining the changes.
|
||||
|
||||
Diff format:
|
||||
\`\`\`
|
||||
<<<<<<< SEARCH
|
||||
:start_line: (required) The line number of original content where the search block starts.
|
||||
:end_line: (required) The line number of original content where the search block ends.
|
||||
-------
|
||||
[exact content to find including whitespace]
|
||||
=======
|
||||
[new content to replace with]
|
||||
>>>>>>> REPLACE
|
||||
|
||||
\`\`\`
|
||||
|
||||
Example:
|
||||
|
||||
Original Markdown file:
|
||||
\`\`\`
|
||||
1 | # Project Notes
|
||||
2 |
|
||||
3 | ## Tasks
|
||||
4 | - [ ] Review documentation
|
||||
5 | - [ ] Update examples
|
||||
6 | - [ ] Add new section
|
||||
\`\`\`
|
||||
|
||||
Search/Replace content:
|
||||
\`\`\`
|
||||
<<<<<<< SEARCH
|
||||
:start_line:3
|
||||
:end_line:6
|
||||
-------
|
||||
## Tasks
|
||||
- [ ] Review documentation
|
||||
- [ ] Update examples
|
||||
- [ ] Add new section
|
||||
=======
|
||||
## Current Tasks
|
||||
- [ ] Review documentation
|
||||
- [x] Update examples
|
||||
- [ ] Add new section
|
||||
- [ ] Schedule team meeting
|
||||
>>>>>>> REPLACE
|
||||
|
||||
\`\`\`
|
||||
|
||||
Search/Replace content with multi edits:
|
||||
\`\`\`
|
||||
<<<<<<< SEARCH
|
||||
:start_line:1
|
||||
:end_line:1
|
||||
-------
|
||||
# Project Notes
|
||||
=======
|
||||
# Project Notes (Updated)
|
||||
>>>>>>> REPLACE
|
||||
|
||||
<<<<<<< SEARCH
|
||||
:start_line:4
|
||||
:end_line:5
|
||||
-------
|
||||
- [ ] Review documentation
|
||||
- [ ] Update examples
|
||||
=======
|
||||
- [ ] Review documentation (priority)
|
||||
- [x] Update examples
|
||||
>>>>>>> REPLACE
|
||||
\`\`\`
|
||||
|
||||
Usage:
|
||||
<apply_diff>
|
||||
<path>File path here</path>
|
||||
<diff>
|
||||
Your search/replace content here
|
||||
You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block.
|
||||
Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file.
|
||||
</diff>
|
||||
</apply_diff>`
|
||||
}
|
||||
|
||||
async applyDiff(
|
||||
originalContent: string,
|
||||
diffContent: string,
|
||||
_paramStartLine?: number,
|
||||
_paramEndLine?: number,
|
||||
): Promise<DiffResult> {
|
||||
let matches = [
|
||||
...diffContent.matchAll(
|
||||
/<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g,
|
||||
),
|
||||
]
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n:end_line: end line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/end_line/SEARCH/REPLACE sections with correct markers`,
|
||||
}
|
||||
}
|
||||
// Detect line ending from original content
|
||||
const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n"
|
||||
let resultLines = originalContent.split(/\r?\n/)
|
||||
let delta = 0
|
||||
let diffResults: DiffResult[] = []
|
||||
let appliedCount = 0
|
||||
const replacements = matches
|
||||
.map((match) => ({
|
||||
startLine: Number(match[2] ?? 0),
|
||||
endLine: Number(match[4] ?? resultLines.length),
|
||||
searchContent: match[6],
|
||||
replaceContent: match[7],
|
||||
}))
|
||||
.sort((a, b) => a.startLine - b.startLine)
|
||||
|
||||
for (let { searchContent, replaceContent, startLine, endLine } of replacements) {
|
||||
startLine += startLine === 0 ? 0 : delta
|
||||
endLine += delta
|
||||
|
||||
// Strip line numbers from search and replace content if every line starts with a line number
|
||||
if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) {
|
||||
searchContent = stripLineNumbers(searchContent)
|
||||
replaceContent = stripLineNumbers(replaceContent)
|
||||
}
|
||||
|
||||
// Split content into lines, handling both \n and \r\n
|
||||
const searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/)
|
||||
const replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/)
|
||||
|
||||
// Validate that empty search requires start line
|
||||
if (searchLines.length === 0 && !startLine) {
|
||||
diffResults.push({
|
||||
success: false,
|
||||
error: `Empty search content requires start_line to be specified\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, specify the line number where content should be inserted`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate that empty search requires same start and end line
|
||||
if (searchLines.length === 0 && startLine && endLine && startLine !== endLine) {
|
||||
diffResults.push({
|
||||
success: false,
|
||||
error: `Empty search content requires start_line and end_line to be the same (got ${startLine}-${endLine})\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, use the same line number for both start_line and end_line`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Initialize search variables
|
||||
let matchIndex = -1
|
||||
let bestMatchScore = 0
|
||||
let bestMatchContent = ""
|
||||
const searchChunk = searchLines.join("\n")
|
||||
|
||||
// Determine search bounds
|
||||
let searchStartIndex = 0
|
||||
let searchEndIndex = resultLines.length
|
||||
|
||||
// Validate and handle line range if provided
|
||||
if (startLine && endLine) {
|
||||
// Convert to 0-based index
|
||||
const exactStartIndex = startLine - 1
|
||||
const exactEndIndex = endLine - 1
|
||||
|
||||
if (exactStartIndex < 0 || exactEndIndex > resultLines.length || exactStartIndex > exactEndIndex) {
|
||||
diffResults.push({
|
||||
success: false,
|
||||
error: `Line range ${startLine}-${endLine} is invalid (file has ${resultLines.length} lines)\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${resultLines.length}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n")
|
||||
const similarity = getSimilarity(originalChunk, searchChunk)
|
||||
if (similarity >= this.fuzzyThreshold) {
|
||||
matchIndex = exactStartIndex
|
||||
bestMatchScore = similarity
|
||||
bestMatchContent = originalChunk
|
||||
} else {
|
||||
// Set bounds for buffered search
|
||||
searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1))
|
||||
searchEndIndex = Math.min(resultLines.length, endLine + this.bufferLines)
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found yet, try middle-out search within bounds
|
||||
if (matchIndex === -1) {
|
||||
const midPoint = Math.floor((searchStartIndex + searchEndIndex) / 2)
|
||||
let leftIndex = midPoint
|
||||
let rightIndex = midPoint + 1
|
||||
|
||||
// Search outward from the middle within bounds
|
||||
while (leftIndex >= searchStartIndex || rightIndex <= searchEndIndex - searchLines.length) {
|
||||
// Check left side if still in range
|
||||
if (leftIndex >= searchStartIndex) {
|
||||
const originalChunk = resultLines.slice(leftIndex, leftIndex + searchLines.length).join("\n")
|
||||
const similarity = getSimilarity(originalChunk, searchChunk)
|
||||
if (similarity > bestMatchScore) {
|
||||
bestMatchScore = similarity
|
||||
matchIndex = leftIndex
|
||||
bestMatchContent = originalChunk
|
||||
}
|
||||
leftIndex--
|
||||
}
|
||||
|
||||
// Check right side if still in range
|
||||
if (rightIndex <= searchEndIndex - searchLines.length) {
|
||||
const originalChunk = resultLines.slice(rightIndex, rightIndex + searchLines.length).join("\n")
|
||||
const similarity = getSimilarity(originalChunk, searchChunk)
|
||||
if (similarity > bestMatchScore) {
|
||||
bestMatchScore = similarity
|
||||
matchIndex = rightIndex
|
||||
bestMatchContent = originalChunk
|
||||
}
|
||||
rightIndex++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Require similarity to meet threshold
|
||||
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
|
||||
const searchChunk = searchLines.join("\n")
|
||||
const originalContentSection =
|
||||
startLine !== undefined && endLine !== undefined
|
||||
? `\n\nOriginal Content:\n${addLineNumbers(
|
||||
resultLines
|
||||
.slice(
|
||||
Math.max(0, startLine - 1 - this.bufferLines),
|
||||
Math.min(resultLines.length, endLine + this.bufferLines),
|
||||
)
|
||||
.join("\n"),
|
||||
Math.max(1, startLine - this.bufferLines),
|
||||
)}`
|
||||
: `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}`
|
||||
|
||||
const bestMatchSection = bestMatchContent
|
||||
? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}`
|
||||
: `\n\nBest Match Found:\n(no match)`
|
||||
|
||||
const lineRange =
|
||||
startLine || endLine
|
||||
? ` at ${startLine ? `start: ${startLine}` : "start"} to ${endLine ? `end: ${endLine}` : "end"}`
|
||||
: ""
|
||||
|
||||
diffResults.push({
|
||||
success: false,
|
||||
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n- Tip: Use read_file to get the latest content of the file before attempting the diff again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the matched lines from the original content
|
||||
const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length)
|
||||
|
||||
// Get the exact indentation (preserving tabs/spaces) of each line
|
||||
const originalIndents = matchedLines.map((line) => {
|
||||
const match = line.match(/^[\t ]*/)
|
||||
return match ? match[0] : ""
|
||||
})
|
||||
|
||||
// Get the exact indentation of each line in the search block
|
||||
const searchIndents = searchLines.map((line) => {
|
||||
const match = line.match(/^[\t ]*/)
|
||||
return match ? match[0] : ""
|
||||
})
|
||||
|
||||
// Apply the replacement while preserving exact indentation
|
||||
const indentedReplaceLines = replaceLines.map((line, i) => {
|
||||
// Get the matched line's exact indentation
|
||||
const matchedIndent = originalIndents[0] || ""
|
||||
|
||||
// Get the current line's indentation relative to the search content
|
||||
const currentIndentMatch = line.match(/^[\t ]*/)
|
||||
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : ""
|
||||
const searchBaseIndent = searchIndents[0] || ""
|
||||
|
||||
// Calculate the relative indentation level
|
||||
const searchBaseLevel = searchBaseIndent.length
|
||||
const currentLevel = currentIndent.length
|
||||
const relativeLevel = currentLevel - searchBaseLevel
|
||||
|
||||
// If relative level is negative, remove indentation from matched indent
|
||||
// If positive, add to matched indent
|
||||
const finalIndent =
|
||||
relativeLevel < 0
|
||||
? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel))
|
||||
: matchedIndent + currentIndent.slice(searchBaseLevel)
|
||||
|
||||
return finalIndent + line.trim()
|
||||
})
|
||||
|
||||
// Construct the final content
|
||||
const beforeMatch = resultLines.slice(0, matchIndex)
|
||||
const afterMatch = resultLines.slice(matchIndex + searchLines.length)
|
||||
resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch]
|
||||
delta = delta - matchedLines.length + replaceLines.length
|
||||
appliedCount++
|
||||
}
|
||||
const finalContent = resultLines.join(lineEnding)
|
||||
if (appliedCount === 0) {
|
||||
return {
|
||||
success: false,
|
||||
failParts: diffResults,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
content: finalContent,
|
||||
failParts: diffResults,
|
||||
}
|
||||
}
|
||||
|
||||
getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
|
||||
const diffContent = toolUse.params.diff
|
||||
if (diffContent) {
|
||||
const icon = "diff-multiple"
|
||||
const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
|
||||
if (toolUse.partial) {
|
||||
if (diffContent.length < 1000 || (diffContent.length / 50) % 10 === 0) {
|
||||
return { icon, text: `${searchBlockCount}` }
|
||||
}
|
||||
} else if (result) {
|
||||
if (result.failParts?.length) {
|
||||
return {
|
||||
icon,
|
||||
text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`,
|
||||
}
|
||||
} else {
|
||||
return { icon, text: `${searchBlockCount}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,35 @@
|
||||
import { diff_match_patch } from "diff-match-patch"
|
||||
import { EditResult, Hunk } from "./types"
|
||||
import { getDMPSimilarity, validateEditResult } from "./search-strategies"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as tmp from "tmp"
|
||||
import { App, FileSystemAdapter, normalizePath } from "obsidian"
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
// Helper function to infer indentation - simplified version
|
||||
function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string {
|
||||
// If the line has explicit indentation in the change, use it exactly
|
||||
const lineMatch = line.match(/^(\s+)/)
|
||||
if (lineMatch) {
|
||||
return lineMatch[1]
|
||||
}
|
||||
import { diff_match_patch } from "diff-match-patch"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
|
||||
// If we have context lines, use the indentation from the first context line
|
||||
const contextLine = contextLines[0]
|
||||
if (contextLine) {
|
||||
const contextMatch = contextLine.match(/^(\s+)/)
|
||||
if (contextMatch) {
|
||||
return contextMatch[1]
|
||||
}
|
||||
}
|
||||
import { validateEditResult } from "./search-strategies"
|
||||
import { EditResult, Hunk } from "./types"
|
||||
|
||||
// Fallback to previous indent
|
||||
return previousIndent
|
||||
}
|
||||
|
||||
// // Helper function to infer indentation - simplified version
|
||||
// function inferIndentation(line: string, contextLines: string[], previousIndent: string = ""): string {
|
||||
// // If the line has explicit indentation in the change, use it exactly
|
||||
// const lineMatch = line.match(/^(\s+)/)
|
||||
// if (lineMatch) {
|
||||
// return lineMatch[1]
|
||||
// }
|
||||
|
||||
// // If we have context lines, use the indentation from the first context line
|
||||
// const contextLine = contextLines[0]
|
||||
// if (contextLine) {
|
||||
// const contextMatch = contextLine.match(/^(\s+)/)
|
||||
// if (contextMatch) {
|
||||
// return contextMatch[1]
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Fallback to previous indent
|
||||
// return previousIndent
|
||||
// }
|
||||
|
||||
// Context matching edit strategy
|
||||
export function applyContextMatching(hunk: Hunk, content: string[], matchPosition: number): EditResult {
|
||||
@@ -147,18 +151,28 @@ export function applyDMP(hunk: Hunk, content: string[], matchPosition: number):
|
||||
}
|
||||
|
||||
// Git fallback strategy that works with full content
|
||||
export async function applyGitFallback(hunk: Hunk, content: string[]): Promise<EditResult> {
|
||||
let tmpDir: tmp.DirResult | undefined
|
||||
export async function applyGitFallback(app: App, hunk: Hunk, content: string[]): Promise<EditResult> {
|
||||
// let tmpDir: tmp.DirResult | undefined
|
||||
const adapter = app.vault.adapter as FileSystemAdapter;
|
||||
const vaultBasePath = adapter.getBasePath();
|
||||
const tmpGitPath = normalizePath(path.join(vaultBasePath, ".tmp_git"));
|
||||
|
||||
console.log("tmpGitPath", tmpGitPath)
|
||||
|
||||
try {
|
||||
tmpDir = tmp.dirSync({ unsafeCleanup: true })
|
||||
const git: SimpleGit = simpleGit(tmpDir.name)
|
||||
const exists = await adapter.exists(tmpGitPath);
|
||||
if (exists) {
|
||||
await adapter.rmdir(tmpGitPath, true);
|
||||
}
|
||||
await adapter.mkdir(tmpGitPath);
|
||||
// tmpDir = tmp.dirSync({ unsafeCleanup: true })
|
||||
const git: SimpleGit = simpleGit(tmpGitPath)
|
||||
|
||||
await git.init()
|
||||
await git.addConfig("user.name", "Temp")
|
||||
await git.addConfig("user.email", "temp@example.com")
|
||||
|
||||
const filePath = path.join(tmpDir.name, "file.txt")
|
||||
const filePath = path.join(tmpGitPath, "file.txt")
|
||||
|
||||
const searchLines = hunk.changes
|
||||
.filter((change) => change.type === "context" || change.type === "remove")
|
||||
@@ -256,14 +270,15 @@ export async function applyGitFallback(hunk: Hunk, content: string[]): Promise<E
|
||||
console.error("Git fallback strategy failed:", error)
|
||||
return { confidence: 0, result: content, strategy: "git-fallback" }
|
||||
} finally {
|
||||
if (tmpDir) {
|
||||
tmpDir.removeCallback()
|
||||
if (tmpGitPath) {
|
||||
await adapter.rmdir(tmpGitPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main edit function that tries strategies sequentially
|
||||
export async function applyEdit(
|
||||
app: App,
|
||||
hunk: Hunk,
|
||||
content: string[],
|
||||
matchPosition: number,
|
||||
@@ -275,14 +290,14 @@ export async function applyEdit(
|
||||
console.log(
|
||||
`Search confidence (${confidence}) below minimum threshold (${confidenceThreshold}), trying git fallback...`,
|
||||
)
|
||||
return applyGitFallback(hunk, content)
|
||||
return applyGitFallback(app, hunk, content)
|
||||
}
|
||||
|
||||
// Try each strategy in sequence until one succeeds
|
||||
const strategies = [
|
||||
{ name: "dmp", apply: () => applyDMP(hunk, content, matchPosition) },
|
||||
{ name: "context", apply: () => applyContextMatching(hunk, content, matchPosition) },
|
||||
{ name: "git-fallback", apply: () => applyGitFallback(hunk, content) },
|
||||
{ name: "git-fallback", apply: () => applyGitFallback(app, hunk, content) },
|
||||
]
|
||||
|
||||
// Try strategies sequentially until one succeeds
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { Diff, Hunk, Change } from "./types"
|
||||
import { findBestMatch, prepareSearchString } from "./search-strategies"
|
||||
import { applyEdit } from "./edit-strategies"
|
||||
import { App } from 'obsidian'
|
||||
|
||||
import { DiffResult, DiffStrategy } from "../../types"
|
||||
|
||||
import { applyEdit } from "./edit-strategies"
|
||||
import { findBestMatch, prepareSearchString } from "./search-strategies"
|
||||
import { Change, Diff, Hunk } from "./types"
|
||||
|
||||
// 中文引号转英文引号
|
||||
export function convertQuotes(str: string) {
|
||||
return str.replace(/[“”]/g, '"');
|
||||
}
|
||||
|
||||
export class NewUnifiedDiffStrategy implements DiffStrategy {
|
||||
private readonly confidenceThreshold: number
|
||||
private app: App
|
||||
|
||||
constructor(confidenceThreshold: number = 1) {
|
||||
getName(): string {
|
||||
return "NewUnified"
|
||||
}
|
||||
|
||||
constructor(app: App, confidenceThreshold: number = 1) {
|
||||
this.app = app
|
||||
this.confidenceThreshold = Math.max(confidenceThreshold, 0.8)
|
||||
}
|
||||
|
||||
private parseUnifiedDiff(diff: string): Diff {
|
||||
const MAX_CONTEXT_LINES = 6 // Number of context lines to keep before/after changes
|
||||
const lines = diff.split("\n")
|
||||
// console.log("lines: ", lines)
|
||||
const hunks: Hunk[] = []
|
||||
let currentHunk: Hunk | null = null
|
||||
|
||||
@@ -60,7 +75,7 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
|
||||
}
|
||||
|
||||
const content = line.slice(1)
|
||||
const indentMatch = content.match(/^(\s*)/)
|
||||
const indentMatch = /^(\s*)/.exec(content)
|
||||
const indent = indentMatch ? indentMatch[0] : ""
|
||||
const trimmedContent = content.slice(indent.length)
|
||||
|
||||
@@ -85,6 +100,8 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
|
||||
indent,
|
||||
originalLine: content,
|
||||
})
|
||||
} else if (line.startsWith("reason: ")) {
|
||||
// ignore reason
|
||||
} else {
|
||||
const finalContent = trimmedContent ? " " + trimmedContent : " "
|
||||
currentHunk.changes.push({
|
||||
@@ -108,9 +125,9 @@ export class NewUnifiedDiffStrategy implements DiffStrategy {
|
||||
}
|
||||
|
||||
getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string {
|
||||
return `# apply_diff Tool - Generate Precise Code Changes
|
||||
return `# apply_diff Tool - Generate Precise Markdown Changes
|
||||
|
||||
Generate a unified diff that can be cleanly applied to modify code files.
|
||||
Generate a unified diff that can be cleanly applied to modify markdown files.
|
||||
|
||||
## Step-by-Step Instructions:
|
||||
|
||||
@@ -120,47 +137,45 @@ Generate a unified diff that can be cleanly applied to modify code files.
|
||||
|
||||
2. For each change section:
|
||||
- Begin with "@@ ... @@" separator line without line numbers
|
||||
- Include 2-3 lines of context before and after changes
|
||||
- Mark removed lines with "-"
|
||||
- Mark added lines with "+"
|
||||
- Preserve exact indentation
|
||||
- Mark added lines with "+" prefix (without line numbers)
|
||||
- Mark removed lines with "-" prefix (without line numbers)
|
||||
- Mark reason with "reason: "
|
||||
- Preserve exact spacing and formatting
|
||||
|
||||
3. Group related changes:
|
||||
- Keep related modifications in the same hunk
|
||||
- Start new hunks for logically separate changes
|
||||
- When modifying functions/methods, include the entire block
|
||||
|
||||
## Requirements:
|
||||
|
||||
1. MUST include exact indentation
|
||||
2. MUST include sufficient context for unique matching
|
||||
3. MUST group related changes together
|
||||
4. MUST use proper unified diff format
|
||||
5. MUST NOT include timestamps in file headers
|
||||
6. MUST NOT include line numbers in the @@ header
|
||||
1. MUST include reason, avoid unnecessary modifications
|
||||
2. MUST include exact spacing and formatting
|
||||
3. MUST include sufficient context for unique matching
|
||||
4. MUST group related changes together
|
||||
5. MUST use proper unified diff format
|
||||
6. MUST NOT include timestamps in file headers
|
||||
7. MUST NOT include line numbers in the @@ header
|
||||
8. MUST NOT include line numbers in the added lines and removed lines
|
||||
|
||||
## Examples:
|
||||
|
||||
✅ Good diff (follows all requirements):
|
||||
\`\`\`diff
|
||||
--- src/utils.ts
|
||||
+++ src/utils.ts
|
||||
--- docs/example.md
|
||||
+++ docs/example.md
|
||||
@@ ... @@
|
||||
def calculate_total(items):
|
||||
- total = 0
|
||||
- for item in items:
|
||||
- total += item.price
|
||||
+ return sum(item.price for item in items)
|
||||
-old content
|
||||
+new content
|
||||
reason: change reason
|
||||
\`\`\`
|
||||
|
||||
❌ Bad diff (violates requirements #1 and #2):
|
||||
❌ Bad diff (violates requirements #8)
|
||||
\`\`\`diff
|
||||
--- src/utils.ts
|
||||
+++ src/utils.ts
|
||||
--- docs/example.md
|
||||
+++ docs/example.md
|
||||
@@ ... @@
|
||||
-total = 0
|
||||
-for item in items:
|
||||
+return sum(item.price for item in items)
|
||||
- 6 | old content
|
||||
+ 6 | new content
|
||||
\`\`\`
|
||||
|
||||
Parameters:
|
||||
@@ -169,7 +184,7 @@ Parameters:
|
||||
|
||||
Usage:
|
||||
<apply_diff>
|
||||
<path>path/to/file.ext</path>
|
||||
<path>path/to/file.md</path>
|
||||
<diff>
|
||||
Your diff here
|
||||
</diff>
|
||||
@@ -236,7 +251,7 @@ Your diff here
|
||||
endLine?: number,
|
||||
): Promise<DiffResult> {
|
||||
const parsedDiff = this.parseUnifiedDiff(diffContent)
|
||||
const originalLines = originalContent.split("\n")
|
||||
const originalLines = convertQuotes(originalContent).split("\n")
|
||||
let result = [...originalLines]
|
||||
|
||||
if (!parsedDiff.hunks.length) {
|
||||
@@ -247,13 +262,12 @@ Your diff here
|
||||
}
|
||||
|
||||
for (const hunk of parsedDiff.hunks) {
|
||||
const contextStr = prepareSearchString(hunk.changes)
|
||||
const contextStr = convertQuotes(prepareSearchString(hunk.changes))
|
||||
const {
|
||||
index: matchPosition,
|
||||
confidence,
|
||||
strategy,
|
||||
} = findBestMatch(contextStr, result, 0, this.confidenceThreshold)
|
||||
|
||||
if (confidence < this.confidenceThreshold) {
|
||||
console.log("Full hunk application failed, trying sub-hunks strategy")
|
||||
// Try splitting the hunk into smaller hunks
|
||||
@@ -267,6 +281,7 @@ Your diff here
|
||||
|
||||
if (subSearchResult.confidence >= this.confidenceThreshold) {
|
||||
const subEditResult = await applyEdit(
|
||||
this.app,
|
||||
subHunk,
|
||||
subHunkResult,
|
||||
subSearchResult.index,
|
||||
@@ -324,7 +339,14 @@ Your diff here
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
|
||||
const editResult = await applyEdit(hunk, result, matchPosition, confidence, this.confidenceThreshold)
|
||||
const editResult = await applyEdit(
|
||||
this.app,
|
||||
hunk,
|
||||
result,
|
||||
matchPosition,
|
||||
confidence,
|
||||
this.confidenceThreshold,
|
||||
)
|
||||
if (editResult.confidence >= this.confidenceThreshold) {
|
||||
result = editResult.result
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { compareTwoStrings } from "string-similarity"
|
||||
import { closest } from "fastest-levenshtein"
|
||||
import { diff_match_patch } from "diff-match-patch"
|
||||
import { closest } from "fastest-levenshtein"
|
||||
import { compareTwoStrings } from "string-similarity"
|
||||
|
||||
import { Change, Hunk } from "./types"
|
||||
|
||||
export type SearchResult = {
|
||||
@@ -44,7 +45,7 @@ function evaluateContentUniqueness(searchStr: string, content: string[]): number
|
||||
|
||||
// Helper function to prepare search string from context
|
||||
export function prepareSearchString(changes: Change[]): string {
|
||||
const lines = changes.filter((c) => c.type === "context" || c.type === "remove").map((c) => c.originalLine)
|
||||
const lines = changes.filter((c) => c.type === "remove").map((c) => c.content)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -198,12 +199,16 @@ export function findExactMatch(
|
||||
startIndex: number = 0,
|
||||
confidenceThreshold: number = 0.97,
|
||||
): SearchResult {
|
||||
// console.log("searchStr: ", searchStr)
|
||||
// console.log("content: ", content)
|
||||
const searchLines = searchStr.split("\n")
|
||||
const windows = createOverlappingWindows(content.slice(startIndex), searchLines.length)
|
||||
const matches: (SearchResult & { windowIndex: number })[] = []
|
||||
|
||||
windows.forEach((windowData, windowIndex) => {
|
||||
const windowStr = windowData.window.join("\n")
|
||||
// console.log("searchStr: ", searchStr)
|
||||
// console.log("windowStr:", windowStr)
|
||||
const exactMatch = windowStr.indexOf(searchStr)
|
||||
|
||||
if (exactMatch !== -1) {
|
||||
@@ -399,10 +404,18 @@ export function findBestMatch(
|
||||
|
||||
for (const strategy of strategies) {
|
||||
const result = strategy(searchStr, content, startIndex, confidenceThreshold)
|
||||
if (searchStr === "由于年久失修,街区路面坑洼不平,污水横流,垃圾遍地,甚至可见弹痕血迹。") {
|
||||
console.log("findBestMatch result: ", strategy.name, result)
|
||||
}
|
||||
if (result.confidence > bestResult.confidence) {
|
||||
bestResult = result
|
||||
}
|
||||
}
|
||||
// if (bestResult.confidence < 0.97) {
|
||||
// console.log("searchStr: ", searchStr)
|
||||
// console.log("content: ", content)
|
||||
// console.log("findBestMatch result: ", bestResult)
|
||||
// }
|
||||
|
||||
return bestResult
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DiffStrategy, DiffResult } from "../types"
|
||||
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
|
||||
import { distance } from "fastest-levenshtein"
|
||||
|
||||
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../utils/extract-text"
|
||||
import { DiffResult, DiffStrategy } from "../types"
|
||||
|
||||
const BUFFER_LINES = 20 // Number of extra context lines to show before and after matches
|
||||
|
||||
function getSimilarity(original: string, search: string): number {
|
||||
@@ -31,6 +32,10 @@ export class SearchReplaceDiffStrategy implements DiffStrategy {
|
||||
private fuzzyThreshold: number
|
||||
private bufferLines: number
|
||||
|
||||
getName(): string {
|
||||
return "SearchReplace"
|
||||
}
|
||||
|
||||
constructor(fuzzyThreshold?: number, bufferLines?: number) {
|
||||
// Use provided threshold or default to exact matching (1.0)
|
||||
// Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9)
|
||||
@@ -225,14 +230,14 @@ Your search/replace content here
|
||||
const originalContentSection =
|
||||
startLine !== undefined && endLine !== undefined
|
||||
? `\n\nOriginal Content:\n${addLineNumbers(
|
||||
originalLines
|
||||
.slice(
|
||||
Math.max(0, startLine - 1 - this.bufferLines),
|
||||
Math.min(originalLines.length, endLine + this.bufferLines),
|
||||
)
|
||||
.join("\n"),
|
||||
Math.max(1, startLine - this.bufferLines),
|
||||
)}`
|
||||
originalLines
|
||||
.slice(
|
||||
Math.max(0, startLine - 1 - this.bufferLines),
|
||||
Math.min(originalLines.length, endLine + this.bufferLines),
|
||||
)
|
||||
.join("\n"),
|
||||
Math.max(1, startLine - this.bufferLines),
|
||||
)}`
|
||||
: `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}`
|
||||
|
||||
const bestMatchSection = bestMatchContent
|
||||
|
||||
@@ -3,20 +3,28 @@
|
||||
*/
|
||||
|
||||
export type DiffResult =
|
||||
| { success: true; content: string }
|
||||
| {
|
||||
success: false
|
||||
error: string
|
||||
details?: {
|
||||
similarity?: number
|
||||
threshold?: number
|
||||
matchedRange?: { start: number; end: number }
|
||||
searchContent?: string
|
||||
bestMatch?: string
|
||||
}
|
||||
}
|
||||
| { success: true; content: string; failParts?: DiffResult[] }
|
||||
| ({
|
||||
success: false
|
||||
error?: string
|
||||
details?: {
|
||||
similarity?: number
|
||||
threshold?: number
|
||||
matchedRange?: { start: number; end: number }
|
||||
searchContent?: string
|
||||
bestMatch?: string
|
||||
}
|
||||
failParts?: DiffResult[]
|
||||
} & ({ error: string } | { failParts: DiffResult[] }))
|
||||
|
||||
export interface DiffStrategy {
|
||||
|
||||
/**
|
||||
* Get the name of this diff strategy for analytics and debugging
|
||||
* @returns The name of the diff strategy
|
||||
*/
|
||||
getName(): string
|
||||
|
||||
/**
|
||||
* Get the tool description for this diff strategy
|
||||
* @param args The tool arguments including cwd and toolOptions
|
||||
|
||||
Reference in New Issue
Block a user