update apply diff

This commit is contained in:
duanfuxiang
2025-03-23 09:34:44 +08:00
parent 570e8d9564
commit 635db9babd
34 changed files with 3161 additions and 410 deletions

View File

@@ -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 }

File diff suppressed because it is too large Load Diff

View 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 {}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +1,14 @@
import { DiffStrategy } from "../../diff/DiffStrategy"
function getEditingInstructions(diffStrategy?: DiffStrategy, experiments?: Record<string, boolean>): string {
function getEditingInstructions(diffStrategy?: DiffStrategy): string {
const instructions: string[] = []
const availableTools: string[] = []
const experiments = {
insert_content: true,
search_and_replace: true,
}
// Collect available editing tools
if (diffStrategy) {
availableTools.push(
@@ -90,7 +95,7 @@ RULES
- Your current obsidian directory is: ${cwd.toPosix()}
${getSearchInstructions(searchTool)}
- When creating new notes in Obsidian, organize them according to the existing vault structure unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the content logically, adhering to Obsidian conventions with appropriate frontmatter, headings, lists, and formatting. Unless otherwise specified, new notes should follow Markdown syntax with appropriate use of links ([[note name]]), tags (#tag), callouts, and other Obsidian-specific formatting.
${getEditingInstructions(diffStrategy, experiments)}
${getEditingInstructions(diffStrategy)}
- Be sure to consider the structure of the Obsidian vault (folders, naming conventions, note organization) when determining the appropriate format and content for new or modified notes. Also consider what files may be most relevant to accomplishing the task, for example examining backlinks, linked mentions, or tags would help you understand the relationships between notes, which you could incorporate into any content you write.
- When making changes to content, always consider the context within the broader vault. Ensure that your changes maintain existing links, tags, and references, and that they follow the user's established formatting standards and organization.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
@@ -100,7 +105,7 @@ ${getEditingInstructions(diffStrategy, experiments)}
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
- You are STRICTLY FORBIDDEN from starting your messages with "Great", "Certainly", "Okay", "Sure". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say "Great, I've updated the markdown" but instead something like "I've updated the markdown". It is important you be clear and technical in your messages.
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
- At the end of the first user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the Obsidian environment. This includes the current file being edited, open tabs, and the vault structure. While this information can be valuable for understanding the context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
- Pay special attention to the open tabs in environment_details, as they indicate which notes the user is currently working with and may be most relevant to their task. Similarly, the current file information shows which note is currently in focus and likely the primary subject of the user's request.
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to create a structured note, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.`
}

View File

@@ -44,8 +44,8 @@ async function generatePrompt(
// throw new Error("Extension context is required for generating system prompt")
// }
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// // If diff is disabled, don't pass the diffStrategy
// const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// Get the full mode config to ensure we have the role definition
const modeConfig = getModeBySlug(mode, customModeConfigs) || modes.find((m) => m.slug === mode) || modes[0]
@@ -54,7 +54,7 @@ async function generatePrompt(
const [modesSection, mcpServersSection] = await Promise.all([
getModesSection(),
modeConfig.groups.some((groupEntry) => getGroupName(groupEntry) === "mcp")
? getMcpServersSection(mcpHub, effectiveDiffStrategy, enableMcpServerCreation)
? getMcpServersSection(mcpHub, diffStrategy, enableMcpServerCreation)
: Promise.resolve(""),
])
@@ -67,7 +67,7 @@ ${getToolDescriptionsForMode(
cwd,
filesSearchMethod,
supportsComputerUse,
effectiveDiffStrategy,
diffStrategy,
browserViewportSize,
mcpHub,
customModeConfigs,
@@ -91,7 +91,7 @@ ${getRulesSection(
cwd,
filesSearchMethod,
supportsComputerUse,
effectiveDiffStrategy,
diffStrategy,
experiments,
)}
@@ -110,8 +110,8 @@ export const SYSTEM_PROMPT = async (
mode: Mode = defaultModeSlug,
filesSearchMethod: string = 'regex',
preferredLanguage?: string,
mcpHub?: McpHub,
diffStrategy?: DiffStrategy,
mcpHub?: McpHub,
browserViewportSize?: string,
customModePrompts?: CustomModePrompts,
customModes?: ModeConfig[],
@@ -150,8 +150,8 @@ export const SYSTEM_PROMPT = async (
// ${await addCustomInstructions(promptComponent?.customInstructions || currentMode.customInstructions || "", globalCustomInstructions || "", cwd, mode, { preferredLanguage })}`
// }
// If diff is disabled, don't pass the diffStrategy
const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
// // If diff is disabled, don't pass the diffStrategy
// const effectiveDiffStrategy = diffEnabled ? diffStrategy : undefined
return generatePrompt(
// context,
@@ -160,7 +160,7 @@ export const SYSTEM_PROMPT = async (
currentMode.slug,
filesSearchMethod,
mcpHub,
effectiveDiffStrategy,
diffStrategy,
browserViewportSize,
promptComponent,
customModes,

View File

@@ -1,4 +1,4 @@
export function getAskFollowupQuestionDescription(userLanguage: string): string {
export function getAskFollowupQuestionDescription(): string {
return `## ask_followup_question
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
@@ -10,5 +10,6 @@ Usage:
Example: Requesting to ask the user for their preferred citation style for an academic document
<ask_followup_question>
<question>Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)?</question>`
<question>Which citation style would you like to use for your academic paper (APA, MLA, Chicago, etc.)?</question>
</ask_followup_question>`
}

View File

@@ -18,6 +18,7 @@ Usage:
}
]</operations>
</insert_content>
Example: Insert a new section heading and paragraph
<insert_content>
<path>chapter1.md</path>

View File

@@ -26,7 +26,7 @@ Usage:
]</operations>
</search_and_replace>
Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essay
Example 1: Replace "climate change" with "climate crisis" in lines 1-10 of an essay
<search_and_replace>
<path>essays/environmental-impact.md</path>
<operations>[
@@ -38,7 +38,8 @@ Example: Replace "climate change" with "climate crisis" in lines 1-10 of an essa
}
]</operations>
</search_and_replace>
Example: Update citation format throughout a document using regex
Example 2: Update citation format throughout a document using regex
<search_and_replace>
<path>research-paper.md</path>
<operations>[

View File

@@ -16,17 +16,17 @@ Usage:
<query>Your search query here</query>
</search_web>
Examples1:
Example 1:
<search_web>
<query>capital of France population statistics 2023</query>
</search_web>
Examples2:
Example 2:
<search_web>
<query>"renewable energy" growth statistics Europe</query>
</search_web>
Examples3:
Example 3:
<search_web>
<query>react vs angular vs vue.js comparison</query>
</search_web>`