From fce3b6e538d10aa2eaf0bd75a09e0f26a2fe58ba Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Wed, 2 Oct 2024 01:53:55 -0700 Subject: [PATCH] Aider glue. --- src/modules/3rdparty/aider/editBlockCoder.ts | 484 ++++++++++++++++++ .../3rdparty/aider/editBlockPrompts.ts | 7 +- src/modules/3rdparty/aider/glue.ts | 78 +++ 3 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 src/modules/3rdparty/aider/editBlockCoder.ts create mode 100644 src/modules/3rdparty/aider/glue.ts diff --git a/src/modules/3rdparty/aider/editBlockCoder.ts b/src/modules/3rdparty/aider/editBlockCoder.ts new file mode 100644 index 000000000..f77cfb36b --- /dev/null +++ b/src/modules/3rdparty/aider/editBlockCoder.ts @@ -0,0 +1,484 @@ +/* + * This file includes code derived from Aider (https://github.com/paul-gauthier/aider) + * Originally licensed under the Apache License, Version 2.0 + * Modifications and translations to JavaScript made by Enrico Ros + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; + +interface Edit { + path: string; + original: string; + updated: string; +} + +class EditBlockCoder { + private partialResponseContent: string; + private fence: [string, string]; + private filenames: string[]; // List of valid filenames + private io: IO; + + constructor( + partialResponseContent: string, + fence: [string, string], + filenames: string[], // Pass in your list of filenames + io: IO, + ) { + this.partialResponseContent = partialResponseContent; + this.fence = fence; + this.filenames = filenames; + this.io = io; + } + + /** + * Identifies all edits from the content. + */ + public getEdits(): Edit[] { + const content = this.partialResponseContent; + // Extract edits from the content + const edits = findOriginalUpdateBlocks( + content, + this.fence, + this.filenames, + ); + + return edits; + } + + /** + * Applies all edits to the respective files. + */ + public applyEdits(edits: Edit[]): void { + const failed: Edit[] = []; + const passed: Edit[] = []; + + for (const edit of edits) { + const { path: relativePath, original, updated } = edit; + let fullPath = this.absRootPath(relativePath); + + if (!fs.existsSync(fullPath)) { + console.warn(`File ${relativePath} does not exist. Skipping edit.`); + failed.push(edit); + continue; + } + + let content = this.io.readText(fullPath); + let newContent = doReplace(fullPath, content, original, updated, this.fence); + + if (newContent) { + this.io.writeText(fullPath, newContent); + passed.push(edit); + } else { + failed.push(edit); + } + } + + if (failed.length > 0) { + this.handleFailedEdits(failed, passed); + } + } + + /** + * Handles edits that failed to apply. + */ + private handleFailedEdits(failed: Edit[], passed: Edit[]): void { + const blocks = failed.length === 1 ? 'block' : 'blocks'; + let message = `# ${failed.length} SEARCH/REPLACE ${blocks} failed to match!\n`; + + for (const edit of failed) { + const { path, original, updated } = edit; + + const fullPath = this.absRootPath(path); + const content = this.io.readText(fullPath); + + message += ` +## SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in ${path} +<<<<<<< SEARCH +${original}======= +${updated}>>>>>>> REPLACE + +`; + const suggestion = findSimilarLines(original, content); + if (suggestion) { + message += `Did you mean to match some of these actual lines from ${path}? + +${this.fence[0]} +${suggestion} +${this.fence[1]} + +`; + } + + if (updated && content.includes(updated)) { + message += `Are you sure you need this SEARCH/REPLACE block? +The REPLACE lines are already in ${path}! + +`; + } + } + + message += `The SEARCH section must exactly match an existing block of lines including all whitespace, comments, indentation, docstrings, etc.\n`; + + if (passed.length > 0) { + const pblocks = passed.length === 1 ? 'block' : 'blocks'; + message += ` +# The other ${passed.length} SEARCH/REPLACE ${pblocks} were applied successfully. +Don't re-send them. +Just reply with fixed versions of the ${blocks} above that failed to match. +`; + } + + throw new Error(message); + } + + /** + * Resolves a relative path to an absolute path. + */ + private absRootPath(relPath: string): string { + // Replace with your application's root directory logic if needed + return path.resolve(process.cwd(), relPath); + } +} + +// Helper classes and functions + +/** + * IO class for reading and writing files. + */ +class IO { + public readText(filePath: string): string { + return fs.readFileSync(filePath, 'utf8'); + } + + public writeText(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, 'utf8'); + } +} + +/** + * Parses the content to find original and updated blocks. + */ +function findOriginalUpdateBlocks( + content: string, + fence: [string, string], + validFnames: string[] = [], +): Edit[] { + const edits: Edit[] = []; + const lines = content.split('\n'); + let i = 0; + let currentFilename: string | null = null; + + const headPattern = /^<{5,9} SEARCH/; + const dividerPattern = /^={5,9}$/; + const updatedPattern = /^>{5,9} REPLACE/; + + while (i < lines.length) { + let line = lines[i]; + + // Handle SEARCH/REPLACE blocks + if (headPattern.test(line.trim())) { + let filename = findFilename(lines.slice(Math.max(0, i - 3), i), fence, validFnames); + filename = filename || currentFilename; + if (!filename) { + throw new Error(`Bad/missing filename before the fence ${fence[0]}`); + } + currentFilename = filename; + + const originalText: string[] = []; + i += 1; + while (i < lines.length && !dividerPattern.test(lines[i].trim())) { + originalText.push(lines[i]); + i += 1; + } + if (i >= lines.length || !dividerPattern.test(lines[i].trim())) { + throw new Error(`Expected '======='`); + } + + const updatedText: string[] = []; + i += 1; + while (i < lines.length && !updatedPattern.test(lines[i].trim()) && !dividerPattern.test(lines[i].trim())) { + updatedText.push(lines[i]); + i += 1; + } + if (i >= lines.length || (!updatedPattern.test(lines[i].trim()) && !dividerPattern.test(lines[i].trim()))) { + throw new Error(`Expected '>>>>>>> REPLACE' or '======='`); + } + + edits.push({ + path: filename, + original: originalText.join('\n'), + updated: updatedText.join('\n'), + }); + } + i += 1; + } + + return edits; +} + +/** + * Tries to find the filename from previous lines. + */ +function findFilename( + lines: string[], + fence: [string, string], + validFnames: string[], +): string | null { + const reversedLines = [...lines].reverse(); + const filenames: string[] = []; + + for (const line of reversedLines.slice(0, 3)) { + const filename = stripFilename(line, fence); + if (filename) filenames.push(filename); + if (!line.startsWith(fence[0])) break; + } + + if (filenames.length === 0) return null; + + // Check for exact match + for (const fname of filenames) { + if (validFnames.includes(fname)) return fname; + } + + // Check for basename match + for (const fname of filenames) { + for (const vfname of validFnames) { + if (fname === path.basename(vfname)) return vfname; + } + } + + // Return the first filename with an extension + return filenames.find(fname => fname.includes('.')) || filenames[0]; +} + +/** + * Strips wrapping characters from the filename. + */ +function stripFilename(filename: string, fence: [string, string]): string | null { + filename = filename.trim(); + + if (filename === '...') return null; + + if (filename.startsWith(fence[0])) return null; + + filename = filename.replace(/[:#`*]/g, '').trim(); + return filename || null; +} + +/** + * Performs the replacement in the file content. + */ +function doReplace( + fname: string, + content: string, + beforeText: string, + afterText: string, + fence: [string, string], +): string | null { + beforeText = stripQuotedWrapping(beforeText, fname, fence); + afterText = stripQuotedWrapping(afterText, fname, fence); + + // Handle new file creation + if (!fs.existsSync(fname) && !beforeText.trim()) { + fs.writeFileSync(fname, '', 'utf8'); + content = ''; + } + + if (!content) return null; + + if (!beforeText.trim()) { + // Append to existing file + return content + afterText; + } else { + const newContent = replaceMostSimilarChunk(content, beforeText, afterText); + return newContent || null; + } +} + +/** + * Strips quoted wrapping from the text. + */ +function stripQuotedWrapping( + text: string, + fname: string | undefined, + fence: [string, string], +): string { + if (!text) return text; + + let lines = text.split('\n'); + + if (fname && lines[0].trim().endsWith(path.basename(fname))) { + lines.shift(); + } + + if (lines[0].startsWith(fence[0]) && lines[lines.length - 1].startsWith(fence[1])) { + lines = lines.slice(1, -1); + } + + let result = lines.join('\n'); + if (result && !result.endsWith('\n')) result += '\n'; + + return result; +} + +/** + * Attempts to replace the most similar chunk in the content. + */ +function replaceMostSimilarChunk( + whole: string, + part: string, + replace: string, +): string | null { + const wholeLines = whole.endsWith('\n') ? whole : whole + '\n'; + const partLines = part.endsWith('\n') ? part : part + '\n'; + const replaceLines = replace.endsWith('\n') ? replace : replace + '\n'; + + const wholeArray = wholeLines.split('\n'); + const partArray = partLines.split('\n'); + const replaceArray = replaceLines.split('\n'); + + // Try for an exact match + const result = perfectReplace(wholeArray, partArray, replaceArray); + + if (result) return result.join('\n'); + + // Try matching while ignoring leading whitespace + const whitespaceResult = replaceIgnoringLeadingWhitespace(wholeArray, partArray, replaceArray); + + if (whitespaceResult) return whitespaceResult.join('\n'); + + return null; +} + +/** + * Performs a perfect replacement if an exact match is found. + */ +function perfectReplace( + whole: string[], + part: string[], + replace: string[], +): string[] | null { + const partStr = part.join('\n'); + for (let i = 0; i <= whole.length - part.length; i++) { + const slice = whole.slice(i, i + part.length).join('\n'); + if (slice === partStr) { + return [...whole.slice(0, i), ...replace, ...whole.slice(i + part.length)]; + } + } + return null; +} + +/** + * Replaces content while ignoring leading whitespace differences. + */ +function replaceIgnoringLeadingWhitespace( + whole: string[], + part: string[], + replace: string[], +): string[] | null { + for (let i = 0; i <= whole.length - part.length; i++) { + const slice = whole.slice(i, i + part.length); + const match = slice.every((line, idx) => line.trimStart() === part[idx].trimStart()); + if (match) { + // Adjust leading whitespace based on the existing content + const leadingWhitespace = slice[0].match(/^\s*/)?.[0] || ''; + const adjustedReplace = replace.map(line => leadingWhitespace + line.trimStart()); + return [...whole.slice(0, i), ...adjustedReplace, ...whole.slice(i + part.length)]; + } + } + return null; +} + +/** + * Finds similar lines in the content to provide suggestions. + */ +function findSimilarLines( + searchLines: string, + contentLines: string, + threshold: number = 0.6, +): string { + const searchArray = searchLines.split('\n'); + const contentArray = contentLines.split('\n'); + + let bestRatio = 0; + let bestMatch: string[] | null = null; + let bestMatchIndex = -1; + + for (let i = 0; i <= contentArray.length - searchArray.length; i++) { + const chunk = contentArray.slice(i, i + searchArray.length); + const ratio = stringSimilarity(searchArray.join('\n'), chunk.join('\n')); + if (ratio > bestRatio) { + bestRatio = ratio; + bestMatch = chunk; + bestMatchIndex = i; + } + } + + if (bestRatio < threshold || !bestMatch) return ''; + + // Show context around the best match + const N = 5; + const start = Math.max(0, bestMatchIndex - N); + const end = Math.min(contentArray.length, bestMatchIndex + searchArray.length + N); + + return contentArray.slice(start, end).join('\n'); +} + +/** + * Calculates the similarity between two strings. + */ +function stringSimilarity(str1: string, str2: string): number { + let longer = str1; + let shorter = str2; + if (str1.length < str2.length) { + longer = str2; + shorter = str1; + } + const longerLength = longer.length; + if (longerLength === 0) { + return 1.0; + } + return (longerLength - editDistance(longer, shorter)) / longerLength; +} + +/** + * Computes the edit distance between two strings. + */ +function editDistance(s1: string, s2: string): number { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + + const costs: number[] = []; + for (let i = 0; i <= s1.length; i++) { + let lastValue = i; + for (let j = 0; j <= s2.length; j++) { + if (i === 0) + costs[j] = j; + else { + if (j > 0) { + let newValue = costs[j - 1]; + if (s1.charAt(i - 1) !== s2.charAt(j - 1)) + newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + } + if (i > 0) + costs[s2.length] = lastValue; + } + return costs[s2.length]; +} \ No newline at end of file diff --git a/src/modules/3rdparty/aider/editBlockPrompts.ts b/src/modules/3rdparty/aider/editBlockPrompts.ts index b2d50ac8d..386d6743a 100644 --- a/src/modules/3rdparty/aider/editBlockPrompts.ts +++ b/src/modules/3rdparty/aider/editBlockPrompts.ts @@ -76,10 +76,15 @@ Examples of when to suggest shell commands: }, 'shellCmdPrompt'); } -export const noShellCmdPrompt = ` +export function getNoShellCmdPrompt(platform: string) { + const template = ` Keep in mind these details about the user's platform and environment: {{platform}} `; + return processPromptTemplate(template, { + platform, + }, 'noShellCmdPrompt'); +} export const exampleMessages = [ { diff --git a/src/modules/3rdparty/aider/glue.ts b/src/modules/3rdparty/aider/glue.ts new file mode 100644 index 000000000..6eac68176 --- /dev/null +++ b/src/modules/3rdparty/aider/glue.ts @@ -0,0 +1,78 @@ +/* + * This file includes code derived from Aider (https://github.com/paul-gauthier/aider) + * Originally licensed under the Apache License, Version 2.0 + * Modifications and translations to JavaScript made by Enrico Ros + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { aiderCoderPrompts } from './coderPrompts'; +import { exampleMessages, getMainSystemPrompt, getNoShellCmdPrompt, getSystemReminder } from './editBlockPrompts'; + +type ChatMessage = { + role: 'user' | 'assistant' | 'system'; + text: string; +} + +/** + * Builds a EditBlock-style prompt. + * + * @param userMessage The request from the user. + * @param platformMessage example: 'The user will apply the edits automatically without further review.' + * @param isModelLazy False for powerful models. + * @param useSystemPrompt True to put the main system prompt as a system message. + */ +export function getEditBlockDiffPrompt(userMessage: string, platformMessage: string, isModelLazy: boolean, useSystemPrompt: boolean): ChatMessage[] { + const history: ChatMessage[] = []; + + // 1. main system prompt + const lazyPrompt = isModelLazy ? aiderCoderPrompts.lazyPrompt : ''; + const noShellPrompt = getNoShellCmdPrompt(platformMessage); + let mainSysPrompt = getMainSystemPrompt(lazyPrompt, noShellPrompt); + if (exampleMessages.length > 0) { + mainSysPrompt += '\n# Example conversations:\n\n'; + for (const example of exampleMessages) + mainSysPrompt += `## ${example.role.toUpperCase()}: ${example.content}\n\n`; + } + // mainSysPrompt = mainSysPrompt.trim(); + const mainSysReminder = getSystemReminder(lazyPrompt, ''); + mainSysPrompt += '\n' + mainSysReminder; + + if (useSystemPrompt) + history.push({ + role: 'system', text: mainSysPrompt, + }); + else + history.push({ + role: 'user', text: mainSysPrompt, + }, { + role: 'assistant', text: 'Ok.', + }); + + // [...] examples, readonly_files, repo, done, **chat_files**, **cur**, **reminder** + + // 6. chat_files + history.push({ + role: 'user', text: aiderCoderPrompts.filesNoFullFilesWithRepoMap, + }, { + role: 'assistant', text: aiderCoderPrompts.filesNoFullFilesWithRepoMapReply, + }); + + // 7. cur + reminder + userMessage = userMessage + '\n' + getSystemReminder(lazyPrompt, ''); + history.push({ + role: 'user', text: userMessage, + }); + + return history; +} \ No newline at end of file