diff options
Diffstat (limited to 'server/continuedev/plugins/steps')
25 files changed, 1942 insertions, 0 deletions
diff --git a/server/continuedev/plugins/steps/README.md b/server/continuedev/plugins/steps/README.md new file mode 100644 index 00000000..a8cae90b --- /dev/null +++ b/server/continuedev/plugins/steps/README.md @@ -0,0 +1,50 @@ +# Steps + +Steps are the composable unit of action in Continue. They define a `run` method which has access to the entire `ContinueSDK`, allowing you to take actions inside the IDE, call language models, and more. In this folder you can find a number of good examples. + +## How to write a step + +a. Start by creating a subclass of `Step` + +You should first consider what will be the parameters of your recipe. These are defined as attributes in the Pydantic class. For example, if you wanted a "filepath" attribute that would look like this: + +```python +class HelloWorldStep(Step): + filepath: str + ... +``` + +b. Next, write the `run` method + +This method takes the ContinueSDK as a parameter, giving you all the tools you need to write your steps (if it's missing something, let us know, we'll add it!). You can write any code inside the run method; this is what will happen when your step is run, line for line. As an example, here's a step that will open a file and append "Hello World!": + +```python +class HelloWorldStep(Step): + filepath: str + + async def run(self, sdk: ContinueSDK): + await sdk.ide.setFileOpen(self.filepath) + await sdk.append_to_file(self.filepath, "Hello World!") +``` + +c. Finally, every Step is displayed with a description of what it has done + +If you'd like to override the default description of your step, which is just the class name, then implement the `describe` method. You can: + +- Return a static string +- Store state in a class attribute (prepend with a double underscore, which signifies (through Pydantic) that this is not a parameter for the Step, just internal state) during the run method, and then grab this in the describe method. +- Use state in conjunction with the `models` parameter of the describe method to autogenerate a description with a language model. For example, if you'd used an attribute called `__code_written` to store a string representing some code that was written, you could implement describe as `return models.summarize.complete(f"{self.\_\_code_written}\n\nSummarize the changes made in the above code.")`. + +Here's an example: + +```python +class HelloWorldStep(Step): + filepath: str + + async def run(self, sdk: ContinueSDK): + await sdk.ide.setFileOpen(self.filepath) + await sdk.append_to_file(self.filepath, "Hello World!") + + def describe(self, models: Models): + return f"Appended 'Hello World!' to {self.filepath}" +``` diff --git a/server/continuedev/plugins/steps/__init__.py b/server/continuedev/plugins/steps/__init__.py new file mode 100644 index 00000000..a181a956 --- /dev/null +++ b/server/continuedev/plugins/steps/__init__.py @@ -0,0 +1,13 @@ +# from .chroma import ( +# AnswerQuestionChroma, # noqa: F401 +# CreateCodebaseIndexChroma, # noqa: F401 +# EditFileChroma, # noqa: F401 +# ) +from .clear_history import ClearHistoryStep # noqa: F401 +from .cmd import GenerateShellCommandStep # noqa: F401 +from .comment_code import CommentCodeStep # noqa: F401 +from .help import HelpStep # noqa: F401 +from .main import EditHighlightedCodeStep # noqa: F401 +from .open_config import OpenConfigStep # noqa: F401 + +# from .share_session import ShareSessionStep # noqa: F401 diff --git a/server/continuedev/plugins/steps/chat.py b/server/continuedev/plugins/steps/chat.py new file mode 100644 index 00000000..1b0f76f9 --- /dev/null +++ b/server/continuedev/plugins/steps/chat.py @@ -0,0 +1,379 @@ +import html +import json +import os +from textwrap import dedent +from typing import Any, Coroutine, List + +import openai +from directory_tree import display_tree +from dotenv import load_dotenv +from pydantic import Field + +from ...core.main import ChatMessage, FunctionCall, Models, Step, step_to_json_schema +from ...core.sdk import ContinueSDK +from ...core.steps import MessageStep +from ...libs.llm.openai import OpenAI +from ...libs.llm.openai_free_trial import OpenAIFreeTrial +from ...libs.util.devdata import dev_data_logger +from ...libs.util.strings import remove_quotes_and_escapes +from ...libs.util.telemetry import posthog_logger +from .main import EditHighlightedCodeStep + +load_dotenv() +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +openai.api_key = OPENAI_API_KEY + +FREE_USAGE_STEP_NAME = "Please enter OpenAI API key" + + +def add_ellipsis(text: str, max_length: int = 200) -> str: + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + + +class SimpleChatStep(Step): + name: str = "Generating Response..." + manage_own_chat_context: bool = True + description: str = "" + messages: List[ChatMessage] = None + + async def run(self, sdk: ContinueSDK): + # Check if proxy server API key + if ( + isinstance(sdk.models.default, OpenAIFreeTrial) + and ( + sdk.models.default.api_key is None + or sdk.models.default.api_key.strip() == "" + ) + and len(list(filter(lambda x: not x.step.hide, sdk.history.timeline))) >= 10 + and len( + list( + filter( + lambda x: x.step.name == FREE_USAGE_STEP_NAME, + sdk.history.timeline, + ) + ) + ) + == 0 + ): + await sdk.run_step( + MessageStep( + name=FREE_USAGE_STEP_NAME, + message=dedent( + """\ + To make it easier to use Continue, you're getting limited free usage. When you have the chance, please enter your own OpenAI key in `~/.continue/config.py`. You can open the file by using the '/config' slash command in the text box below. + + Here's an example of how to edit the file: + ```python + ... + config=ContinueConfig( + ... + models=Models( + default=OpenAIFreeTrial(api_key="<API_KEY>", model="gpt-4"), + summarize=OpenAIFreeTrial(api_key="<API_KEY>", model="gpt-3.5-turbo") + ) + ) + ``` + + You can also learn more about customizations [here](https://continue.dev/docs/customization). + """ + ), + ) + ) + + messages = self.messages or await sdk.get_chat_context() + + generator = sdk.models.chat.stream_chat( + messages, temperature=sdk.config.temperature + ) + + posthog_logger.capture_event( + "model_use", + { + "model": sdk.models.default.model, + "provider": sdk.models.default.__class__.__name__, + }, + ) + dev_data_logger.capture( + "model_use", + { + "model": sdk.models.default.model, + "provider": sdk.models.default.__class__.__name__, + }, + ) + + async for chunk in generator: + if sdk.current_step_was_deleted(): + # So that the message doesn't disappear + self.hide = False + await sdk.update_ui() + break + + if "content" in chunk: + self.description += chunk["content"] + + # HTML unencode + end_size = len(chunk["content"]) - 6 + if "&" in self.description[-end_size:]: + self.description = self.description[:-end_size] + html.unescape( + self.description[-end_size:] + ) + + await sdk.update_ui() + + if sdk.config.disable_summaries: + self.name = "" + else: + self.name = "Generating title..." + await sdk.update_ui() + self.name = add_ellipsis( + remove_quotes_and_escapes( + await sdk.models.summarize.complete( + f'"{self.description}"\n\nPlease write a short title summarizing the message quoted above. Use no more than 10 words:', + max_tokens=20, + log=False, + ) + ), + 200, + ) + + await sdk.update_ui() + + self.chat_context.append( + ChatMessage(role="assistant", content=self.description, summary=self.name) + ) + + # TODO: Never actually closing. + await generator.aclose() + + +class AddFileStep(Step): + name: str = "Add File" + description = "Add a file to the workspace. Should always view the directory tree before this." + filename: str + file_contents: str + + async def describe( + self, models: Models + ) -> Coroutine[Any, Any, Coroutine[str, None, None]]: + return f"Added a file named `{self.filename}` to the workspace." + + async def run(self, sdk: ContinueSDK): + await sdk.add_file(self.filename, self.file_contents) + + await sdk.ide.setFileOpen( + os.path.join(sdk.ide.workspace_directory, self.filename) + ) + + +class DeleteFileStep(Step): + name: str = "Delete File" + description = "Delete a file from the workspace." + filename: str + + async def describe( + self, models: Models + ) -> Coroutine[Any, Any, Coroutine[str, None, None]]: + return f"Deleted a file named `{self.filename}` from the workspace." + + async def run(self, sdk: ContinueSDK): + await sdk.delete_file(self.filename) + + +class AddDirectoryStep(Step): + name: str = "Add Directory" + description = "Add a directory to the workspace." + directory_name: str + + async def describe( + self, models: Models + ) -> Coroutine[Any, Any, Coroutine[str, None, None]]: + return f"Added a directory named `{self.directory_name}` to the workspace." + + async def run(self, sdk: ContinueSDK): + try: + await sdk.add_directory(self.directory_name) + except FileExistsError: + self.description = f"Directory {self.directory_name} already exists." + + +class RunTerminalCommandStep(Step): + name: str = "Run Terminal Command" + description: str = "Run a terminal command." + command: str + + async def run(self, sdk: ContinueSDK): + self.description = f"Copy this command and run in your terminal:\n\n```bash\n{self.command}\n```" + + +class ViewDirectoryTreeStep(Step): + name: str = "View Directory Tree" + description: str = "View the directory tree to learn which folder and files exist. You should always do this before adding new files." + + async def describe( + self, models: Models + ) -> Coroutine[Any, Any, Coroutine[str, None, None]]: + return "Viewed the directory tree." + + async def run(self, sdk: ContinueSDK): + self.description = ( + f"```\n{display_tree(sdk.ide.workspace_directory, True, max_depth=2)}\n```" + ) + + +class EditFileStep(Step): + name: str = "Edit File" + description: str = "Edit a file in the workspace that is not currently open." + filename: str = Field(..., description="The name of the file to edit.") + instructions: str = Field(..., description="The instructions to edit the file.") + hide: bool = True + + async def run(self, sdk: ContinueSDK): + await sdk.edit_file(self.filename, self.instructions) + + +class ChatWithFunctions(Step): + user_input: str + functions: List[Step] = [ + AddFileStep(filename="", file_contents=""), + EditFileStep(filename="", instructions=""), + EditHighlightedCodeStep(user_input=""), + ViewDirectoryTreeStep(), + AddDirectoryStep(directory_name=""), + DeleteFileStep(filename=""), + RunTerminalCommandStep(command=""), + ] + name: str = "Input" + manage_own_chat_context: bool = True + description: str = "" + hide: bool = True + + async def run(self, sdk: ContinueSDK): + await sdk.update_ui() + + step_name_step_class_map = { + step.name.replace(" ", ""): step.__class__ for step in self.functions + } + + functions = [step_to_json_schema(function) for function in self.functions] + + self.chat_context.append( + ChatMessage(role="user", content=self.user_input, summary=self.user_input) + ) + + last_function_called_name = None + last_function_called_params = None + while True: + was_function_called = False + func_args = "" + func_name = "" + msg_content = "" + msg_step = None + + gpt350613 = OpenAI(model="gpt-3.5-turbo-0613") + await sdk.start_model(gpt350613) + + async for msg_chunk in gpt350613.stream_chat( + await sdk.get_chat_context(), functions=functions + ): + if sdk.current_step_was_deleted(): + return + + if "content" in msg_chunk and msg_chunk["content"] is not None: + msg_content += msg_chunk["content"] + # if last_function_called_index_in_history is not None: + # while sdk.history.timeline[last_function_called_index].step.hide: + # last_function_called_index += 1 + # sdk.history.timeline[last_function_called_index_in_history].step.description = msg_content + if msg_step is None: + msg_step = MessageStep( + name="Chat", message=msg_chunk["content"] + ) + await sdk.run_step(msg_step) + else: + msg_step.description = msg_content + await sdk.update_ui() + elif "function_call" in msg_chunk or func_name != "": + was_function_called = True + if "function_call" in msg_chunk: + if "arguments" in msg_chunk["function_call"]: + func_args += msg_chunk["function_call"]["arguments"] + if "name" in msg_chunk["function_call"]: + func_name += msg_chunk["function_call"]["name"] + + if not was_function_called: + self.chat_context.append( + ChatMessage( + role="assistant", content=msg_content, summary=msg_content + ) + ) + break + else: + if func_name == "python" and "python" not in step_name_step_class_map: + # GPT must be fine-tuned to believe this exists, but it doesn't always + func_name = "EditHighlightedCodeStep" + func_args = json.dumps({"user_input": self.user_input}) + # self.chat_context.append(ChatMessage( + # role="assistant", + # content=None, + # function_call=FunctionCall( + # name=func_name, + # arguments=func_args + # ), + # summary=f"Called function {func_name}" + # )) + # self.chat_context.append(ChatMessage( + # role="user", + # content="The 'python' function does not exist. Don't call it. Try again to call another function.", + # summary="'python' function does not exist." + # )) + # msg_step.hide = True + # continue + # Call the function, then continue to chat + func_args = "{}" if func_args == "" else func_args + try: + fn_call_params = json.loads(func_args) + except json.JSONDecodeError: + raise Exception("The model returned invalid JSON. Please try again") + self.chat_context.append( + ChatMessage( + role="assistant", + content=None, + function_call=FunctionCall(name=func_name, arguments=func_args), + summary=f"Called function {func_name}", + ) + ) + sdk.history.current_index + 1 + if func_name not in step_name_step_class_map: + raise Exception( + f"The model tried to call a function ({func_name}) that does not exist. Please try again." + ) + + # if func_name == "AddFileStep": + # step_to_run.hide = True + # self.description += f"\nAdded file `{func_args['filename']}`" + # elif func_name == "AddDirectoryStep": + # step_to_run.hide = True + # self.description += f"\nAdded directory `{func_args['directory_name']}`" + # else: + # self.description += f"\n`Running function {func_name}`\n\n" + if func_name == "EditHighlightedCodeStep": + fn_call_params["user_input"] = self.user_input + elif func_name == "EditFile": + fn_call_params["instructions"] = self.user_input + + step_to_run = step_name_step_class_map[func_name](**fn_call_params) + if ( + last_function_called_name is not None + and last_function_called_name == func_name + and last_function_called_params is not None + and last_function_called_params == fn_call_params + ): + # If it's calling the same function more than once in a row, it's probably looping and confused + return + last_function_called_name = func_name + last_function_called_params = fn_call_params + + await sdk.run_step(step_to_run) + await sdk.update_ui() diff --git a/server/continuedev/plugins/steps/chroma.py b/server/continuedev/plugins/steps/chroma.py new file mode 100644 index 00000000..f357a872 --- /dev/null +++ b/server/continuedev/plugins/steps/chroma.py @@ -0,0 +1,86 @@ +from textwrap import dedent +from typing import Coroutine, Union + +from ...core.main import Step +from ...core.observation import Observation +from ...core.sdk import ContinueSDK +from ...core.steps import EditFileStep +from ...libs.chroma.query import ChromaIndexManager + + +class CreateCodebaseIndexChroma(Step): + name: str = "Create Codebase Index" + hide: bool = True + + async def describe(self, llm) -> Coroutine[str, None, None]: + return "Indexing the codebase..." + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + index = ChromaIndexManager(await sdk.ide.getWorkspaceDirectory()) + if not index.check_index_exists(): + self.hide = False + index.create_codebase_index() + + +class AnswerQuestionChroma(Step): + question: str + _answer: Union[str, None] = None + name: str = "Answer Question" + + async def describe(self, llm) -> Coroutine[str, None, None]: + if self._answer is None: + return f"Answering the question: {self.question}" + else: + return self._answer + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + index = ChromaIndexManager(await sdk.ide.getWorkspaceDirectory()) + results = index.query_codebase_index(self.question) + + code_snippets = "" + + files = [] + for node in results.source_nodes: + resource_name = list(node.node.relationships.values())[0] + filepath = resource_name[: resource_name.index("::")] + files.append(filepath) + code_snippets += f"""{filepath}```\n{node.node.text}\n```\n\n""" + + prompt = dedent( + f"""Here are a few snippets of code that might be useful in answering the question: + + {code_snippets} + + Here is the question to answer: + + {self.question} + + Here is the answer:""" + ) + + answer = await sdk.models.summarize.complete(prompt) + # Make paths relative to the workspace directory + answer = answer.replace(await sdk.ide.getWorkspaceDirectory(), "") + + self._answer = answer + + await sdk.ide.setFileOpen(files[0]) + + +class EditFileChroma(Step): + request: str + hide: bool = True + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + index = ChromaIndexManager(await sdk.ide.getWorkspaceDirectory()) + results = index.query_codebase_index(self.request) + + resource_name = list(results.source_nodes[0].node.relationships.values())[0] + filepath = resource_name[: resource_name.index("::")] + + await sdk.run_step( + EditFileStep( + filepath=filepath, + prompt=f"Here is the code:\n\n{{code}}\n\nHere is the user request:\n\n{self.request}\n\nHere is the code after making the requested changes:\n", + ) + ) diff --git a/server/continuedev/plugins/steps/clear_history.py b/server/continuedev/plugins/steps/clear_history.py new file mode 100644 index 00000000..8f21518b --- /dev/null +++ b/server/continuedev/plugins/steps/clear_history.py @@ -0,0 +1,10 @@ +from ...core.main import Step +from ...core.sdk import ContinueSDK + + +class ClearHistoryStep(Step): + name: str = "Clear History" + hide: bool = True + + async def run(self, sdk: ContinueSDK): + await sdk.clear_history() diff --git a/server/continuedev/plugins/steps/cmd.py b/server/continuedev/plugins/steps/cmd.py new file mode 100644 index 00000000..a38f6323 --- /dev/null +++ b/server/continuedev/plugins/steps/cmd.py @@ -0,0 +1,30 @@ +from textwrap import dedent +from typing import Coroutine + +from ...core.main import Step +from ...core.observation import Observation +from ...core.sdk import ContinueSDK +from ...libs.util.strings import remove_quotes_and_escapes + + +class GenerateShellCommandStep(Step): + user_input: str + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + cmd = await sdk.models.default.complete( + dedent( + f"""\ + The user has made a request to run a shell command. Their description of what it should do is: + + "{self.user_input}" + + Please write a shell command that will do what the user requested. Your output should consist of only the command itself, without any explanation or example output. Do not use any newlines. Only output the command that when inserted into the terminal will do precisely what was requested. + """ + ) + ) + + cmd = remove_quotes_and_escapes(cmd.strip()).replace("\n", "").replace("\r", "") + + await sdk.ide.runCommand(cmd) + + self.description = f"Generated shell command: {cmd}" diff --git a/server/continuedev/plugins/steps/comment_code.py b/server/continuedev/plugins/steps/comment_code.py new file mode 100644 index 00000000..1eee791d --- /dev/null +++ b/server/continuedev/plugins/steps/comment_code.py @@ -0,0 +1,16 @@ +from ...core.main import ContinueSDK, Models, Step +from .main import EditHighlightedCodeStep + + +class CommentCodeStep(Step): + hide: bool = True + + async def describe(self, models: Models): + return "Writing comments" + + async def run(self, sdk: ContinueSDK): + await sdk.run_step( + EditHighlightedCodeStep( + user_input="Write comprehensive comments in the canonical format for every class and function" + ) + ) diff --git a/server/continuedev/plugins/steps/custom_command.py b/server/continuedev/plugins/steps/custom_command.py new file mode 100644 index 00000000..4128415b --- /dev/null +++ b/server/continuedev/plugins/steps/custom_command.py @@ -0,0 +1,29 @@ +from ...core.main import Step +from ...core.sdk import ContinueSDK, Models +from ...libs.util.templating import render_templated_string +from ..steps.chat import SimpleChatStep + + +class CustomCommandStep(Step): + name: str + prompt: str + user_input: str + slash_command: str + hide: bool = True + + async def describe(self, models: Models): + return self.prompt + + async def run(self, sdk: ContinueSDK): + task = render_templated_string(self.prompt) + + prompt_user_input = f"Task: {task}. Additional info: {self.user_input}" + messages = await sdk.get_chat_context() + # Find the last chat message with this slash command and replace it with the user input + for i in range(len(messages) - 1, -1, -1): + if messages[i].role == "user" and messages[i].content.startswith( + self.slash_command + ): + messages[i] = messages[i].copy(update={"content": prompt_user_input}) + break + await sdk.run_step(SimpleChatStep(messages=messages)) diff --git a/server/continuedev/plugins/steps/draft/abstract_method.py b/server/continuedev/plugins/steps/draft/abstract_method.py new file mode 100644 index 00000000..7ceefe9b --- /dev/null +++ b/server/continuedev/plugins/steps/draft/abstract_method.py @@ -0,0 +1,21 @@ +from ....core.main import Step +from ....core.sdk import ContinueSDK + + +class ImplementAbstractMethodStep(Step): + name: str = "Implement abstract method for all subclasses" + method_name: str + class_name: str + + async def run(self, sdk: ContinueSDK): + if sdk.lsp is None: + self.description = "Language Server Protocol is not enabled" + return + + implementations = await sdk.lsp.go_to_implementations(self.class_name) + + for implementation in implementations: + await sdk.edit_file( + range_in_files=[implementation.range_in_file], + prompt=f"Implement method `{self.method_name}` for this subclass of `{self.class_name}`", + ) diff --git a/server/continuedev/plugins/steps/draft/redux.py b/server/continuedev/plugins/steps/draft/redux.py new file mode 100644 index 00000000..83b5e592 --- /dev/null +++ b/server/continuedev/plugins/steps/draft/redux.py @@ -0,0 +1,50 @@ +from ....core.main import Step +from ....core.sdk import ContinueSDK +from ....core.steps import EditFileStep + + +class EditReduxStateStep(Step): + description: str # e.g. "I want to load data from the weatherapi.com API" + + async def run(self, sdk: ContinueSDK): + # Find the right file to edit + + # RootStore + store_filename = "" + sdk.run_step( + EditFileStep( + filename=store_filename, + prompt=f"Edit the root store to add a new slice for {self.description}", + ) + ) + store_file_contents = await sdk.ide.readFile(store_filename) + + # Selector + selector_filename = "" + sdk.run_step( + EditFileStep( + filepath=selector_filename, + prompt=f"Edit the selector to add a new property for {self.description}. The store looks like this: {store_file_contents}", + ) + ) + + # Reducer + reducer_filename = "" + sdk.run_step( + EditFileStep( + filepath=reducer_filename, + prompt=f"Edit the reducer to add a new property for {self.description}. The store looks like this: {store_file_contents}", + ) + ) + """ + Starts with implementing selector + 1. RootStore + 2. Selector + 3. Reducer or entire slice + + Need to first determine whether this is an: + 1. edit + 2. add new reducer and property in existing slice + 3. add whole new slice + 4. build redux from scratch + """ diff --git a/server/continuedev/plugins/steps/draft/typeorm.py b/server/continuedev/plugins/steps/draft/typeorm.py new file mode 100644 index 00000000..c79fa041 --- /dev/null +++ b/server/continuedev/plugins/steps/draft/typeorm.py @@ -0,0 +1,54 @@ +from textwrap import dedent + +from ....core.main import Step +from ....core.sdk import ContinueSDK + + +class CreateTableStep(Step): + sql_str: str + name: str = "Create a table in TypeORM" + + async def run(self, sdk: ContinueSDK): + # Write TypeORM entity + entity_name = self.sql_str.split(" ")[2].capitalize() + await sdk.edit_file( + f"src/entity/{entity_name}.ts", + dedent( + f"""\ + {self.sql_str} + + Write a TypeORM entity called {entity_name} for this table, importing as necessary:""" + ), + ) + + # Add entity to data-source.ts + await sdk.edit_file( + filepath="src/data-source.ts", prompt=f"Add the {entity_name} entity:" + ) + + # Generate blank migration for the entity + out = await sdk.run( + f"npx typeorm migration:create ./src/migration/Create{entity_name}Table" + ) + migration_filepath = out.text.split(" ")[1] + + # Wait for user input + await sdk.wait_for_user_confirmation("Fill in the migration?") + + # Fill in the migration + await sdk.edit_file( + migration_filepath, + dedent( + f"""\ + This is the table that was created: + + {self.sql_str} + + Fill in the migration for the table:""" + ), + ) + + # Run the migration + await sdk.run( + "npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts" + ) diff --git a/server/continuedev/plugins/steps/feedback.py b/server/continuedev/plugins/steps/feedback.py new file mode 100644 index 00000000..df1142a1 --- /dev/null +++ b/server/continuedev/plugins/steps/feedback.py @@ -0,0 +1,14 @@ +from ...core.main import Models, Step +from ...core.sdk import ContinueSDK +from ...libs.util.telemetry import posthog_logger + + +class FeedbackStep(Step): + user_input: str + name = "Thanks for your feedback!" + + async def describe(self, models: Models): + return f"`{self.user_input}`\n\nWe'll see your feedback and make improvements as soon as possible. If you'd like to directly email us, you can contact [nate@continue.dev](mailto:nate@continue.dev?subject=Feedback%20On%20Continue)." + + async def run(self, sdk: ContinueSDK): + posthog_logger.capture_event("feedback", {"feedback": self.user_input}) diff --git a/server/continuedev/plugins/steps/find_and_replace.py b/server/continuedev/plugins/steps/find_and_replace.py new file mode 100644 index 00000000..287e286d --- /dev/null +++ b/server/continuedev/plugins/steps/find_and_replace.py @@ -0,0 +1,30 @@ +from ...core.main import Models, Step +from ...core.sdk import ContinueSDK +from ...models.filesystem_edit import FileEdit, Range + + +class FindAndReplaceStep(Step): + name: str = "Find and replace" + filepath: str + pattern: str + replacement: str + + async def describe(self, models: Models): + return f"Replaced all instances of `{self.pattern}` with `{self.replacement}` in `{self.filepath}`" + + async def run(self, sdk: ContinueSDK): + file_content = await sdk.ide.readFile(self.filepath) + while self.pattern in file_content: + start_index = file_content.index(self.pattern) + end_index = start_index + len(self.pattern) + await sdk.ide.applyFileSystemEdit( + FileEdit( + filepath=self.filepath, + range=Range.from_indices(file_content, start_index, end_index - 1), + replacement=self.replacement, + ) + ) + file_content = ( + file_content[:start_index] + self.replacement + file_content[end_index:] + ) + await sdk.ide.saveFile(self.filepath) diff --git a/server/continuedev/plugins/steps/help.py b/server/continuedev/plugins/steps/help.py new file mode 100644 index 00000000..148dddb8 --- /dev/null +++ b/server/continuedev/plugins/steps/help.py @@ -0,0 +1,70 @@ +from textwrap import dedent + +from ...core.main import ChatMessage, Step +from ...core.sdk import ContinueSDK +from ...libs.util.telemetry import posthog_logger + +help = dedent( + """\ + Continue is an open-source coding autopilot. It is a VS Code extension that brings the power of ChatGPT to your IDE. + + It gathers context for you and stores your interactions automatically, so that you can avoid copy/paste now and benefit from a customized Large Language Model (LLM) later. + + Continue can be used to... + 1. Edit chunks of code with specific instructions (e.g. "/edit migrate this digital ocean terraform file into one that works for GCP") + 2. Get answers to questions without switching windows (e.g. "how do I find running process on port 8000?") + 3. Generate files from scratch (e.g. "/edit Create a Python CLI tool that uses the posthog api to get events from DAUs") + + You tell Continue to edit a specific section of code by highlighting it. If you highlight multiple code sections, then it will only edit the one with the purple glow around it. You can switch which one has the purple glow by clicking the paint brush. + + If you don't highlight any code, then Continue will insert at the location of your cursor. + + Continue passes all of the sections of code you highlight, the code above and below the to-be edited highlighted code section, and all previous steps above input box as context to the LLM. + + You can use cmd+m (Mac) / ctrl+m (Windows) to open Continue. You can use cmd+shift+e / ctrl+shift+e to open file Explorer. You can add your own OpenAI API key to VS Code Settings with `cmd+,` + + If Continue is stuck loading, try using `cmd+shift+p` to open the command palette, search "Reload Window", and then select it. This will reload VS Code and Continue and often fixes issues. + + If you have feedback, please use /feedback to let us know how you would like to use Continue. We are excited to hear from you!""" +) + + +class HelpStep(Step): + name: str = "Help" + user_input: str + manage_own_chat_context: bool = True + description: str = "" + + async def run(self, sdk: ContinueSDK): + question = self.user_input + + if question.strip() == "": + self.description = help + else: + self.description = "The following output is generated by a language model, which may hallucinate. Type just '/help'to see a fixed answer. You can also learn more by reading [the docs](https://continue.dev/docs).\n\n" + prompt = dedent( + f""" + Information: + + {help} + + Instructions: + + Please us the information below to provide a succinct answer to the following question: {question} + + Do not cite any slash commands other than those you've been told about, which are: /edit and /feedback. Never refer or link to any URL.""" + ) + + self.chat_context.append( + ChatMessage(role="user", content=prompt, summary="Help") + ) + messages = await sdk.get_chat_context() + generator = sdk.models.default.stream_chat(messages) + async for chunk in generator: + if "content" in chunk: + self.description += chunk["content"] + await sdk.update_ui() + + posthog_logger.capture_event( + "help", {"question": question, "answer": self.description} + ) diff --git a/server/continuedev/plugins/steps/input/nl_multiselect.py b/server/continuedev/plugins/steps/input/nl_multiselect.py new file mode 100644 index 00000000..f4b5e7a6 --- /dev/null +++ b/server/continuedev/plugins/steps/input/nl_multiselect.py @@ -0,0 +1,32 @@ +from typing import List, Union + +from ....core.main import Step +from ....core.sdk import ContinueSDK +from ....core.steps import WaitForUserInputStep + + +class NLMultiselectStep(Step): + hide: bool = True + + prompt: str + options: List[str] + + async def run(self, sdk: ContinueSDK): + user_response = ( + await sdk.run_step(WaitForUserInputStep(prompt=self.prompt)) + ).text + + def extract_option(text: str) -> Union[str, None]: + for option in self.options: + if option in text: + return option + return None + + first_try = extract_option(user_response.lower()) + if first_try is not None: + return first_try + + gpt_parsed = await sdk.models.default.complete( + f"These are the available options are: [{', '.join(self.options)}]. The user requested {user_response}. This is the exact string from the options array that they selected:" + ) + return extract_option(gpt_parsed) or self.options[0] diff --git a/server/continuedev/plugins/steps/main.py b/server/continuedev/plugins/steps/main.py new file mode 100644 index 00000000..936fd7e0 --- /dev/null +++ b/server/continuedev/plugins/steps/main.py @@ -0,0 +1,422 @@ +import os +from textwrap import dedent +from typing import Coroutine, List, Optional, Union + +from pydantic import BaseModel, Field + +from ...core.main import ContinueCustomException, Step +from ...core.observation import Observation +from ...core.sdk import ContinueSDK, Models +from ...core.steps import DefaultModelEditCodeStep +from ...libs.llm.base import LLM +from ...libs.llm.prompt_utils import MarkdownStyleEncoderDecoder +from ...libs.util.calculate_diff import calculate_diff2 +from ...libs.util.logging import logger +from ...models.filesystem import RangeInFile, RangeInFileWithContents +from ...models.filesystem_edit import EditDiff, FileEdit +from ...models.main import Range, Traceback + + +class Policy(BaseModel): + pass + + +class RunPolicyUntilDoneStep(Step): + policy: "Policy" + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + next_step = self.policy.next(sdk.config, sdk.history) + while next_step is not None: + observation = await sdk.run_step(next_step) + next_step = self.policy.next(sdk.config, sdk.history) + return observation + + +class FasterEditHighlightedCodeStep(Step): + user_input: str + hide = True + _completion: str = "Edit Code" + _edit_diffs: Union[List[EditDiff], None] = None + _prompt: str = dedent( + """\ + You will be given code to edit in order to perfectly satisfy the user request. All the changes you make must be described as replacements, which you should format in the following way: + FILEPATH + <FILE_TO_EDIT> + REPLACE_ME + <CODE_TO_REPLACE> + REPLACE_WITH + <CODE_TO_REPLACE_WITH> + + where <CODE_TO_REPLACE> and <CODE_TO_REPLACE_WITH> can be multiple lines, but should be the minimum needed to make the edit. Be sure to maintain existing whitespace at the start of lines. + + For example, if you want to replace the code `x = 1` with `x = 2` in main.py, you would write: + FILEPATH + main.py + REPLACE_ME + x = 1 + REPLACE_WITH + x = 2 + If you wanted to delete the code + ``` + def sum(a, b): + return a + b + ``` + in main.py, you would write: + FILEPATH + main.py + REPLACE_ME + def sum(a, b): + return a + b + REPLACE_WITH + + You may need to make multiple edits; respond with exactly as many as needed. + + Below is the code before changes: + + {code} + + This is the user request: "{user_input}" + Here is the description of changes to make: +""" + ) + + async def describe(self, models: Models) -> Coroutine[str, None, None]: + return "Editing highlighted code" + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + range_in_files = await sdk.get_code_context(only_editing=True) + if len(range_in_files) == 0: + # Get the full contents of all visible files + files = await sdk.ide.getVisibleFiles() + contents = {} + for file in files: + contents[file] = await sdk.ide.readFile(file) + + range_in_files = [ + RangeInFileWithContents.from_entire_file(filepath, content) + for filepath, content in contents.items() + ] + + enc_dec = MarkdownStyleEncoderDecoder(range_in_files) + code_string = enc_dec.encode() + prompt = self._prompt.format(code=code_string, user_input=self.user_input) + + rif_dict = {} + for rif in range_in_files: + rif_dict[rif.filepath] = rif.contents + + completion = await sdk.models.summarize.complete(prompt) + + # Temporarily doing this to generate description. + self._prompt = prompt + self._completion = completion + logger.debug(completion) + + # ALTERNATIVE DECODING STEP HERE + raw_file_edits = [] + lines = completion.split("\n") + current_edit = {} + status = "FILEPATH" + for i in range(0, len(lines)): + line = lines[i] + if line == "FILEPATH": + if "FILEPATH" in current_edit: + raw_file_edits.append(current_edit) + current_edit = {} + status = "FILEPATH" + elif line == "REPLACE_ME": + status = "REPLACE_ME" + elif line == "REPLACE_WITH": + status = "REPLACE_WITH" + elif status == "FILEPATH": + current_edit["filepath"] = line + elif status == "REPLACE_ME": + if "replace_me" in current_edit: + current_edit["replace_me"] += "\n" + line + else: + current_edit["replace_me"] = line + elif status == "REPLACE_WITH": + if "replace_with" in current_edit: + current_edit["replace_with"] += "\n" + line + else: + current_edit["replace_with"] = line + if "filepath" in current_edit: + raw_file_edits.append(current_edit) + + file_edits = [] + for edit in raw_file_edits: + filepath = edit["filepath"] + replace_me = edit["replace_me"] + replace_with = edit["replace_with"] + file_edits.append( + FileEdit( + filepath=filepath, + range=Range.from_lines_snippet_in_file( + content=rif_dict[filepath], snippet=replace_me + ), + replacement=replace_with, + ) + ) + # ------------------------------ + + self._edit_diffs = [] + for file_edit in file_edits: + diff = await sdk.apply_filesystem_edit(file_edit) + self._edit_diffs.append(diff) + + for filepath in set([file_edit.filepath for file_edit in file_edits]): + await sdk.ide.saveFile(filepath) + await sdk.ide.setFileOpen(filepath) + + return None + + +class StarCoderEditHighlightedCodeStep(Step): + user_input: str + name: str = "Editing Code" + hide = False + _prompt: str = "<commit_before>{code}<commit_msg>{user_request}<commit_after>" + + _prompt_and_completion: str = "" + + async def describe(self, models: Models) -> Coroutine[str, None, None]: + return await models.summarize.complete( + f"{self._prompt_and_completion}\n\nPlease give brief a description of the changes made above using markdown bullet points:" + ) + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + range_in_files = await sdk.get_code_context(only_editing=True) + found_highlighted_code = len(range_in_files) > 0 + if not found_highlighted_code: + # Get the full contents of all visible files + files = await sdk.ide.getVisibleFiles() + contents = {} + for file in files: + contents[file] = await sdk.ide.readFile(file) + + range_in_files = [ + RangeInFileWithContents.from_entire_file(filepath, content) + for filepath, content in contents.items() + ] + + rif_dict = {} + for rif in range_in_files: + rif_dict[rif.filepath] = rif.contents + + for rif in range_in_files: + prompt = self._prompt.format( + code=rif.contents, user_request=self.user_input + ) + + if found_highlighted_code: + full_file_contents = await sdk.ide.readFile(rif.filepath) + segs = full_file_contents.split(rif.contents) + prompt = f"<file_prefix>{segs[0]}<file_suffix>{segs[1]}" + prompt + + completion = str(await sdk.models.starcoder.complete(prompt)) + eot_token = "<|endoftext|>" + completion = completion.removesuffix(eot_token) + + if found_highlighted_code: + rif.contents = segs[0] + rif.contents + segs[1] + completion = segs[0] + completion + segs[1] + + self._prompt_and_completion += prompt + completion + + edits = calculate_diff2( + rif.filepath, rif.contents, completion.removesuffix("\n") + ) + for edit in edits: + await sdk.ide.applyFileSystemEdit(edit) + + # await sdk.ide.applyFileSystemEdit( + # FileEdit(filepath=rif.filepath, range=rif.range, replacement=completion)) + await sdk.ide.saveFile(rif.filepath) + await sdk.ide.setFileOpen(rif.filepath) + + +def decode_escaped_path(path: str) -> str: + """We use a custom escaping scheme to record the full path of a file as a + corresponding basename, but withut URL encoding, because then the URI just gets + interpreted as a full path again.""" + return path.replace("$f$", "/").replace("$b$", "\\") + + +def encode_escaped_path(path: str) -> str: + """We use a custom escaping scheme to record the full path of a file as a + corresponding basename, but withut URL encoding, because then the URI just gets + interpreted as a full path again.""" + return path.replace("/", "$f$").replace("\\", "$b$") + + +class EditAlreadyEditedRangeStep(Step): + hide = True + model: Optional[LLM] = None + range_in_file: RangeInFile + + user_input: str + + _prompt = dedent( + """\ + You were previously asked to edit this code. The request was: + + "{prev_user_input}" + + And you generated this diff: + + {diff} + + Could you please re-edit this code to follow these secondary instructions? + + "{user_input}" + """ + ) + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + if os.path.basename(self.range_in_file.filepath) in os.listdir( + os.path.expanduser(os.path.join("~", ".continue", "diffs")) + ): + decoded_basename = decode_escaped_path( + os.path.basename(self.range_in_file.filepath) + ) + self.range_in_file.filepath = decoded_basename + + self.range_in_file.range = sdk.context.get("last_edit_range") + + if self.range_in_file.range.start == self.range_in_file.range.end: + self.range_in_file.range = Range.from_entire_file( + await sdk.ide.readFile(self.range_in_file.filepath) + ) + + await sdk.run_step( + DefaultModelEditCodeStep( + model=self.model, + user_input=self._prompt.format( + prev_user_input=sdk.context.get("last_edit_user_input"), + diff=sdk.context.get("last_edit_diff"), + user_input=self.user_input, + ), + range_in_files=[self.range_in_file], + ) + ) + + +class EditHighlightedCodeStep(Step): + user_input: str = Field( + ..., + title="User Input", + description="The natural language request describing how to edit the code", + ) + model: Optional[LLM] = None + hide = True + description: str = "Change the contents of the currently highlighted code or open file. You should call this function if the user asks seems to be asking for a code change." + + summary_prompt: Optional[str] = None + + async def describe(self, models: Models) -> Coroutine[str, None, None]: + return "Editing code" + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + range_in_files = sdk.get_code_context(only_editing=True) + + # If nothing highlighted, insert at the cursor if possible + if len(range_in_files) == 0: + highlighted_code = await sdk.ide.getHighlightedCode() + if highlighted_code is not None: + for rif in highlighted_code: + if rif.range.start == rif.range.end: + range_in_files.append( + RangeInFileWithContents.from_range_in_file(rif, "") + ) + + # If still no highlighted code, raise error + if len(range_in_files) == 0: + raise ContinueCustomException( + message="Please highlight some code and try again.", + title="No Code Selected (highlight and select with cmd+shift+M)", + ) + + # If all of the ranges are point ranges, only edit the last one + if all([rif.range.start == rif.range.end for rif in range_in_files]): + range_in_files = [range_in_files[-1]] + + range_in_files = list( + map( + lambda x: RangeInFile(filepath=x.filepath, range=x.range), + range_in_files, + ) + ) + + for range_in_file in range_in_files: + # Check whether re-editing + if ( + os.path.dirname(range_in_file.filepath) + == os.path.expanduser(os.path.join("~", ".continue", "diffs")) + or encode_escaped_path(range_in_file.filepath) + in os.listdir( + os.path.expanduser(os.path.join("~", ".continue", "diffs")) + ) + ) and sdk.context.get("last_edit_user_input") is not None: + await sdk.run_step( + EditAlreadyEditedRangeStep( + range_in_file=range_in_file, + user_input=self.user_input, + model=self.model, + ) + ) + return + + args = { + "user_input": self.user_input, + "range_in_files": range_in_files, + "model": self.model, + } + if self.summary_prompt: + args["summary_prompt"] = self.summary_prompt + + await sdk.run_step(DefaultModelEditCodeStep(**args)) + + +class UserInputStep(Step): + user_input: str + + +class SolveTracebackStep(Step): + traceback: Traceback + + async def describe(self, models: Models) -> Coroutine[str, None, None]: + return f"```\n{self.traceback.full_traceback}\n```" + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + prompt = dedent( + """I ran into this problem with my Python code: + + {traceback} + + Below are the files that might need to be fixed: + + {code} + + This is what the code should be in order to avoid the problem: + """ + ).format(traceback=self.traceback.full_traceback, code="{code}") + + range_in_files = [] + for frame in self.traceback.frames: + content = await sdk.ide.readFile(frame.filepath) + range_in_files.append(RangeInFile.from_entire_file(frame.filepath, content)) + + await sdk.run_step( + DefaultModelEditCodeStep(range_in_files=range_in_files, user_input=prompt) + ) + return None + + +class EmptyStep(Step): + hide: bool = True + + async def describe(self, models: Models) -> Coroutine[str, None, None]: + return "" + + async def run(self, sdk: ContinueSDK) -> Coroutine[Observation, None, None]: + pass diff --git a/server/continuedev/plugins/steps/on_traceback.py b/server/continuedev/plugins/steps/on_traceback.py new file mode 100644 index 00000000..b72ce809 --- /dev/null +++ b/server/continuedev/plugins/steps/on_traceback.py @@ -0,0 +1,206 @@ +import os +from textwrap import dedent +from typing import Dict, List, Optional, Tuple + +from ...core.main import ChatMessage, ContinueCustomException, Step +from ...core.sdk import ContinueSDK +from ...core.steps import UserInputStep +from ...libs.util.filter_files import should_filter_path +from ...libs.util.traceback.traceback_parsers import ( + get_javascript_traceback, + get_python_traceback, + parse_python_traceback, +) +from ...models.filesystem import RangeInFile +from ...models.main import Range, Traceback, TracebackFrame +from .chat import SimpleChatStep + + +def extract_traceback_str(output: str) -> str: + tb = output.strip() + for tb_parser in [get_python_traceback, get_javascript_traceback]: + if parsed_tb := tb_parser(tb): + return parsed_tb + + +class DefaultOnTracebackStep(Step): + output: str + name: str = "Help With Traceback" + hide: bool = True + + async def find_relevant_files(self, sdk: ContinueSDK): + # Add context for any files in the traceback that are in the workspace + for line in self.output.split("\n"): + segs = line.split(" ") + for seg in segs: + if ( + seg.startswith(os.path.sep) + and os.path.exists(seg) # TODO: Use sdk.ide.fileExists + and os.path.commonprefix([seg, sdk.ide.workspace_directory]) + == sdk.ide.workspace_directory + ): + file_contents = await sdk.ide.readFile(seg) + self.chat_context.append( + ChatMessage( + role="user", + content=f"The contents of {seg}:\n```\n{file_contents}\n```", + summary="", + ) + ) + # TODO: The ideal is that these are added as context items, so then the user can see them + # And this function is where you can get arbitrarily fancy about adding context + + async def run(self, sdk: ContinueSDK): + if self.output.strip() == "": + raise ContinueCustomException( + title="No terminal open", + message="You must have a terminal open in order to automatically debug with Continue.", + ) + + if get_python_traceback(self.output) is not None and sdk.lsp is not None: + await sdk.run_step(SolvePythonTracebackStep(output=self.output)) + return + + tb = extract_traceback_str(self.output) or self.output[-8000:] + + await sdk.run_step( + UserInputStep( + user_input=f"""I got the following error, can you please help explain how to fix it?\n\n{tb}""", + ) + ) + await sdk.run_step(SimpleChatStep(name="Help With Traceback")) + + +def filter_frames(frames: List[TracebackFrame]) -> List[TracebackFrame]: + """Filter out frames that are not relevant to the user's code.""" + return list(filter(lambda x: should_filter_path(x.filepath), frames)) + + +def find_external_call( + frames: List[TracebackFrame], +) -> Optional[Tuple[TracebackFrame, TracebackFrame]]: + """Moving up from the bottom of the stack, if the frames are not user code, then find the last frame before it becomes user code.""" + if not should_filter_path(frames[-1].filepath): + # No external call, error comes directly from user code + return None + + for i in range(len(frames) - 2, -1, -1): + if not should_filter_path(frames[i].filepath): + return frames[i], frames[i + 1] + + +def get_func_source_for_frame(frame: Dict) -> str: + """Get the source for the function called in the frame.""" + pass + + +async def fetch_docs_for_external_call(external_call: Dict, next_frame: Dict) -> str: + """Fetch docs for the external call.""" + pass + + +class SolvePythonTracebackStep(Step): + output: str + name: str = "Solve Traceback" + hide: bool = True + + async def external_call_prompt( + self, sdk: ContinueSDK, external_call: Tuple[Dict, Dict], tb_string: str + ) -> str: + external_call, next_frame = external_call + source_line = external_call["source_line"] + external_func_source = get_func_source_for_frame(next_frame) + docs = await fetch_docs_for_external_call(external_call, next_frame) + + prompt = dedent( + f"""\ + I got the following error: + + {tb_string} + + I tried to call an external library like this: + + ```python + {source_line} + ``` + + This is the definition of the function I tried to call: + + ```python + {external_func_source} + ``` + + Here's the documentation for the external library I tried to call: + + {docs} + + Explain how to fix the error. + """ + ) + + return prompt + + async def normal_traceback_prompt( + self, sdk: ContinueSDK, tb: Traceback, tb_string: str + ) -> str: + function_bodies = await get_functions_from_traceback(tb, sdk) + + prompt = ( + "Here are the functions from the traceback (most recent call last):\n\n" + ) + for i, function_body in enumerate(function_bodies): + prompt += f'File "{tb.frames[i].filepath}", line {tb.frames[i].lineno}, in {tb.frames[i].function}\n\n```python\n{function_body or tb.frames[i].code}\n```\n\n' + + prompt += ( + "Here is the traceback:\n\n```\n" + + tb_string + + "\n```\n\nExplain how to fix the error." + ) + + return prompt + + async def run(self, sdk: ContinueSDK): + tb_string = get_python_traceback(self.output) + tb = parse_python_traceback(tb_string) + + if external_call := find_external_call(tb.frames): + prompt = await self.external_call_prompt(sdk, external_call, tb_string) + else: + prompt = await self.normal_traceback_prompt(sdk, tb, tb_string) + + await sdk.run_step( + UserInputStep( + user_input=prompt, + ) + ) + await sdk.run_step(SimpleChatStep(name="Help With Traceback")) + + +async def get_function_body(frame: TracebackFrame, sdk: ContinueSDK) -> Optional[str]: + """Get the function body from the traceback frame.""" + if sdk.lsp is None: + return None + + document_symbols = await sdk.lsp.document_symbol(frame.filepath) + for symbol in document_symbols: + if symbol.name == frame.function: + r = symbol.location.range + return await sdk.ide.readRangeInFile( + RangeInFile( + filepath=frame.filepath, + range=Range.from_shorthand( + r.start.line, r.start.character, r.end.line, r.end.character + ), + ) + ) + return None + + +async def get_functions_from_traceback(tb: Traceback, sdk: ContinueSDK) -> List[str]: + """Get the function bodies from the traceback.""" + function_bodies = [] + for frame in tb.frames: + if frame.function: + function_bodies.append(await get_function_body(frame, sdk)) + + return function_bodies diff --git a/server/continuedev/plugins/steps/open_config.py b/server/continuedev/plugins/steps/open_config.py new file mode 100644 index 00000000..c57939f8 --- /dev/null +++ b/server/continuedev/plugins/steps/open_config.py @@ -0,0 +1,17 @@ +from textwrap import dedent + +from ...core.main import Step +from ...core.sdk import ContinueSDK +from ...libs.util.paths import getConfigFilePath + + +class OpenConfigStep(Step): + name: str = "Open config" + + async def describe(self, models): + return dedent( + 'Read [the docs](https://continue.dev/docs/customization/overview) to learn more about how you can customize Continue using `"config.py"`.' + ) + + async def run(self, sdk: ContinueSDK): + await sdk.ide.setFileOpen(getConfigFilePath()) diff --git a/server/continuedev/plugins/steps/react.py b/server/continuedev/plugins/steps/react.py new file mode 100644 index 00000000..1b9bc265 --- /dev/null +++ b/server/continuedev/plugins/steps/react.py @@ -0,0 +1,44 @@ +from textwrap import dedent +from typing import List, Tuple, Union + +from ...core.main import Step +from ...core.sdk import ContinueSDK + + +class NLDecisionStep(Step): + user_input: str + default_step: Union[Step, None] = None + steps: List[Tuple[Step, str]] + + hide: bool = False + name: str = "Deciding what to do next" + + async def run(self, sdk: ContinueSDK): + step_descriptions = "\n".join( + [f"- {step[0].name}: {step[1]}" for step in self.steps] + ) + prompt = dedent( + f"""\ + The following steps are available, in the format "- [step name]: [step description]": + {step_descriptions} + + The user gave the following input: + + {self.user_input} + + Select the step which should be taken next to satisfy the user input. Say only the name of the selected step. You must choose one:""" + ) + + resp = (await sdk.models.summarize.complete(prompt)).lower() + + step_to_run = None + for step in self.steps: + if step[0].name.lower() in resp: + step_to_run = step[0] + + step_to_run = step_to_run or self.default_step or self.steps[0] + + self.hide = True + await sdk.update_ui() + + await sdk.run_step(step_to_run) diff --git a/server/continuedev/plugins/steps/refactor.py b/server/continuedev/plugins/steps/refactor.py new file mode 100644 index 00000000..56e9e09e --- /dev/null +++ b/server/continuedev/plugins/steps/refactor.py @@ -0,0 +1,136 @@ +import asyncio +from typing import List, Optional + +from ripgrepy import Ripgrepy + +from ...core.main import Step +from ...core.models import Models +from ...core.sdk import ContinueSDK +from ...libs.llm.prompts.edit import simplified_edit_prompt +from ...libs.util.ripgrep import get_rg_path +from ...libs.util.strings import remove_quotes_and_escapes, strip_code_block +from ...libs.util.templating import render_prompt_template +from ...models.filesystem import RangeInFile +from ...models.filesystem_edit import FileEdit +from ...models.main import PositionInFile, Range + + +class RefactorReferencesStep(Step): + name: str = "Refactor references of a symbol" + user_input: str + symbol_location: PositionInFile + + async def describe(self, models: Models): + return f"Renamed all instances of `{self.function_name}` to `{self.new_function_name}` in `{self.filepath}`" + + async def run(self, sdk: ContinueSDK): + while sdk.lsp is None or not sdk.lsp.ready: + await asyncio.sleep(0.1) + + references = await sdk.lsp.find_references( + self.symbol_location.position, self.symbol_location.filepath, False + ) + await sdk.run_step( + ParallelEditStep(user_input=self.user_input, range_in_files=references) + ) + + +class RefactorBySearchStep(Step): + name: str = "Refactor by search" + + pattern: str + user_input: str + + rg_path: Optional[str] = None + "Optional path to ripgrep executable" + + def get_range_for_result(self, result) -> RangeInFile: + pass + + async def run(self, sdk: ContinueSDK): + rg = Ripgrepy( + self.pattern, + sdk.ide.workspace_directory, + rg_path=self.rg_path or get_rg_path(), + ) + + results = rg.I().context(2).run() + range_in_files = [self.get_range_for_result(result) for result in results] + + await sdk.run_step( + ParallelEditStep(user_input=self.user_input, range_in_files=range_in_files) + ) + + +class ParallelEditStep(Step): + name: str = "Edit multiple ranges in parallel" + user_input: str + range_in_files: List[RangeInFile] + + hide: bool = True + + async def single_edit(self, sdk: ContinueSDK, range_in_file: RangeInFile): + # TODO: Can use folding info to get a more intuitively shaped range + expanded_range = await sdk.lsp.get_enclosing_folding_range(range_in_file) + if ( + expanded_range is None + or expanded_range.range.start.line != range_in_file.range.start.line + ): + expanded_range = Range.from_shorthand( + range_in_file.range.start.line, 0, range_in_file.range.end.line + 1, 0 + ) + else: + expanded_range = expanded_range.range + + new_rif = RangeInFile( + filepath=range_in_file.filepath, + range=expanded_range, + ) + code_to_edit = await sdk.ide.readRangeInFile(range_in_file=new_rif) + + # code_to_edit, common_whitespace = dedent_and_get_common_whitespace(code_to_edit) + + prompt = render_prompt_template( + simplified_edit_prompt, + history=[], + other_data={ + "code_to_edit": code_to_edit, + "user_input": self.user_input, + }, + ) + print(prompt + "\n\n-------------------\n\n") + + new_code = await sdk.models.edit.complete(prompt=prompt) + new_code = strip_code_block(remove_quotes_and_escapes(new_code)) + "\n" + # new_code = ( + # "\n".join([common_whitespace + line for line in new_code.split("\n")]) + # + "\n" + # ) + + print(new_code + "\n\n-------------------\n\n") + + await sdk.ide.applyFileSystemEdit( + FileEdit( + filepath=range_in_file.filepath, + range=expanded_range, + replacement=new_code, + ) + ) + + async def edit_file(self, sdk: ContinueSDK, filepath: str): + ranges_in_file = [ + range_in_file + for range_in_file in self.range_in_files + if range_in_file.filepath == filepath + ] + # Sort in reverse order so that we don't mess up the ranges + ranges_in_file.sort(key=lambda x: x.range.start.line, reverse=True) + for i in range(len(ranges_in_file)): + await self.single_edit(sdk=sdk, range_in_file=ranges_in_file[i]) + + async def run(self, sdk: ContinueSDK): + tasks = [] + for filepath in set([rif.filepath for rif in self.range_in_files]): + tasks.append(self.edit_file(sdk=sdk, filepath=filepath)) + + await asyncio.gather(*tasks) diff --git a/server/continuedev/plugins/steps/search_directory.py b/server/continuedev/plugins/steps/search_directory.py new file mode 100644 index 00000000..83516719 --- /dev/null +++ b/server/continuedev/plugins/steps/search_directory.py @@ -0,0 +1,84 @@ +import asyncio +import os +import re +from textwrap import dedent +from typing import List, Union + +from ...core.main import Step +from ...core.sdk import ContinueSDK +from ...libs.util.create_async_task import create_async_task +from ...models.filesystem import RangeInFile +from ...models.main import Range + +# Already have some code for this somewhere +IGNORE_DIRS = ["env", "venv", ".venv"] +IGNORE_FILES = [".env"] + + +def find_all_matches_in_dir(pattern: str, dirpath: str) -> List[RangeInFile]: + range_in_files = [] + for root, dirs, files in os.walk(dirpath): + dirname = os.path.basename(root) + if dirname.startswith(".") or dirname in IGNORE_DIRS: + continue # continue! + for file in files: + if file in IGNORE_FILES: + continue # pun intended + with open(os.path.join(root, file), "r") as f: + # Find the index of all occurrences of the pattern in the file. Use re. + file_content = f.read() + results = re.finditer(pattern, file_content) + range_in_files += [ + RangeInFile( + filepath=os.path.join(root, file), + range=Range.from_indices( + file_content, result.start(), result.end() + ), + ) + for result in results + ] + + return range_in_files + + +class WriteRegexPatternStep(Step): + user_request: str + + async def run(self, sdk: ContinueSDK): + # Ask the user for a regex pattern + pattern = await sdk.models.summarize.complete( + dedent( + f"""\ + This is the user request: + + {self.user_request} + + Please write either a regex pattern or just a string that be used with python's re module to find all matches requested by the user. It will be used as `re.findall(<PATTERN_YOU_WILL_WRITE>, file_content)`. Your output should be only the regex or string, nothing else:""" + ) + ) + + return pattern + + +class EditAllMatchesStep(Step): + pattern: str + user_request: str + directory: Union[str, None] = None + + async def run(self, sdk: ContinueSDK): + # Search all files for a given string + range_in_files = find_all_matches_in_dir( + self.pattern, self.directory or await sdk.ide.getWorkspaceDirectory() + ) + + tasks = [ + create_async_task( + sdk.edit_file( + range=range_in_file.range, + filename=range_in_file.filepath, + prompt=self.user_request, + ) + ) + for range_in_file in range_in_files + ] + await asyncio.gather(*tasks) diff --git a/server/continuedev/plugins/steps/setup_model.py b/server/continuedev/plugins/steps/setup_model.py new file mode 100644 index 00000000..87e52f1b --- /dev/null +++ b/server/continuedev/plugins/steps/setup_model.py @@ -0,0 +1,38 @@ +from ...core.main import Step +from ...core.sdk import ContinueSDK +from ...libs.util.paths import getConfigFilePath +from ...models.filesystem import RangeInFile +from ...models.main import Range + +MODEL_CLASS_TO_MESSAGE = { + "OpenAI": "Obtain your OpenAI API key from [here](https://platform.openai.com/account/api-keys) and paste it into the `api_key` field at config.models.default.api_key in `config.py`. Then reload the VS Code window for changes to take effect.", + "OpenAIFreeTrial": "To get started with OpenAI models, obtain your OpenAI API key from [here](https://platform.openai.com/account/api-keys) and paste it into the `api_key` field at config.models.default.api_key in `config.py`. Then reload the VS Code window for changes to take effect.", + "AnthropicLLM": "To get started with Anthropic, you first need to sign up for the beta [here](https://claude.ai/login) to obtain an API key. Once you have the key, paste it into the `api_key` field at config.models.default.api_key in `config.py`. Then reload the VS Code window for changes to take effect.", + "ReplicateLLM": "To get started with Replicate, sign up to obtain an API key [here](https://replicate.ai/), then paste it into the `api_key` field at config.models.default.api_key in `config.py`.", + "Ollama": "To get started with Ollama, download the app from [ollama.ai](https://ollama.ai/). Once it is downloaded, be sure to pull at least one model and use its name in the model field in config.py (e.g. `model='codellama'`).", + "GGML": "GGML models can be run locally using the `llama-cpp-python` library. To learn how to set up a local llama-cpp-python server, read [here](https://github.com/continuedev/ggml-server-example). Once it is started on port 8000, you're all set!", + "TogetherLLM": "To get started using models from Together, first obtain your Together API key from [here](https://together.ai). Paste it into the `api_key` field at config.models.default.api_key in `config.py`. Then, on their models page, press 'start' on the model of your choice and make sure the `model=` parameter in the config file for the `TogetherLLM` class reflects the name of this model. Finally, reload the VS Code window for changes to take effect.", + "LlamaCpp": "To get started with this model, clone the [`llama.cpp` repo](https://github.com/ggerganov/llama.cpp) and follow the instructions to set up the server [here](https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md#build). Any of the parameters described in the README can be passed to the `llama_cpp_args` field in the `LlamaCpp` class in `config.py`.", + "HuggingFaceInferenceAPI": "To get started with the HuggingFace Inference API, first deploy a model and obtain your API key from [here](https://huggingface.co/inference-api). Paste it into the `hf_token` field at config.models.default.hf_token in `config.py`. Finally, reload the VS Code window for changes to take effect.", + "GooglePaLMAPI": "To get started with the Google PaLM API, create an API key in Makersuite [here](https://makersuite.google.com/u/2/app/apikey), then paste it into the `api_key` field at config.models.default.api_key in `config.py`.", +} + + +class SetupModelStep(Step): + model_class: str + name: str = "Setup model in config.py" + + async def run(self, sdk: ContinueSDK): + await sdk.ide.setFileOpen(getConfigFilePath()) + self.description = MODEL_CLASS_TO_MESSAGE.get( + self.model_class, "Please finish setting up this model in `config.py`" + ) + + config_contents = await sdk.ide.readFile(getConfigFilePath()) + start = config_contents.find("default=") + len("default=") + end = config_contents.find("saved=") - 1 + range = Range.from_indices(config_contents, start, end) + range.end.line -= 1 + await sdk.ide.highlightCode( + RangeInFile(filepath=getConfigFilePath(), range=range) + ) diff --git a/server/continuedev/plugins/steps/share_session.py b/server/continuedev/plugins/steps/share_session.py new file mode 100644 index 00000000..1d68dc90 --- /dev/null +++ b/server/continuedev/plugins/steps/share_session.py @@ -0,0 +1,52 @@ +import json +import os +import time +from typing import Optional + +from ...core.main import FullState, Step +from ...core.sdk import ContinueSDK +from ...libs.util.paths import getGlobalFolderPath, getSessionFilePath +from ...server.session_manager import session_manager + + +class ShareSessionStep(Step): + session_id: Optional[str] = None + + async def run(self, sdk: ContinueSDK): + if self.session_id is None: + self.session_id = sdk.ide.session_id + + await session_manager.persist_session(self.session_id) + time.sleep(0.5) + + # Load the session data and format as a markdown file + session_filepath = getSessionFilePath(self.session_id) + with open(session_filepath, "r") as f: + session_state = FullState(**json.load(f)) + + import datetime + + date_created = datetime.datetime.fromtimestamp( + float(session_state.session_info.date_created) + ).strftime("%Y-%m-%d %H:%M:%S") + content = f"This is a session transcript from [Continue](https://continue.dev) on {date_created}.\n\n" + + for node in session_state.history.timeline[:-2]: + if node.step.hide: + continue # ay + + content += f"## {node.step.name}\n" + content += f"{node.step.description}\n\n" + + # Save to a markdown file + save_filepath = os.path.join( + getGlobalFolderPath(), f"{session_state.session_info.title}.md" + ) + + with open(save_filepath, "w") as f: + f.write(content) + + # Open the file + await sdk.ide.setFileOpen(save_filepath) + + self.description = f"The session transcript has been saved to a markdown file at {save_filepath}." diff --git a/server/continuedev/plugins/steps/steps_on_startup.py b/server/continuedev/plugins/steps/steps_on_startup.py new file mode 100644 index 00000000..58d56703 --- /dev/null +++ b/server/continuedev/plugins/steps/steps_on_startup.py @@ -0,0 +1,19 @@ +from ...core.main import Step +from ...core.sdk import ContinueSDK, Models + + +class StepsOnStartupStep(Step): + hide: bool = True + + async def describe(self, models: Models): + return "Running steps on startup" + + async def run(self, sdk: ContinueSDK): + steps_on_startup = sdk.config.steps_on_startup + + for step_type in steps_on_startup: + if isinstance(step_type, Step): + step = step_type + else: + step = step_type() + await sdk.run_step(step) diff --git a/server/continuedev/plugins/steps/welcome.py b/server/continuedev/plugins/steps/welcome.py new file mode 100644 index 00000000..ef1acfc1 --- /dev/null +++ b/server/continuedev/plugins/steps/welcome.py @@ -0,0 +1,40 @@ +import os +from textwrap import dedent + +from ...core.main import Step +from ...core.sdk import ContinueSDK, Models +from ...models.filesystem_edit import AddFile + + +class WelcomeStep(Step): + name: str = "Welcome to Continue!" + hide: bool = True + + async def describe(self, models: Models): + return "Welcome to Continue!" + + async def run(self, sdk: ContinueSDK): + continue_dir = os.path.expanduser("~/.continue") + filepath = os.path.join(continue_dir, "calculator.py") + if os.path.exists(filepath): + return + if not os.path.exists(continue_dir): + os.mkdir(continue_dir) + + await sdk.ide.applyFileSystemEdit( + AddFile( + filepath=filepath, + content=dedent( + """\ + \"\"\" + Welcome to Continue! To learn how to use it, delete this comment and try to use Continue for the following: + - "Write me a calculator class" + - Ask for a new method (e.g. "exp", "mod", "sqrt") + - Type /comment to write comments for the entire class + - Ask about how the class works, how to write it in another language, etc. + \"\"\"""" + ), + ) + ) + + # await sdk.ide.setFileOpen(filepath=filepath) |