From 2b63b91bfb6941c57178eb23c6b6aef5e339f057 Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Sun, 29 Sep 2024 02:25:22 -0400 Subject: [PATCH] Refactor out file helpers into fs.ts --- src/core/ClaudeDev.ts | 125 +++++----------------- src/core/webview/ClaudeDevProvider.ts | 16 +-- src/services/browser/UrlContentFetcher.ts | 6 +- src/services/tree-sitter/index.ts | 6 +- src/utils/cost.ts | 24 +++++ src/utils/fs.ts | 47 ++++++++ 6 files changed, 107 insertions(+), 117 deletions(-) create mode 100644 src/utils/cost.ts create mode 100644 src/utils/fs.ts diff --git a/src/core/ClaudeDev.ts b/src/core/ClaudeDev.ts index abb505a..613824f 100644 --- a/src/core/ClaudeDev.ts +++ b/src/core/ClaudeDev.ts @@ -42,6 +42,8 @@ import { formatResponse } from "./prompts/responses" import { SYSTEM_PROMPT } from "./prompts/system" import { truncateHalfConversation } from "./sliding-window" import { ClaudeDevProvider } from "./webview/ClaudeDevProvider" +import { calculateApiCost } from "../utils/cost" +import { createDirectoriesForFile, fileExistsAtPath } from "../utils/fs" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -128,10 +130,7 @@ export class ClaudeDev { private async getSavedApiConversationHistory(): Promise { const filePath = path.join(await this.ensureTaskDirectoryExists(), "api_conversation_history.json") - const fileExists = await fs - .access(filePath) - .then(() => true) - .catch(() => false) + const fileExists = await fileExistsAtPath(filePath) if (fileExists) { return JSON.parse(await fs.readFile(filePath, "utf8")) } @@ -160,10 +159,7 @@ export class ClaudeDev { private async getSavedClaudeMessages(): Promise { const filePath = path.join(await this.ensureTaskDirectoryExists(), "claude_messages.json") - const fileExists = await fs - .access(filePath) - .then(() => true) - .catch(() => false) + const fileExists = await fileExistsAtPath(filePath) if (fileExists) { return JSON.parse(await fs.readFile(filePath, "utf8")) } @@ -350,6 +346,16 @@ export class ClaudeDev { } } + async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { + await this.say( + "error", + `Claude tried to use ${toolName}${ + relPath ? ` for '${relPath.toPosix()}'` : "" + } without value for required parameter '${paramName}'. Retrying...` + ) + return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) + } + private async startTask(task?: string, images?: string[]): Promise { // conversationHistory (for API) and claudeMessages (for webview) need to be in sync // if the extension process were killed, then on restart the claudeMessages might not be empty, so we need to set it to [] when we create a new ClaudeDev client (otherwise webview would show stale messages from previous session) @@ -591,28 +597,6 @@ export class ClaudeDev { this.urlContentFetcher.closeBrowser() } - calculateApiCost( - inputTokens: number, - outputTokens: number, - cacheCreationInputTokens?: number, - cacheReadInputTokens?: number - ): number { - const modelCacheWritesPrice = this.api.getModel().info.cacheWritesPrice - let cacheWritesCost = 0 - if (cacheCreationInputTokens && modelCacheWritesPrice) { - cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens - } - const modelCacheReadsPrice = this.api.getModel().info.cacheReadsPrice - let cacheReadsCost = 0 - if (cacheReadInputTokens && modelCacheReadsPrice) { - cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens - } - const baseInputCost = (this.api.getModel().info.inputPrice / 1_000_000) * inputTokens - const outputCost = (this.api.getModel().info.outputPrice / 1_000_000) * outputTokens - const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost - return totalCost - } - // return is [didUserRejectTool, ToolResponse] async writeToFile(relPath?: string, newContent?: string): Promise<[boolean, ToolResponse]> { if (relPath === undefined) { @@ -636,10 +620,7 @@ export class ClaudeDev { this.consecutiveMistakeCount = 0 try { const absolutePath = path.resolve(cwd, relPath) - const fileExists = await fs - .access(absolutePath) - .then(() => true) - .catch(() => false) + const fileExists = await fileExistsAtPath(absolutePath) // if the file is already open, ensure it's not dirty before getting its contents if (fileExists) { @@ -671,7 +652,7 @@ export class ClaudeDev { // for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation // Keep track of newly created directories - const createdDirs: string[] = await this.createDirectoriesForFile(absolutePath) + const createdDirs: string[] = await createDirectoriesForFile(absolutePath) // console.log(`Created directories: ${createdDirs.join(", ")}`) // make sure the file exists before we open it if (!fileExists) { @@ -992,51 +973,6 @@ export class ClaudeDev { } } - /** - * Asynchronously creates all non-existing subdirectories for a given file path - * and collects them in an array for later deletion. - * - * @param filePath - The full path to a file. - * @returns A promise that resolves to an array of newly created directories. - */ - async createDirectoriesForFile(filePath: string): Promise { - const newDirectories: string[] = [] - const normalizedFilePath = path.normalize(filePath) // Normalize path for cross-platform compatibility - const directoryPath = path.dirname(normalizedFilePath) - - let currentPath = directoryPath - const dirsToCreate: string[] = [] - - // Traverse up the directory tree and collect missing directories - while (!(await this.exists(currentPath))) { - dirsToCreate.push(currentPath) - currentPath = path.dirname(currentPath) - } - - // Create directories from the topmost missing one down to the target directory - for (let i = dirsToCreate.length - 1; i >= 0; i--) { - await fs.mkdir(dirsToCreate[i]) - newDirectories.push(dirsToCreate[i]) - } - - return newDirectories - } - - /** - * Helper function to check if a path exists. - * - * @param path - The path to check. - * @returns A promise that resolves to true if the path exists, false otherwise. - */ - async exists(filePath: string): Promise { - try { - await fs.access(filePath) - return true - } catch { - return false - } - } - createPrettyPatch(filename = "file", oldStr: string, newStr: string) { const patch = diff.createPatch(filename.toPosix(), oldStr, newStr) const lines = patch.split("\n") @@ -1404,10 +1340,7 @@ ${this.customInstructions.trim()} fileExists = this.isEditingExistingFile } else { const absolutePath = path.resolve(cwd, relPath) - fileExists = await fs - .access(absolutePath) - .then(() => true) - .catch(() => false) + fileExists = await fileExistsAtPath(absolutePath) this.isEditingExistingFile = fileExists } @@ -1440,7 +1373,7 @@ ${this.customInstructions.trim()} // for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation // Keep track of newly created directories - this.editFileCreatedDirs = await this.createDirectoriesForFile(absolutePath) + this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath) // console.log(`Created directories: ${createdDirs.join(", ")}`) // make sure the file exists before we open it if (!fileExists) { @@ -1528,7 +1461,7 @@ ${this.customInstructions.trim()} // for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation // Keep track of newly created directories - this.editFileCreatedDirs = await this.createDirectoriesForFile(absolutePath) + this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath) // console.log(`Created directories: ${createdDirs.join(", ")}`) // make sure the file exists before we open it if (!fileExists) { @@ -2438,7 +2371,15 @@ ${this.customInstructions.trim()} tokensOut: outputTokens, cacheWrites: cacheWriteTokens, cacheReads: cacheReadTokens, - cost: totalCost ?? this.calculateApiCost(inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens), + cost: + totalCost ?? + calculateApiCost( + this.api.getModel().info, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens + ), }) await this.saveClaudeMessages() await this.providerRef.deref()?.postStateToWebview() @@ -2666,14 +2607,4 @@ ${this.customInstructions.trim()} return `\n${details.trim()}\n` } - - async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { - await this.say( - "error", - `Claude tried to use ${toolName}${ - relPath ? ` for '${relPath.toPosix()}'` : "" - } without value for required parameter '${paramName}'. Retrying...` - ) - return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) - } } diff --git a/src/core/webview/ClaudeDevProvider.ts b/src/core/webview/ClaudeDevProvider.ts index 4cc2d46..3bf4d42 100644 --- a/src/core/webview/ClaudeDevProvider.ts +++ b/src/core/webview/ClaudeDevProvider.ts @@ -17,6 +17,7 @@ import { getTheme } from "../../integrations/theme/getTheme" import { openFile, openImage } from "../../integrations/misc/open-file" import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { openMention } from "../mentions" +import { fileExistsAtPath } from "../../utils/fs" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -505,10 +506,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider { const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id) const apiConversationHistoryFilePath = path.join(taskDirPath, "api_conversation_history.json") const claudeMessagesFilePath = path.join(taskDirPath, "claude_messages.json") - const fileExists = await fs - .access(apiConversationHistoryFilePath) - .then(() => true) - .catch(() => false) + const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) if (fileExists) { const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) return { @@ -547,17 +545,11 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider { const { taskDirPath, apiConversationHistoryFilePath, claudeMessagesFilePath } = await this.getTaskWithId(id) // Delete the task files - const apiConversationHistoryFileExists = await fs - .access(apiConversationHistoryFilePath) - .then(() => true) - .catch(() => false) + const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath) if (apiConversationHistoryFileExists) { await fs.unlink(apiConversationHistoryFilePath) } - const claudeMessagesFileExists = await fs - .access(claudeMessagesFilePath) - .then(() => true) - .catch(() => false) + const claudeMessagesFileExists = await fileExistsAtPath(claudeMessagesFilePath) if (claudeMessagesFileExists) { await fs.unlink(claudeMessagesFilePath) } diff --git a/src/services/browser/UrlContentFetcher.ts b/src/services/browser/UrlContentFetcher.ts index 615180b..ea5dc83 100644 --- a/src/services/browser/UrlContentFetcher.ts +++ b/src/services/browser/UrlContentFetcher.ts @@ -8,6 +8,7 @@ import TurndownService from "turndown" import PCR from "puppeteer-chromium-resolver" import pWaitFor from "p-wait-for" import delay from "delay" +import { fileExistsAtPath } from "../../utils/fs" interface PCRStats { puppeteer: { launch: typeof launch } @@ -30,10 +31,7 @@ export class UrlContentFetcher { } const puppeteerDir = path.join(globalStoragePath, "puppeteer") - const dirExists = await fs - .access(puppeteerDir) - .then(() => true) - .catch(() => false) + const dirExists = await fileExistsAtPath(puppeteerDir) if (!dirExists) { await fs.mkdir(puppeteerDir, { recursive: true }) } diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index 2792817..83e02ac 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -2,14 +2,12 @@ import * as fs from "fs/promises" import * as path from "path" import { listFiles } from "../glob/list-files" import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser" +import { fileExistsAtPath } from "../../utils/fs" // TODO: implement caching behavior to avoid having to keep analyzing project for new tasks. export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise { // check if the path exists - const dirExists = await fs - .access(path.resolve(dirPath)) - .then(() => true) - .catch(() => false) + const dirExists = await fileExistsAtPath(path.resolve(dirPath)) if (!dirExists) { return "This directory does not exist or you do not have permission to access it." } diff --git a/src/utils/cost.ts b/src/utils/cost.ts new file mode 100644 index 0000000..309da6e --- /dev/null +++ b/src/utils/cost.ts @@ -0,0 +1,24 @@ +import { ModelInfo } from "../shared/api" + +export function calculateApiCost( + modelInfo: ModelInfo, + inputTokens: number, + outputTokens: number, + cacheCreationInputTokens?: number, + cacheReadInputTokens?: number +): number { + const modelCacheWritesPrice = modelInfo.cacheWritesPrice + let cacheWritesCost = 0 + if (cacheCreationInputTokens && modelCacheWritesPrice) { + cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens + } + const modelCacheReadsPrice = modelInfo.cacheReadsPrice + let cacheReadsCost = 0 + if (cacheReadInputTokens && modelCacheReadsPrice) { + cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens + } + const baseInputCost = (modelInfo.inputPrice / 1_000_000) * inputTokens + const outputCost = (modelInfo.outputPrice / 1_000_000) * outputTokens + const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost + return totalCost +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..9f7af84 --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,47 @@ +import fs from "fs/promises" +import * as path from "path" + +/** + * Asynchronously creates all non-existing subdirectories for a given file path + * and collects them in an array for later deletion. + * + * @param filePath - The full path to a file. + * @returns A promise that resolves to an array of newly created directories. + */ +export async function createDirectoriesForFile(filePath: string): Promise { + const newDirectories: string[] = [] + const normalizedFilePath = path.normalize(filePath) // Normalize path for cross-platform compatibility + const directoryPath = path.dirname(normalizedFilePath) + + let currentPath = directoryPath + const dirsToCreate: string[] = [] + + // Traverse up the directory tree and collect missing directories + while (!(await fileExistsAtPath(currentPath))) { + dirsToCreate.push(currentPath) + currentPath = path.dirname(currentPath) + } + + // Create directories from the topmost missing one down to the target directory + for (let i = dirsToCreate.length - 1; i >= 0; i--) { + await fs.mkdir(dirsToCreate[i]) + newDirectories.push(dirsToCreate[i]) + } + + return newDirectories +} + +/** + * Helper function to check if a path exists. + * + * @param path - The path to check. + * @returns A promise that resolves to true if the path exists, false otherwise. + */ +export async function fileExistsAtPath(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +}