diff --git a/hi.txt b/hi.txt deleted file mode 100644 index 139597f9..00000000 --- a/hi.txt +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/assistants/coder.ts b/src/assistants/coder.ts index 0441a9b3..503b3add 100644 --- a/src/assistants/coder.ts +++ b/src/assistants/coder.ts @@ -123,7 +123,6 @@ export class Coder extends BaseAssistant { rawOutput: response, contextPaths: contextPaths, assistantType: "coder", - filePathsChanged: [], }); return conversations.addJoule(prevConversation, newJoule); } @@ -142,7 +141,7 @@ export class Coder extends BaseAssistant { const changeSet = await this.applyChanges(gitRepo, searchReplaceList); const newCommit = await changeSets.commitChangeSet(changeSet, gitRepo); const diffInfo = { - diffPreview: await utils.getUdiffPreview(gitRepo, newCommit), + diffPreview: await utils.getUdiffPreviewFromCommit(gitRepo, newCommit), filePathsChanged: Array.from(Object.keys(changeSet.filesChanged)), }; const newJoule = joules.createJouleBotWithChanges( diff --git a/src/backend/changeSets.ts b/src/backend/changeSets.ts index 6c34f38b..b768b2e1 100644 --- a/src/backend/changeSets.ts +++ b/src/backend/changeSets.ts @@ -5,6 +5,18 @@ import path from "path"; import * as files from "./meltyFiles"; import { generateCommitMessage } from "./commitMessageGenerator"; +export async function getChangeSet(gitRepo: GitRepo, commit: string) { + const repository = gitRepo.repository; + await repository.status(); + + const changeSet = await meltyFiles.getChangeSet( + gitRepo, + commit, + parentCommit + ); + return changeSet; +} + /** * Commits changes in a changeset * @param changeSet The change set to apply @@ -40,10 +52,8 @@ export async function commitChangeSet(changeSet: ChangeSet, gitRepo: GitRepo) { ) ); - const changedFiles = Object.keys(changeSet.filesChanged); const commitMessage = await generateCommitMessage( - changedFiles, - gitRepo.rootPath + utils.getDiffPreviewFromChangeSet(changeSet) ); await repository.commit(`[by melty] ${commitMessage}`, { empty: true, diff --git a/src/backend/claudeAPI.ts b/src/backend/claudeAPI.ts index 1be81016..2ca69dbe 100644 --- a/src/backend/claudeAPI.ts +++ b/src/backend/claudeAPI.ts @@ -13,6 +13,16 @@ export async function streamClaude( processPartial: (text: string) => void, model: Models = Models.Claude35Sonnet ): Promise { + if (claudeConversation.messages.length === 0) { + throw new Error("No messages to stream"); + } + if ( + claudeConversation.messages[claudeConversation.messages.length - 1].role === + "assistant" + ) { + throw new Error("Last message is an assistant message"); + } + const config = vscode.workspace.getConfiguration("melty"); const apiKey = config.get("anthropicApiKey"); diff --git a/src/backend/commitMessageGenerator.ts b/src/backend/commitMessageGenerator.ts index 3aa55f07..5f4102ed 100644 --- a/src/backend/commitMessageGenerator.ts +++ b/src/backend/commitMessageGenerator.ts @@ -2,10 +2,11 @@ import * as fs from "fs"; import * as path from "path"; import { Anthropic } from "@anthropic-ai/sdk"; import * as vscode from "vscode"; +import * as files from "./meltyFiles"; +import { GitRepo, ChangeSet } from "../types"; export async function generateCommitMessage( - changedFiles: string[], - rootPath: string + udiffPreview: string ): Promise { const config = vscode.workspace.getConfiguration("melty"); const apiKey = config.get("anthropicApiKey"); @@ -20,37 +21,36 @@ export async function generateCommitMessage( apiKey: apiKey, }); - // const fileContents = changedFiles - // .map((file) => { - // const filePath = path.join(rootPath, file); - // return `${file}:\n${fs.readFileSync(filePath, "utf-8")}`; - // }) - // .join("\n\n"); - - const fileContents = ""; - const prompt = `You are an expert software engineer. -Review the provided context and diffs which are about to be committed to a git repo. -Review the diffs carefully. +Review the provided diff which is about to be committed to a git repo. +Review the diff carefully. Generate a commit message for those changes. The commit message MUST use the imperative tense. -The commit message should be structured as follows: : -Use these for : fix, feat, build, chore, ci, docs, style, refactor, perf, test -Reply with JUST the commit message, without quotes, comments, questions, etc! +If the diff contains no files changed, you can just reply with "empty commit". + +Example: + +Add logging to foo/bar/baz.py -Changes to be committed: +Here is the diff: -${fileContents}`; + +${udiffPreview} +`; try { const response = await anthropic.messages.create({ model: "claude-3-sonnet-20240229", max_tokens: 100, temperature: 0.7, - messages: [{ role: "user", content: prompt }], + messages: [ + { role: "user", content: prompt }, + { role: "assistant", content: "" }, + ], }); - const commitMessage = response.content[0].text.trim(); + const responseText = response.content[0].text.trim(); + const commitMessage = responseText.split("")[0].trim(); return commitMessage; } catch (error) { console.error("Error generating commit message:", error); diff --git a/src/backend/conversations.ts b/src/backend/conversations.ts index b353819b..c25d6989 100644 --- a/src/backend/conversations.ts +++ b/src/backend/conversations.ts @@ -1,5 +1,5 @@ import { Joule, Conversation } from "../types"; -import * as joules from "./joules"; +import * as vscode from "vscode"; export function create(): Conversation { return { joules: [] }; @@ -12,17 +12,26 @@ export function addJoule( return { joules: [...conversation.joules, joule] }; } -export function respondHuman( - conversation: Conversation, - message: string, - commit: string | null -): Conversation { - const newJoule = joules.createJouleHuman(message, commit); - return addJoule(conversation, newJoule); -} - export function lastJoule(conversation: Conversation): Joule | undefined { return conversation.joules.length ? conversation.joules[conversation.joules.length - 1] : undefined; } + +export function forceRemoveHumanJoules( + conversation: Conversation +): Conversation { + vscode.window.showInformationMessage( + "Melty is force-removing failed messages to recover from an issue" + ); + // remove any human joules at the end of the conversation + const indexOfLastBotJoule = + conversation.joules.length - + 1 - + Array.from(conversation.joules) + .reverse() + .findIndex((joule) => joule.author === "bot"); + return { + joules: conversation.joules.slice(0, indexOfLastBotJoule + 1), + }; +} diff --git a/src/backend/pseudoCommits.ts b/src/backend/pseudoCommits.ts deleted file mode 100644 index 2cb56ca4..00000000 --- a/src/backend/pseudoCommits.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { - PseudoCommit, - PseudoCommitInGit, - PseudoCommitInMemory, - MeltyFile, - GitRepo, -} from "../types"; -import * as files from "./meltyFiles"; -import * as fs from "fs"; -import * as path from "path"; -import * as utils from "../util/utils"; -import { generateCommitMessage } from "./commitMessageGenerator"; - -function createFromCommitWithUdiffPreview( - commit: string, - preview: string -): PseudoCommit { - return { impl: { status: "committed", commit, udiffPreview: preview } }; -} - -export async function createFromCommit( - commit: string, - gitRepo: GitRepo, - associateCommitDiffWithPseudoCommit: boolean -): Promise { - const udiff = associateCommitDiffWithPseudoCommit - ? await (async () => { - const repository = gitRepo?.repository; - const diff = await repository.diffBetween(commit + "^", commit); - const udiffs = await Promise.all( - diff.map(async (change: any) => { - return await repository.diffBetween( - commit + "^", - commit, - change.uri.fsPath - ); - }) - ); - return udiffs.join("\n"); - })() - : ""; - - return createFromCommitWithUdiffPreview(commit, udiff); -} - -export function createFromDiffAndParentCommit( - parentCommit: string, - filesChanged: { [relativePath: string]: MeltyFile } -): PseudoCommit { - const pseudoCommitInMemory: PseudoCommitInMemory = { - status: "inMemory", - filesChanged: filesChanged, - parentCommit: parentCommit, - }; - return { - impl: pseudoCommitInMemory, - }; -} - -/** - * Creates a new pseudoCommit that is a copy of the previous one, but with udiff reset. - */ -export function createFromPrevious( - previousPseudoCommit: PseudoCommit -): PseudoCommit { - if (previousPseudoCommit.impl.status !== "committed") { - throw new Error( - "not implemented: createFromPrevious from uncommitted repostate" - ); - } - - const pseudoCommitInMemory: PseudoCommitInMemory = { - status: "inMemory", - filesChanged: {}, - parentCommit: previousPseudoCommit.impl.commit, - }; - return { impl: pseudoCommitInMemory }; -} - -export async function diff( - pseudoCommit: PseudoCommit, - repository: any -): Promise { - if (pseudoCommit.impl.status === "inMemory") { - throw new Error("not implemented: getDiff from committed repostate"); - } else { - const pseudoCommitInGit = pseudoCommit.impl; - const commit = pseudoCommitInGit.commit; - const diff = await repository.diffBetween(commit + "^", commit); - const udiffs = await Promise.all( - diff.map(async (change: any) => { - return await repository.diffBetween( - commit + "^", - commit, - change.uri.fsPath - ); - }) - ); - return udiffs.join("\n"); - } -} - -export function parentCommit(pseudoCommit: PseudoCommit): string | undefined { - if (pseudoCommit.impl.status === "inMemory") { - return pseudoCommit.impl.parentCommit; - } else { - return undefined; - } -} - -export function commit(pseudoCommit: PseudoCommit): string | undefined { - if (pseudoCommit.impl.status === "committed") { - return pseudoCommit.impl.commit; - } else { - return undefined; - } -} - -/** - * Puts files in this repo state onto disk and creates a commit for them if there isn't one yet. - * Out of caution, it will error if there are uncommitted changes, and it may error - * if git is not already on this pseudoCommit's parent (only if changes were previously in memory). - * If doCommit is false, it will fudge stuff and not actually create a commit. - * Returns a new pseudoCommit that is guaranteed to track the new commit. - */ -export async function actualize( - pseudoCommit: PseudoCommit, - gitRepo: GitRepo, - doCommit: boolean -): Promise { - const repository = gitRepo.repository; - - await repository.status(); - // check for uncommitted changes - if (!utils.repoIsClean(repository)) { - utils.handleGitError( - "Actualizing despite unclean repo. Seems a bit weird..." - ); - } - - if (pseudoCommit.impl.status === "committed") { - utils.ensureRepoIsOnCommit(repository, pseudoCommit.impl.commit); - // no update to pseudoCommit needed - } else { - let newPseudoCommit = null; - if (doCommit) { - const pseudoCommitInMemory = pseudoCommit.impl; - - utils.ensureRepoIsOnCommit(repository, pseudoCommitInMemory.parentCommit); - - const filesChanged = pseudoCommitInMemory.filesChanged; - Object.entries(filesChanged).forEach(([_path, file]) => { - fs.mkdirSync(path.dirname(files.absolutePath(file, gitRepo.rootPath)), { - recursive: true, - }); - fs.writeFileSync( - files.absolutePath(file, gitRepo.rootPath), - files.contents(file) - ); - }); - - await repository.add( - Object.values(filesChanged).map((file) => - files.absolutePath(file, gitRepo.rootPath) - ) - ); - - const changedFiles = Object.keys(filesChanged); - const commitMessage = await generateCommitMessage( - changedFiles, - gitRepo.rootPath - ); - await repository.commit(`[by melty] ${commitMessage}`, { empty: true }); - - await repository.status(); - const newCommit = repository.state.HEAD!.commit; - newPseudoCommit = await createFromCommit(newCommit, gitRepo, true); - } else { - // fudge things so that we get a "commited" pseudocommit but without no diff - // TODO I think this is super hacky - newPseudoCommit = await createFromCommit( - pseudoCommit.impl.parentCommit, - gitRepo, - false - ); - } - - // update pseudoCommit in place - pseudoCommit.impl = newPseudoCommit.impl; - } -} - -export function hasFile( - gitRepo: GitRepo, - pseudoCommit: PseudoCommit, - filePath: string -): boolean { - const fileIsInMemory = - pseudoCommit.impl.status === "inMemory" - ? filePath in pseudoCommit.impl.filesChanged - : false; - if (fileIsInMemory) { - return true; - } - - const baseCommit = - pseudoCommit.impl.status === "committed" - ? pseudoCommit.impl.commit - : pseudoCommit.impl.parentCommit; - utils.ensureRepoIsOnCommit(gitRepo.repository, baseCommit); - return fs.existsSync(path.join(gitRepo.rootPath, filePath)); -} - -export function getFileContents( - gitRepo: GitRepo, - pseudoCommit: PseudoCommit, - filePath: string -): string { - if ( - pseudoCommit.impl.status === "inMemory" && - filePath in pseudoCommit.impl.filesChanged - ) { - return files.contents(pseudoCommit.impl.filesChanged[filePath]); - } else { - const baseCommit = - pseudoCommit.impl.status === "committed" - ? pseudoCommit.impl.commit - : pseudoCommit.impl.parentCommit; - utils.ensureRepoIsOnCommit(gitRepo.repository, baseCommit); - return fs.readFileSync(path.join(gitRepo.rootPath, filePath), "utf8"); - } -} - -export function upsertFileContents( - pseudoCommit: PseudoCommit, - path: string, - contents: string -): PseudoCommit { - const file = files.create(path, contents); - - let { filesChanged, parentCommit } = (() => { - if (pseudoCommit.impl.status === "inMemory") { - // another off same parent, updating the list of files changed - return { - filesChanged: { - ...pseudoCommit.impl.filesChanged, - [path]: file, - }, - parentCommit: pseudoCommit.impl.parentCommit, - }; - } else { - // PseudoCommitInGit: use pseudoCommit as the parent, start a new list of files changed - return { - filesChanged: { [path]: file }, - parentCommit: pseudoCommit.impl.commit, - }; - } - })(); - - return { - impl: { - status: "inMemory", - parentCommit: parentCommit, - filesChanged: filesChanged, - }, - }; -} - -export function getEditedFiles(pseudoCommit: PseudoCommit): string[] { - if (pseudoCommit.impl.status === "inMemory") { - return Object.keys(pseudoCommit.impl.filesChanged); - } else { - throw new Error("not implemented: getEditedFiles from committed repostate"); - } -} diff --git a/src/backend/tasks.ts b/src/backend/tasks.ts index 5a94bc3d..508cca13 100644 --- a/src/backend/tasks.ts +++ b/src/backend/tasks.ts @@ -97,11 +97,10 @@ export class Task implements Task { const indexChanges = this.gitRepo!.repository.state.indexChanges; if (indexChanges.length > 0) { - const filesChanged = indexChanges.map((change: any) => change.uri.fsPath); - const message = await generateCommitMessage( - filesChanged, - this.gitRepo!.rootPath + const udiffPreview = await utils.getUdiffPreviewFromWorking( + this.gitRepo! ); + const message = await generateCommitMessage(udiffPreview); await this.gitRepo!.repository.commit(`[via melty] ${message}`); } @@ -191,9 +190,15 @@ export class Task implements Task { const latestCommit = this.gitRepo!.repository.state.HEAD?.commit; const diffInfo = { filePathsChanged: null, - diffPreview: await utils.getUdiffPreview(this.gitRepo!, latestCommit), + diffPreview: await utils.getUdiffPreviewFromCommit( + this.gitRepo!, + latestCommit + ), }; + // hacky! + this.conversation = conversations.forceRemoveHumanJoules(this.conversation); + const newJoule: Joule = didCommit ? joules.createJouleHumanWithChanges(message, latestCommit, diffInfo) : joules.createJouleHuman(message); diff --git a/src/hi.txt b/src/hi.txt deleted file mode 100644 index 297edb3e..00000000 --- a/src/hi.txt +++ /dev/null @@ -1,2 +0,0 @@ -Hello, world! - diff --git a/src/util/utils.ts b/src/util/utils.ts index 12a56086..920241f4 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -4,6 +4,7 @@ import * as config from "./config"; import * as path from "path"; import { GitRepo } from "../types"; import { Task } from "../backend/tasks"; +import { ChangeSet } from "../types"; export function handleGitError(message: string) { if (config.STRICT_GIT) { @@ -87,7 +88,20 @@ export function getNonce() { return text; } -export async function getUdiffPreview( +/** + * Gets the diff of working changes against HEAD + */ +export async function getUdiffPreviewFromWorking( + gitRepo: GitRepo +): Promise { + const repository = gitRepo.repository; + return await repository.diff("HEAD"); +} + +/** + * Gets the diff from a commit to its parent + */ +export async function getUdiffPreviewFromCommit( gitRepo: GitRepo, commit: string ): Promise { @@ -104,3 +118,16 @@ export async function getUdiffPreview( ); return udiffs.join("\n"); } + +/** + * Gets diff preview for a change set (NOT a udiff bc this is easier) + */ +export function getDiffPreviewFromChangeSet(changeSet: ChangeSet): string { + return Object.values(changeSet.filesChanged) + .map((file) => { + return ` +${file.contents} +`; + }) + .join("\n\n"); +}