diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 515cf5a866e..efa5bcf3f7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,4 +159,4 @@ The project's documentation is built using Jekyll and hosted on GitHub Pages. To bundle exec jekyll serve ``` -The built documentation will be available in the `aider/website/_site` directory. +The built documentation will be available in the `cecli/website/_site` directory. diff --git a/README.md b/README.md index dcac7b5cba9..8fd7d3ec3c8 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,9 @@ The current priorities are to improve core capabilities and user experience of t 7. **Sub Agents** * [ ] Add `/fork` and `/rejoin` commands to manually manage parts of the conversation history - * [ ] Add an instance-able view of the conversation system so sub agents get their own context and workspaces - * [ ] Modify coder classes to have discrete identifiers for themselves/management utilities for them to have their own slices of the world - * [ ] Refactor global files like todo lists to live inside instance folders to avoid state conflicts + * [x] Add an instance-able view of the conversation system so sub agents get their own context and workspaces + * [x] Modify coder classes to have discrete identifiers for themselves/management utilities for them to have their own slices of the world + * [x] Refactor global files like todo lists to live inside instance folders to avoid state conflicts * [ ] Add a `spawn` tool that launches a sub agent as a background command that the parent model waits for to finish * [ ] Add visibility into active sub agent calls in TUI diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 60788ee71a6..c2ddaafc29d 100755 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -990,7 +990,7 @@ async def run_test_real( from cecli import models from cecli.coders import Coder - from cecli.helpers.conversation import ConversationFiles, ConversationManager + from cecli.helpers.conversation import ConversationService from cecli.io import InputOutput from cecli.main import SwitchCoderSignal @@ -1211,13 +1211,12 @@ async def run_test_real( dur = 0 test_outcomes = [] - ConversationManager.initialize( - coder, + ConversationService.get_manager(coder).initialize( reset=True, reformat=True, ) - ConversationFiles.reset() + ConversationService.get_files(coder).reset() for i in range(tries): start = time.time() diff --git a/cecli/__init__.py b/cecli/__init__.py index 123f22388b9..531b06e4f23 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.97.4.dev" +__version__ = "0.98.0.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index 6d26d53bd13..5d466b41275 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -14,7 +14,7 @@ MarkdownHelpFormatter, YamlHelpFormatter, ) -from cecli.deprecated_args import add_deprecated_model_args +from cecli.deprecated_args import add_deprecated_mcp_args, add_deprecated_model_args from .dump import dump # noqa: F401 @@ -364,10 +364,11 @@ def get_parser(default_config_files, git_root): default=None, ) group.add_argument( - "--mcp-servers-file", - metavar="MCP_CONFIG_FILE", - help="Specify a file path with MCP server configurations", - default=None, + "--mcp-servers-files", + metavar="MCP_CONFIG_FILES", + help="Specify a file path with MCP server configurations (can be specified multiple times)", + action="append", + default=[], ) group.add_argument( "--mcp-transport", @@ -961,6 +962,12 @@ def get_parser(default_config_files, git_root): metavar="FILE", help="specify a read-only file (can be used multiple times, glob patterns supported)", ).complete = shtab.FILE + group.add_argument( + "--rules", + action="append", + metavar="FILE", + help="specify a rules file (can be used multiple times, glob patterns supported)", + ).complete = shtab.FILE group.add_argument( "--vim", action="store_true", @@ -1089,6 +1096,9 @@ def get_parser(default_config_files, git_root): # Add deprecated model shortcut arguments add_deprecated_model_args(parser, group) + group = parser.add_argument_group("Deprecated agent settings") + add_deprecated_mcp_args(parser, group) + return parser diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index f523adbe4e1..aa764bff223 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -15,11 +15,7 @@ from cecli.change_tracker import ChangeTracker from cecli.helpers import nested from cecli.helpers.background_commands import BackgroundCommandManager -from cecli.helpers.conversation import ConversationChunks - -# All conversation functions are now available via ConversationChunks class -from cecli.helpers.conversation.manager import ConversationManager -from cecli.helpers.conversation.tags import MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag from cecli.helpers.similarity import ( cosine_similarity, create_bigram_vector, @@ -42,6 +38,7 @@ class AgentCoder(Coder): prompt_format = "agent" context_management_enabled = True hashlines = True + stop_on_empty = False def __init__(self, *args, **kwargs): self.recently_removed = {} @@ -49,7 +46,7 @@ def __init__(self, *args, **kwargs): self.tool_usage_retries = 20 self.last_round_tools = [] self.tool_call_vectors = [] - self.tool_similarity_threshold = 0.90 + self.tool_similarity_threshold = 0.95 self.max_tool_vector_history = 20 self.read_tools = { "command", @@ -60,17 +57,17 @@ def __init__(self, *args, **kwargs): "viewfileswithsymbol", "grep", "listchanges", - "shownumberedcontext", + "showcontext", "thinking", "updatetodolist", } self.write_tools = { "deletetext", - "indenttext", "inserttext", "replacetext", "undochange", } + self.edit_allowed = False self.max_tool_calls = 10000 self.large_file_token_threshold = 8192 self.skills_manager = None @@ -243,10 +240,15 @@ async def initialize_mcp_tools(self): async def _execute_local_tool_calls(self, tool_calls_list): tool_responses = [] + used_write_tool = False + for tool_call in tool_calls_list: tool_name = tool_call.function.name result_message = "" try: + if tool_name.lower() in self.write_tools: + used_write_tool = True + args_string = tool_call.function.arguments.strip() parsed_args_list = [] @@ -268,14 +270,14 @@ async def _execute_local_tool_calls(self, tool_calls_list): except json.JSONDecodeError as e: self.model_kwargs = {} self.io.tool_warning( - f"Could not parse JSON chunk for tool {tool_name}: {chunk}" + f"Malformed JSON arguments in tool {tool_name}: {chunk}" ) tool_responses.append( { "role": "tool", "tool_call_id": tool_call.id, "content": ( - f"Could not parse JSON chunk for tool {tool_name}: {str(e)}" + f"Malformed JSON arguments in tool {tool_name}: {str(e)}" ), } ) @@ -335,6 +337,31 @@ async def _execute_local_tool_calls(self, tool_calls_list): tool_responses.append( {"role": "tool", "tool_call_id": tool_call.id, "content": result_message} ) + + if self.auto_lint and used_write_tool and not self.edit_allowed: + edited = list(self.files_edited_by_tools) + lint_errors = self.lint_edited(edited, show_output=False) + self.lint_outcome = not lint_errors + + if lint_errors: + lint_errors = lint_errors.replace( + "# Fix any linting errors below, if possible.", + "# Fix any linting errors below, if possible and then continue with your task.", + 1, + ) + ConversationService.get_manager(self).add_message( + message_dict=dict(role="user", content=lint_errors), + tag=MessageTag.CUR, + hash_key=("lint_errors", "agent"), + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, + force=True, + ) + else: + ConversationService.get_manager(self).remove_message_by_hash_key( + ("lint_errors", "agent") + ) + return tool_responses async def _execute_mcp_tool(self, server, tool_name, params): @@ -412,7 +439,7 @@ def _calculate_context_block_tokens(self, force=False): if block_type in self.allowed_context_blocks: block_content = self._generate_context_block(block_type) if block_content: - self.context_block_tokens[block_type] = self.main_model.token_count( + self.context_block_tokens[block_type] = self.get_active_model().token_count( block_content ) self.tokens_calculated = True @@ -538,37 +565,37 @@ def format_chat_chunks(self): # Choose appropriate fence based on file content self.choose_fence() - ConversationChunks.initialize_conversation_system(self) - # Decrement mark_for_delete values before adding new messages - ConversationManager.decrement_mark_for_delete() + ConversationService.get_chunks(self).initialize_conversation_system() # Clean up ConversationFiles and remove corresponding messages - ConversationChunks.cleanup_files(self) + ConversationService.get_chunks(self).cleanup_files() # Add reminder message with list of readonly and editable files - ConversationChunks.add_file_list_reminder(self) + ConversationService.get_chunks(self).add_file_list_reminder() # Add system messages (including examples and reminder) - ConversationChunks.add_system_messages(self) + ConversationService.get_chunks(self).add_system_messages() # Add static context blocks (priority 50 - between SYSTEM and EXAMPLES) - ConversationChunks.add_static_context_blocks(self) + ConversationService.get_chunks(self).add_static_context_blocks() + + # Add rules messages + ConversationService.get_chunks(self).add_rules_messages() # Handle file messages using conversation module helper methods # These methods will add messages to ConversationManager - ConversationChunks.add_repo_map_messages(self) + ConversationService.get_chunks(self).add_repo_map_messages() # Add pre-message context blocks (priority 125 - between REPO and READONLY_FILES) - ConversationChunks.add_pre_message_context_blocks(self) + ConversationService.get_chunks(self).add_pre_message_context_blocks() - ConversationChunks.add_readonly_files_messages(self) - ConversationChunks.add_chat_files_messages(self) - # ConversationChunks.add_file_context_messages(self) + ConversationService.get_chunks(self).add_readonly_files_messages() + ConversationService.get_chunks(self).add_chat_files_messages() # Add post-message context blocks (priority 250 - between CUR and REMINDER) - ConversationChunks.add_post_message_context_blocks(self) + ConversationService.get_chunks(self).add_post_message_context_blocks() - return ConversationManager.get_messages_dict() + return ConversationService.get_manager(self).get_messages_dict() def get_context_summary(self): """ @@ -584,7 +611,7 @@ def get_context_summary(self): self._calculate_context_block_tokens() result = '\n' result += "## Current Context Overview\n\n" - max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 + max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0 if max_input_tokens: result += f"Model context limit: {max_input_tokens:,} tokens\n\n" total_file_tokens = 0 @@ -598,7 +625,7 @@ def get_context_summary(self): rel_fname = self.get_rel_fname(fname) content = self.io.read_text(fname) if content is not None: - tokens = self.main_model.token_count(content) + tokens = self.get_active_model().token_count(content) total_file_tokens += tokens editable_tokens += tokens size_indicator = ( @@ -622,7 +649,7 @@ def get_context_summary(self): rel_fname = self.get_rel_fname(fname) content = self.io.read_text(fname) if content is not None: - tokens = self.main_model.token_count(content) + tokens = self.get_active_model().token_count(content) total_file_tokens += tokens readonly_tokens += tokens size_indicator = ( @@ -726,6 +753,12 @@ async def process_tool_calls(self, tool_call_response): # Ensure we call base implementation to trigger execution of all tools (native + extracted) return await super().process_tool_calls(tool_call_response) + def get_active_model(self): + if self.main_model.agent_model: + return self.main_model.agent_model + + return self.main_model + async def reply_completed(self): """Process the completed response from the LLM. @@ -797,7 +830,7 @@ async def reply_completed(self): if tool_calls_found and self.num_reflections < self.max_reflections: self.tool_call_count = 0 self.files_added_in_exploration = set() - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) + cur_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) original_question = "Please continue your exploration and provide a final answer." if cur_messages: for msg in reversed(cur_messages): @@ -832,38 +865,6 @@ async def hot_reload(self): if self.hot_reload_enabled: self.skills_manager.hot_reload() - async def _execute_tool_with_registry(self, norm_tool_name, params): - """ - Execute a tool using the tool registry. - - Args: - norm_tool_name: Normalized tool name (lowercase) - params: Dictionary of parameters - - Returns: - str: Result message - """ - if norm_tool_name in ToolRegistry.get_registered_tools(): - tool_module = ToolRegistry.get_tool(norm_tool_name) - try: - result = tool_module.process_response(self, params) - if asyncio.iscoroutine(result): - result = await result - return result - except Exception as e: - self.io.tool_error(f"""Error during {norm_tool_name} execution: {e} -{traceback.format_exc()}""") - return f"Error executing {norm_tool_name}: {str(e)}" - if self.mcp_tools: - for server_name, server_tools in self.mcp_tools: - if any(t.get("function", {}).get("name") == norm_tool_name for t in server_tools): - server = self.mcp_manager.get_server(server_name) - if server: - return await self._execute_mcp_tool(server, norm_tool_name, params) - else: - return f"Error: Could not find server instance for {server_name}" - return f"Error: Unknown tool name '{norm_tool_name}'" - def _get_repetitive_tools(self): """ Identifies repetitive tool usage patterns from rounds of tool calls. @@ -979,14 +980,23 @@ def _generate_tool_context(self, repetitive_tools): for i, tool in enumerate(recent_history, 1): context_parts.append(f"{i}. {tool}") + if not self.edit_allowed: + context_parts.append("\n\n") + context_parts.append("## File Editing Tools Disabled") + context_parts.append( + "File editing tools are currently disabled.Use `ShowContext` to determine the" + " current hashline prefixes needed to perform an edit and activate them when you" + " are ready to edit a file." + ) + context_parts.append("\n\n") if repetitive_tools: if not self.model_kwargs: self.model_kwargs = { "temperature": ( 1 - if isinstance(self.main_model.use_temperature, bool) - else float(self.main_model.use_temperature) + if isinstance(self.get_active_model().use_temperature, bool) + else float(self.get_active_model().use_temperature) ) + 0.1, "frequency_penalty": 0.2, "presence_penalty": 0.1, @@ -1002,13 +1012,14 @@ def _generate_tool_context(self, repetitive_tools): self.model_kwargs["temperature"] = min( ( 1 - if isinstance(self.main_model.use_temperature, bool) - else float(self.main_model.use_temperature) + if isinstance(self.get_active_model().use_temperature, bool) + else float(self.get_active_model().use_temperature) ), max(temperature - 0.15, 1), ) self.model_kwargs["frequency_penalty"] = min(0, max(freq_penalty - 0.15, 0)) + self.model_kwargs["temperature"] = min(self.model_kwargs["temperature"], 1) # One twentieth of the time, just straight reset the randomness if random.random() < 0.05: self.model_kwargs = {} @@ -1018,9 +1029,9 @@ def _generate_tool_context(self, repetitive_tools): self._last_repetitive_warning_severity += 1 repetition_warning = f""" -## Repetition Detected: Strategy Adjustment Required +## Repetition Detected You have been using the following tools repetitively: {', '.join([f'`{t}`' for t in repetitive_tools])}. -**Constraint:** Do not repeat the same parameters for these tools in your next turns. Try something different. +Do not repeat the same parameters for these tools in your next turns. Prioritize editing. """ if self._last_repetitive_warning_severity > 5: @@ -1069,13 +1080,11 @@ def _generate_tool_context(self, repetitive_tools): ) repetition_warning += f""" -### CRITICAL: Execution Loop Detected -You are currently "spinning gears". To break the exploration loop, you must: -1. **Analyze**: Use the `Thinking` tool exactly once to summarize what you have found so far and why you were stuck. -2. **Pivot**: Abandon or modify your current exploration strategy. Try focusing on different files or running tests. -3. **Reframe**: To ensure your logic reset, include a 2-sentence story about {animal} {verb} {fruit} in your thoughts. - -Prioritize editing or verification over further exploration. +## CRITICAL: Execution Loop Detected +You may be stuck in a cycle. To break the exploration loop and continue making progress, please do the following: +1. **Analyze**: Summarize your findings. Describe how you can stop repeating yourself and make progress. +2. **Reframe**: To help with creativity, include a 2-sentence story about {animal} {verb} {fruit} in your thoughts. +3. **Pivot**: Modify your current exploration strategy. Try alternative methods. Prioritize editing. """ context_parts.append(repetition_warning) @@ -1097,8 +1106,8 @@ def _generate_write_context(self): context_parts = [ '', "A file was just edited.", - "Make sure that something of value was done.", - "Do not just leave placeholder or sub content.", + "Review the diff to make sure that something of value was done.", + "Do not just leave placeholder content or partial implementations.", "", ] return "\n".join(context_parts) @@ -1132,7 +1141,7 @@ def _add_file_to_context(self, file_path, explicit=False): if content is None: return f"Error reading file: {file_path}" if self.context_management_enabled: - file_tokens = self.main_model.token_count(content) + file_tokens = self.get_active_model().token_count(content) if file_tokens > self.large_file_token_threshold: self.io.tool_output( f"⚠️ '{file_path}' is very large ({file_tokens} tokens). Use" @@ -1165,13 +1174,18 @@ async def preproc_user_input(self, inp): This clearly delineates user input from other sections in the context window. """ inp = await super().preproc_user_input(inp) - if inp and not inp.startswith(''): - inp = f'\n{inp}\n' + inp = self.wrap_user_input(inp) self.agent_finished = False self.turn_count = 0 return inp + def wrap_user_input(self, inp): + if inp and not inp.startswith(''): + inp = f'\n{inp}\n' + + return inp + def get_directory_structure(self): """ Generate a structured directory listing of the project file structure. diff --git a/cecli/coders/architect_coder.py b/cecli/coders/architect_coder.py index ed253d80eef..9eb6e53d68e 100644 --- a/cecli/coders/architect_coder.py +++ b/cecli/coders/architect_coder.py @@ -1,7 +1,7 @@ import asyncio from ..commands import SwitchCoderSignal -from ..helpers.conversation import ConversationManager +from ..helpers.conversation import ConversationService from .ask_coder import AskCoder from .base_coder import Coder @@ -61,8 +61,9 @@ async def reply_completed(self): editor_coder = await Coder.create(**new_kwargs) # Re-initialize ConversationManager with editor coder - ConversationManager.initialize(editor_coder, reset=True, reformat=True, preserve_tags=True) - + ConversationService.get_manager(editor_coder).initialize( + reset=True, reformat=True, preserve_tags=True + ) if self.verbose: editor_coder.show_announcements() @@ -78,8 +79,7 @@ async def reply_completed(self): await editor_coder.generate(user_message=content, preproc=False) # Clear manager and restore original state - ConversationManager.initialize( - original_coder or self, + ConversationService.get_manager(original_coder or self).initialize( reset=True, reformat=True, preserve_tags=True, @@ -90,8 +90,7 @@ async def reply_completed(self): except Exception as e: self.io.tool_error(e) # Restore original state on error - ConversationManager.initialize( - original_coder or self, + ConversationService.get_manager(original_coder or self).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index c77c0e6c202..766208ee344 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -41,11 +41,7 @@ from cecli.commands import Commands, SwitchCoderSignal from cecli.exceptions import LiteLLMExceptions from cecli.helpers import command_parser, coroutines, nested, responses -from cecli.helpers.conversation import ( - ConversationChunks, - ConversationManager, - MessageTag, -) +from cecli.helpers.conversation import ConversationService, MessageTag from cecli.helpers.profiler import TokenProfiler from cecli.history import ChatSummary from cecli.hooks import HookIntegration @@ -110,6 +106,7 @@ class Coder: abs_fnames = None abs_read_only_fnames = None abs_read_only_stubs_fnames = None + abs_rules_fnames = None repo = None last_coder_commit_hash = None coder_edited_files = None @@ -158,6 +155,7 @@ class Coder: uuid = "" model_kwargs = {} cost_multiplier = 1 + stop_on_empty = True # Task coordination state variables input_running = False @@ -218,6 +216,7 @@ async def create( read_only_stubs_fnames=list( from_coder.abs_read_only_stubs_fnames ), # Copy read-only stubs + rules_fnames=list(from_coder.abs_rules_fnames), # Copy read-only stubs done_messages=[], cur_messages=[], coder_commit_hashes=from_coder.coder_commit_hashes, @@ -283,6 +282,7 @@ def __init__( add_gitignore_files=False, read_only_fnames=None, read_only_stubs_fnames=None, + rules_fnames=None, show_diffs=False, auto_commits=True, dirty_commits=True, @@ -388,19 +388,24 @@ def __init__( self.message_tokens_received = 0 self.token_profiler = TokenProfiler( - enable_printing=getattr(args, "show_speed", False) if args else False + enable_printing=nested.getter(self.args, "show_speed", False) ) self.verbose = verbose self.abs_fnames = set() self.abs_read_only_fnames = set() self.add_gitignore_files = add_gitignore_files self.abs_read_only_stubs_fnames = set() + self.abs_rules_fnames = set() self.io = io self.io.coder = weakref.ref(self) - self.manual_copy_paste = getattr(main_model, "copy_paste_transport", "api") == "clipboard" - self.copy_paste_mode = getattr(main_model, "copy_paste_mode", False) or auto_copy_context + self.manual_copy_paste = ( + nested.getter(main_model, "copy_paste_transport", "api") == "clipboard" + ) + self.copy_paste_mode = ( + nested.getter(main_model, "copy_paste_mode", False) or auto_copy_context + ) self.shell_commands = [] self.partial_response_tool_calls = [] @@ -419,18 +424,20 @@ def __init__( # Set the reasoning tag name based on model settings or default self.reasoning_tag_name = ( - self.main_model.reasoning_tag if self.main_model.reasoning_tag else REASONING_TAG + self.get_active_model().reasoning_tag + if self.get_active_model().reasoning_tag + else REASONING_TAG ) self.stream = stream and main_model.streaming and not self.manual_copy_paste - if cache_prompts and self.main_model.cache_control: + if cache_prompts and self.get_active_model().cache_control: self.add_cache_headers = True self.show_diffs = show_diffs # Initialize conversation system if enabled - ConversationChunks.initialize_conversation_system(self) + ConversationService.get_chunks(self).initialize_conversation_system() self.commands = commands or Commands(self.io, self, args=args) self.commands.coder = self @@ -504,21 +511,30 @@ def __init__( f"Error: Read-only (stub) file {fname} does not exist. Skipping." ) + if rules_fnames: + self.abs_rules_fnames = set() + for fname in rules_fnames: + abs_fname = self.abs_root_path(fname) + if os.path.exists(abs_fname): + self.abs_rules_fnames.add(abs_fname) + else: + self.io.tool_warning(f"Error: Rules file {fname} does not exist. Skipping.") + if map_tokens is None: use_repo_map = main_model.use_repo_map map_tokens = 1024 else: use_repo_map = map_tokens > 0 - max_inp_tokens = self.main_model.info.get("max_input_tokens") or 0 + max_inp_tokens = self.get_active_model().info.get("max_input_tokens") or 0 - has_map_prompt = hasattr(self, "gpt_prompts") and self.gpt_prompts.repo_content_prefix + has_map_prompt = nested.getter(self, "gpt_prompts.repo_content_prefix") if use_repo_map and self.repo and has_map_prompt: self.repo_map = RepoMap( map_tokens, self.map_cache_dir, - self.main_model, + self.get_active_model(), io, self.gpt_prompts.repo_content_prefix, self.verbose, @@ -532,8 +548,8 @@ def __init__( ) self.summarizer = summarizer or ChatSummary( - [self.main_model.weak_model, self.main_model], - self.main_model.max_chat_history_tokens, + [self.get_active_model().weak_model, self.get_active_model()], + self.get_active_model().max_chat_history_tokens, ) self.summarizer_thread = None @@ -631,12 +647,12 @@ def gpt_prompts(self): @property def done_messages(self): """Get DONE messages from ConversationManager.""" - return ConversationManager.get_messages_dict(MessageTag.DONE) + return ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE) @property def cur_messages(self): """Get CUR messages from ConversationManager.""" - return ConversationManager.get_messages_dict(MessageTag.CUR) + return ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) def get_announcements(self): lines = [] @@ -708,7 +724,7 @@ def get_announcements(self): if map_tokens > 0: refresh = self.repo_map.refresh lines.append(f"Repo-map: using {map_tokens} tokens, {refresh} refresh") - max_map_tokens = self.main_model.get_repo_map_tokens() * 2 + max_map_tokens = self.get_active_model().get_repo_map_tokens() * 2 if map_tokens > max_map_tokens: lines.append( f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much" @@ -729,7 +745,7 @@ def get_announcements(self): rel_fname = self.get_rel_fname(fname) lines.append(f"Added {rel_fname} to the chat (read-only stub).") - if ConversationManager.get_messages_dict(MessageTag.DONE): + if ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE): lines.append("Restored previous conversation history.") if self.io.multiline_mode and not self.args.tui: @@ -890,7 +906,7 @@ def get_files_content(self, fnames=None): # Apply context management if enabled for large files if self.context_management_enabled: # Calculate tokens for this file - file_tokens = self.main_model.token_count(content) + file_tokens = self.get_active_model().token_count(content) if file_tokens > self.large_file_token_threshold: # Instead of truncating, show the file's definitions/structure @@ -958,7 +974,7 @@ def get_read_only_files_content(self): # Apply context management if enabled for large files (same as get_files_content) if self.context_management_enabled: # Calculate tokens for this file - file_tokens = self.main_model.token_count(content) + file_tokens = self.get_active_model().token_count(content) if file_tokens > self.large_file_token_threshold: # Instead of truncating, show the file's definitions/structure @@ -1004,7 +1020,7 @@ def get_read_only_files_content(self): def get_cur_message_text(self): text = "" # Get CUR messages from ConversationManager - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) + cur_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) for msg in cur_messages: # For some models the content is None if the message # contains tool calls. @@ -1169,13 +1185,15 @@ def _include_in_map(abs_path): return None def get_images_message(self, fnames): - supports_images = self.main_model.info.get("supports_vision") - supports_pdfs = self.main_model.info.get("supports_pdf_input") or self.main_model.info.get( - "max_pdf_size_mb" - ) + supports_images = self.get_active_model().info.get("supports_vision") + supports_pdfs = self.get_active_model().info.get( + "supports_pdf_input" + ) or self.get_active_model().info.get("max_pdf_size_mb") # https://github.com/BerriAI/litellm/pull/6928 - supports_pdfs = supports_pdfs or "claude-3-5-sonnet-20241022" in self.main_model.name + supports_pdfs = ( + supports_pdfs or "claude-3-5-sonnet-20241022" in self.get_active_model().name + ) if not (supports_images or supports_pdfs): return [] @@ -1208,9 +1226,7 @@ def get_images_message(self, fnames): if content: # Register image file with ConversationFiles for tracking - from cecli.helpers.conversation.files import ConversationFiles - - ConversationFiles.add_image_file(fname) + ConversationService.get_files(self).add_image_file(fname) messages.append({"role": "user", "content": content, "image_file": fname}) @@ -1524,7 +1540,9 @@ async def get_input(self): all_read_only_fnames = self.abs_read_only_fnames | self.abs_read_only_stubs_fnames all_read_only_files = [self.get_rel_fname(fname) for fname in all_read_only_fnames] all_files = sorted(set(inchat_files + all_read_only_files)) - edit_format = "" if self.edit_format == self.main_model.edit_format else self.edit_format + edit_format = ( + "" if self.edit_format == self.get_active_model().edit_format else self.edit_format + ) return await self.io.get_input( self.root, @@ -1557,6 +1575,9 @@ async def preproc_user_input(self, inp): return inp + def wrap_user_input(self, inp): + return inp + async def run_one(self, user_message, preproc): self.init_before_message() @@ -1579,6 +1600,7 @@ async def run_one(self, user_message, preproc): while True: self.reflected_message = None + self.empty_response = False self.tool_reflection = False if float(self.total_cost) > self.cost_multiplier * ( @@ -1598,23 +1620,29 @@ async def run_one(self, user_message, preproc): await self.hot_reload() - if not self.reflected_message: - await self.auto_save_session(force=True) - break + if not self.empty_response: + if not self.reflected_message: + await self.auto_save_session(force=True) + break - if self.num_reflections >= self.max_reflections: - self.io.tool_warning(f"Only {self.max_reflections} reflections allowed, stopping.") - break + if self.num_reflections >= self.max_reflections: + self.io.tool_warning( + f"Only {self.max_reflections} reflections allowed, stopping." + ) + break - self.num_reflections += 1 + self.num_reflections += 1 - if self.tool_reflection: - self.num_reflections -= 1 + if self.tool_reflection: + self.num_reflections -= 1 - if self.reflected_message is True: - message = None - else: - message = self.reflected_message + if self.reflected_message is True: + message = None + else: + message = self.reflected_message + elif self.stop_on_empty: + await self.auto_save_session(force=True) + break if self.enable_context_compaction: await self.compact_context_if_needed() @@ -1708,11 +1736,9 @@ async def compact_context_if_needed(self, force=False, message=""): # Check if combined messages exceed the token limit, # Get messages from ConversationManager - # Get messages from ConversationManager - done_messages = ConversationManager.get_messages_dict(MessageTag.DONE) - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) - diff_messages = ConversationManager.get_messages_dict(MessageTag.DIFFS) - + done_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE) + cur_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) + diff_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.DIFFS) # Exclude first cur_message since that's the user's initial input done_tokens = self.summarizer.count_tokens(done_messages) cur_tokens = self.summarizer.count_tokens(cur_messages[1:] if len(cur_messages) > 1 else []) @@ -1748,15 +1774,15 @@ async def compact_context_if_needed(self, force=False, message=""): raise ValueError("Summarization returned an empty result.") # Replace old DONE messages with the summary in ConversationManager - ConversationManager.clear_tag(MessageTag.DONE) - ConversationManager.add_message( + ConversationService.get_manager(self).clear_tag(MessageTag.DONE) + ConversationService.get_manager(self).add_message( message_dict={ "role": "user", "content": summary_text, }, tag=MessageTag.DONE, ) - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict={ "role": "assistant", "content": ( @@ -1785,11 +1811,10 @@ async def compact_context_if_needed(self, force=False, message=""): raise ValueError("Summarization of current messages returned an empty result.") # Replace current CUR messages with the summary in ConversationManager - ConversationManager.clear_tag(MessageTag.CUR) + ConversationService.get_manager(self).clear_tag(MessageTag.CUR) - # Keep the first message (user's initial input) if it exists if self.last_user_message: - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict={ "role": "user", "content": self.last_user_message, @@ -1798,7 +1823,7 @@ async def compact_context_if_needed(self, force=False, message=""): ) # Add the summary conversation - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict={ "role": "assistant", "content": "Ok. I am awaiting your summary of our goals to proceed.", @@ -1806,14 +1831,14 @@ async def compact_context_if_needed(self, force=False, message=""): tag=MessageTag.CUR, force=True, ) - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict={ "role": "user", "content": f"Here is a summary of our current goals:\n{cur_summary_text}", }, tag=MessageTag.CUR, ) - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict={ "role": "assistant", "content": ( @@ -1826,18 +1851,34 @@ async def compact_context_if_needed(self, force=False, message=""): force=True, ) + # Find the last assistant messages in the current conversation + latest_messages = [] + + # Search from the end to find the most recent assistant messages + for msg in reversed(cur_messages): + latest_messages.append(msg) + + if msg["role"] == "assistant": + break + + for msg in reversed(latest_messages): + ConversationService.get_manager(self).add_message( + message_dict={ + "role": msg["role"], + "content": msg["content"], + }, + tag=MessageTag.CUR, + ) + self.io.tool_output("...chat history compacted.") self.io.update_spinner(self.io.last_spinner_text) # Clear all diff and file context messages - ConversationManager.clear_tag(MessageTag.DIFFS) - ConversationManager.clear_tag(MessageTag.FILE_CONTEXTS) + ConversationService.get_manager(self).clear_tag(MessageTag.DIFFS) + ConversationService.get_manager(self).clear_tag(MessageTag.FILE_CONTEXTS) # Reset ConversationFiles cache entirely - from cecli.helpers.conversation.files import ConversationFiles - - ConversationFiles.clear_file_cache() - + ConversationService.get_files(self).clear_file_cache() except Exception as e: self.io.tool_warning(f"Context compaction failed: {e}") self.io.tool_warning("Proceeding with full history for now.") @@ -1973,15 +2014,14 @@ def fmt_system_prompt(self, prompt): final_reminders = [] lazy_prompt = "" - if self.main_model.lazy: + if self.get_active_model().lazy: lazy_prompt = self.gpt_prompts.lazy_prompt final_reminders.append(lazy_prompt) overeager_prompt = "" - if self.main_model.overeager: + if self.get_active_model().overeager: overeager_prompt = self.gpt_prompts.overeager_prompt final_reminders.append(overeager_prompt) - user_lang = self.get_user_language() if user_lang: final_reminders.append(f"Reply in {user_lang}.\n") @@ -2037,31 +2077,30 @@ def format_chat_chunks(self): # Choose appropriate fence based on file content self.choose_fence() - ConversationChunks.initialize_conversation_system(self) - - # Decrement mark_for_delete values before adding new messages - ConversationManager.decrement_mark_for_delete() + ConversationService.get_chunks(self).initialize_conversation_system() # Clean up ConversationFiles and remove corresponding messages - ConversationChunks.cleanup_files(self) + ConversationService.get_chunks(self).cleanup_files() # Add reminder message with list of readonly and editable files - ConversationChunks.add_file_list_reminder(self) + ConversationService.get_chunks(self).add_file_list_reminder() # Add system messages (system prompt, examples, reminder) - ConversationChunks.add_system_messages(self) + ConversationService.get_chunks(self).add_system_messages() - # Add repository map messages (they add themselves via add_repo_map_messages) - ConversationChunks.add_repo_map_messages(self) + # Add rules messages + ConversationService.get_chunks(self).add_rules_messages() - # Add read-only file messages (they add themselves via add_readonly_files_messages) - ConversationChunks.add_readonly_files_messages(self) + # Add repository map messages + ConversationService.get_chunks(self).add_repo_map_messages() - # Add chat and edit file messages (they add themselves via add_chat_files_messages) - ConversationChunks.add_chat_files_messages(self) + # Add read-only file messages + ConversationService.get_chunks(self).add_readonly_files_messages() + # Add chat and edit file messages + ConversationService.get_chunks(self).add_chat_files_messages() # Return formatted messages for LLM - return ConversationManager.get_messages_dict() + return ConversationService.get_manager(self).get_messages_dict() def format_messages(self): chunks = self.format_chat_chunks() @@ -2096,13 +2135,13 @@ def warm_cache_worker(): self.warming_pings_left -= 1 self.next_cache_warm = time.time() + delay - kwargs = dict(self.main_model.extra_params) or dict() + kwargs = dict(self.get_active_model().extra_params) or dict() kwargs["max_tokens"] = 1 try: completion = litellm.completion( - model=self.main_model.name, - messages=self.cache_warming_chunks.cacheable_messages(), + model=self.get_active_model().name, + messages=ConversationService.get_manager(self).get_messages_dict(), stream=False, **kwargs, ) @@ -2125,13 +2164,13 @@ def warm_cache_worker(): async def check_tokens(self, messages): """Check if the messages will fit within the model's token limits.""" - input_tokens = self.main_model.token_count(messages) - max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 + input_tokens = self.get_active_model().token_count(messages) + max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0 if max_input_tokens and input_tokens >= max_input_tokens: self.io.tool_error( f"Your estimated chat context of {input_tokens:,} tokens exceeds the" - f" {max_input_tokens:,} token limit for {self.main_model.name}!" + f" {max_input_tokens:,} token limit for {self.get_active_model().name}!" ) self.io.tool_output("To reduce the chat context:") self.io.tool_output("- Use /drop to remove unneeded files from the chat") @@ -2146,22 +2185,30 @@ async def check_tokens(self, messages): return False return True + def get_active_model(self): + return self.main_model + async def send_message(self, inp): # Notify IO that LLM processing is starting self.io.llm_started() if inp: # Make sure current coder actually has control of conversation system - ConversationChunks.initialize_conversation_system(self) + ConversationService.get_chunks(self).initialize_conversation_system() self.format_chat_chunks() # Always add user message to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="user", content=inp), tag=MessageTag.CUR, hash_key=("user_message", inp, str(time.monotonic_ns())), + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) + ConversationService.get_manager(self).decrement_message_markers() + import asyncio + loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, self.format_messages) messages = result @@ -2175,10 +2222,10 @@ async def send_message(self, inp): self.multi_response_content = "" if self.show_pretty(): spinner_text = ( - f"Waiting for {self.main_model.name} • ${self.format_cost(self.total_cost)} session" + f"Waiting for {self.get_active_model().name} •" + f" ${self.format_cost(self.total_cost)} session" ) self.io.start_spinner(spinner_text) - if self.stream: self.mdstream = True else: @@ -2233,7 +2280,7 @@ async def send_message(self, inp): break except FinishReasonLength: # We hit the output limit! - if not self.main_model.info.get("supports_assistant_prefill"): + if not self.get_active_model().info.get("supports_assistant_prefill"): exhausted = True break @@ -2267,24 +2314,24 @@ async def send_message(self, inp): self.io.tool_output() self.show_usage_report() await self.add_assistant_reply_to_cur_messages() - if exhausted: - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) + cur_messages = ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) if cur_messages and cur_messages[-1]["role"] == "user": # Always add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict( role="assistant", content="FinishReasonLength exception: you sent too many tokens", ), tag=MessageTag.CUR, force=True, + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) await self.show_exhausted_error() self.num_exhausted_context_windows += 1 return - if self.partial_response_function_call: args = self.parse_partial_args() if args: @@ -2298,19 +2345,23 @@ async def send_message(self, inp): if interrupted: # Always add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="user", content="^C KeyboardInterrupt"), tag=MessageTag.CUR, force=True, + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) # Always add assistant response to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict( role="assistant", content="I see that you interrupted my previous reply." ), tag=MessageTag.CUR, force=True, + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) return @@ -2339,7 +2390,7 @@ async def send_message(self, inp): tool_call_response, a, b = self.consolidate_chunks() if await self.process_tool_calls(tool_call_response): self.num_tool_calls += 1 - self.reflected_message = True + self.reflected_message = self.reflected_message or True return except Exception as e: self.io.tool_error(f"Error processing tool calls: {str(e)}") @@ -2370,16 +2421,19 @@ async def send_message(self, inp): shared_output = await self.run_shell_commands() if shared_output: - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="user", content=shared_output), tag=MessageTag.CUR, force=True, # Force update existing message + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) - - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="assistant", content="Ok"), tag=MessageTag.CUR, force=True, # Force update existing message + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) if edited and self.auto_test and self.test_cmd: @@ -2455,10 +2509,12 @@ async def process_tool_calls(self, tool_call_response): # Add all tool responses for tool_response in tool_responses: - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=tool_response, tag=MessageTag.CUR, hash_key=(tool_response["tool_call_id"], str(time.monotonic_ns())), + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) return True @@ -2575,7 +2631,7 @@ async def _exec_server_tools(server, tool_calls_list): parsed_args_list.append(json.loads(chunk)) except json.JSONDecodeError: self.io.tool_warning( - "Could not parse JSON chunk for tool" + "Malformed JSON arguments in tool" f" {tool_call.function.name}: {chunk}" ) continue @@ -2739,16 +2795,16 @@ async def hot_reload(self): async def show_exhausted_error(self): output_tokens = 0 if self.partial_response_content: - output_tokens = self.main_model.token_count(self.partial_response_content) - max_output_tokens = self.main_model.info.get("max_output_tokens") or 0 + output_tokens = self.get_active_model().token_count(self.partial_response_content) + max_output_tokens = self.get_active_model().info.get("max_output_tokens") or 0 messages = self.format_messages() if hasattr(messages, "all_messages"): # Old system: messages is a ChatChunks object messages = messages.all_messages() # New system: messages is already a list - input_tokens = self.main_model.token_count(messages) - max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 + input_tokens = self.get_active_model().token_count(messages) + max_input_tokens = self.get_active_model().info.get("max_input_tokens") or 0 total_tokens = input_tokens + output_tokens @@ -2767,7 +2823,7 @@ async def show_exhausted_error(self): tot_err = " -- possibly exhausted context window!" res = ["", ""] - res.append(f"Model {self.main_model.name} has hit a token limit!") + res.append(f"Model {self.get_active_model().name} has hit a token limit!") res.append("Token counts below are approximate.") res.append("") res.append(f"Input tokens: ~{input_tokens:,} of {max_input_tokens:,}{inp_err}") @@ -2779,7 +2835,7 @@ async def show_exhausted_error(self): res.append("To reduce output tokens:") res.append("- Ask for smaller changes in each request.") res.append("- Break your code into smaller source files.") - if "diff" not in self.main_model.edit_format: + if "diff" not in self.get_active_model().edit_format: res.append("- Use a stronger model that can return diffs.") if input_tokens >= max_input_tokens or total_tokens >= max_input_tokens: @@ -2794,7 +2850,7 @@ async def show_exhausted_error(self): self.io.tool_error(res) await self.io.offer_url(urls.token_limits) - def lint_edited(self, fnames): + def lint_edited(self, fnames, show_output=True): res = "" for fname in fnames: if not fname: @@ -2806,7 +2862,7 @@ def lint_edited(self, fnames): res += errors res += "\n" - if res: + if res and show_output: self.io.tool_warning(res) return res @@ -2861,10 +2917,12 @@ async def add_assistant_reply_to_cur_messages(self): self.io.tool_warning("Execution stopped by end message hook") return - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=msg, tag=MessageTag.CUR, hash_key=("assistant_message", str(msg), str(time.monotonic_ns())), + promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) def get_file_mentions(self, content, ignore_current=False): @@ -2962,7 +3020,7 @@ async def send(self, messages, model=None, functions=None, tools=None): self.io.reset_streaming_response() if not model: - model = self.main_model + model = self.get_active_model() self.partial_response_content = "" self.partial_response_reasoning_content = "" @@ -3075,6 +3133,19 @@ async def show_send_output(self, completion): self.io.assistant_output(show_resp, pretty=self.show_pretty()) + if ( + self.edit_format == "agent" + and self.stream + and not show_resp + and nested.getter(self, "_has_empty_reflected") + ): + await asyncio.sleep(4) + self._has_empty_reflected = True + self.reflected_message = True + self.empty_response = True + else: + self._has_empty_reflected = False + if ( hasattr(completion.choices[0], "finish_reason") and completion.choices[0].finish_reason == "length" @@ -3414,8 +3485,8 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None): self.message_tokens_sent += prompt_tokens else: - prompt_tokens = self.main_model.token_count(messages) - completion_tokens = self.main_model.token_count(self.partial_response_content) + prompt_tokens = self.get_active_model().token_count(messages) + completion_tokens = self.get_active_model().token_count(self.partial_response_content) self.message_tokens_sent += prompt_tokens self.message_tokens_received += completion_tokens @@ -3431,7 +3502,7 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None): tokens_report, self.message_tokens_sent, self.message_tokens_received ) - if not self.main_model.info.get("input_cost_per_token"): + if not self.get_active_model().info.get("input_cost_per_token"): self.usage_report = tokens_report return @@ -3475,10 +3546,10 @@ def compute_costs_from_tokens( ): cost = 0 - input_cost_per_token = self.main_model.info.get("input_cost_per_token") or 0 - output_cost_per_token = self.main_model.info.get("output_cost_per_token") or 0 + input_cost_per_token = self.get_active_model().info.get("input_cost_per_token") or 0 + output_cost_per_token = self.get_active_model().info.get("output_cost_per_token") or 0 input_cost_per_token_cache_hit = ( - self.main_model.info.get("input_cost_per_token_cache_hit") or 0 + self.get_active_model().info.get("input_cost_per_token_cache_hit") or 0 ) # deepseek @@ -3526,7 +3597,7 @@ def get_multi_response_content_in_progress(self, final=False): return cur + new def get_file_stub(self, fname): - return RepoMap.get_file_stub(fname, self.io) + return ConversationService.get_files(self).get_file_stub(fname) def get_rel_fname(self, fname): try: @@ -3674,7 +3745,7 @@ def check_added_files(self): if is_image_file(fname): continue content = self.io.read_text(fname) - tokens += self.main_model.token_count(content) + tokens += self.get_active_model().token_count(content) if tokens < warn_number_of_tokens: return @@ -3832,7 +3903,7 @@ async def auto_commit(self, edited, context=None): if not context: context = self.get_context_from_history( - ConversationManager.get_messages_dict(MessageTag.CUR) + ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) ) try: diff --git a/cecli/coders/hashline_coder.py b/cecli/coders/hashline_coder.py index ad457458c2b..b353e073aad 100644 --- a/cecli/coders/hashline_coder.py +++ b/cecli/coders/hashline_coder.py @@ -650,8 +650,13 @@ def find_original_update_blocks(content, fence=DEFAULT_FENCE, valid_fnames=None) # Check if original_text is a hashline JSON block try: # Try to parse as JSON - # parsed = json.loads(original_text_str.strip()) - parsed = extract_base64url_parts(original_text_str.strip()) + try: + parsed = json.loads(original_text_str.strip()) + if not isinstance(parsed, list) or len(parsed) != 3: + parsed = extract_base64url_parts(original_text_str.strip()) + except Exception: + parsed = extract_base64url_parts(original_text_str.strip()) + # Check if it's a list with 3 elements (start_hash, end_hash, operation) if isinstance(parsed, list) and len(parsed) == 3: # Validate the format: all strings @@ -678,7 +683,7 @@ def find_original_update_blocks(content, fence=DEFAULT_FENCE, valid_fnames=None) def extract_base64url_parts(input_string): # Remove any character that is NOT a-z, A-Z, 0-9, -, or _ - clean_str = re.sub(r"[^a-zA-Z0-9\-_]", "", input_string) + clean_str = re.sub(r"[^a-zA-Z0-9\~_@]", "", input_string) return [ clean_str[:4], # First 4 chars diff --git a/cecli/coders/search_replace.py b/cecli/coders/search_replace.py index a51ef94970e..0848a5c012d 100755 --- a/cecli/coders/search_replace.py +++ b/cecli/coders/search_replace.py @@ -449,7 +449,7 @@ def git_cherry_pick_osr_onto_o(texts): search_text, replace_text, original_text = texts with GitTemporaryDirectory() as dname: - repo = git.Repo(dname) + repo = git.Repo(dname, odbt=git.GitCmdObjectDB) fname = Path(dname) / "file.txt" @@ -486,7 +486,7 @@ def git_cherry_pick_sr_onto_so(texts): search_text, replace_text, original_text = texts with GitTemporaryDirectory() as dname: - repo = git.Repo(dname) + repo = git.Repo(dname, odbt=git.GitCmdObjectDB) fname = Path(dname) / "file.txt" diff --git a/cecli/coders/single_wholefile_func_coder.py b/cecli/coders/single_wholefile_func_coder.py index 6690d311b62..ad6e9010612 100644 --- a/cecli/coders/single_wholefile_func_coder.py +++ b/cecli/coders/single_wholefile_func_coder.py @@ -1,5 +1,5 @@ from cecli import diffs -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag from ..dump import dump # noqa: F401 from .base_coder import Coder @@ -41,13 +41,13 @@ def __init__(self, *args, **kwargs): async def add_assistant_reply_to_cur_messages(self, edited): if edited: # Always add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="assistant", content=self.gpt_prompts.redacted_edit_message), tag=MessageTag.CUR, ) else: # Always add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="assistant", content=self.partial_response_content), tag=MessageTag.CUR, ) diff --git a/cecli/coders/wholefile_func_coder.py b/cecli/coders/wholefile_func_coder.py index 2c9668a52dd..1c53188ac33 100644 --- a/cecli/coders/wholefile_func_coder.py +++ b/cecli/coders/wholefile_func_coder.py @@ -1,5 +1,5 @@ from cecli import diffs -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag from ..dump import dump # noqa: F401 from .base_coder import Coder @@ -52,14 +52,13 @@ def __init__(self, *args, **kwargs): async def add_assistant_reply_to_cur_messages(self, edited): if edited: # Always add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(self).add_message( message_dict=dict(role="assistant", content=self.gpt_prompts.redacted_edit_message), tag=MessageTag.CUR, ) else: # Always add to conversation manager - ConversationManager.add_message( - message_dict=dict(role="assistant", content=self.partial_response_content), + ConversationService.get_manager(self).add_message( tag=MessageTag.CUR, ) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 9f214034011..9a3bcc6d08a 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -54,6 +54,7 @@ from .remove_skill import RemoveSkillCommand from .report import ReportCommand from .reset import ResetCommand +from .rules import RulesCommand from .run import RunCommand from .save import SaveCommand from .save_session import SaveSessionCommand @@ -129,6 +130,7 @@ CommandRegistry.register(RemoveSkillCommand) CommandRegistry.register(ReportCommand) CommandRegistry.register(ResetCommand) +CommandRegistry.register(RulesCommand) CommandRegistry.register(RunCommand) CommandRegistry.register(SaveCommand) CommandRegistry.register(SaveSessionCommand) @@ -203,6 +205,7 @@ "RemoveSkillCommand", "ReportCommand", "ResetCommand", + "RulesCommand", "RunCommand", "SaveCommand", "SaveSessionCommand", diff --git a/cecli/commands/agent_model.py b/cecli/commands/agent_model.py index 36461a2ceea..1b10617a129 100644 --- a/cecli/commands/agent_model.py +++ b/cecli/commands/agent_model.py @@ -3,7 +3,7 @@ import cecli.models as models from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class AgentModelCommand(BaseCommand): @@ -26,12 +26,8 @@ async def execute(cls, io, coder, args, **kwargs): # Create a new model with the same main model and editor model, but updated agent model model = models.Model( coder.main_model.name, - editor_model=coder.main_model.editor_model.name, - weak_model=coder.main_model.weak_model.name, + from_model=coder.main_model, agent_model=model_name, - io=io, - retries=coder.main_model.retries, - debug=coder.main_model.debug, ) await models.sanity_check_models(io, model) @@ -65,8 +61,7 @@ async def execute(cls, io, coder, args, **kwargs): temp_coder = await Coder.create(**new_kwargs) # Re-initialize ConversationManager with temp coder - ConversationManager.initialize( - temp_coder, + ConversationService.get_manager(temp_coder).initialize( reset=True, reformat=True, preserve_tags=True, @@ -82,8 +77,7 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Clear manager and restore original state - ConversationManager.initialize( - original_coder, + ConversationService.get_manager(original_coder).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/commands/clear.py b/cecli/commands/clear.py index a7743460129..7fb98001ca5 100644 --- a/cecli/commands/clear.py +++ b/cecli/commands/clear.py @@ -11,18 +11,14 @@ class ClearCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): # Clear chat history using ConversationManager - from cecli.helpers.conversation import ( - ConversationFiles, - ConversationManager, - MessageTag, - ) - - ConversationManager.clear_tag(MessageTag.CUR) - ConversationManager.clear_tag(MessageTag.DONE) - ConversationManager.clear_tag(MessageTag.DIFFS) - ConversationManager.clear_tag(MessageTag.FILE_CONTEXTS) - - ConversationFiles.reset() + from cecli.helpers.conversation import ConversationService, MessageTag + + ConversationService.get_manager(coder).clear_tag(MessageTag.CUR) + ConversationService.get_manager(coder).clear_tag(MessageTag.DONE) + ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS) + ConversationService.get_manager(coder).clear_tag(MessageTag.FILE_CONTEXTS) + + ConversationService.get_files(coder).reset() # Clear TUI output if available if coder.tui and coder.tui(): diff --git a/cecli/commands/context_management.py b/cecli/commands/context_management.py index 137344efe2e..6d7e14bc99e 100644 --- a/cecli/commands/context_management.py +++ b/cecli/commands/context_management.py @@ -2,11 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ( - ConversationFiles, - ConversationManager, - MessageTag, -) +from cecli.helpers.conversation import ConversationService, MessageTag class ContextManagementCommand(BaseCommand): @@ -24,10 +20,10 @@ async def execute(cls, io, coder, args, **kwargs): # Toggle the setting coder.context_management_enabled = not coder.context_management_enabled - ConversationManager.clear_tag(MessageTag.READONLY_FILES) - ConversationManager.clear_tag(MessageTag.EDIT_FILES) - ConversationManager.clear_tag(MessageTag.CHAT_FILES) - ConversationFiles.clear_file_cache() + ConversationService.get_manager(coder).clear_tag(MessageTag.READONLY_FILES) + ConversationService.get_manager(coder).clear_tag(MessageTag.EDIT_FILES) + ConversationService.get_manager(coder).clear_tag(MessageTag.CHAT_FILES) + ConversationService.get_files(coder).clear_file_cache() # Report the new state if coder.context_management_enabled: diff --git a/cecli/commands/copy.py b/cecli/commands/copy.py index 44b964ac0d1..0d984d09644 100644 --- a/cecli/commands/copy.py +++ b/cecli/commands/copy.py @@ -4,7 +4,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class CopyCommand(BaseCommand): @@ -14,7 +14,7 @@ class CopyCommand(BaseCommand): @classmethod async def execute(cls, io, coder, args, **kwargs): # Get all messages from ConversationManager - all_messages = ConversationManager.get_messages_dict() + all_messages = ConversationService.get_manager(coder).get_messages_dict() assistant_messages = [msg for msg in reversed(all_messages) if msg["role"] == "assistant"] if not assistant_messages: diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 266c250dbcd..b8b6d33dfc2 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -269,12 +269,6 @@ def _get_session_file_path(self, session_name): return session_dir / f"{safe_name}{ext}" -def parse_quoted_filenames(args): - filenames = re.findall('\\"(.+?)\\"|(\\S+)', args) - filenames = [name for sublist in filenames for name in sublist if name] - return filenames - - def get_help_md(): md = Commands(None, None).get_help_md() return md diff --git a/cecli/commands/drop.py b/cecli/commands/drop.py index 13d72951852..477712441a9 100644 --- a/cecli/commands/drop.py +++ b/cecli/commands/drop.py @@ -48,7 +48,9 @@ async def execute(cls, io, coder, args, **kwargs): cls._handle_read_only_files( io, coder, expanded_word, coder.abs_read_only_stubs_fnames, "read-only (stub)" ) - + cls._handle_read_only_files( + io, coder, expanded_word, coder.abs_rules_fnames, "rules" + ) # For editable files, use glob if word contains glob chars, otherwise use substring if any(c in expanded_word for c in "*?[]"): matched_files = cls._glob_filtered_to_repo(coder, expanded_word) @@ -193,8 +195,13 @@ def _glob_filtered_to_repo(cls, coder, pattern): def get_completions(cls, io, coder, args) -> List[str]: """Get completion options for drop command.""" # Return files currently in chat - files = coder.get_inchat_relative_files() - return [cls._quote_fname(fn) for fn in files] + files = set(coder.get_inchat_relative_files()) + + # Add rules files to completions + for abs_rule in coder.abs_rules_fnames: + files.add(coder.get_rel_fname(abs_rule)) + + return [cls._quote_fname(fn) for fn in sorted(files)] @classmethod def _quote_fname(cls, fname): diff --git a/cecli/commands/editor_model.py b/cecli/commands/editor_model.py index 78ed20368ea..beb97a86ae9 100644 --- a/cecli/commands/editor_model.py +++ b/cecli/commands/editor_model.py @@ -3,7 +3,7 @@ import cecli.models as models from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class EditorModelCommand(BaseCommand): @@ -26,12 +26,8 @@ async def execute(cls, io, coder, args, **kwargs): # Create a new model with the same main model and editor model, but updated editor model model = models.Model( coder.main_model.name, + from_model=coder.main_model, editor_model=model_name, - weak_model=coder.main_model.weak_model.name, - agent_model=coder.main_model.agent_model.name, - io=io, - retries=coder.main_model.retries, - debug=coder.main_model.debug, ) await models.sanity_check_models(io, model) @@ -65,8 +61,7 @@ async def execute(cls, io, coder, args, **kwargs): temp_coder = await Coder.create(**new_kwargs) # Re-initialize ConversationManager with temp coder - ConversationManager.initialize( - temp_coder, + ConversationService.get_manager(temp_coder).initialize( reset=True, reformat=True, preserve_tags=True, @@ -82,8 +77,7 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Clear manager and restore original state - ConversationManager.initialize( - original_coder, + ConversationService.get_manager(original_coder).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/commands/ls.py b/cecli/commands/ls.py index f4a0d797851..217dccc51f6 100644 --- a/cecli/commands/ls.py +++ b/cecli/commands/ls.py @@ -33,7 +33,13 @@ async def execute(cls, io, coder, args, **kwargs): rel_file_path = coder.get_rel_fname(abs_file_path) read_only_stub_files.append(rel_file_path) - if not chat_files and not read_only_files and not read_only_stub_files: + # Add rules files + rules_files = [] + for abs_file_path in coder.abs_rules_fnames: + rel_file_path = coder.get_rel_fname(abs_file_path) + rules_files.append(rel_file_path) + + if not chat_files and not read_only_files and not read_only_stub_files and not rules_files: io.tool_output("\nNo files in chat, git repo, or read-only list.") return format_command_result(io, "ls", "Listed files") @@ -42,6 +48,11 @@ async def execute(cls, io, coder, args, **kwargs): # for file in other_files: # io.tool_output(f" {file}") + if rules_files: + io.tool_output("\nRules files:\n") + for file in sorted(rules_files): + io.tool_output(f" {file}") + # Read-only files: if read_only_files or read_only_stub_files: io.tool_output("\nRead-only files:\n") @@ -72,4 +83,5 @@ def get_help(cls) -> str: help_text += " - Files in chat (editable)\n" help_text += " - Read-only files (view-only)\n" help_text += " - Read-only stub files (view-only, truncated)\n" + help_text += " - Rules files (reference)\n" return help_text diff --git a/cecli/commands/map.py b/cecli/commands/map.py index 0968dd51a37..e362dd23162 100644 --- a/cecli/commands/map.py +++ b/cecli/commands/map.py @@ -2,7 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationChunks +from cecli.helpers.conversation import ConversationService class MapCommand(BaseCommand): @@ -14,7 +14,7 @@ async def execute(cls, io, coder, args, **kwargs): """Execute the map command with given parameters.""" repo_map = coder.get_repo_map() if repo_map: - repo_string = ConversationChunks.get_repo_map_string(repo_map) + repo_string = ConversationService.get_chunks(coder).get_repo_map_string(repo_map) io.tool_output(repo_string) else: io.tool_output("No repository map available.") diff --git a/cecli/commands/map_refresh.py b/cecli/commands/map_refresh.py index b8462aa5fda..a190fdcc11b 100644 --- a/cecli/commands/map_refresh.py +++ b/cecli/commands/map_refresh.py @@ -2,7 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag class MapRefreshCommand(BaseCommand): @@ -13,7 +13,7 @@ class MapRefreshCommand(BaseCommand): async def execute(cls, io, coder, args, **kwargs): """Execute the map-refresh command with given parameters.""" # Clear any existing REPO tagged messages before refreshing - ConversationManager.clear_tag(MessageTag.REPO) + ConversationService.get_manager(coder).clear_tag(MessageTag.REPO) # Parse the argument arg_str = args.strip() if args else "" diff --git a/cecli/commands/model.py b/cecli/commands/model.py index a1a627958ec..8d4212b2f27 100644 --- a/cecli/commands/model.py +++ b/cecli/commands/model.py @@ -3,7 +3,7 @@ import cecli.models as models from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class ModelCommand(BaseCommand): @@ -22,11 +22,7 @@ async def execute(cls, io, coder, args, **kwargs): model = models.Model( model_name, - editor_model=coder.main_model.editor_model.name, - weak_model=coder.main_model.weak_model.name, - io=io, - retries=coder.main_model.retries, - debug=coder.main_model.debug, + from_model=coder.main_model, ) await models.sanity_check_models(io, model) @@ -69,8 +65,7 @@ async def execute(cls, io, coder, args, **kwargs): temp_coder = await Coder.create(**new_kwargs) # Re-initialize ConversationManager with temp coder - ConversationManager.initialize( - temp_coder, + ConversationService.get_manager(temp_coder).initialize( reset=True, reformat=True, preserve_tags=True, @@ -86,8 +81,7 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Clear manager and restore original state - ConversationManager.initialize( - original_coder, + ConversationService.get_manager(original_coder).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/commands/reset.py b/cecli/commands/reset.py index 79eae109b78..53d002f29f3 100644 --- a/cecli/commands/reset.py +++ b/cecli/commands/reset.py @@ -2,7 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationFiles, ConversationManager +from cecli.helpers.conversation import ConversationService class ResetCommand(BaseCommand): @@ -16,12 +16,12 @@ async def execute(cls, io, coder, args, **kwargs): cls._drop_all_files(io, coder, kwargs.get("original_read_only_fnames")) # Clear everything in ConversationManager and ConversationFiles - ConversationManager.reset() # Clear all messages and reset manager - ConversationFiles.reset() # Clear all file caches + ConversationService.get_manager(coder).reset() # Clear all messages and reset manager + ConversationService.get_files(coder).reset() # Clear all file caches - # Re-initialize ConversationManager with current coder - ConversationManager.initialize(coder, reformat=True) - ConversationFiles.initialize(coder) + # Re-initialize Conversation components with current coder + ConversationService.get_manager(coder).initialize(reformat=True) + ConversationService.get_files(coder) # Ensure instance exists/initialized # Clear TUI output if available if coder.tui and coder.tui(): diff --git a/cecli/commands/rules.py b/cecli/commands/rules.py new file mode 100644 index 00000000000..4aeddbf6873 --- /dev/null +++ b/cecli/commands/rules.py @@ -0,0 +1,148 @@ +import os +import re +from pathlib import Path +from typing import List + +from cecli.commands.utils.base_command import BaseCommand +from cecli.commands.utils.helpers import ( + format_command_result, + get_file_completions, + parse_quoted_filenames, + quote_filename, +) +from cecli.utils import run_fzf + + +class RulesCommand(BaseCommand): + NORM_NAME = "rules" + DESCRIPTION = "Add rule files to the chat for reference" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Execute the rules command with given parameters.""" + if not args.strip(): + rules_in_chat = [coder.get_rel_fname(f) for f in coder.abs_rules_fnames] + if rules_in_chat: + io.tool_output("Current rules files:") + for rule_file in sorted(rules_in_chat): + io.tool_output(f" {rule_file}") + io.tool_output() + + all_files = coder.get_all_relative_files() + # For rules, we might want to allow adding files already in chat as editable/read-only + # but let's keep it simple and filter out what's already in abs_rules_fnames + addable_files = sorted(set(all_files) - set(rules_in_chat)) + + if not addable_files: + if not rules_in_chat: + io.tool_output("No files available to add as rules.") + return format_command_result( + io, "rules", "Listed rules" if rules_in_chat else "No files available to add" + ) + + selected_files = run_fzf(addable_files, multi=True, coder=coder) + if not selected_files: + return format_command_result( + io, "rules", "Listed rules" if rules_in_chat else "No files selected" + ) + args = " ".join([quote_filename(f) for f in selected_files]) + + all_matched_files = set() + filenames = parse_quoted_filenames(args) + + for word in filenames: + if Path(word).is_absolute(): + fname = Path(word) + else: + fname = Path(coder.root) / word + + if fname.exists(): + if fname.is_file(): + all_matched_files.add(str(fname)) + continue + # an existing dir, escape any special chars so they won't be globs + word = re.sub(r"([\*\?\[\]])", r"[\1]", word) + + matched_files = cls.glob_files(coder, word) + if matched_files: + all_matched_files.update(matched_files) + continue + + if "*" in str(fname) or "?" in str(fname): + io.tool_error(f"No match for wildcard pattern: {word}") + continue + + io.tool_error(f"File or directory does not exist: {word}") + + for matched_file in sorted(all_matched_files): + abs_file_path = coder.abs_root_path(matched_file) + + if abs_file_path in coder.abs_rules_fnames: + io.tool_error(f"{matched_file} is already in the chat as a rules file") + continue + + # Rules can be added regardless of whether they are in other sets + coder.abs_rules_fnames.add(abs_file_path) + fname = coder.get_rel_fname(abs_file_path) + io.tool_output(f"Added {fname} to rules") + + return format_command_result(io, "rules", "Processed rules files") + + @classmethod + def glob_files(cls, coder, pattern: str) -> List[str]: + """Glob pattern and return all matching files.""" + if not pattern.strip(): + return [] + + try: + if os.path.isabs(pattern): + raw_matched_files = [Path(pattern)] + else: + try: + raw_matched_files = list(Path(coder.root).glob(pattern)) + except (IndexError, AttributeError): + raw_matched_files = [] + except ValueError: + raw_matched_files = [] + + matched_files = [] + for fn in raw_matched_files: + if fn.is_file(): + matched_files.append(fn) + elif fn.is_dir(): + for f in fn.rglob("*"): + if f.is_file(): + matched_files.append(f) + + return [str(fn) for fn in matched_files] + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Get completion options for rules command.""" + directory_completions = get_file_completions( + coder, + args=args, + completion_type="directory", + include_directories=True, + filter_in_chat=False, + ) + + all_completions = get_file_completions( + coder, args=args, completion_type="all", include_directories=False, filter_in_chat=False + ) + + joint_set = set(directory_completions) | set(all_completions) + return sorted(joint_set) + + @classmethod + def get_help(cls) -> str: + """Get help text for the rules command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /rules # Interactive file selection using fuzzy finder\n" + help_text += " /rules # Add specific files or glob patterns as rules\n" + help_text += "\nExamples:\n" + help_text += " /rules .cursorrules # Add .cursorrules as a rule file\n" + help_text += " /rules rules/*.md # Add all markdown files in rules directory\n" + help_text += "\nThis command adds files to the chat as rules for reference.\n" + return help_text diff --git a/cecli/commands/run.py b/cecli/commands/run.py index 8bd186404c3..a23d5326c6b 100644 --- a/cecli/commands/run.py +++ b/cecli/commands/run.py @@ -4,7 +4,7 @@ import cecli.prompts.utils.system as prompts from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag from cecli.run_cmd import run_cmd @@ -62,9 +62,13 @@ async def execute(cls, io, coder, args, **kwargs): ) # Add user message with CUR tag - ConversationManager.add_message(dict(role="user", content=msg), MessageTag.CUR) + ConversationService.get_manager(coder).add_message( + dict(role="user", content=msg), MessageTag.CUR + ) # Add assistant acknowledgment with CUR tag - ConversationManager.add_message(dict(role="assistant", content="Ok."), MessageTag.CUR) + ConversationService.get_manager(coder).add_message( + dict(role="assistant", content="Ok."), MessageTag.CUR + ) if add_on_nonzero_exit and exit_status != 0: # Return the formatted output message for test failures diff --git a/cecli/commands/tokens.py b/cecli/commands/tokens.py index 0b2d4c86657..1ef30e6e825 100644 --- a/cecli/commands/tokens.py +++ b/cecli/commands/tokens.py @@ -2,7 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag class TokensCommand(BaseCommand): @@ -31,7 +31,7 @@ async def execute(cls, io, coder, args, **kwargs): system_tokens = 0 for tag in system_tags: - msgs = ConversationManager.get_messages_dict(tag=tag) + msgs = ConversationService.get_manager(coder).get_messages_dict(tag=tag) if msgs: system_tokens += coder.main_model.token_count(msgs) @@ -56,11 +56,12 @@ async def execute(cls, io, coder, args, **kwargs): res.append((system_tokens, "system messages", "")) # chat history - msgs_done = ConversationManager.get_messages_dict(tag=MessageTag.DONE) - msgs_cur = ConversationManager.get_messages_dict(tag=MessageTag.CUR) - msgs_diffs = ConversationManager.get_messages_dict(tag=MessageTag.DIFFS) - msgs_file_contexts = ConversationManager.get_messages_dict(tag=MessageTag.FILE_CONTEXTS) - + msgs_done = ConversationService.get_manager(coder).get_messages_dict(tag=MessageTag.DONE) + msgs_cur = ConversationService.get_manager(coder).get_messages_dict(tag=MessageTag.CUR) + msgs_diffs = ConversationService.get_manager(coder).get_messages_dict(tag=MessageTag.DIFFS) + msgs_file_contexts = ConversationService.get_manager(coder).get_messages_dict( + tag=MessageTag.FILE_CONTEXTS + ) tokens_done = 0 tokens_cur = 0 tokens_diffs = 0 @@ -88,10 +89,16 @@ async def execute(cls, io, coder, args, **kwargs): if tokens_file_contexts: res.append((tokens_file_contexts, "numbered context messages", "part of chat history")) + # rules files + msgs_rules = ConversationService.get_manager(coder).get_messages_dict(tag=MessageTag.RULES) + if msgs_rules: + tokens_rules = coder.main_model.token_count(msgs_rules) + res.append((tokens_rules, "rules files", "/drop to remove")) + # repo map if coder.repo_map: tokens = coder.main_model.token_count( - ConversationManager.get_messages_dict(tag=MessageTag.REPO) + ConversationService.get_manager(coder).get_messages_dict(tag=MessageTag.REPO) ) res.append((tokens, "repository map", "use --map-tokens to resize")) @@ -109,7 +116,9 @@ async def execute(cls, io, coder, args, **kwargs): file_res = [] # Calculate tokens for read-only files using READONLY_FILES tag - readonly_msgs = ConversationManager.get_messages_dict(tag=MessageTag.READONLY_FILES) + readonly_msgs = ConversationService.get_manager(coder).get_messages_dict( + tag=MessageTag.READONLY_FILES + ) if readonly_msgs: # Group messages by file (each file has user and assistant messages) file_tokens = {} @@ -146,7 +155,7 @@ async def execute(cls, io, coder, args, **kwargs): editable_file_tokens = {} for tag in editable_tags: - msgs = ConversationManager.get_messages_dict(tag=tag) + msgs = ConversationService.get_manager(coder).get_messages_dict(tag=tag) if msgs: for msg in msgs: # Extract file name from message content diff --git a/cecli/commands/undo.py b/cecli/commands/undo.py index e4e09ea8211..66b02c24f11 100644 --- a/cecli/commands/undo.py +++ b/cecli/commands/undo.py @@ -14,15 +14,11 @@ class UndoCommand(BaseCommand): async def execute(cls, io, coder, args, **kwargs): try: # Clear chat history using ConversationManager - from cecli.helpers.conversation import ( - ConversationFiles, - ConversationManager, - MessageTag, - ) + from cecli.helpers.conversation import ConversationService, MessageTag - ConversationManager.clear_tag(MessageTag.DIFFS) - ConversationManager.clear_tag(MessageTag.FILE_CONTEXTS) - ConversationFiles.reset() + ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS) + ConversationService.get_manager(coder).clear_tag(MessageTag.FILE_CONTEXTS) + ConversationService.get_files(coder).reset() return await cls._raw_cmd_undo(io, coder, args) except ANY_GIT_ERROR as err: diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index 5e5327a77f9..3200dacc961 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -1,7 +1,7 @@ from abc import ABC, ABCMeta, abstractmethod from typing import List -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class CommandMeta(ABCMeta): @@ -152,14 +152,14 @@ async def _generic_chat_command(cls, io, coder, args, edit_format, placeholder=N new_coder = await Coder.create(**kwargs) # Re-initialize ConversationManager with new coder - ConversationManager.initialize(new_coder, reset=True, reformat=True, preserve_tags=True) - + ConversationService.get_manager(new_coder).initialize( + reset=True, reformat=True, preserve_tags=True + ) await new_coder.generate(user_message=user_msg, preproc=False) coder.coder_commit_hashes = new_coder.coder_commit_hashes # Clear manager and restore original state - ConversationManager.initialize( - original_coder, + ConversationService.get_manager(original_coder).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index cf0b07f9b88..475de317874 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -1,5 +1,4 @@ import os -import re from pathlib import Path from typing import List @@ -19,9 +18,65 @@ def quote_filename(fname: str) -> str: def parse_quoted_filenames(args: str) -> List[str]: """Parse filenames from command arguments, handling quoted names.""" - filenames = re.findall(r"\"(.+?)\"|(\S+)", args) - filenames = [name for sublist in filenames for name in sublist if name] - return filenames + # Handle empty input + if not args.strip(): + return [] + + result = [] + i = 0 + n = len(args) + + while i < n: + # Skip whitespace + while i < n and args[i].isspace(): + i += 1 + + if i >= n: + break + + # Check for quoted string + if args[i] == '"': + i += 1 # Skip opening quote + start = i + while i < n and args[i] != '"': + # Handle escaped quotes inside quoted string + if args[i] == "\\" and i + 1 < n and args[i + 1] == '"': + i += 2 # Skip escaped quote + else: + i += 1 + + if i < n: + result.append(args[start:i]) + i += 1 # Skip closing quote + else: + # Unclosed quote, treat everything as the argument + result.append(args[start:]) + break + else: + # Unquoted argument - collect until whitespace or escaped space + start = i + while i < n and not args[i].isspace(): + # Check for escaped space or backslash + if args[i] == "\\" and i + 1 < n: + if args[i + 1] == " ": + # Escaped space - include both characters and continue + i += 2 + continue + elif args[i + 1] == "\\": + # Escaped backslash - skip one backslash + i += 2 + continue + + # Regular character or backslash not followed by space/backslash + if args[i] == "\\" and i + 1 >= n: + # Lone backslash at end of input - include it + pass + + i += 1 + + result.append(args[start:i]) + + return result def glob_filtered_to_repo(pattern: str, root: str, repo) -> List[Path]: diff --git a/cecli/commands/weak_model.py b/cecli/commands/weak_model.py index 15f2d0e9b4b..97f75c0c2f3 100644 --- a/cecli/commands/weak_model.py +++ b/cecli/commands/weak_model.py @@ -3,7 +3,7 @@ import cecli.models as models from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager +from cecli.helpers.conversation import ConversationService class WeakModelCommand(BaseCommand): @@ -26,11 +26,8 @@ async def execute(cls, io, coder, args, **kwargs): # Create a new model with the same main model and editor model, but updated weak model model = models.Model( coder.main_model.name, - editor_model=coder.main_model.editor_model.name, + from_model=coder.main_model, weak_model=model_name, - io=io, - retries=coder.main_model.retries, - debug=coder.main_model.debug, ) await models.sanity_check_models(io, model) @@ -64,8 +61,7 @@ async def execute(cls, io, coder, args, **kwargs): temp_coder = await Coder.create(**new_kwargs) # Re-initialize ConversationManager with temp coder - ConversationManager.initialize( - temp_coder, + ConversationService.get_manager(temp_coder).initialize( reset=True, reformat=True, preserve_tags=True, @@ -81,8 +77,7 @@ async def execute(cls, io, coder, args, **kwargs): coder.coder_commit_hashes = temp_coder.coder_commit_hashes # Clear manager and restore original state - ConversationManager.initialize( - original_coder, + ConversationService.get_manager(original_coder).initialize( reset=True, reformat=True, preserve_tags=True, diff --git a/cecli/commands/web.py b/cecli/commands/web.py index 079548552dd..fac419e884c 100644 --- a/cecli/commands/web.py +++ b/cecli/commands/web.py @@ -2,7 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.commands.utils.helpers import format_command_result -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag from cecli.scrape import Scraper, install_playwright @@ -58,9 +58,13 @@ async def execute(cls, io, coder, args, **kwargs): io.tool_output("... added to chat.") # Add user message with CUR tag - ConversationManager.add_message(dict(role="user", content=content), MessageTag.CUR) + ConversationService.get_manager(coder).add_message( + dict(role="user", content=content), MessageTag.CUR + ) # Add assistant acknowledgment with CUR tag - ConversationManager.add_message(dict(role="assistant", content="Ok."), MessageTag.CUR) + ConversationService.get_manager(coder).add_message( + dict(role="assistant", content="Ok."), MessageTag.CUR + ) return format_command_result(io, "web", f"Scraped and added content from {url} to chat") diff --git a/cecli/deprecated_args.py b/cecli/deprecated_args.py index e5b3986f31f..ca80605fa8b 100644 --- a/cecli/deprecated_args.py +++ b/cecli/deprecated_args.py @@ -142,6 +142,17 @@ def add_deprecated_model_args(parser, group): ) +def add_deprecated_mcp_args(parser, group): + """Add deprecated mcp arguments to the argparse parser.""" + group.add_argument( + "--mcp-servers-file", + help=argparse.SUPPRESS, + action="append", + dest="mcp_servers_file_deprecated", + env_var="CECLI_MCP_SERVERS_FILE", + ) + + def handle_deprecated_model_args(args, io): """Handle deprecated model shortcut arguments and provide appropriate warnings.""" # Define model mapping diff --git a/cecli/helpers/conversation/__init__.py b/cecli/helpers/conversation/__init__.py index cc6a41411d8..a8f2038c7a3 100644 --- a/cecli/helpers/conversation/__init__.py +++ b/cecli/helpers/conversation/__init__.py @@ -1,20 +1,15 @@ -""" -Conversation management system for cecli. - -This module provides a unified, priority-ordered message stream system -that replaces the current chunk-based approach. -""" - from .base_message import BaseMessage from .files import ConversationFiles from .integration import ConversationChunks from .manager import ConversationManager +from .service import ConversationService from .tags import MessageTag __all__ = [ - "BaseMessage", + "ConversationService", "ConversationManager", "ConversationFiles", - "MessageTag", "ConversationChunks", + "MessageTag", + "BaseMessage", ] diff --git a/cecli/helpers/conversation/base_message.py b/cecli/helpers/conversation/base_message.py index 052259b528e..3123f246d5e 100644 --- a/cecli/helpers/conversation/base_message.py +++ b/cecli/helpers/conversation/base_message.py @@ -28,6 +28,8 @@ class BaseMessage: priority: int = field(default=0) timestamp: int = field(default_factory=lambda: time.monotonic_ns()) mark_for_delete: Optional[int] = field(default=None) + mark_for_demotion: Optional[int] = field(default=None) + promotion: Optional[int] = field(default=None) hash_key: Optional[Tuple[str, ...]] = field(default=None) message_id: Optional[str] = field(default=None) @@ -110,6 +112,17 @@ def to_dict(self) -> Dict[str, Any]: return result + def is_promoted(self) -> bool: + """ + Returns True if mark_for_demotion < 0. + + Returns: + Whether the message should use its promotion priority + """ + if self.mark_for_demotion is None: + return False + return self.mark_for_demotion >= 0 + def is_expired(self) -> bool: """ Returns True if mark_for_delete < 0. diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index d2cd8a6ee8c..b6a710000c5 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -1,49 +1,63 @@ import os import weakref from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID from cecli.helpers.hashline import get_hashline_content_diff, hashline from cecli.repomap import RepoMap -from .manager import ConversationManager +from .service import ConversationService from .tags import MessageTag class ConversationFiles: """ - Singleton class that handles file content caching, change detection, + Handles file content caching, change detection, and diff generation for file-based messages. - - Design: Singleton class with static methods, not requiring initialization. """ - # Class-level storage for singleton pattern - _file_contents_original: Dict[str, str] = {} - _file_contents_snapshot: Dict[str, str] = {} - _file_timestamps: Dict[str, float] = {} - _file_diffs: Dict[str, str] = {} - _file_to_message_id: Dict[str, str] = {} - # Track image files separately since they don't have text content - _image_files: Dict[str, bool] = {} - # Track numbered context ranges for files - _numbered_contexts: Dict[str, List[Tuple[int, int]]] = {} - _coder_ref = None - _initialized = False + _instances: Dict[UUID, "ConversationFiles"] = {} + + def __init__(self, coder): + self.coder = weakref.ref(coder) + self.uuid = coder.uuid + self._file_contents_original: Dict[str, str] = {} + self._file_contents_snapshot: Dict[str, str] = {} + self._file_timestamps: Dict[str, float] = {} + self._file_diffs: Dict[str, str] = {} + self._file_to_message_id: Dict[str, str] = {} + self._image_files: Dict[str, bool] = {} + self._numbered_contexts: Dict[str, List[Tuple[int, int]]] = {} + self._initialized = True @classmethod - def initialize(cls, coder) -> None: - """ - Set up singleton with weak reference to coder. + def get_instance(cls, coder) -> "ConversationFiles": + """Get or create files instance for coder.""" + if coder.uuid not in cls._instances: + cls._instances[coder.uuid] = cls(coder) - Args: - coder: The coder instance to reference - """ - cls._coder_ref = weakref.ref(coder) - cls._initialized = True + # Update weakref for SwitchCoderSignal + if coder is not cls._instances[coder.uuid].get_coder(): + cls._instances[coder.uuid].coder = weakref.ref(coder) + + return cls._instances[coder.uuid] @classmethod + def destroy_instance(cls, coder_uuid: UUID): + """Explicit cleanup for sub-agents.""" + if coder_uuid in cls._instances: + del cls._instances[coder_uuid] + + def get_coder(self): + """Get strong reference to coder (or None if destroyed).""" + return self.coder() + + def initialize(self) -> None: + """Initialize (already handled in __init__).""" + self._initialized = True + def add_file( - cls, + self, fname: str, content: Optional[str] = None, force_refresh: bool = False, @@ -65,11 +79,11 @@ def add_file( # Check if we need to refresh current_mtime = os.path.getmtime(abs_fname) if os.path.exists(abs_fname) else 0 - if force_refresh or abs_fname not in cls._file_contents_original: + if force_refresh or abs_fname not in self._file_contents_original: # Read content from disk if not provided if content is None: # Use coder.io.read_text() - coder should always be available - coder = cls.get_coder() + coder = self.get_coder() try: content = coder.io.read_text(abs_fname) if coder.hashlines: @@ -82,18 +96,17 @@ def add_file( content = "" # Empty content for unreadable files # Update cache - cls._file_contents_original[abs_fname] = content - cls._file_contents_snapshot[abs_fname] = content - cls._file_timestamps[abs_fname] = current_mtime + self._file_contents_original[abs_fname] = content + self._file_contents_snapshot[abs_fname] = content + self._file_timestamps[abs_fname] = current_mtime # Clear previous diff - cls._file_diffs.pop(abs_fname, None) + self._file_diffs.pop(abs_fname, None) - return cls._file_contents_original.get(abs_fname, "") + return self._file_contents_original.get(abs_fname, "") - @classmethod def get_file_content( - cls, + self, fname: str, generate_stub: bool = False, context_management_enabled: bool = False, @@ -116,11 +129,11 @@ def get_file_content( """ abs_fname = os.path.abspath(fname) # First, ensure file is in cache (read-through cache) - if abs_fname not in cls._file_contents_original: - cls.add_file(fname) + if abs_fname not in self._file_contents_original: + self.add_file(fname) # Get content from cache - content = cls._file_contents_original.get(abs_fname) + content = self._file_contents_original.get(abs_fname) if content is None: return None @@ -132,7 +145,7 @@ def get_file_content( if not context_management_enabled: return content - coder = cls.get_coder() + coder = self.get_coder() # Check if file is large content_length = coder.main_model.token_count(content) @@ -143,8 +156,7 @@ def get_file_content( # Use RepoMap to generate file stub return RepoMap.get_file_stub(fname, coder.io, line_numbers=True) - @classmethod - def has_file_changed(cls, fname: str) -> bool: + def has_file_changed(self, fname: str) -> bool: """ Check if file has been modified since last cache. @@ -156,19 +168,18 @@ def has_file_changed(cls, fname: str) -> bool: """ abs_fname = os.path.abspath(fname) - if abs_fname not in cls._file_contents_original: + if abs_fname not in self._file_contents_original: return True if not os.path.exists(abs_fname): return True current_mtime = os.path.getmtime(abs_fname) - cached_mtime = cls._file_timestamps.get(abs_fname, 0) + cached_mtime = self._file_timestamps.get(abs_fname, 0) return current_mtime > cached_mtime - @classmethod - def generate_diff(cls, fname: str) -> Optional[str]: + def generate_diff(self, fname: str) -> Optional[str]: """ Generate diff between cached content and current file content. @@ -179,11 +190,11 @@ def generate_diff(cls, fname: str) -> Optional[str]: Unified diff string or None if no changes """ abs_fname = os.path.abspath(fname) - if abs_fname not in cls._file_contents_original: + if abs_fname not in self._file_contents_original: return None # Read current content using coder.io.read_text() - coder = cls.get_coder() + coder = self.get_coder() rel_fname = coder.get_rel_fname(fname) try: current_content = coder.io.read_text(abs_fname) @@ -197,8 +208,8 @@ def generate_diff(cls, fname: str) -> Optional[str]: return None # Get the last snapshot (use file cache as fallback for backward compatibility) - snapshot_content = cls._file_contents_snapshot.get( - abs_fname, cls._file_contents_original[abs_fname] + snapshot_content = self._file_contents_snapshot.get( + abs_fname, self._file_contents_original[abs_fname] ) # Generate diff between snapshot and current content using hashline helper @@ -211,12 +222,11 @@ def generate_diff(cls, fname: str) -> Optional[str]: # If there's a diff, update the last snapshot with current content if diff_text.strip(): - cls._file_contents_snapshot[abs_fname] = current_content + self._file_contents_snapshot[abs_fname] = current_content return diff_text if diff_text.strip() else None - @classmethod - def update_file_diff(cls, fname: str) -> Optional[str]: + def update_file_diff(self, fname: str) -> Optional[str]: """ Update diff for file and add diff message to conversation. @@ -226,13 +236,13 @@ def update_file_diff(cls, fname: str) -> Optional[str]: Returns: Diff string or None if no changes """ - coder = cls.get_coder() - diff = cls.generate_diff(fname) + coder = self.get_coder() + diff = self.generate_diff(fname) if diff: # Store diff abs_fname = os.path.abspath(fname) - cls._file_diffs[abs_fname] = diff + self._file_diffs[abs_fname] = diff rel_fname = fname @@ -243,19 +253,21 @@ def update_file_diff(cls, fname: str) -> Optional[str]: diff_message = { "role": "user", "content": ( - f"{rel_fname} has been updated. Here is a diff of the changes:\n\n{diff}" + f"{rel_fname} has been updated. Here is a git diff of the changes to" + f" review:\n\n{diff}" ), } - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=diff_message, tag=MessageTag.DIFFS, + promotion=ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) return diff - @classmethod - def get_file_stub(cls, fname: str) -> str: + def get_file_stub(self, fname: str) -> str: """ Get repository map stub for large files. @@ -267,7 +279,7 @@ def get_file_stub(cls, fname: str) -> str: Returns: Repository map stub or full content for small files """ - coder = cls.get_coder() + coder = self.get_coder() if not coder: return "" @@ -277,7 +289,7 @@ def get_file_stub(cls, fname: str) -> str: large_file_token_threshold = getattr(coder, "large_file_token_threshold", 8192) # Use the enhanced get_file_content method with stub generation - content = cls.get_file_content( + content = self.get_file_content( fname=fname, generate_stub=True, context_management_enabled=context_management_enabled, @@ -286,8 +298,7 @@ def get_file_stub(cls, fname: str) -> str: return content or "" - @classmethod - def clear_file_cache(cls, fname: Optional[str] = None) -> None: + def clear_file_cache(self, fname: Optional[str] = None) -> None: """ Clear cache for specific file or all files. @@ -295,24 +306,23 @@ def clear_file_cache(cls, fname: Optional[str] = None) -> None: fname: Optional specific file to clear (None = clear all) """ if fname is None: - cls._file_contents_original.clear() - cls._file_contents_snapshot.clear() - cls._file_timestamps.clear() - cls._file_diffs.clear() - cls._file_to_message_id.clear() - cls._numbered_contexts.clear() # New line + self._file_contents_original.clear() + self._file_contents_snapshot.clear() + self._file_timestamps.clear() + self._file_diffs.clear() + self._file_to_message_id.clear() + self._numbered_contexts.clear() else: abs_fname = os.path.abspath(fname) - cls._file_contents_original.pop(abs_fname, None) - cls._file_contents_snapshot.pop(abs_fname, None) - cls._file_timestamps.pop(abs_fname, None) - cls._file_diffs.pop(abs_fname, None) - cls._file_to_message_id.pop(abs_fname, None) - cls._image_files.pop(abs_fname, None) - cls._numbered_contexts.pop(abs_fname, None) # New line - - @classmethod - def add_image_file(cls, fname: str) -> None: + self._file_contents_original.pop(abs_fname, None) + self._file_contents_snapshot.pop(abs_fname, None) + self._file_timestamps.pop(abs_fname, None) + self._file_diffs.pop(abs_fname, None) + self._file_to_message_id.pop(abs_fname, None) + self._image_files.pop(abs_fname, None) + self._numbered_contexts.pop(abs_fname, None) + + def add_image_file(self, fname: str) -> None: """ Track an image file. @@ -320,10 +330,9 @@ def add_image_file(cls, fname: str) -> None: fname: Absolute file path of image """ abs_fname = os.path.abspath(fname) - cls._image_files[abs_fname] = True + self._image_files[abs_fname] = True - @classmethod - def remove_image_file(cls, fname: str) -> None: + def remove_image_file(self, fname: str) -> None: """ Remove an image file from tracking. @@ -331,29 +340,20 @@ def remove_image_file(cls, fname: str) -> None: fname: Absolute file path of image """ abs_fname = os.path.abspath(fname) - cls._image_files.pop(abs_fname, None) + self._image_files.pop(abs_fname, None) - @classmethod - def get_all_tracked_files(cls) -> set: + def get_all_tracked_files(self) -> set: """ Get all tracked files (both regular and image files). Returns: Set of all tracked file paths """ - regular_files = set(cls._file_contents_original.keys()) - image_files = set(cls._image_files.keys()) + regular_files = set(self._file_contents_original.keys()) + image_files = set(self._image_files.keys()) return regular_files.union(image_files) - @classmethod - def get_coder(cls): - """Get current coder instance via weak reference.""" - if cls._coder_ref: - return cls._coder_ref() - return None - - @classmethod - def update_file_context(cls, file_path: str, start_line: int, end_line: int) -> None: + def update_file_context(self, file_path: str, start_line: int, end_line: int) -> None: """ Update numbered contexts for a file with a new range. @@ -369,7 +369,7 @@ def update_file_context(cls, file_path: str, start_line: int, end_line: int) -> start_line, end_line = end_line, start_line # Get existing ranges - existing_ranges = cls._numbered_contexts.get(abs_fname, []) + existing_ranges = self._numbered_contexts.get(abs_fname, []) # Add new range new_range = (start_line, end_line) @@ -395,14 +395,19 @@ def update_file_context(cls, file_path: str, start_line: int, end_line: int) -> merged_ranges.append([current_start, current_end]) # Convert back to tuples - cls._numbered_contexts[abs_fname] = [(start, end) for start, end in merged_ranges] + self._numbered_contexts[abs_fname] = [(start, end) for start, end in merged_ranges] # Remove using hash key (file_context, abs_fname) - ConversationManager.remove_message_by_hash_key(("file_context_user", abs_fname)) - ConversationManager.remove_message_by_hash_key(("file_context_assistant", abs_fname)) + coder = self.get_coder() + if coder: + ConversationService.get_manager(coder).remove_message_by_hash_key( + ("file_context_user", abs_fname) + ) + ConversationService.get_manager(coder).remove_message_by_hash_key( + ("file_context_assistant", abs_fname) + ) - @classmethod - def get_file_context(cls, file_path: str) -> str: + def get_file_context(self, file_path: str) -> str: """ Generate hashline representation of cached context ranges. @@ -415,12 +420,12 @@ def get_file_context(cls, file_path: str) -> str: abs_fname = os.path.abspath(file_path) # Get cached ranges - ranges = cls._numbered_contexts.get(abs_fname, []) + ranges = self._numbered_contexts.get(abs_fname, []) if not ranges: return "" # Get coder instance - coder = cls.get_coder() + coder = self.get_coder() if not coder: return "" @@ -455,8 +460,7 @@ def get_file_context(cls, file_path: str) -> str: # Join with ellipsis separator return "\n...\n\n".join(context_parts) - @classmethod - def remove_file_context(cls, file_path: str) -> None: + def remove_file_context(self, file_path: str) -> None: """ Remove all cached context for a file. @@ -466,43 +470,43 @@ def remove_file_context(cls, file_path: str) -> None: abs_fname = os.path.abspath(file_path) # Remove from numbered contexts - cls._numbered_contexts.pop(abs_fname, None) + self._numbered_contexts.pop(abs_fname, None) # Remove using hash key (file_context, abs_fname) - ConversationManager.remove_message_by_hash_key(("file_context_user", abs_fname)) - ConversationManager.remove_message_by_hash_key(("file_context_assistant", abs_fname)) + coder = self.get_coder() + if coder: + ConversationService.get_manager(coder).remove_message_by_hash_key( + ("file_context_user", abs_fname) + ) + ConversationService.get_manager(coder).remove_message_by_hash_key( + ("file_context_assistant", abs_fname) + ) - @classmethod - def clear_all_numbered_contexts(cls) -> None: + def clear_all_numbered_contexts(self) -> None: """Clear all numbered contexts for all files.""" - cls._numbered_contexts.clear() + self._numbered_contexts.clear() - @classmethod - def _get_numbered_contexts(cls) -> Dict[str, List[Tuple[int, int]]]: + def _get_numbered_contexts(self) -> Dict[str, List[Tuple[int, int]]]: """Get the numbered contexts dictionary.""" - return cls._numbered_contexts + return self._numbered_contexts - @classmethod - def reset(cls) -> None: + def reset(self) -> None: """Clear all file caches and reset to initial state.""" - cls.clear_file_cache() - cls.clear_all_numbered_contexts() - cls._coder_ref = None - cls._initialized = False + self.clear_file_cache() + self.clear_all_numbered_contexts() + self._initialized = False - # Debug methods - @classmethod - def debug_print_cache(cls) -> None: + def debug_print_cache(self) -> None: """Print file cache contents and modification status.""" - print(f"File Cache ({len(cls._file_contents_original)} files):") - for fname, content in cls._file_contents_original.items(): - mtime = cls._file_timestamps.get(fname, 0) - has_changed = cls.has_file_changed(fname) + print(f"File Cache ({len(self._file_contents_original)} files):") + for fname, content in self._file_contents_original.items(): + mtime = self._file_timestamps.get(fname, 0) + has_changed = self.has_file_changed(fname) status = "CHANGED" if has_changed else "CACHED" line_count = len(content.splitlines()) # Check if snapshot differs from cache - snapshot_content = cls._file_contents_snapshot.get(fname) + snapshot_content = self._file_contents_snapshot.get(fname) snapshot_differs = snapshot_content != content if snapshot_content else False snapshot_status = "DIFFERS" if snapshot_differs else "SAME" @@ -511,21 +515,20 @@ def debug_print_cache(cls) -> None: f"lines={line_count}, cached_len={len(content)}, snapshot={snapshot_status}" ) - @classmethod - def debug_get_cache_info(cls) -> Dict[str, Any]: + def debug_get_cache_info(self) -> Dict[str, Any]: """Return dict with cache size, file count, and diff count.""" # Count how many snapshots differ from their original cache snapshot_diff_count = 0 - for fname, cached_content in cls._file_contents_original.items(): - snapshot_content = cls._file_contents_snapshot.get(fname) + for fname, cached_content in self._file_contents_original.items(): + snapshot_content = self._file_contents_snapshot.get(fname) if snapshot_content and snapshot_content != cached_content: snapshot_diff_count += 1 return { - "cache_size": len(cls._file_contents_original), - "snapshot_size": len(cls._file_contents_snapshot), + "cache_size": len(self._file_contents_original), + "snapshot_size": len(self._file_contents_snapshot), "snapshot_diff_count": snapshot_diff_count, - "file_count": len(cls._file_timestamps), - "diff_count": len(cls._file_diffs), - "message_mappings": len(cls._file_to_message_id), + "file_count": len(self._file_timestamps), + "diff_count": len(self._file_diffs), + "message_mappings": len(self._file_to_message_id), } diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 80c1570a3e6..40dac2a64aa 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -1,74 +1,96 @@ import json +import weakref from typing import Any, Dict, List +from uuid import UUID import xxhash from cecli.utils import is_image_file -from .files import ConversationFiles -from .manager import ConversationManager +from .service import ConversationService from .tags import MessageTag class ConversationChunks: - """ - Collection of conversation management functions as class methods. + _instances: Dict[UUID, "ConversationChunks"] = {} - This class provides a namespace for conversation-related functions - to reduce module exports and improve organization. - """ + def __init__(self, coder): + self.coder = weakref.ref(coder) + self.uuid = coder.uuid @classmethod - def initialize_conversation_system(cls, coder) -> None: + def get_instance(cls, coder) -> "ConversationChunks": + """Get or create chunks instance for coder.""" + if coder.uuid not in cls._instances: + cls._instances[coder.uuid] = cls(coder) + + # Update weakref for SwitchCoderSignal + if coder is not cls._instances[coder.uuid].get_coder(): + cls._instances[coder.uuid].coder = weakref.ref(coder) + + return cls._instances[coder.uuid] + + @classmethod + def destroy_instance(cls, coder_uuid: UUID): + """Explicit cleanup for sub-agents.""" + if coder_uuid in cls._instances: + del cls._instances[coder_uuid] + + def get_coder(self): + """Get strong reference to coder (or None if destroyed).""" + return self.coder() + + def initialize_conversation_system(self) -> None: """ Initialize the conversation system with a coder instance. - - Args: - coder: The coder instance to reference """ - ConversationManager.initialize(coder) - ConversationFiles.initialize(coder) + coder = self.get_coder() + if not coder: + return - @classmethod - def add_system_messages(cls, coder) -> None: + ConversationService.get_manager(coder).initialize() + ConversationService.get_files(coder).initialize() + + def add_system_messages(self) -> None: """ Add system messages to conversation. - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + system_prompt = coder.gpt_prompts.main_system if system_prompt: # Apply system_prompt_prefix if set on the model if coder.main_model.system_prompt_prefix: system_prompt = coder.main_model.system_prompt_prefix + "\n" + system_prompt - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict={"role": "system", "content": coder.fmt_system_prompt(system_prompt)}, tag=MessageTag.SYSTEM, hash_key=("main", "system_prompt"), force=True, ) - # Add examples if available - if hasattr(coder.gpt_prompts, "example_messages"): - example_messages = coder.gpt_prompts.example_messages + # Add example messages if any + example_messages = coder.gpt_prompts.example_messages + if example_messages: for i, msg in enumerate(example_messages): msg_copy = msg.copy() msg_copy["content"] = coder.fmt_system_prompt(msg_copy["content"]) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=msg_copy, tag=MessageTag.EXAMPLES, priority=75 + i, # Slight offset for ordering within examples ) - # Add reminder if available - if coder.gpt_prompts.system_reminder: + # Add system reminder as a pre-prompt context block + if hasattr(coder.gpt_prompts, "system_reminder") and coder.gpt_prompts.system_reminder: msg = dict( role="user", content=coder.fmt_system_prompt(coder.gpt_prompts.system_reminder), ) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=msg, tag=MessageTag.REMINDER, hash_key=("main", "system_reminder"), @@ -76,21 +98,26 @@ def add_system_messages(cls, coder) -> None: mark_for_delete=0, ) - @classmethod - def cleanup_files(cls, coder) -> None: + def cleanup_files(self) -> None: """ Clean up ConversationFiles and remove corresponding messages from ConversationManager for files that are no longer in the coder's read-only or chat file sets. - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return # Check diff message ratio and clear if too many diffs - diff_messages = ConversationManager.get_messages_dict(MessageTag.DIFFS) - read_only_messages = ConversationManager.get_messages_dict(MessageTag.READONLY_FILES) - chat_messages = ConversationManager.get_messages_dict(MessageTag.CHAT_FILES) - edit_messages = ConversationManager.get_messages_dict(MessageTag.EDIT_FILES) + diff_messages = ConversationService.get_manager(coder).get_messages_dict(MessageTag.DIFFS) + read_only_messages = ConversationService.get_manager(coder).get_messages_dict( + MessageTag.READONLY_FILES + ) + chat_messages = ConversationService.get_manager(coder).get_messages_dict( + MessageTag.CHAT_FILES + ) + edit_messages = ConversationService.get_manager(coder).get_messages_dict( + MessageTag.EDIT_FILES + ) # Calculate token counts for token-based ratio check diff_tokens = coder.main_model.token_count(diff_messages) if diff_messages else 0 @@ -123,13 +150,13 @@ def cleanup_files(cls, coder) -> None: if should_clear: # Clear all diff messages - ConversationManager.clear_tag(MessageTag.DIFFS) - ConversationManager.clear_tag(MessageTag.FILE_CONTEXTS) + ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS) + ConversationService.get_manager(coder).clear_tag(MessageTag.FILE_CONTEXTS) # Clear ConversationFiles caches to force regeneration - ConversationFiles.clear_file_cache() + ConversationService.get_files(coder).clear_file_cache() # Get all tracked files (both regular and image files) - tracked_files = ConversationFiles.get_all_tracked_files() + tracked_files = ConversationService.get_files(coder).get_all_tracked_files() # Get joint set of files that should be tracked # Read-only files (absolute paths) - include both regular and stub files @@ -151,30 +178,38 @@ def cleanup_files(cls, coder) -> None: for tracked_file in tracked_files: if tracked_file not in should_be_tracked: # Remove file from ConversationFiles cache - ConversationFiles.clear_file_cache(tracked_file) + ConversationService.get_files(coder).clear_file_cache(tracked_file) # Remove corresponding messages from ConversationManager # Try to remove regular file messages user_hash_key = ("file_user", tracked_file) assistant_hash_key = ("file_assistant", tracked_file) - ConversationManager.remove_message_by_hash_key(user_hash_key) - ConversationManager.remove_message_by_hash_key(assistant_hash_key) + ConversationService.get_manager(coder).remove_message_by_hash_key(user_hash_key) + ConversationService.get_manager(coder).remove_message_by_hash_key( + assistant_hash_key + ) # Try to remove image file messages image_user_hash_key = ("image_user", tracked_file) image_assistant_hash_key = ("image_assistant", tracked_file) - ConversationManager.remove_message_by_hash_key(image_user_hash_key) - ConversationManager.remove_message_by_hash_key(image_assistant_hash_key) + ConversationService.get_manager(coder).remove_message_by_hash_key( + image_user_hash_key + ) + ConversationService.get_manager(coder).remove_message_by_hash_key( + image_assistant_hash_key + ) - @classmethod - def add_file_list_reminder(cls, coder) -> None: + ConversationService.get_manager(coder).clear_tag(MessageTag.RULES) + + def add_file_list_reminder(self) -> None: """ Add a reminder message with list of readonly and editable files. The reminder lasts for exactly one turn (mark_for_delete=0). - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + # Get relative paths for display readonly_rel_files = [] if hasattr(coder, "abs_read_only_fnames"): @@ -194,16 +229,16 @@ def add_file_list_reminder(cls, coder) -> None: reminder_lines.append(f" - {f}") if editable_rel_files: - if reminder_lines: # Add separator if we already have readonly files + if len(reminder_lines) > 1: # Add separator if we already have readonly files reminder_lines.append("") reminder_lines.append("Editable files:") for f in editable_rel_files: reminder_lines.append(f" - {f}") - if reminder_lines: # Only add reminder if there are files + if len(reminder_lines) > 1: # Only add reminder if there are files reminder_lines.append("\n") reminder_content = "\n".join(reminder_lines) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict={ "role": "user", "content": reminder_content, @@ -214,18 +249,10 @@ def add_file_list_reminder(cls, coder) -> None: mark_for_delete=0, # Lasts for exactly one turn ) - @classmethod - def get_repo_map_string(cls, repo_data: Dict[str, Any]) -> str: + def get_repo_map_string(self, repo_data: Dict[str, Any]) -> str: """ Convert repository map data dict to formatted string representation. - - Args: - repo_data: Repository map data dict from get_repo_map() - - Returns: - Formatted string representation of repository map """ - # Get the combined and new dicts combined_dict = repo_data.get("combined_dict", {}) new_dict = repo_data.get("new_dict", {}) @@ -242,6 +269,7 @@ def get_repo_map_string(cls, repo_data: Dict[str, Any]) -> str: # Format the dict into text formatted_lines = [] + has_content = False # Add prefix if present if repo_data.get("prefix"): @@ -253,6 +281,7 @@ def get_repo_map_string(cls, repo_data: Dict[str, Any]) -> str: if not tags_info: # Special file without tags + has_content = True formatted_lines.append(f"### {rel_fname}") formatted_lines.append("") else: @@ -262,6 +291,7 @@ def get_repo_map_string(cls, repo_data: Dict[str, Any]) -> str: sorted_tags = sorted(tags_info.items(), key=lambda x: x[1].get("line", 0)) for tag_name, tag_info in sorted_tags: + has_content = True kind = tag_info.get("kind", "") start_line = tag_info.get("start_line", 0) end_line = tag_info.get("end_line", 0) @@ -283,32 +313,24 @@ def get_repo_map_string(cls, repo_data: Dict[str, Any]) -> str: if formatted_lines and formatted_lines[-1] == "": formatted_lines.pop() - if formatted_lines: + if formatted_lines and has_content: return "\n".join(formatted_lines) else: return "" - @classmethod - def add_repo_map_messages(cls, coder) -> List[Dict[str, Any]]: + def add_repo_map_messages(self) -> List[Dict[str, Any]]: """ Get repository map messages using new system. - - Args: - coder: The coder instance - - Returns: - List of repository map messages """ - from .manager import ConversationManager - from .tags import MessageTag - - ConversationManager.initialize(coder) + coder = self.get_coder() + if not coder: + return [] # Check if we have too many REPO tagged messages (20 or more) - repo_messages = ConversationManager.get_messages_dict(MessageTag.REPO) + repo_messages = ConversationService.get_manager(coder).get_messages_dict(MessageTag.REPO) if len(repo_messages) >= 20: # Clear all REPO tagged messages - ConversationManager.clear_tag(MessageTag.REPO) + ConversationService.get_manager(coder).clear_tag(MessageTag.REPO) # Clear the combined repomap dict to force fresh regeneration if ( hasattr(coder, "repo_map") @@ -350,8 +372,8 @@ def add_repo_map_messages(cls, coder) -> List[Dict[str, Any]]: new_json = xxhash.xxh3_128_hexdigest(json.dumps(new_dict, sort_keys=True).encode("utf-8")) dicts_are_same = combined_json == new_json - # Get formatted repository content using the new helper function - repo_content = cls.get_repo_map_string(repo_data) + # Get formatted repository content using the helper function + repo_content = self.get_repo_map_string(repo_data) if repo_content: # Only add messages if there's content # Create repository map messages @@ -368,7 +390,7 @@ def add_repo_map_messages(cls, coder) -> List[Dict[str, Any]]: priority = None if dicts_are_same else 200 content_hash = xxhash.xxh3_128_hexdigest(repo_content.encode("utf-8")) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=msg, tag=MessageTag.REPO, priority=priority, @@ -379,17 +401,71 @@ def add_repo_map_messages(cls, coder) -> List[Dict[str, Any]]: return repo_messages - @classmethod - def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: + def add_rules_messages(self) -> List[Dict[str, Any]]: """ - Get read-only file messages using new system. + Get rules file messages for reference. + These are always reloaded from disk and use the RULES tag. + """ + coder = self.get_coder() + if not coder: + return [] + + messages = [] + if not hasattr(coder, "abs_rules_fnames") or not coder.abs_rules_fnames: + return messages + + for fname in sorted(coder.abs_rules_fnames): + # Read file content directly from disk + try: + content = coder.io.read_text(fname) + if content is None: + continue + + except Exception: + continue + + rel_fname = coder.get_rel_fname(fname) + + # Create user message + user_msg = { + "role": "user", + "content": f"Rules defined in {rel_fname}:\n\n{content}", + } + # Create assistant message + assistant_msg = { + "role": "assistant", + "content": f"I understand the rules in {rel_fname} and will follow them.", + } + + # Add to ConversationManager with RULES tag + ConversationService.get_manager(coder).add_message( + message_dict=user_msg, + tag=MessageTag.RULES, + hash_key=("rules_user", fname), + force=True, + update_timestamp=False, + ) - Args: - coder: The coder instance + ConversationService.get_manager(coder).add_message( + message_dict=assistant_msg, + tag=MessageTag.RULES, + hash_key=("rules_assistant", fname), + force=True, + update_timestamp=False, + ) + + messages.extend([user_msg, assistant_msg]) - Returns: - List of read-only file messages + return messages + + def add_readonly_files_messages(self) -> List[Dict[str, Any]]: + """ + Get read-only file messages using new system. """ + coder = self.get_coder() + if not coder: + return [] + messages = [] refresh = not coder.file_diffs @@ -413,10 +489,10 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: # Process regular files for fname in regular_files: # First, add file to cache and check for changes - ConversationFiles.add_file(fname, force_refresh=refresh) + ConversationService.get_files(coder).add_file(fname, force_refresh=refresh) # Get file content (with proper caching and stub generation) - content = ConversationFiles.get_file_stub(fname) + content = ConversationService.get_files(coder).get_file_stub(fname) if content: # Add user message with file path as hash_key rel_fname = coder.get_rel_fname(fname) @@ -434,7 +510,7 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: "content": f"{file_preamble}\n{rel_fname}\n\n{content}\n\n{file_postamble}", } - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=user_msg, tag=MessageTag.READONLY_FILES, hash_key=("file_user", fname), # Use file path as part of hash_key @@ -448,7 +524,7 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: "role": "assistant", "content": "I understand, thank you for sharing the file contents.", } - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=assistant_msg, tag=MessageTag.READONLY_FILES, hash_key=("file_assistant", fname), # Use file path as part of hash_key @@ -458,8 +534,8 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: messages.append(assistant_msg) # Check if file has changed and add diff message if needed - if ConversationFiles.has_file_changed(fname): - ConversationFiles.update_file_diff(fname) + if ConversationService.get_files(coder).has_file_changed(fname): + ConversationService.get_files(coder).update_file_diff(fname) # Handle image files using coder.get_images_message() if image_files: @@ -479,12 +555,12 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: fname = img_msg.get("image_file") if fname: # Add to ConversationManager with individual file hash key - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=img_msg, tag=MessageTag.READONLY_FILES, hash_key=("image_user", fname), ) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=assistant_msg, tag=MessageTag.READONLY_FILES, hash_key=("image_assistant", fname), @@ -492,17 +568,14 @@ def add_readonly_files_messages(cls, coder) -> List[Dict[str, Any]]: return messages - @classmethod - def add_chat_files_messages(cls, coder) -> Dict[str, Any]: + def add_chat_files_messages(self) -> Dict[str, Any]: """ Get chat file messages using new system. - - Args: - coder: The coder instance - - Returns: - Dictionary with chat_files and edit_files lists """ + coder = self.get_coder() + if not coder: + return {"chat_files": [], "edit_files": []} + result = {"chat_files": [], "edit_files": []} refresh = not coder.file_diffs @@ -523,12 +596,12 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: # Process regular files for fname in regular_files: # First, add file to cache and check for changes - ConversationFiles.add_file(fname, force_refresh=refresh) + ConversationService.get_files(coder).add_file(fname, force_refresh=refresh) # Get file content (with proper caching and stub generation) - content = ConversationFiles.get_file_stub(fname) + content = ConversationService.get_files(coder).get_file_stub(fname) if not content: - ConversationFiles.clear_file_cache(fname) + ConversationService.get_files(coder).clear_file_cache(fname) continue rel_fname = coder.get_rel_fname(fname) @@ -557,7 +630,7 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: result["chat_files"].extend([user_msg, assistant_msg]) # Add user message to ConversationManager with file path as hash_key - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=user_msg, tag=tag, hash_key=("file_user", fname), # Use file path as part of hash_key @@ -566,7 +639,7 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: ) # Add assistant message to ConversationManager with file path as hash_key - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=assistant_msg, tag=tag, hash_key=("file_assistant", fname), # Use file path as part of hash_key @@ -575,8 +648,8 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: ) # Check if file has changed and add diff message if needed - if ConversationFiles.has_file_changed(fname): - ConversationFiles.update_file_diff(fname) + if ConversationService.get_files(coder).has_file_changed(fname): + ConversationService.get_files(coder).update_file_diff(fname) # Handle image files using coder.get_images_message() if image_files: @@ -596,12 +669,12 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: fname = img_msg.get("image_file") if fname: # Add to ConversationManager with individual file hash key - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=img_msg, tag=MessageTag.CHAT_FILES, hash_key=("image_user", fname), ) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=assistant_msg, tag=MessageTag.CHAT_FILES, hash_key=("image_assistant", fname), @@ -609,23 +682,23 @@ def add_chat_files_messages(cls, coder) -> Dict[str, Any]: return result - @classmethod - def add_file_context_messages(cls, coder) -> None: + def add_file_context_messages(self) -> None: """ Create and insert FILE_CONTEXTS messages based on cached contexts. - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + # Get numbered contexts - numbered_contexts = ConversationFiles._get_numbered_contexts() + numbered_contexts = ConversationService.get_files(coder)._get_numbered_contexts() for file_path, ranges in numbered_contexts.items(): if not ranges: continue # Generate context content - context_content = ConversationFiles.get_file_context(file_path) + context_content = ConversationService.get_files(coder).get_file_context(file_path) if not context_content: continue @@ -634,96 +707,52 @@ def add_file_context_messages(cls, coder) -> None: user_msg = { "role": "user", - "content": f"Numbered Context For:\n{rel_fname}\n\n{context_content}", + "content": f"Hashline-Prefixed Context For:\n{rel_fname}\n\n{context_content}", } assistant_msg = { "role": "assistant", - "content": "I understand, thank you for sharing the file contents.", + "content": "I understand, thank you for sharing the prefixed file contents.", } # Add to conversation manager - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=user_msg, tag=MessageTag.FILE_CONTEXTS, hash_key=("file_context_user", file_path), force=True, + promotion=ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict=assistant_msg, tag=MessageTag.FILE_CONTEXTS, hash_key=("file_context_assistant", file_path), force=True, + promotion=ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, ) - @classmethod - def add_assistant_reply(cls, coder, partial_response_chunks) -> None: - """ - Add assistant's reply to current conversation messages. - - Args: - coder: The coder instance - partial_response_chunks: Response chunks from LLM - """ - # Extract response from chunks - # This is a simplified version - actual extraction would be more complex - response_content = "" - tool_calls = None - - for chunk in partial_response_chunks: - if hasattr(chunk, "choices") and chunk.choices: - delta = chunk.choices[0].delta - if hasattr(delta, "content") and delta.content: - response_content += delta.content - if hasattr(delta, "tool_calls") and delta.tool_calls: - if tool_calls is None: - tool_calls = [] - tool_calls.extend(delta.tool_calls) - - # Create message dictionary - message_dict = {"role": "assistant"} - if response_content: - message_dict["content"] = response_content - if tool_calls: - message_dict["tool_calls"] = tool_calls - - # Add to conversation - ConversationManager.add_message( - message_dict=message_dict, - tag=MessageTag.CUR, - ) - - @classmethod - def clear_conversation(cls, coder) -> None: - """ - Clear all user and assistant messages from conversation. - - Args: - coder: The coder instance - """ - # Clear CUR and DONE messages - ConversationManager.clear_tag(MessageTag.CUR) - ConversationManager.clear_tag(MessageTag.DONE) - - @classmethod - def reset(cls) -> None: + def reset(self) -> None: """ Reset the entire conversation system to initial state. """ - ConversationManager.reset() - ConversationFiles.reset() + coder = self.get_coder() + ConversationService.get_manager(coder).reset() + if coder: + ConversationService.get_files(coder).reset() - @classmethod - def add_static_context_blocks(cls, coder) -> None: + def add_static_context_blocks(self) -> None: """ Add static context blocks to conversation (priority 50). Static blocks include: environment_info, directory_structure, skills - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return @@ -749,24 +778,24 @@ def add_static_context_blocks(cls, coder) -> None: # Add static blocks to conversation manager with stable hash keys for block_type, block_content in message_blocks.items(): - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict={"role": "user", "content": block_content}, tag=MessageTag.STATIC, hash_key=("static", block_type), force=True, ) - @classmethod - def add_pre_message_context_blocks(cls, coder) -> None: + def add_pre_message_context_blocks(self) -> None: """ Add pre-message context blocks to conversation (priority 125). Pre-message blocks include: symbol_outline, git_status, todo_list, loaded_skills, context_summary - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return @@ -792,7 +821,7 @@ def add_pre_message_context_blocks(cls, coder) -> None: # Process other blocks for block_type, block_content in message_blocks.items(): - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict={"role": "user", "content": block_content}, tag=MessageTag.STATIC, # Use STATIC tag but with different priority priority=125, # Between REPO (100) and READONLY_FILES (200) @@ -800,16 +829,16 @@ def add_pre_message_context_blocks(cls, coder) -> None: force=True, ) - @classmethod - def add_post_message_context_blocks(cls, coder) -> None: + def add_post_message_context_blocks(self) -> None: """ Add post-message context blocks to conversation (priority 250). Post-message blocks include: tool_context/write_context, background_command_output - - Args: - coder: The coder instance """ + coder = self.get_coder() + if not coder: + return + if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return @@ -851,7 +880,7 @@ def add_post_message_context_blocks(cls, coder) -> None: # Add post-message blocks to conversation manager with stable hash keys for block_type, block_content in message_blocks.items(): - ConversationManager.add_message( + ConversationService.get_manager(coder).add_message( message_dict={"role": "user", "content": block_content}, tag=MessageTag.STATIC, # Use STATIC tag but with different priority priority=250, # Between CUR (200) and REMINDER (300) @@ -860,12 +889,13 @@ def add_post_message_context_blocks(cls, coder) -> None: force=True, ) - @classmethod - def debug_print_conversation_state(cls) -> None: + def debug_print_conversation_state(self) -> None: """ Print debug information about conversation state. """ + coder = self.get_coder() print("=== Conversation Manager State ===") - ConversationManager.debug_print_stream() - print("\n=== Conversation Files State ===") - ConversationFiles.debug_print_cache() + ConversationService.get_manager(coder).debug_print_stream() + if coder: + print("\n=== Conversation Files State ===") + ConversationService.get_files(coder).debug_print_cache() diff --git a/cecli/helpers/conversation/manager.py b/cecli/helpers/conversation/manager.py index 6385d42d3b0..5561979189b 100644 --- a/cecli/helpers/conversation/manager.py +++ b/cecli/helpers/conversation/manager.py @@ -3,6 +3,7 @@ import time import weakref from typing import Any, Dict, List, Optional, Tuple, Union +from uuid import UUID from cecli.helpers import nested @@ -11,49 +12,54 @@ class ConversationManager: - """ - Singleton class that manages the collection of BaseMessage instances. - Provides utility methods for ordering, filtering, and lifecycle management. + _instances: Dict[UUID, "ConversationManager"] = {} + + def __init__(self, coder): + self.coder = weakref.ref(coder) + self.uuid = coder.uuid + self._messages: List[BaseMessage] = [] + self._message_index: Dict[str, BaseMessage] = {} + self._initialized = False + self._debug_enabled = False + self._previous_messages_dict: List[Dict[str, Any]] = [] + self._tag_cache: Dict[str, List[Dict[str, Any]]] = {} + self._ALL_MESSAGES_CACHE_KEY = "__all__" + self.DEFAULT_TAG_PROMOTION_VALUE: int = 999 - Design: Singleton class with static methods, not requiring initialization. - """ - - # Class-level storage for singleton pattern - _messages: List[BaseMessage] = [] - _message_index: Dict[str, BaseMessage] = {} - _coder_ref = None - _initialized = False + @classmethod + def get_instance(cls, coder) -> "ConversationManager": + """Get or create manager for coder.""" + if coder.uuid not in cls._instances: + cls._instances[coder.uuid] = cls(coder) - # Debugging - _debug_enabled: bool = False - _previous_messages_dict: List[Dict[str, Any]] = [] + # Update weakref for SwitchCoderSignal + if coder is not cls._instances[coder.uuid].get_coder(): + cls._instances[coder.uuid].coder = weakref.ref(coder) - # Caching for tagged message dict queries - _tag_cache: Dict[str, List[Dict[str, Any]]] = {} - _ALL_MESSAGES_CACHE_KEY = "__all__" # Special key for caching all messages (tag=None) + return cls._instances[coder.uuid] @classmethod + def destroy_instance(cls, coder_uuid: UUID): + """Explicit cleanup for sub-agents.""" + if coder_uuid in cls._instances: + del cls._instances[coder_uuid] + + def get_coder(self): + """Get strong reference to coder (or None if destroyed).""" + return self.coder() + def initialize( - cls, - coder, + self, reset: bool = False, reformat: bool = False, preserve_tags: Optional[Union[List[str], bool]] = None, ) -> None: - """ - Set up singleton with weak reference to coder. + """Set up manager with weak reference to coder.""" + coder = self.get_coder() + if not coder: + return - Args: - coder: The coder instance to reference - reset: Whether to re-initialize the conversation history itself - reformat: Whether to format chat history - (useful for initialization outside of coder class) - preserve_tags: Optional list of tag strings to preserve during reset. - If provided, messages with these tags will be preserved - when reset=True and re-added AFTER the reformat block. - """ - cls._coder_ref = weakref.ref(coder) - cls._initialized = True + self._initialized = True preserved_messages = [] if preserve_tags is True: @@ -65,61 +71,53 @@ def initialize( ] if reset and preserve_tags: - # New approach: loop over every single tag type and only clear tags NOT in preserve_tags - # Get all MessageTag values all_tag_types = list(MessageTag) - - # Clear tags that are NOT in preserve_tags for tag_type in all_tag_types: if tag_type.value not in preserve_tags: - cls.clear_tag(tag_type) - - # Get all remaining messages left over after preservation - preserved_messages = cls.get_messages() + self.clear_tag(tag_type) + preserved_messages = self.get_messages() elif reset: - # Original behavior: clear everything - cls.reset() + self.reset() if reformat: if hasattr(coder, "format_chat_chunks"): coder.format_chat_chunks() - # If preserve_tags is truthy, re-add preserved messages with updated timestamps after reformat block if preserve_tags and preserved_messages: offset = 0 for msg in preserved_messages: offset += 1 msg.timestamp = time.monotonic_ns() + offset - # Enable debug mode if coder has verbose attribute and it's True if hasattr(coder, "verbose") and coder.verbose: - cls._debug_enabled = True + self._debug_enabled = True - @classmethod - def set_debug_enabled(cls, enabled: bool) -> None: + def set_debug_enabled(self, enabled: bool) -> None: """ Enable or disable debug mode. Args: enabled: True to enable debug mode, False to disable """ - cls._debug_enabled = enabled + self._debug_enabled = enabled if enabled: - print("[DEBUG] ConversationManager debug mode enabled") + print(f"[DEBUG] ConversationManager debug mode enabled for coder {self.uuid}") else: - print("[DEBUG] ConversationManager debug mode disabled") + print(f"[DEBUG] ConversationManager debug mode disabled for coder {self.uuid}") - @classmethod def add_message( - cls, + self, message_dict: Dict[str, Any], tag: str, priority: Optional[int] = None, timestamp: Optional[int] = None, mark_for_delete: Optional[int] = None, + mark_for_demotion: Optional[int] = None, + promotion: Optional[int] = None, hash_key: Optional[Tuple[str, ...]] = None, force: bool = False, update_timestamp: bool = True, + update_promotion: bool = False, message_id: Optional[str] = None, ) -> BaseMessage: """ @@ -132,40 +130,40 @@ def add_message( priority: Priority value (lower = earlier) timestamp: Creation timestamp in nanoseconds mark_for_delete: Countdown for deletion (None = permanent) - hash_key: Custom hash key for message identification - force: If True, update existing message with same hash - update_timestamp: If True, update timestamp when force=True (default True) - - Returns: - The created or updated BaseMessage instance + mark_for_demotion: Countdown for demotion (None = permanent) + promotion: Promotion priority value + hash_key: Optional tuple for pattern-based removal + force: Whether to update existing message + update_timestamp: Whether to update timestamp on force update + update_promotion: Whether to update promotion on force update + message_id: Optional explicit message ID """ - # Validate tag if not isinstance(tag, MessageTag): try: tag = MessageTag(tag) except ValueError: raise ValueError(f"Invalid tag: {tag}") - # Set defaults if not provided if priority is None: priority = get_default_priority(tag) if timestamp is None: timestamp = time.monotonic_ns() + get_default_timestamp_offset(tag) - # Create message instance message = BaseMessage( message_dict=message_dict, - tag=tag.value, # Store as string for serialization + tag=tag.value, priority=priority, timestamp=timestamp, mark_for_delete=mark_for_delete, + mark_for_demotion=mark_for_demotion, + promotion=promotion, hash_key=hash_key, message_id=message_id, ) # Check if message already exists - existing_message = cls._message_index.get(message.message_id) + existing_message = self._message_index.get(message.message_id) if existing_message: if force: @@ -175,46 +173,66 @@ def add_message( existing_message.priority = priority if update_timestamp: existing_message.timestamp = timestamp + if update_promotion: + existing_message.mark_for_demotion = mark_for_demotion + existing_message.promotion = promotion existing_message.mark_for_delete = mark_for_delete # Clear cache for this tag and all messages cache since message was updated - cls._tag_cache.pop(tag.value, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag.value, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) return existing_message else: # Return existing message without updating return existing_message else: # Add new message - cls._messages.append(message) - cls._message_index[message.message_id] = message + self._messages.append(message) + self._message_index[message.message_id] = message # Clear cache for this tag and all messages cache since new message was added - cls._tag_cache.pop(tag.value, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag.value, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) return message - @classmethod - def get_messages(cls) -> List[BaseMessage]: + def base_sort(self, messages: List[BaseMessage]) -> List[BaseMessage]: """ - Returns messages sorted by priority (lowest first), then raw order in list. + Sorts messages by effective priority (promotion if mark_for_demotion has not elapsed yet), then timestamp. + + Args: + messages: List of BaseMessage instances to sort Returns: - List of BaseMessage instances in sorted order + Sorted list of messages """ - # Filter out expired messages first - cls._remove_expired_messages() - - # Sort by priority (ascending), then timestamp (ascending), preserving original order for ties return [ msg for _, msg in sorted( - enumerate(cls._messages), - key=lambda pair: (pair[1].priority, pair[1].timestamp, pair[0]), + enumerate(messages), + key=lambda pair: ( + ( + pair[1].promotion + if pair[1].is_promoted() and pair[1].promotion is not None + else pair[1].priority + ), + pair[1].timestamp, + pair[0], + ), ) ] - @classmethod + def get_messages(self) -> List[BaseMessage]: + """ + Returns messages sorted by priority (lowest first), then raw order in list. + + Returns: + List of BaseMessage instances in sorted order + """ + # Filter out expired messages first + self._remove_expired_messages() + + return self.base_sort(self._messages) + def get_messages_dict( - cls, tag: Optional[str] = None, reload: bool = False + self, tag: Optional[str] = None, reload: bool = False ) -> List[Dict[str, Any]]: """ Returns sorted list of message_dict for LLM consumption. @@ -226,7 +244,7 @@ def get_messages_dict( Returns: List of message dictionaries in sorted order """ - coder = cls.get_coder() + coder = self.get_coder() # Check cache for all queries (including tag=None) if not reload: @@ -238,13 +256,13 @@ def get_messages_dict( raise ValueError(f"Invalid tag: {tag}") cache_key = tag.value else: - cache_key = cls._ALL_MESSAGES_CACHE_KEY + cache_key = self._ALL_MESSAGES_CACHE_KEY # Return cached result if available - if cache_key in cls._tag_cache: - return cls._tag_cache[cache_key] + if cache_key in self._tag_cache: + return self._tag_cache[cache_key] - messages = cls.get_messages() + messages = self.get_messages() # Filter by tag if specified if tag is not None: @@ -267,24 +285,24 @@ def get_messages_dict( raise ValueError(f"Invalid tag: {tag}") cache_key = tag.value else: - cache_key = cls._ALL_MESSAGES_CACHE_KEY + cache_key = self._ALL_MESSAGES_CACHE_KEY - cls._tag_cache[cache_key] = messages_dict + self._tag_cache[cache_key] = messages_dict # Debug: Compare with previous messages if debug is enabled # We need to compare the full unfiltered message stream, not just filtered views - if cls._debug_enabled and tag is None: + if self._debug_enabled and tag is None: # Get the full unfiltered messages for comparison - all_messages = cls.get_messages() + all_messages = self.get_messages() all_messages_dict = [msg.to_dict() for msg in all_messages] # Compare with previous full message dict - cls._debug_compare_messages(cls._previous_messages_dict, all_messages_dict) + self._debug_compare_messages(self._previous_messages_dict, all_messages_dict) # Store current full message dict for next comparison - cls._previous_messages_dict = all_messages_dict + self._previous_messages_dict = all_messages_dict - if (cls._debug_enabled and tag is None) or ( + if (self._debug_enabled and tag is None) or ( nested.getter(coder, "args.debug") and tag is None ): import os @@ -300,14 +318,13 @@ def get_messages_dict( coder and hasattr(coder, "add_cache_headers") and coder.add_cache_headers + and hasattr(coder, "main_model") and not coder.main_model.caches_by_default ): - messages_dict = cls._add_cache_control(messages_dict) - + messages_dict = self._add_cache_control(messages_dict) return messages_dict - @classmethod - def clear_tag(cls, tag: str) -> None: + def clear_tag(self, tag: str) -> None: """Remove all messages with given tag.""" if not isinstance(tag, MessageTag): try: @@ -318,21 +335,20 @@ def clear_tag(cls, tag: str) -> None: tag_str = tag.value messages_to_remove = [] - for message in cls._messages: + for message in self._messages: if message.tag == tag_str: messages_to_remove.append(message) for message in messages_to_remove: - cls._messages.remove(message) - del cls._message_index[message.message_id] + self._messages.remove(message) + del self._message_index[message.message_id] # Clear cache for this tag and all messages cache since messages were removed if messages_to_remove: - cls._tag_cache.pop(tag_str, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag_str, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) - @classmethod - def remove_messages_by_hash_key_pattern(cls, pattern_checker) -> None: + def remove_messages_by_hash_key_pattern(self, pattern_checker) -> None: """ Remove messages whose hash_key matches a pattern. @@ -342,25 +358,24 @@ def remove_messages_by_hash_key_pattern(cls, pattern_checker) -> None: """ messages_to_remove = [] - for message in cls._messages: + for message in self._messages: if message.hash_key and pattern_checker(message.hash_key): messages_to_remove.append(message) # Remove messages and track affected tags tags_to_clear = set() for message in messages_to_remove: - cls._messages.remove(message) - del cls._message_index[message.message_id] + self._messages.remove(message) + del self._message_index[message.message_id] tags_to_clear.add(message.tag) # Clear cache for affected tags and all messages cache if any messages were removed if messages_to_remove: for tag in tags_to_clear: - cls._tag_cache.pop(tag, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) - @classmethod - def remove_message_by_hash_key(cls, hash_key: Tuple[str, ...]) -> bool: + def remove_message_by_hash_key(self, hash_key: Tuple[str, ...]) -> bool: """ Remove a message by its exact hash key. @@ -370,25 +385,24 @@ def remove_message_by_hash_key(cls, hash_key: Tuple[str, ...]) -> bool: Returns: True if a message was removed, False otherwise """ - messages_to_remove = [m for m in cls._messages if m.hash_key == hash_key] + messages_to_remove = [m for m in self._messages if m.hash_key == hash_key] if not messages_to_remove: return False tags_to_clear = set() for message in messages_to_remove: - cls._messages.remove(message) - if message.message_id in cls._message_index: - del cls._message_index[message.message_id] + self._messages.remove(message) + if message.message_id in self._message_index: + del self._message_index[message.message_id] tags_to_clear.add(message.tag) for tag in tags_to_clear: - cls._tag_cache.pop(tag, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) return True - @classmethod - def get_tag_messages(cls, tag: str) -> List[BaseMessage]: + def get_tag_messages(self, tag: str) -> List[BaseMessage]: """Get all messages of given tag in sorted order.""" if not isinstance(tag, MessageTag): try: @@ -397,72 +411,63 @@ def get_tag_messages(cls, tag: str) -> List[BaseMessage]: raise ValueError(f"Invalid tag: {tag}") tag_str = tag.value - messages = [msg for msg in cls._messages if msg.tag == tag_str] - return sorted(messages, key=lambda msg: (msg.priority, msg.timestamp)) + messages = [msg for msg in self._messages if msg.tag == tag_str] + return self.base_sort(messages) - @classmethod - def decrement_mark_for_delete(cls) -> None: + def decrement_message_markers(self) -> None: """Decrement all mark_for_delete values, remove expired messages.""" messages_to_remove = [] - for message in cls._messages: + for message in self._messages: if message.mark_for_delete is not None: message.mark_for_delete -= 1 if message.is_expired(): messages_to_remove.append(message) + if message.mark_for_demotion is not None: + message.mark_for_demotion -= 1 + # Remove expired messages and clear cache for each tag tags_to_clear = set() for message in messages_to_remove: - cls._messages.remove(message) - del cls._message_index[message.message_id] + self._messages.remove(message) + del self._message_index[message.message_id] tags_to_clear.add(message.tag) # Clear cache for affected tags and all messages cache if any messages were removed if messages_to_remove: for tag in tags_to_clear: - cls._tag_cache.pop(tag, None) - cls._tag_cache.pop(cls._ALL_MESSAGES_CACHE_KEY, None) + self._tag_cache.pop(tag, None) + self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) - @classmethod - def get_coder(cls): - """Get current coder instance via weak reference.""" - if cls._coder_ref: - return cls._coder_ref() - return None - - @classmethod - def reset(cls) -> None: + def reset(self) -> None: """Clear all messages and reset to initial state.""" - cls._messages.clear() - cls._message_index.clear() - cls._coder_ref = None - cls._initialized = False - cls._tag_cache.clear() + self._messages.clear() + self._message_index.clear() + self._initialized = False + self._tag_cache.clear() + self._previous_messages_dict.clear() - @classmethod - def clear_cache(cls) -> None: + def clear_cache(self) -> None: """Clear the tag cache.""" - cls._tag_cache.clear() + self._tag_cache.clear() - @classmethod - def _remove_expired_messages(cls) -> None: + def _remove_expired_messages(self) -> None: """Internal method to remove expired messages.""" messages_to_remove = [] - for message in cls._messages: + for message in self._messages: if message.is_expired(): messages_to_remove.append(message) for message in messages_to_remove: - cls._messages.remove(message) - del cls._message_index[message.message_id] + self._messages.remove(message) + del self._message_index[message.message_id] # Debug methods - @classmethod - def debug_print_stream(cls) -> None: + def debug_print_stream(self) -> None: """Print the conversation stream with hashes, priorities, timestamps, and tags.""" - messages = cls.get_messages() + messages = self.get_messages() print(f"Conversation Stream ({len(messages)} messages):") for i, msg in enumerate(messages): role = msg.message_dict.get("role", "unknown") @@ -473,44 +478,43 @@ def debug_print_stream(cls) -> None: f"'{content_preview}...'" ) - @classmethod - def debug_get_stream_info(cls) -> Dict[str, Any]: + def debug_get_stream_info(self) -> Dict[str, Any]: """Return dict with stream length, hash list, and modification count.""" - messages = cls.get_messages() + messages = self.get_messages() return { "stream_length": len(messages), + "message_count": len(self._messages), "hashes": [msg.message_id[:8] for msg in messages], "tags": [msg.tag for msg in messages], "priorities": [msg.priority for msg in messages], + "debug_enabled": self._debug_enabled, } - @classmethod - def debug_validate_state(cls) -> bool: + def debug_validate_state(self) -> bool: """Validate internal consistency of message list and index.""" # Check that all messages in list are in index - for msg in cls._messages: - if msg.message_id not in cls._message_index: + for msg in self._messages: + if msg.message_id not in self._message_index: return False - if cls._message_index[msg.message_id] is not msg: + if self._message_index[msg.message_id] is not msg: return False # Check that all messages in index are in list - for msg_id, msg in cls._message_index.items(): - if msg not in cls._messages: + for msg_id, msg in self._message_index.items(): + if msg not in self._messages: return False if msg.message_id != msg_id: return False # Check for duplicate message IDs - message_ids = [msg.message_id for msg in cls._messages] + message_ids = [msg.message_id for msg in self._messages] if len(message_ids) != len(set(message_ids)): return False return True - @classmethod def _debug_compare_messages( - cls, messages_before: List[Dict[str, Any]], messages_after: List[Dict[str, Any]] + self, messages_before: List[Dict[str, Any]], messages_after: List[Dict[str, Any]] ) -> None: """ Debug helper to compare messages before and after adding new chunk ones calculation. @@ -584,8 +588,7 @@ def _debug_compare_messages( ) print(f"[DEBUG] Is Proper Superset: {after_joined.startswith(before_unsuffixed_joined)}") - @classmethod - def _add_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _add_cache_control(self, messages_dict: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Add cache control headers to messages dict for LLM consumption. Uses 3 cache blocks based on message roles: @@ -609,6 +612,7 @@ def _add_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[st # Only consider messages with role "user" or "assistant" (not "tool") last_message_idx = -1 second_last_message_idx = -1 + seen_context = False # Find the last non-" List[Dict[st role = msg.get("role", "") tool_calls = msg.get("tool_calls", []) - if tool_calls is not None and len(tool_calls): + if tool_calls is not None and len(tool_calls) and not seen_context: continue if isinstance(content, str) and content.strip().startswith("'): continue + else: + last_message_idx = i + break - if role not in ["system", "user"]: + if role not in ["system", "user", "assistant"]: continue - last_message_idx = i - break + if seen_context: + last_message_idx = i + break + else: + continue # Find the second-to-last message with valid role if last_message_idx >= 0: @@ -638,10 +649,7 @@ def _add_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[st role = msg.get("role", "") tool_calls = msg.get("tool_calls", []) - if tool_calls is not None and len(tool_calls): - continue - - if role not in ["system", "user"]: + if role not in ["system", "user", "assistant"]: continue second_last_message_idx = i @@ -661,22 +669,21 @@ def _add_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[st # Add cache control to system message if found if system_message_idx >= 0: - messages_dict = cls._add_cache_control_to_message(messages_dict, system_message_idx) + messages_dict = self._add_cache_control_to_message(messages_dict, system_message_idx) # Add cache control to last message if last_message_idx >= 0: - messages_dict = cls._add_cache_control_to_message(messages_dict, last_message_idx) + messages_dict = self._add_cache_control_to_message(messages_dict, last_message_idx) # Add cache control to second-to-last message if it exists if second_last_message_idx >= 0: - messages_dict = cls._add_cache_control_to_message( + messages_dict = self._add_cache_control_to_message( messages_dict, second_last_message_idx, penultimate=True ) return messages_dict - @classmethod - def _strip_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _strip_cache_control(self, messages_dict: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Strip cache control entries from messages dict. @@ -712,9 +719,8 @@ def _strip_cache_control(cls, messages_dict: List[Dict[str, Any]]) -> List[Dict[ return result - @classmethod def _add_cache_control_to_message( - cls, messages_dict: List[Dict[str, Any]], idx: int, penultimate: bool = False + self, messages_dict: List[Dict[str, Any]], idx: int, penultimate: bool = False ) -> List[Dict[str, Any]]: """ Add cache control to a specific message in the messages dict. diff --git a/cecli/helpers/conversation/service.py b/cecli/helpers/conversation/service.py new file mode 100644 index 00000000000..61f72a2ff8a --- /dev/null +++ b/cecli/helpers/conversation/service.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +if TYPE_CHECKING: + from .files import ConversationFiles + from .integration import ConversationChunks + from .manager import ConversationManager + + +class ConversationService: + """Facade for accessing conversation components.""" + + @staticmethod + def get_manager(coder) -> "ConversationManager": + from .manager import ConversationManager + + return ConversationManager.get_instance(coder) + + @staticmethod + def get_chunks(coder) -> "ConversationChunks": + from .integration import ConversationChunks + + return ConversationChunks.get_instance(coder) + + @staticmethod + def get_files(coder) -> "ConversationFiles": + from .files import ConversationFiles + + return ConversationFiles.get_instance(coder) + + @staticmethod + def destroy_instances(coder_uuid: UUID): + """Explicit cleanup for sub-agents.""" + from .files import ConversationFiles + from .integration import ConversationChunks + from .manager import ConversationManager + + ConversationManager.destroy_instance(coder_uuid) + ConversationChunks.destroy_instance(coder_uuid) + ConversationFiles.destroy_instance(coder_uuid) diff --git a/cecli/helpers/conversation/tags.py b/cecli/helpers/conversation/tags.py index 4972a1b90de..1d259821b83 100644 --- a/cecli/helpers/conversation/tags.py +++ b/cecli/helpers/conversation/tags.py @@ -14,6 +14,7 @@ class MessageTag(str, Enum): STATIC = "static" EXAMPLES = "examples" REPO = "repo" + RULES = "rules" READONLY_FILES = "readonly_files" CHAT_FILES = "chat_files" EDIT_FILES = "edit_files" @@ -30,7 +31,8 @@ class MessageTag(str, Enum): MessageTag.SYSTEM: 0, MessageTag.STATIC: 50, MessageTag.EXAMPLES: 75, - MessageTag.REPO: 100, + MessageTag.RULES: 100, + MessageTag.REPO: 150, MessageTag.READONLY_FILES: 200, MessageTag.CHAT_FILES: 200, MessageTag.EDIT_FILES: 200, @@ -48,6 +50,7 @@ class MessageTag(str, Enum): MessageTag.SYSTEM: 0, MessageTag.STATIC: 0, MessageTag.EXAMPLES: 0, + MessageTag.RULES: 0, MessageTag.REPO: 0, MessageTag.READONLY_FILES: 0, MessageTag.CHAT_FILES: 0, diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index a2d8167d5dd..3d3d905488d 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1308,11 +1308,11 @@ def apply_hashline_operations( hashed_lines, start_hash_fragment, start_line_num_str ) - if found_start is None: - # Fall back to fragment matching if exact match fails - found_start = find_hashline_by_fragment( - hashed_lines, start_hash_fragment, start_line_num - ) + # if found_start is None: + # # Fall back to fragment matching if exact match fails + # found_start = find_hashline_by_fragment( + # hashed_lines, start_hash_fragment, start_line_num + # ) if found_start is None: raise HashlineError( @@ -1363,7 +1363,7 @@ def apply_hashline_operations( ) except Exception as e: raise HashlineError( - f"Could not resolve hash range {start_hash}-{end_hash}: {str(e)}" + f"Could not resolve hash range {start_hash}...{end_hash}: {str(e)}" ) except Exception as e: @@ -1468,7 +1468,11 @@ def apply_hashline_operations( result = strip_hashline(result_with_hashes) # Respect original trailing newline - if not original_content.endswith("\n") and result.endswith("\n"): + if original_content.endswith("\n") and result and not result.endswith("\n"): + # Original ends with newline but result doesn't - add one + result += "\n" + elif not original_content.endswith("\n") and result.endswith("\n"): + # Original doesn't end with newline but result does - remove it result = result[:-1] return result, successful_ops, failed_ops diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 49970bdabea..f4d16ba1879 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -4,15 +4,15 @@ class HashPos: - B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_" # The actual coprime period (64 * 63) PERIOD = 4032 - # Regex pattern for HashPos format: [{4-char-hash}] - HASH_PREFIX_RE = re.compile(r"^[\[\(\{\|]([0-9a-zA-Z\-_@]{4})[\|\}\)\]]") - # Regex for normalization: optional leading bracket, 4 hash chars, then a bracket - NORMALIZE_RE = re.compile(r"^[\[\(\{\|]?([0-9a-zA-Z\-_@]{4})[\|\}\)\]]") + # Regex pattern for HashPos format: {4-char-hash}:: + HASH_PREFIX_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})::") + # Regex for normalization: 4 hash chars optionally followed by '::' + NORMALIZE_RE = re.compile(r"^([0-9a-zA-Z\~_@]{4})(?:)?::") # Regex for a raw 4-character fragment - FRAGMENT_RE = re.compile(r"^[0-9a-zA-Z\-_@]{4}$") + FRAGMENT_RE = re.compile(r"^[0-9a-zA-Z\~_@]{4}$") def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() @@ -33,7 +33,9 @@ def generate_private_id(self, text: str) -> str: def generate_public_id(self, text: str, line_idx: int) -> str: content_bits = self._get_content_bits(text) anchor_bits = self._get_anchor_bits(line_idx) - packed = (content_bits << 12) | (anchor_bits ^ content_bits) + # Apply modular offset to content bits using anchor bits + offset_content = (content_bits + anchor_bits) & 0xFFF + packed = (offset_content << 12) | anchor_bits res = "" for _ in range(4): @@ -46,8 +48,10 @@ def unpack_public_id(self, public_id: str) -> tuple[int, int]: for i, char in enumerate(public_id): packed |= self.B64.index(char) << (6 * i) - content_bits = (packed >> 12) & 0xFFF - anchor_bits = (packed & 0xFFF) ^ content_bits + offset_content = (packed >> 12) & 0xFFF + anchor_bits = packed & 0xFFF + # Reverse the modular offset to recover original content bits + content_bits = (offset_content - anchor_bits) & 0xFFF return content_bits, anchor_bits def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> str: @@ -58,16 +62,16 @@ def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> if use_private_ids else self.generate_public_id(line, i + start_line) ) - formatted_lines.append(f"[{prefix}]{line}") + formatted_lines.append(f"{prefix}::{line}") return "\n".join(formatted_lines) def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: - target_dna, target_anchor = self.unpack_public_id(public_id) + target_content, target_anchor = self.unpack_public_id(public_id) content_matches = [] perfect_matches = [] for i, line in enumerate(self.lines): - if self._get_content_bits(line) == target_dna: + if self._get_content_bits(line) == target_content: current_anchor = self._get_anchor_bits(i + start_line) if current_anchor == target_anchor: perfect_matches.append(i) @@ -75,7 +79,9 @@ def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: dist = abs(current_anchor - target_anchor) # Use the actual coprime period for the circular logic dist = min(dist, self.PERIOD - dist) - content_matches.append((dist, i)) + + if dist <= 5: + content_matches.append((dist, i)) if perfect_matches: return perfect_matches @@ -91,7 +97,7 @@ def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: 1. Resolve all candidates for both IDs. 2. Find the pair of (start, end) that are logically ordered and have the lowest combined distance score. - 3. Returns (start_index, end_index). + 3. Returns (start_index, end_index) """ starts = self.resolve_to_lines(start_id) ends = self.resolve_to_lines(end_id) @@ -109,7 +115,8 @@ def resolve_range(self, start_id: str, end_id: str) -> tuple[int, int]: return s, e raise ValueError( - f"Found matches for {start_id} and {end_id}, but no logically ordered range." + f"Found matches for {start_id} and {end_id}, but no logically ordered range or unique" + " matches." ) @staticmethod @@ -117,8 +124,8 @@ def strip_prefix(text: str) -> str: r""" Remove HashPos prefixes from the start of every line. - Removes prefixes that match the pattern: "[{4-char-hash}]" - where the hash is exactly 4 characters from the set [0-9a-zA-Z\-_@]. + Removes prefixes that match the pattern: "{4-char-hash}" + where the hash is exactly 4 characters from the set [0-9a-zA-Z\~_@] followed by '::'. Args: text: Input text with HashPos prefixes @@ -156,10 +163,9 @@ def normalize(hashpos_str: str) -> str: """ Normalize a HashPos string to the 4-character hash fragment. - Accepts HashPos strings in "[{hash_prefix}]" format, "{hash_prefix}]" format, - or a raw "{hash_prefix}" fragment. + Accepts HashPos strings in "{hash_prefix}::" format or a raw "{hash_prefix}" fragment. Also extracts HashPos from strings that contain content after the HashPos, - e.g., "[H7M5]Line 1" + e.g., "H7M5::Line 1" Args: hashpos_str: HashPos string in various formats @@ -185,5 +191,5 @@ def normalize(hashpos_str: str) -> str: raise ValueError( f"Invalid HashPos format '{hashpos_str}'. " r"Expected \"{hash_prefix}\" " - r"where hash_prefix is exactly 4 characters from the set [0-9a-zA-Z\-_@]." + r"where hash_prefix is exactly 4 characters from the set [0-9a-zA-Z\~_@]." ) diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index 89e771d3483..63a6463dd6f 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -78,12 +78,11 @@ def thought_signature(model, messages): def concatenate_user_messages(messages): - """Concatenate user messages at the end of the array separated by assistant "(empty response)" messages. + """Concatenate user messages separated by assistant "(empty response)" messages. - This function works backwards from the end of the messages array, collecting - user messages until it encounters an assistant message that is not "(empty response)", - a tool message, or a system message. All collected user messages are concatenated - into a single user message at the end, and the original user messages are removed. + This function iterates through the messages array, collecting sequences of + user messages and assistant "(empty response)" messages. All collected user + messages in a sequence are concatenated into a single user message. Args: messages: List of message dictionaries @@ -94,57 +93,42 @@ def concatenate_user_messages(messages): if not messages: return messages + result = [] user_messages_to_concat = [] - i = len(messages) - 1 - while i >= 0: - msg = messages[i] - role = msg.get("role") - content = msg.get("content", "") + def get_text(c): + if isinstance(c, str): + return c + if isinstance(c, list) and len(c) > 0: + return c[0].get("text", "") if isinstance(c[0], dict) else str(c[0]) + return str(c) - if isinstance(content, list): - break + def flush_user_messages(): + if user_messages_to_concat: + concatenated_content = "\n".join(get_text(c) for c in user_messages_to_concat) + result.append({"role": "user", "content": concatenated_content}) + user_messages_to_concat.clear() - if role == "user": - user_messages_to_concat.insert(0, content) # Insert at beginning to maintain order - i -= 1 - continue + for msg in messages: + role = msg.get("role") + content = msg.get("content", "") - # If it's an assistant message with "(empty response)", skip it and continue backwards - if role == "assistant" and content == "(empty response)": - i -= 1 + if role == "user" and not isinstance(content, list): + user_messages_to_concat.append(content) + elif role == "assistant" and content == "(empty response)": continue + else: + flush_user_messages() + result.append(msg) - # If we hit any other type of message (non-empty assistant, tool, system, etc.), stop - break - - # If we collected any user messages to concatenate - if user_messages_to_concat: - # Remove the original user messages (and any skipped empty assistant messages) - # by keeping only messages up to index i (inclusive) - result = messages[: i + 1] if i >= 0 else [] - - # Helper to extract text from strings or structured content lists - def get_text(c): - if isinstance(c, str): - return c - if isinstance(c, list) and len(c) > 0: - # Extracts 'text' from the first block if it's a dict - return c[0].get("text", "") if isinstance(c[0], dict) else str(c[0]) - return str(c) - - concatenated_content = "\n".join(get_text(c) for c in user_messages_to_concat) - result.append({"role": "user", "content": concatenated_content}) - - return result - - return messages + flush_user_messages() + return result def model_request_parser(model, messages): messages = thought_signature(model, messages) messages = remove_empty_tool_calls(messages) + messages = concatenate_user_messages(messages) messages = ensure_alternating_roles(messages) messages = add_reasoning_content(messages) - messages = concatenate_user_messages(messages) return messages diff --git a/cecli/main.py b/cecli/main.py index 2fea8b4946a..df8d5985558 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -50,11 +50,7 @@ from cecli.commands import Commands, SwitchCoderSignal from cecli.deprecated_args import handle_deprecated_model_args from cecli.format_settings import format_settings, scrub_sensitive_info -from cecli.helpers.conversation import ( - ConversationChunks, - ConversationManager, - MessageTag, -) +from cecli.helpers.conversation import ConversationService, MessageTag from cecli.helpers.copypaste import ClipboardWatcher from cecli.helpers.file_searcher import generate_search_path_list from cecli.history import ChatSummary @@ -124,7 +120,7 @@ def check_config_files_for_yes(config_files): def get_git_root(): """Try and guess the git repo, since the conf.yml can be at the repo root""" try: - repo = git.Repo(search_parent_directories=True) + repo = git.Repo(search_parent_directories=True, odbt=git.GitCmdObjectDB) return repo.working_tree_dir except (git.InvalidGitRepositoryError, FileNotFoundError): return None @@ -181,7 +177,7 @@ async def setup_git(git_root, io): repo = None if git_root: try: - repo = git.Repo(git_root) + repo = git.Repo(git_root, odbt=git.GitCmdObjectDB) except ANY_GIT_ERROR: pass elif cwd == Path.home(): @@ -222,7 +218,7 @@ async def check_gitignore(git_root, io, ask=True): if not git_root: return try: - repo = git.Repo(git_root) + repo = git.Repo(git_root, odbt=git.GitCmdObjectDB) patterns_to_add = [] if not repo.ignored(".cecli"): patterns_to_add.append(".cecli*") @@ -763,6 +759,15 @@ def get_io(pretty): read_only_fnames.extend(str(f) for f in path.rglob("*") if f.is_file()) else: read_only_fnames.append(str(path)) + rules_patterns = args.rules or [] + rules_expanded = utils.expand_glob_patterns(rules_patterns) + rules_fnames = [] + for fn in rules_expanded: + path = Path(fn).expanduser().resolve() + if path.is_dir(): + rules_fnames.extend(str(f) for f in path.rglob("*") if f.is_file()) + else: + rules_fnames.append(str(path)) if len(all_files) > 1: good = True for fname in all_files: @@ -1065,8 +1070,17 @@ def apply_model_overrides(model_name): # Default since some models do not have max_input_tokens specified somehow args.context_compaction_max_tokens = 65536 try: + if getattr(args, "mcp_servers_file_deprecated", None): + io.tool_warning( + "The --mcp-servers-file argument is deprecated and will be removed in a future" + " version. Please use --mcp-servers-files instead." + ) + if not args.mcp_servers_files: + args.mcp_servers_files = [] + args.mcp_servers_files.extend(args.mcp_servers_file_deprecated) + mcp_servers = load_mcp_servers( - args.mcp_servers, args.mcp_servers_file, io, args.verbose, args.mcp_transport + args.mcp_servers, args.mcp_servers_files, io, args.verbose, args.mcp_transport ) mcp_manager = await McpServerManager.from_servers(mcp_servers, io, args.verbose) # Load hooks if specified @@ -1089,6 +1103,7 @@ def apply_model_overrides(model_name): fnames=fnames, read_only_fnames=read_only_fnames, read_only_stubs_fnames=[], + rules_fnames=rules_fnames, show_diffs=args.show_diffs, auto_commits=args.auto_commits, dirty_commits=args.dirty_commits, @@ -1198,7 +1213,7 @@ def apply_model_overrides(model_name): if args.show_repo_map: repo_map = coder.get_repo_map() if repo_map: - repo_string = ConversationChunks.get_repo_map_string(repo_map) + repo_string = ConversationService.get_chunks(coder).get_repo_map_string(repo_map) pre_init_io.tool_output(repo_string) return await graceful_exit(coder) if args.apply: @@ -1306,7 +1321,7 @@ def apply_model_overrides(model_name): await coder.mcp_manager.disconnect_server("Local") for tag in [MessageTag.SYSTEM, MessageTag.EXAMPLES, MessageTag.STATIC]: - ConversationManager.clear_tag(tag) + ConversationService.get_manager(coder).clear_tag(tag) coder = await Coder.create(**kwargs) @@ -1474,6 +1489,7 @@ async def graceful_exit(coder=None, exit_code=0): if coder.mcp_manager and coder.mcp_manager.is_connected: await coder.mcp_manager.disconnect_all() + return exit_code diff --git a/cecli/mcp/manager.py b/cecli/mcp/manager.py index 60c2a2dbd52..ad50dc69bd3 100644 --- a/cecli/mcp/manager.py +++ b/cecli/mcp/manager.py @@ -138,7 +138,7 @@ async def connect_server(self, name: str) -> bool: self._connected_servers.add(server) self._log_verbose(f"Connected to MCP server: {name}") return True - except Exception as e: + except (Exception, asyncio.CancelledError) as e: if server.name != "unnamed-server": self._log_error(f"Failed to connect to MCP server {name}: {e}") return False diff --git a/cecli/mcp/utils.py b/cecli/mcp/utils.py index 0bfc919f991..d308021b718 100644 --- a/cecli/mcp/utils.py +++ b/cecli/mcp/utils.py @@ -102,7 +102,7 @@ def _resolve_mcp_config_path(file_path, io, verbose=False): try: import git - repo = git.Repo(search_parent_directories=True) + repo = git.Repo(search_parent_directories=True, odbt=git.GitCmdObjectDB) git_root = Path(repo.working_tree_dir) except (ImportError, git.InvalidGitRepositoryError, FileNotFoundError): pass @@ -149,7 +149,7 @@ def _parse_mcp_servers_from_file(file_path, io, verbose=False, mcp_transport="st def load_mcp_servers( - mcp_servers, mcp_servers_file, io, verbose=False, mcp_transport="stdio" + mcp_servers, mcp_servers_files, io, verbose=False, mcp_transport="stdio" ) -> list["McpServer"]: """Load MCP servers from a JSON string or file.""" servers = [] @@ -160,9 +160,14 @@ def load_mcp_servers( if servers: return servers - # If JSON string failed or wasn't provided, try the file - if mcp_servers_file: - servers = _parse_mcp_servers_from_file(mcp_servers_file, io, verbose, mcp_transport) + # If JSON string failed or wasn't provided, try the files + if mcp_servers_files: + servers = [] + for mcp_servers_file in mcp_servers_files: + file_servers = _parse_mcp_servers_from_file( + mcp_servers_file, io, verbose, mcp_transport + ) + servers.extend(file_servers) if not servers: # A default MCP server is actually now necessary for the overall agentic loop diff --git a/cecli/models.py b/cecli/models.py index 1cbc19dbc7b..e2b8bd5db81 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -314,21 +314,38 @@ class Model(ModelSettings): def __init__( self, model, - weak_model=None, - editor_model=None, - agent_model=None, - editor_edit_format=None, - verbose=False, - io=None, - override_kwargs=None, - retries=None, - debug=False, + from_model=None, + **kwargs, ): provided_model = model or "" if isinstance(provided_model, Model): provided_model = provided_model.name elif not isinstance(provided_model, str): provided_model = str(provided_model) + + io = kwargs.get("io", nested.getter(from_model, "io", None)) + verbose = kwargs.get("verbose", nested.getter(from_model, "verbose", False)) + override_kwargs = kwargs.get( + "override_kwargs", nested.getter(from_model, "override_kwargs", None) + ) + retries = kwargs.get("retries", nested.getter(from_model, "retries", None)) + debug = kwargs.get("debug", nested.getter(from_model, "debug", False)) + + if kwargs.get("sub_models", True): + agent_model = kwargs.get("agent_model", nested.getter(from_model, "agent_model", None)) + weak_model = kwargs.get("weak_model", nested.getter(from_model, "weak_model", None)) + editor_model = kwargs.get( + "editor_model", nested.getter(from_model, "editor_model", None) + ) + editor_edit_format = kwargs.get( + "editor_edit_format", nested.getter(from_model, "editor_edit_format", None) + ) + else: + agent_model = kwargs.get("agent_model", None) + weak_model = kwargs.get("weak_model", None) + editor_model = kwargs.get("editor_model", None) + editor_edit_format = kwargs.get("editor_edit_format", None) + self.io = io self.verbose = verbose self.override_kwargs = override_kwargs or {} @@ -576,8 +593,8 @@ def enable_copy_paste_mode(self, *, transport="api"): self.copy_paste_mode = True self.copy_paste_transport = transport - def get_weak_model(self, provided_weak_model): - if provided_weak_model is False: + def get_weak_model(self, provided_model): + if provided_model is False: self.weak_model = self self.weak_model_name = None return @@ -585,23 +602,23 @@ def get_weak_model(self, provided_weak_model): self.weak_model = self self.weak_model_name = None return - if isinstance(provided_weak_model, Model): - self.weak_model = provided_weak_model - self.weak_model_name = provided_weak_model.name + if isinstance(provided_model, Model): + self.weak_model = provided_model + self.weak_model_name = provided_model.name return - if provided_weak_model: - self.weak_model_name = provided_weak_model + if provided_model: + self.weak_model_name = provided_model if not self.weak_model_name: self.weak_model = self return if self.weak_model_name == self.name: self.weak_model = self return - self.weak_model = Model(self.weak_model_name, weak_model=False, io=self.io) + self.weak_model = Model(self.weak_model_name, from_model=self, sub_model=False) return self.weak_model - def get_agent_model(self, provided_weak_model): - if provided_weak_model is False: + def get_agent_model(self, provided_model): + if provided_model is False: self.agent_model = self self.agent_model_name = None return @@ -609,46 +626,46 @@ def get_agent_model(self, provided_weak_model): self.agent_model = self self.agent_model_name = None return - if isinstance(provided_weak_model, Model): - self.agent_model = provided_weak_model - self.agent_model_name = provided_weak_model.name + if isinstance(provided_model, Model): + self.agent_model = provided_model + self.agent_model_name = provided_model.name return - if provided_weak_model: - self.agent_model_name = provided_weak_model + if provided_model: + self.agent_model_name = provided_model if not self.agent_model_name: self.agent_model = self return if self.agent_model_name == self.name: self.agent_model = self return - self.agent_model = Model(self.agent_model_name, agent_model=False, io=self.io) + self.agent_model = Model(self.agent_model_name, from_model=self, sub_model=False) return self.agent_model - def commit_message_models(self): - return [self.weak_model, self] - - def get_editor_model(self, provided_editor_model, editor_edit_format): + def get_editor_model(self, provided_model, editor_edit_format): if self.copy_paste_transport == "clipboard": - provided_editor_model = False + provided_model = False self.editor_model_name = self.name self.editor_model = self - if isinstance(provided_editor_model, Model): - self.editor_model = provided_editor_model - self.editor_model_name = provided_editor_model.name - elif provided_editor_model: - self.editor_model_name = provided_editor_model + if isinstance(provided_model, Model): + self.editor_model = provided_model + self.editor_model_name = provided_model.name + elif provided_model: + self.editor_model_name = provided_model if editor_edit_format: self.editor_edit_format = editor_edit_format if not self.editor_model_name or self.editor_model_name == self.name: self.editor_model = self else: - self.editor_model = Model(self.editor_model_name, editor_model=False, io=self.io) + self.editor_model = Model(self.editor_model_name, from_model=self, sub_model=False) if not self.editor_edit_format: self.editor_edit_format = self.editor_model.edit_format if self.editor_edit_format in ("diff", "whole", "diff-fenced"): self.editor_edit_format = "editor-" + self.editor_edit_format return self.editor_model + def commit_message_models(self): + return [self.weak_model, self] + def _ensure_extra_params_dict(self): if self.extra_params is None: self.extra_params = {} diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index a8a890f9e43..f51717fa77f 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -17,33 +17,45 @@ files_no_full_files_with_repo_map: | main_system: | ## Core Directives - - **Role**: Act as an expert software engineer. - - **Act Proactively**: Autonomously use discovery and management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to fulfill the request. Chain tool calls across multiple turns for continuous exploration. - - **Be Decisive**: Trust your findings. Do not repeat identical searches or ask redundant questions once a path is established. - - **Be Efficient**: Batch tool calls where supported. Respect usage limits while maximizing the utility of each turn. + **Act Proactively**: Autonomously use discovery and management tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ContextManager`) to fulfill the request. Chain tool calls across multiple turns for continuous exploration. + **Be Decisive**: Trust your findings. Do not repeat identical searches or ask redundant questions once a path is established. + **Be Efficient**: Batch tool calls when tools allow you to. Respect usage limits while maximizing the utility of each turn. + + ### 1. FILE FORMAT + Files are provided in "hashline" format. Each line starts with a content hash followed by `::`. + + **Example File Format :** + il9n::#!/usr/bin/env python3 + faoZ:: + uXdn::def example_method(): + WAR5:: return "example" + vwkS:: + + ## Core Workflow 1. **Plan**: Start by using `UpdateTodoList` to outline the task. - 2. **Explore**: Use `Grep` for broad searches, but if results exceed 50 matches, refine your pattern immediately. Use discovery tools to add files as context. + 2. **Explore**: Use `Grep` for broad searches. If results exceed 50 matches, refine your pattern immediately. Use discovery tools to add files as context. 3. **Execute**: Use the appropriate editing tool. Mark files as editable with `ContextManager` when needed. Proactively use skills if they are available. - 4. **Verify & Recover**: Review every diff. If an edit fails or introduces errors, prioritize `UndoChange` to restore a known good state before attempting a fix. + 4. **Verify & Recover**: Review the diff output of every edit. If an edit fails or introduces linting errors, use `UndoChange` immediately. 5. **Finished**: Use the `Finished` tool only after verifying the solution. Briefly summarize the changes for the user. ## Todo List Management - Use `UpdateTodoList` every 3-10 tool calls to keep the state synchronized. - - Break complex tasks into granular steps to maintain context across long interactions. + - Break complex tasks into granular steps so they remain tractable and context-efficient ### Editing Tools (Precision Protocol) Files use leading hashline content id prefixes inside brackets, i.e. `[{{4 char hash}}]{{line content}}`. Do not attempt to write these content ids. They are automatically generated. - - **MANDATORY Two-Turn Safety Protocol**: - 1. **Turn 1**: Use `ShowNumberedContext` to verify exact, current line identifiers. - 2. **Turn 2**: Execute the edit (Replace, Insert, Delete, Indent) using those verified identifiers. - - **Atomic Scope:** Include the **entire function or logical block**. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. - - **Indentation**: Preserve all spaces and tabs. In Python, a single-space error is a syntax error. Use `IndentText` to fix structural alignment. + **MANDATORY Two-Phase Safety Protocol**: + 1. **Phase 1**: Use `ShowContext` to get the hashline-prefixed content around the pattern to modify. Capture entire functions, logical blocks and closures. You may use multiple calls. + 2. **Phase 2**: Execute the edit (`ReplaceText`, `InsertText`, `DeleteText`) using the verified hashlines prefixes from the `ShowContext` tool. + + **Atomic Scope:** Include the **entire function or logical block**. Never return partial syntax or broken closures. Do not attempt to replace just the beginning or end of a closure. + **Indentation**: Preserve all whitespace (spaces, tabs, and newlines). Use the `.cecli/workspace` directory for all temporary, test, or scratch files. @@ -52,11 +64,13 @@ main_system: | system_reminder: | ## Reminders - - **Strict Scope**: Stay on task. Do not pursue unrequested refactors. - - **Context Hygiene**: Remove files or skills from context using `ContextManager` or `RemoveSkill` once they are no longer needed to save tokens and prevent confusion. - - **Turn Management**: Tool calls trigger the next turn. Do not include tool calls in your final summary to the user. - - **Sandbox**: Use `.cecli/workspace` for all verification and temporary logic. - - **Novelty**: Do not repeat phrases in your responses to the user. You do not need to declare you understand the task. Simply proceed. Only speak when you have something new to say. + **Strict Scope**: Stay on task. Do not alter functionality and syntax that is out of scope or pursue unrequested refactors. Do not attempt to modify large files in one shot. Work step by step. + **Context Hygiene**: Remove files or skills from context using `ContextManager` or `RemoveSkill` once they are no longer needed to save tokens and prevent confusion. + **Turn Management**: Tool calls trigger the next turn. Do not include tool calls in your final summary to the user. You must use `ShowContext` to view the relevant hashline range before each edit. + **Sandbox**: Use `.cecli/workspace` for all verification and temporary logic. + **Novelty**: Do not repeat phrases in your responses to the user. You do not need to declare you understand the task. Simply proceed. Only give status when you have new information. + **Patience**: Do not take short cuts. Work through your task until completion. No task takes too long as long as you are making progress towards the goal. + {lazy_prompt} {shell_cmd_reminder} diff --git a/cecli/prompts/hashline.yml b/cecli/prompts/hashline.yml index d40a8a14fa8..9a812cc6b04 100644 --- a/cecli/prompts/hashline.yml +++ b/cecli/prompts/hashline.yml @@ -6,14 +6,14 @@ main_system: | Act as an expert software developer. Plan carefully, explain your logic briefly, and execute via LOCATE/CONTENTS blocks. ### 1. FILE FORMAT - Files are provided in "Hashline" format. Each line starts with a content hash wrapped in brackets. + Files are provided in "hashline" format. Each line starts with a content hash followed by `::`. **Example File Format :** - [il9n]#!/usr/bin/env python3 - [faoZ] - [uXdn]def example_method(): - [WAR5] return "example" - [vwkS] + il9n::#!/usr/bin/env python3 + faoZ:: + uXdn::def example_method(): + WAR5:: return "example" + vwkS:: ### 2. FILE ACCESS & WORKFLOW - If you need to edit files NOT yet in the chat, list their full paths and ask the user to add them. diff --git a/cecli/repo.py b/cecli/repo.py index 1be8c130106..cd6e36a040e 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -35,6 +35,8 @@ ] ANY_GIT_ERROR = tuple(ANY_GIT_ERROR) +git.Git.USE_SHELL = False + @contextlib.contextmanager def set_git_env(var_name, value, original_value): @@ -110,9 +112,11 @@ def __init__( fname = fname.parent try: - repo_path = git.Repo(fname, search_parent_directories=True).working_dir - repo_path = utils.safe_abs_path(repo_path) - repo_paths.append(repo_path) + with git.Repo( + fname, search_parent_directories=True, odbt=git.GitCmdObjectDB + ) as temp_repo: + repo_path = utils.safe_abs_path(temp_repo.working_dir) + repo_paths.append(repo_path) except ANY_GIT_ERROR: pass @@ -125,12 +129,16 @@ def __init__( raise FileNotFoundError # https://github.com/gitpython-developers/GitPython/issues/427 - self.repo = git.Repo(repo_paths.pop(), odbt=git.GitDB) + self.repo = git.Repo(repo_paths.pop(), odbt=git.GitCmdObjectDB) self.root = utils.safe_abs_path(self.repo.working_tree_dir) if cecli_ignore_file: self.cecli_ignore_file = Path(cecli_ignore_file) + def __del__(self): + if self.repo: + self.repo.close() + async def commit(self, fnames=None, context=None, message=None, coder_edits=False, coder=None): """ Commit the specified files or all dirty files if none are specified. diff --git a/cecli/sessions.py b/cecli/sessions.py index a5ed582c146..b226def8ef5 100644 --- a/cecli/sessions.py +++ b/cecli/sessions.py @@ -6,11 +6,7 @@ from typing import Dict, List, Optional from cecli import models -from cecli.helpers.conversation import ( - ConversationFiles, - ConversationManager, - MessageTag, -) +from cecli.helpers.conversation import ConversationService, MessageTag class SessionManager: @@ -143,9 +139,6 @@ def _build_session_data(self, session_name) -> Dict: self.io.tool_warning(f"Could not read todo list file: {e}") # Get CUR and DONE messages from ConversationManager - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) - done_messages = ConversationManager.get_messages_dict(MessageTag.DONE) - return { "version": 1, "session_name": session_name, @@ -156,8 +149,12 @@ def _build_session_data(self, session_name) -> Dict: "editor_edit_format": self.coder.main_model.editor_edit_format, "edit_format": self.coder.edit_format, "chat_history": { - "done_messages": done_messages, - "cur_messages": cur_messages, + "done_messages": ConversationService.get_manager(self.coder).get_messages_dict( + MessageTag.DONE + ), + "cur_messages": ConversationService.get_manager(self.coder).get_messages_dict( + MessageTag.CUR + ), }, "files": { "editable": editable_files, @@ -232,10 +229,14 @@ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool: session_data.get("model", self.coder.args.model), weak_model=session_data.get("weak_model", self.coder.args.weak_model), editor_model=session_data.get("editor_model", self.coder.args.editor_model), + agent_model=session_data.get("agent_model", self.coder.args.agent_model), editor_edit_format=session_data.get( "editor_edit_format", self.coder.args.editor_edit_format ), + io=self.io, verbose=self.coder.args.verbose, + retries=self.coder.main_model.retries, + debug=self.coder.main_model.debug, ) # Load settings @@ -261,8 +262,8 @@ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool: self.io.tool_warning(f"Could not restore todo list: {e}") # Clear CUR and DONE messages from ConversationManager - ConversationManager.reset() - ConversationFiles.reset() + ConversationService.get_manager(self.coder).reset() + ConversationService.get_files(self.coder).reset() self.coder.format_chat_chunks() # Load chat history @@ -273,13 +274,13 @@ def _apply_session_data(self, session_data: Dict, session_file: Path) -> bool: # Add messages to ConversationManager (source of truth) # Add done messages for msg in done_messages: - ConversationManager.add_message( + ConversationService.get_manager(self.coder).add_message( message_dict=msg, tag=MessageTag.DONE, ) # Add current messages for msg in cur_messages: - ConversationManager.add_message( + ConversationService.get_manager(self.coder).add_message( message_dict=msg, tag=MessageTag.CUR, ) diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index 7777569b6ed..e08adc59209 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -15,14 +15,13 @@ git_show, git_status, grep, - indent_text, insert_text, list_changes, load_skill, ls, remove_skill, replace_text, - show_numbered_context, + show_context, thinking, undo_change, update_todo_list, @@ -44,14 +43,13 @@ git_show, git_status, grep, - indent_text, insert_text, list_changes, load_skill, ls, remove_skill, replace_text, - show_numbered_context, + show_context, thinking, undo_change, update_todo_list, diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 8ef37d3367d..8545c17230b 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -167,7 +167,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout): # Format output output_content = output or "" output_limit = coder.large_file_token_threshold - if len(output_content) > output_limit: + if coder.context_management_enabled and len(output_content) > output_limit: output_content = ( output_content[:output_limit] + f"\n... (output truncated at {output_limit} characters, based on" @@ -236,7 +236,7 @@ async def _execute_foreground(cls, coder, command_string): # Format the output for the result message output_content = combined_output or "" output_limit = coder.large_file_token_threshold - if len(output_content) > output_limit: + if coder.context_management_enabled and len(output_content) > output_limit: output_content = ( output_content[:output_limit] + f"\n... (output truncated at {output_limit} characters, based on" diff --git a/cecli/tools/command_interactive.py b/cecli/tools/command_interactive.py index 49de57f62de..591be7b7379 100644 --- a/cecli/tools/command_interactive.py +++ b/cecli/tools/command_interactive.py @@ -84,7 +84,7 @@ def _run_interactive(): output_content = combined_output or "" # Use the existing token threshold constant as the character limit for truncation output_limit = coder.large_file_token_threshold - if len(output_content) > output_limit: + if coder.context_management_enabled and len(output_content) > output_limit: # Truncate and add a clear message using the constant value output_content = ( output_content[:output_limit] diff --git a/cecli/tools/context_manager.py b/cecli/tools/context_manager.py index ac34c096c37..b5cf402781c 100644 --- a/cecli/tools/context_manager.py +++ b/cecli/tools/context_manager.py @@ -90,6 +90,7 @@ def execute(cls, coder, remove=None, editable=None, view=None, create=None, **kw coder.tui().refresh() coder.context_blocks_cache = {} + coder.edit_allowed = True return "\n".join(messages) diff --git a/cecli/tools/delete_text.py b/cecli/tools/delete_text.py index 4b8f6343745..a57e5df8ae4 100644 --- a/cecli/tools/delete_text.py +++ b/cecli/tools/delete_text.py @@ -1,4 +1,4 @@ -from cecli.helpers.hashline import HashlineError, apply_hashline_operation +from cecli.helpers.hashline import apply_hashline_operation from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ( ToolError, @@ -58,6 +58,14 @@ def execute( """ Delete a block of text using hashline markers. """ + + if not coder.edit_allowed: + raise ToolError( + "Please call `ShowContext` first to make sure edits are appropriately scoped" + ) + else: + coder.edit_allowed = False + tool_name = "DeleteText" try: # 1. Validate file and get content @@ -72,7 +80,8 @@ def execute( operation="delete", text=None, ) - except (ToolError, HashlineError) as e: + except Exception as e: + coder.edit_allowed = True raise ToolError(f"Hashline deletion failed: {str(e)}") # Check if any changes were made diff --git a/cecli/tools/indent_text.py b/cecli/tools/indent_text.py deleted file mode 100644 index 9efb1a1b28d..00000000000 --- a/cecli/tools/indent_text.py +++ /dev/null @@ -1,224 +0,0 @@ -from cecli.helpers.hashline import ( - HashlineError, - apply_hashline_operation, - extract_hashline_range, -) -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - apply_change, - format_tool_result, - handle_tool_error, - validate_file_for_edit, -) - - -class Tool(BaseTool): - NORM_NAME = "indenttext" - SCHEMA = { - "type": "function", - "function": { - "name": "IndentText", - "description": "Indent lines in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_line": { - "type": "string", - "description": ( - 'Hashline format for start line: "{4 char hash}" (without the braces)' - ), - }, - "end_line": { - "type": "string", - "description": ( - 'Hashline format for end line: "{4 char hash}" (without the braces)' - ), - }, - "indent_levels": {"type": "integer", "default": 1}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - }, - "required": ["file_path", "start_line", "end_line"], - }, - }, - } - - @classmethod - def execute( - cls, - coder, - file_path, - start_line, - end_line, - indent_levels=1, - change_id=None, - dry_run=False, - **kwargs, - ): - """ - Indent or unindent a block of lines in a file using hashline markers. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - start_line: Hashline format for start line: "{4 char hash}" (without the braces) - - end_line: Hashline format for end line: "{4 char hash}" (without the braces) - - indent_levels: Number of levels to indent (positive) or unindent (negative) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - tool_name = "IndentText" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Validate indent_levels parameter - try: - indent_levels = int(indent_levels) - except ValueError: - raise ToolError( - f"Invalid indent_levels value: '{indent_levels}'. Must be an integer." - ) - - # 3. Extract the range content using hashline - try: - range_content = extract_hashline_range( - original_content=original_content, - start_line_hash=start_line, - end_line_hash=end_line, - ) - except HashlineError as e: - raise ToolError(f"Hashline range extraction failed: {str(e)}") - - # 4. Apply indentation to the extracted range - # Strip hashline prefixes to get original content - from cecli.helpers.hashline import strip_hashline - - original_range_content = strip_hashline(range_content) - - # Split into lines and apply indentation - range_lines = original_range_content.splitlines(keepends=True) - indent_str = " " * 4 # Assume 4 spaces per level - modified_range_lines = [] - - for line in range_lines: - if indent_levels > 0: - # Indent: add spaces - modified_line = (indent_str * indent_levels) + line - elif indent_levels < 0: - # Unindent: remove spaces - spaces_to_remove = abs(indent_levels) * len(indent_str) - current_leading_spaces = len(line) - len(line.lstrip(" ")) - actual_remove = min(spaces_to_remove, current_leading_spaces) - if actual_remove > 0: - modified_line = line[actual_remove:] - else: - modified_line = line - else: - # indent_levels == 0, no change - modified_line = line - modified_range_lines.append(modified_line) - - # Join back into text - indented_range_content = "".join(modified_range_lines) - - # 5. Check if any changes were made - if original_range_content == indented_range_content: - coder.io.tool_warning("No changes made: indentation would not change file") - return "Warning: No changes made (indentation would not change file)" - - # 6. Handle dry run - if dry_run: - # Parse line numbers for display - try: - start_line_num_str, _ = start_line.split(":", 1) - end_line_num_str, _ = end_line.split(":", 1) - start_line_num = int(start_line_num_str) - end_line_num = int(end_line_num_str) - num_lines = end_line_num - start_line_num + 1 - except (ValueError, IndexError): - num_lines = "unknown" - - action = "indent" if indent_levels > 0 else "unindent" - levels = abs(indent_levels) - level_text = "level" if levels == 1 else "levels" - - dry_run_message = ( - f"Dry run: Would {action} {num_lines} lines ({start_line} to {end_line})" - f" by {levels} {level_text} in {file_path}." - ) - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - - # 7. Apply Change (Not dry run) using replace operation - try: - new_content = apply_hashline_operation( - original_content=original_content, - start_line_hash=start_line, - end_line_hash=end_line, - operation="replace", - text=indented_range_content, - ) - except (ToolError, HashlineError) as e: - raise ToolError(f"Hashline replacement failed: {str(e)}") - - # 8. Apply the change - metadata = { - "start_line": start_line, - "end_line": end_line, - "indent_levels": indent_levels, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "indenttext", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # 9. Format and return result - # Parse line numbers for display - try: - start_line_num_str, _ = start_line.split(":", 1) - end_line_num_str, _ = end_line.split(":", 1) - start_line_num = int(start_line_num_str) - end_line_num = int(end_line_num_str) - num_lines = end_line_num - start_line_num + 1 - except (ValueError, IndexError): - num_lines = "unknown" - - action_past = "Indented" if indent_levels > 0 else "Unindented" - levels = abs(indent_levels) - level_text = "level" if levels == 1 else "levels" - - success_message = ( - f"{action_past} {num_lines} lines ({start_line} to {end_line})" - f" by {levels} {level_text} in {file_path}" - ) - return format_tool_result( - coder, - tool_name, - success_message, - change_id=final_change_id, - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/insert_text.py b/cecli/tools/insert_text.py index 499364d41cd..535428cf5f0 100644 --- a/cecli/tools/insert_text.py +++ b/cecli/tools/insert_text.py @@ -1,6 +1,6 @@ import traceback -from cecli.helpers.hashline import HashlineError, apply_hashline_operation +from cecli.helpers.hashline import apply_hashline_operation from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ( ToolError, @@ -67,6 +67,14 @@ def execute( change_id: Optional ID for tracking changes dry_run: If True, only simulate the change """ + + if not coder.edit_allowed: + raise ToolError( + "Please call `ShowContext` first to make sure edits are appropriately scoped" + ) + else: + coder.edit_allowed = False + tool_name = "InsertText" try: # 1. Validate file and get content @@ -81,7 +89,8 @@ def execute( operation="insert", text=content, ) - except (ToolError, HashlineError) as e: + except Exception as e: + coder.edit_allowed = True raise ToolError(f"Hashline insertion failed: {str(e)}") # Check if any changes were made diff --git a/cecli/tools/replace_text.py b/cecli/tools/replace_text.py index 1f959d5dd7d..c4ac90871d5 100644 --- a/cecli/tools/replace_text.py +++ b/cecli/tools/replace_text.py @@ -4,6 +4,7 @@ HashlineError, apply_hashline_operations, get_hashline_diff, + strip_hashline, ) from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ( @@ -27,7 +28,7 @@ class Tool(BaseTool): " multiple files. Each edit must include its own file_path. Use hashline ranges" " with the start_line and end_line parameters with format" ' "{4 char hash}" (without the braces). For empty files, use "@000" as the hashline' - " reference." + " references." ), "parameters": { "type": "object", @@ -62,7 +63,6 @@ class Tool(BaseTool): "description": "Array of edits to apply.", }, "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, }, "required": ["edits"], }, @@ -75,13 +75,20 @@ def execute( coder, edits=None, change_id=None, - dry_run=False, **kwargs, ): """ Replace text in one or more files. Can handle single edit or array of edits across multiple files. Each edit object must include its own file_path. """ + + if not coder.edit_allowed: + raise ToolError( + "Please call `ShowContext` first to make sure edits are appropriately scoped" + ) + else: + coder.edit_allowed = False + tool_name = "ReplaceText" try: # 1. Validate edits parameter @@ -149,51 +156,41 @@ def execute( "replace_text": edit_replace_text, } file_metadata.append(metadata) - file_successful_edits += 1 - except (ToolError, HashlineError) as e: + except Exception as e: # Record failed edit but continue with others file_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") continue - # Check if any edits succeeded for this file - if file_successful_edits == 0: - all_failed_edits.extend(file_failed_edits) - continue - # Apply all operations in batch try: - new_content, _, _ = apply_hashline_operations( + new_content, successful_ops, failed_ops = apply_hashline_operations( original_content=original_content, operations=operations, ) - except (ToolError, HashlineError) as e: + + if new_content != original_content: + file_successful_edits += len(successful_ops) + else: + raise ToolError("Invalid Edit - Source Not Modified") + + if len(failed_ops): + for failed_op in failed_ops: + op_index = failed_op["index"] + op_error = failed_op["error"] + file_failed_edits.append(f"Edit {op_index + 1}: {str(op_error)}") + except Exception as e: # If batch operation fails, mark all operations as failed for edit_index, _ in file_edits: - all_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") - continue + file_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") - # Check if any changes were made for this file - if original_content == new_content: - all_failed_edits.extend(file_failed_edits) - continue + all_failed_edits.extend(file_failed_edits) - # Handle dry run - if dry_run: - all_results.append( - { - "file_path": file_path_key, - "successful_edits": file_successful_edits, - "failed_edits": file_failed_edits, - "dry_run": True, - } - ) - total_successful_edits += file_successful_edits - all_failed_edits.extend(file_failed_edits) - files_processed += 1 + # Check if any changes were made for this file + if original_content == new_content or file_successful_edits == 0: continue - # Apply Change (Not dry run) + # Apply Change metadata = { "edits": file_metadata, "total_edits": file_successful_edits, @@ -226,7 +223,7 @@ def execute( all_failed_edits.extend(file_failed_edits) files_processed += 1 - except ToolError as e: + except Exception as e: # Record all edits for this file as failed for edit_index, _ in file_edits: all_failed_edits.append(f"Edit {edit_index + 1}: {str(e)}") @@ -234,26 +231,10 @@ def execute( # 4. Check if any edits succeeded overall if total_successful_edits == 0: + coder.edit_allowed = True error_msg = "No edits were successfully applied:\n" + "\n".join(all_failed_edits) raise ToolError(error_msg) - # 5. Handle dry run overall - if dry_run: - dry_run_message = ( - f"Dry run: Would apply {len(edits)} edits across {len(edits_by_file)} files " - f"({total_successful_edits} would succeed, {len(all_failed_edits)} would fail)." - ) - if all_failed_edits: - dry_run_message += "\nFailed edits:\n" + "\n".join(all_failed_edits) - - return format_tool_result( - coder, - tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, - ) - # 6. Format and return result # Log failed edit messages to console for visibility if all_failed_edits: @@ -299,7 +280,11 @@ def execute( @classmethod def format_output(cls, coder, mcp_server, tool_response): color_start, color_end = color_markers(coder) - params = json.loads(tool_response.function.arguments) + + try: + params = json.loads(tool_response.function.arguments) + except json.JSONDecodeError: + coder.io.tool_error("Invalid Tool JSON") tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) @@ -338,11 +323,11 @@ def format_output(cls, coder, mcp_server, tool_response): if original_content is not None: # Generate diff using get_hashline_diff diff_output = get_hashline_diff( - original_content=original_content, + original_content=strip_hashline(original_content), start_line_hash=start_line, end_line_hash=end_line, operation="replace", - text=replace_text, + text=strip_hashline(replace_text), ) except HashlineError as e: # If hashline verification fails, show the error diff --git a/cecli/tools/show_context.py b/cecli/tools/show_context.py new file mode 100644 index 00000000000..7826fb952e6 --- /dev/null +++ b/cecli/tools/show_context.py @@ -0,0 +1,256 @@ +import os + +from cecli.helpers.hashline import hashline, strip_hashline +from cecli.tools.utils.base_tool import BaseTool +from cecli.tools.utils.helpers import ( + ToolError, + handle_tool_error, + is_provided, + resolve_paths, +) + + +class Tool(BaseTool): + NORM_NAME = "showcontext" + SCHEMA = { + "type": "function", + "function": { + "name": "ShowContext", + "description": ( + "Get hashline prefixes of context between start and end patterns in multiple files." + " Accepts an array of show objects, each with file_path, start_text," + " end_text, and optional padding. Special markers '@000' and '000@' can be" + " used for start_text and end_text to represent the first and last lines of" + " the file respectively. Never use hashlines as the start_text and end_text" + " values. These values must be lines from the content of the file." + " They should not contain newlines." + " Avoid using generic keywords." + " Do not use the same pattern for the start_text and end_text." + " It is usually best to use function names and other block identifiers as " + " start_texts and end_texts." + ), + "parameters": { + "type": "object", + "properties": { + "show": { + "type": "array", + "items": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "File path to search in.", + }, + "start_text": { + "type": "string", + "description": ( + "The content marking the beginning of the context range." + " Use '@000' for the first line." + ), + }, + "end_text": { + "type": "string", + "description": ( + "The content marking the end of the context range. Use" + " '000@' for the last line." + ), + }, + "padding": { + "type": "integer", + "default": 5, + "description": ( + "Number of lines of padding to add before start_text and" + " after end_text." + ), + }, + }, + "required": ["file_path", "start_text", "end_text"], + }, + "description": "Array of show operations to perform.", + }, + }, + "required": ["show"], + }, + }, + } + + @classmethod + def execute(cls, coder, show, **kwargs): + """ + Displays numbered lines from multiple files centered around target locations + (patterns or line_numbers), without adding files to context. + Accepts an array of show operations to perform. + Uses utility functions for path resolution and error handling. + """ + tool_name = "showcontext" + try: + # 1. Validate show parameter + if not isinstance(show, list): + raise ToolError("show parameter must be an array") + + if len(show) == 0: + raise ToolError("show array cannot be empty") + + all_outputs = [] + + for show_index, show_op in enumerate(show): + # Extract parameters for this show operation + file_path = show_op.get("file_path") + start_text = show_op.get("start_text") + end_text = show_op.get("end_text") + padding = max(int(show_op.get("padding", 5)), 5) + + if file_path is None: + raise ToolError( + f"Show operation {show_index + 1} missing required file_path parameter" + ) + + # Validate arguments for this operation + if not is_provided(start_text) or not is_provided(end_text): + raise ToolError( + f"Show operation {show_index + 1}: Provide both 'start_text' and" + " 'end_text'." + ) + + if "\n" in start_text or "\n" in end_text: + raise ToolError( + "Patterns must not contain newlines characters. They must match a single" + " line." + ) + start_text = strip_hashline(start_text).strip() + end_text = strip_hashline(end_text).strip() + + # 2. Resolve path + abs_path, rel_path = resolve_paths(coder, file_path) + if not os.path.exists(abs_path): + # Check existence after resolving, as resolve_paths doesn't guarantee existence + raise ToolError(f"File not found: {file_path}") + + # 3. Read file content + content = coder.io.read_text(abs_path) + if content is None: + raise ToolError(f"Could not read file: {file_path}") + lines = content.splitlines() + num_lines = len(lines) + + if num_lines == 0: + # Handle empty file case + output_lines = [f"File {rel_path} is empty."] + if show_index > 0: + all_outputs.append("") + all_outputs.extend(output_lines) + continue + # 4. Determine line range + start_line_idx = -1 + end_line_idx = -1 + found_by = "" + + if start_text is not None and end_text is not None: + if start_text == "@000": + start_indices = [0] + else: + start_indices = [i for i, line in enumerate(lines) if start_text in line] + + if end_text == "000@": + end_indices = [num_lines - 1] + else: + end_indices = [i for i, line in enumerate(lines) if end_text in line] + + if len(start_indices) > 5: + raise ToolError( + f"Start pattern '{start_text}' too broad. Do not search for" + " it again. Be more specific." + ) + + # if len(end_indices) > 5: + # raise ToolError( + # f"End pattern '{end_text}' too broad. Do not search for it" + # " again. Be more specific." + # ) + + best_pair = None + min_dist = float("inf") + + for s in start_indices: + for e in [idx for idx in end_indices if idx >= s]: + dist = e - s + if dist < min_dist: + min_dist = dist + best_pair = (s, e) + + if not start_indices: + raise ToolError( + f"Start pattern '{start_text}' not found in {file_path}. Do not search" + " for it again." + ) + + if not end_indices: + raise ToolError( + f"End pattern '{end_text}' not found in {file_path}. Do not search for" + " it again." + ) + + if best_pair is None: + raise ToolError( + f"End pattern '{end_text}' not found after start pattern in" + f" {file_path}." + ) + s_idx, e_idx = best_pair + + found_by = f"range '{start_text}' to '{end_text}'" + + try: + padding_int = int(padding) + if padding_int < 0: + raise ValueError() + except ValueError: + coder.io.tool_warning(f"Invalid padding '{padding}', using default 5.") + padding_int = 5 + + start_line_idx = max(0, s_idx - padding_int) + end_line_idx = min(num_lines - 1, e_idx + padding_int) + + if start_line_idx == -1 or end_line_idx == -1: + raise ToolError("Internal error: Could not determine line range.") + # 6. Format output for this operation + # Use rel_path for user-facing messages + output_lines = [f"Displaying context around {found_by} in {rel_path}:"] + + # Generate hashline for the entire file + hashed_content = hashline(content) + hashed_lines = hashed_content.splitlines() + + # Extract the context window from hashed lines + context_hashed_lines = hashed_lines[start_line_idx : end_line_idx + 1] + + for i in range(start_line_idx, end_line_idx + 1): + hashed_line = context_hashed_lines[i - start_line_idx] + output_lines.append(hashed_line) + + # Add separator between multiple show operations + if show_index > 0: + all_outputs.append("") + all_outputs.extend(output_lines) + + # Update the conversation cache with the displayed range + from cecli.helpers.conversation import ConversationService + + # Update the conversation cache with the displayed range + # Note: start_line_idx and end_line_idx are 0-based, convert to 1-based for hashline + start_line = start_line_idx + 1 # Convert to 1-based + end_line = end_line_idx + 1 # Convert to 1-based + ConversationService.get_files(coder).update_file_context( + abs_path, start_line, end_line + ) + ConversationService.get_chunks(coder).add_file_context_messages() + # Log success and return the formatted context directly + coder.edit_allowed = True + coder.io.tool_output(f"Successfully retrieved context for {len(show)} file(s)") + return f"Successfully retrieved most recent context for {len(show)} file(s)" + + except ToolError as e: + # Handle expected errors raised by utility functions or validation + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors during processing + return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/show_numbered_context.py b/cecli/tools/show_numbered_context.py deleted file mode 100644 index 93e4d3d0880..00000000000 --- a/cecli/tools/show_numbered_context.py +++ /dev/null @@ -1,217 +0,0 @@ -import os - -from cecli.helpers.hashline import hashline -from cecli.tools.utils.base_tool import BaseTool -from cecli.tools.utils.helpers import ( - ToolError, - handle_tool_error, - is_provided, - resolve_paths, -) - - -class Tool(BaseTool): - NORM_NAME = "shownumberedcontext" - SCHEMA = { - "type": "function", - "function": { - "name": "ShowNumberedContext", - "description": ( - "Show numbered lines of context around patterns or line numbers in multiple files." - " Accepts an array of show objects, each with file_path, pattern/line_number, and" - " context_lines." - ), - "parameters": { - "type": "object", - "properties": { - "show": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "File path to search in.", - }, - "pattern": { - "type": "string", - "description": ( - "Pattern to search for (mutually exclusive with" - " line_number)." - ), - }, - "line_number": { - "type": "integer", - "description": ( - "Line number to show context around (mutually exclusive" - " with pattern)." - ), - }, - "context_lines": { - "type": "integer", - "default": 3, - "description": ( - "Number of context lines to show around the target." - ), - }, - }, - "required": ["file_path"], - }, - "description": "Array of show operations to perform.", - }, - }, - "required": ["show"], - }, - }, - } - - @classmethod - def execute(cls, coder, show, **kwargs): - """ - Displays numbered lines from multiple files centered around target locations - (patterns or line_numbers), without adding files to context. - Accepts an array of show operations to perform. - Uses utility functions for path resolution and error handling. - """ - tool_name = "ShowNumberedContext" - try: - # 1. Validate show parameter - if not isinstance(show, list): - raise ToolError("show parameter must be an array") - - if len(show) == 0: - raise ToolError("show array cannot be empty") - - all_outputs = [] - - for show_index, show_op in enumerate(show): - # Extract parameters for this show operation - file_path = show_op.get("file_path") - pattern = show_op.get("pattern") - line_number = show_op.get("line_number") - context_lines = show_op.get("context_lines", 3) - - if file_path is None: - raise ToolError( - f"Show operation {show_index + 1} missing required file_path parameter" - ) - - # Validate arguments for this operation - pattern_provided = is_provided(pattern) - line_number_provided = is_provided(line_number, treat_zero_as_missing=True) - - if sum([pattern_provided, line_number_provided]) != 1: - raise ToolError( - f"Show operation {show_index + 1}: Provide exactly one of 'pattern' or" - " 'line_number'." - ) - - if not pattern_provided: - pattern = None - if not line_number_provided: - line_number = None - - # 2. Resolve path - abs_path, rel_path = resolve_paths(coder, file_path) - if not os.path.exists(abs_path): - # Check existence after resolving, as resolve_paths doesn't guarantee existence - raise ToolError(f"File not found: {file_path}") - - # 3. Read file content - content = coder.io.read_text(abs_path) - if content is None: - raise ToolError(f"Could not read file: {file_path}") - lines = content.splitlines() - num_lines = len(lines) - - # 4. Determine center line index - center_line_idx = -1 - found_by = "" - - if line_number is not None: - try: - line_number_int = int(line_number) - if 1 <= line_number_int <= num_lines: - center_line_idx = line_number_int - 1 # Convert to 0-based index - found_by = f"line {line_number_int}" - else: - raise ToolError( - f"Line number {line_number_int} is out of range (1-{num_lines}) for" - f" {file_path}." - ) - except ValueError: - raise ToolError(f"Invalid line number '{line_number}'. Must be an integer.") - - elif pattern is not None: - # TODO: Update this section for multiline pattern support later - first_match_line_idx = -1 - for i, line in enumerate(lines): - if pattern in line: - first_match_line_idx = i - break - - if first_match_line_idx != -1: - center_line_idx = first_match_line_idx - found_by = f"pattern '{pattern}' on line {center_line_idx + 1}" - else: - raise ToolError(f"Pattern '{pattern}' not found in {file_path}.") - - if center_line_idx == -1: - # Should not happen if logic above is correct, but as a safeguard - raise ToolError("Internal error: Could not determine center line.") - - # 5. Calculate context window - try: - context_lines_int = int(context_lines) - if context_lines_int < 0: - raise ValueError("Context lines must be non-negative") - except ValueError: - coder.io.tool_warning( - f"Invalid context_lines value '{context_lines}', using default 3." - ) - context_lines_int = 3 - - start_line_idx = max(0, center_line_idx - context_lines_int) - end_line_idx = min(num_lines - 1, center_line_idx + context_lines_int) - - # 6. Format output for this operation - # Use rel_path for user-facing messages - output_lines = [f"Displaying context around {found_by} in {rel_path}:"] - - # Generate hashline for the entire file - hashed_content = hashline(content) - hashed_lines = hashed_content.splitlines() - - # Extract the context window from hashed lines - context_hashed_lines = hashed_lines[start_line_idx : end_line_idx + 1] - - for i in range(start_line_idx, end_line_idx + 1): - hashed_line = context_hashed_lines[i - start_line_idx] - output_lines.append(hashed_line) - - # Add separator between multiple show operations - if show_index > 0: - all_outputs.append("") - all_outputs.extend(output_lines) - - # Update the conversation cache with the displayed range - from cecli.helpers.conversation.files import ConversationFiles - from cecli.helpers.conversation.integration import ConversationChunks - - # Update the conversation cache with the displayed range - # Note: start_line_idx and end_line_idx are 0-based, convert to 1-based for hashline - start_line = start_line_idx + 1 # Convert to 1-based - end_line = end_line_idx + 1 # Convert to 1-based - ConversationFiles.update_file_context(abs_path, start_line, end_line) - ConversationChunks.add_file_context_messages(coder) - - # Log success and return the formatted context directly - coder.io.tool_output(f"Successfully retrieved context for {len(show)} file(s)") - return f"Successfully retrieved context for {len(show)} file(s)" - - except ToolError as e: - # Handle expected errors raised by utility functions or validation - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors during processing - return handle_tool_error(coder, tool_name, e) diff --git a/cecli/tools/undo_change.py b/cecli/tools/undo_change.py index 70cce423eb0..7ac09d2f863 100644 --- a/cecli/tools/undo_change.py +++ b/cecli/tools/undo_change.py @@ -33,6 +33,8 @@ def execute(cls, coder, change_id=None, file_path=None, **kwargs): Returns a result message. """ # Note: Undo does not have a dry_run parameter as it's inherently about reverting a previous action. + coder.edit_allowed = False + try: # Validate parameters if change_id is None and file_path is None: diff --git a/cecli/tools/update_todo_list.py b/cecli/tools/update_todo_list.py index 461f5b4bdf2..27da0793e48 100644 --- a/cecli/tools/update_todo_list.py +++ b/cecli/tools/update_todo_list.py @@ -127,7 +127,10 @@ def execute(cls, coder, tasks, append=False, change_id=None, dry_run=False, **kw # Check if content actually changed if existing_content == new_content: coder.io.tool_warning("No changes made: new content is identical to existing") - return "Warning: No changes made (content identical to existing)" + return ( + "Error: No changes made (content identical to existing)." + "Please make progress implementing the plan instead of updating it." + ) # Handle dry run if dry_run: diff --git a/cecli/tui/__init__.py b/cecli/tui/__init__.py index 2793a64c092..f2854c2602d 100644 --- a/cecli/tui/__init__.py +++ b/cecli/tui/__init__.py @@ -71,13 +71,16 @@ async def launch_tui(coder, output_queue, input_queue, args): Returns: Exit code from TUI """ - worker = CoderWorker(coder, output_queue, input_queue) - app = TUI(worker, output_queue, input_queue, args) + try: + worker = CoderWorker(coder, output_queue, input_queue) + app = TUI(worker, output_queue, input_queue, args) - # Set weak reference to TUI app on the coder instance - coder.tui = weakref.ref(app) + # Set weak reference to TUI app on the coder instance + coder.tui = weakref.ref(app) - return_code = await app.run_async() + return_code = await app.run_async() - worker.stop() - return return_code if return_code else 0 + return return_code if return_code else 0 + finally: + if worker: + worker.stop() diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 5522946cde0..7fac6e4a93b 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -3,6 +3,7 @@ import concurrent.futures import json import queue +import time from functools import lru_cache from pathlib import Path @@ -79,7 +80,7 @@ def __init__(self, coder_worker, output_queue, input_queue, args): }, ) - if other.get("use_terminal_background", False): + if other.get("use_terminal_background", True): patch_textual_strip_render_with_cache() self.bind( @@ -307,11 +308,11 @@ def compose(self) -> ComposeResult: # ASCII banner for startup BANNER = f""" -[bold {BANNER_COLORS[0]}] ██████╗███████╗ ██████╗██╗ ██╗[/bold {BANNER_COLORS[0]}] -[bold {BANNER_COLORS[1]}] ██╔════╝██╔════╝██╔════╝██║ ██║[/bold {BANNER_COLORS[1]}] -[bold {BANNER_COLORS[2]}] ██║ █████╗ ██║ ██║ ██║[/bold {BANNER_COLORS[2]}] -[bold {BANNER_COLORS[3]}] ██║ ██╔══╝ ██║ ██║ ██║[/bold {BANNER_COLORS[3]}] -[bold {BANNER_COLORS[4]}] ╚██████╗███████╗╚██████╗███████╗██║[/bold {BANNER_COLORS[4]}] +[bold {BANNER_COLORS[0]}] ▒▒▒▒▒▒╗▒▒▒▒▒▒▒╗ ▒▒▒▒▒▒╗▒▒╗ ▒▒╗[/bold {BANNER_COLORS[0]}] +[bold {BANNER_COLORS[1]}] ▒▒╔════╝▒▒╔════╝▒▒╔════╝▒▒║ ▒▒║[/bold {BANNER_COLORS[1]}] +[bold {BANNER_COLORS[2]}] ▒▒║ ▒▒▒▒▒╗ ▒▒║ ▒▒║ ▒▒║[/bold {BANNER_COLORS[2]}] +[bold {BANNER_COLORS[3]}] ▒▒║ ▒▒╔══╝ ▒▒║ ▒▒║ ▒▒║[/bold {BANNER_COLORS[3]}] +[bold {BANNER_COLORS[4]}] ╚▒▒▒▒▒▒╗▒▒▒▒▒▒▒╗╚▒▒▒▒▒▒╗▒▒▒▒▒▒▒╗▒▒║[/bold {BANNER_COLORS[4]}] [bold {BANNER_COLORS[5]}] ╚═════╝╚══════╝ ╚═════╝╚══════╝╚═╝[/bold {BANNER_COLORS[5]}] """ @@ -637,9 +638,19 @@ def on_input_area_submit(self, message: InputArea.Submit): if coder: coder.io.start_spinner("Processing...") - self.update_key_hints(generating=True) + if coder and self._currently_generating: + from cecli.helpers.conversation import ConversationService, MessageTag - self.input_queue.put({"text": user_input}) + ConversationService.get_manager(coder).add_message( + message_dict=dict(role="user", content=coder.wrap_user_input(user_input)), + tag=MessageTag.CUR, + hash_key=("user_message", user_input, str(time.monotonic_ns())), + promotion=ConversationService.get_manager(coder).DEFAULT_TAG_PROMOTION_VALUE, + mark_for_demotion=1, + ) + else: + self.update_key_hints(generating=True) + self.input_queue.put({"text": user_input}) def set_input_value(self, text) -> None: """Find the input widget and set focus to it.""" @@ -825,7 +836,7 @@ def on_status_bar_confirm_response(self, message: StatusBar.ConfirmResponse): self.input_queue.put({"confirmed": message.result}) # Commands that use path-based completion - PATH_COMPLETION_COMMANDS = {"/add", "/read-only", "/read-only-stub", "/load", "/save"} + PATH_COMPLETION_COMMANDS = {"/add", "/read-only", "/read-only-stub", "/rules", "/load", "/save"} def _extract_symbols(self) -> set[str]: """Extract code symbols from files in chat using Pygments.""" @@ -956,7 +967,14 @@ def _get_suggestions(self, text: str) -> list[str]: if cmd_part == "/": suggestions = all_commands else: - suggestions = [c for c in all_commands if c.startswith(cmd_part)] + # First get commands that start with the prefix + starts_with = [c for c in all_commands if c.startswith(cmd_part)] + # Then get commands that contain the prefix anywhere (excluding those already matched) + contains = [ + c for c in all_commands if cmd_part[1:] in c and not c.startswith(cmd_part) + ] + + suggestions = starts_with + contains else: # Complete command argument # This handles both: diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 76f763123f5..845466a2f92 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -170,7 +170,7 @@ def assistant_output(self, message, pretty=None): pretty: Whether to use pretty formatting (unused in TUI, kept for compatibility) """ if not message: - return + message = "(empty response)" # Use the streaming path so markdown rendering is applied self.output_queue.put({"type": "start_response"}) diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 1a4934c4b42..a14551de791 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -63,7 +63,7 @@ def _get_display_model(self) -> str: if not self.model_name: return "" # Strip common prefixes like "openrouter/x-ai/" - name = self.app.worker.coder.main_model.name + name = self.app.worker.coder.get_active_model().name if len(name) > 40: if "/" in name: name = name.split("/")[-1] diff --git a/cecli/tui/worker.py b/cecli/tui/worker.py index f42ec284d6f..20b10fb3d2a 100644 --- a/cecli/tui/worker.py +++ b/cecli/tui/worker.py @@ -8,7 +8,7 @@ from cecli.coders import Coder from cecli.commands import SwitchCoderSignal -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag # Suppress asyncio task destroyed warnings during shutdown logging.getLogger("asyncio").setLevel(logging.CRITICAL) @@ -105,12 +105,12 @@ async def _async_run(self): # Skip summarization to avoid blocking LLM calls during mode switch kwargs["summarize_from_coder"] = False - for tag in [MessageTag.SYSTEM, MessageTag.EXAMPLES, MessageTag.STATIC]: - ConversationManager.clear_tag(tag) - new_coder = await Coder.create(**kwargs) new_coder.args = self.coder.args + for tag in [MessageTag.SYSTEM, MessageTag.EXAMPLES, MessageTag.STATIC]: + ConversationService.get_manager(new_coder).clear_tag(tag) + if switch.kwargs.get("show_announcements") is False: new_coder.suppress_announcements_for_next_prompt = True @@ -160,6 +160,8 @@ def stop(self): # Loop may already be closed pass + self.interrupt() + # Wait for thread to finish if self.thread and self.thread.is_alive(): self.thread.join(timeout=2.0) diff --git a/cecli/watch.py b/cecli/watch.py index bf1a323147d..a573e739eff 100644 --- a/cecli/watch.py +++ b/cecli/watch.py @@ -6,7 +6,6 @@ from grep_ast import TreeContext from pathspec import PathSpec from pathspec.patterns import GitWildMatchPattern -from watchfiles import watch from cecli.dump import dump # noqa from cecli.watch_prompts import watch_ask_prompt, watch_code_prompt @@ -145,6 +144,8 @@ def handle_changes(self, changes): def watch_files(self): """Watch for file changes and process them""" try: + from watchfiles import watch + roots_to_watch = self.get_roots_to_watch() for changes in watch( diff --git a/cecli/website/_includes/head_custom.html b/cecli/website/_includes/head_custom.html index b05437e32d9..92c9e8b36e1 100644 --- a/cecli/website/_includes/head_custom.html +++ b/cecli/website/_includes/head_custom.html @@ -28,9 +28,9 @@ vertical-align: middle; } - /* Sidebar gradient styling to match hero section */ .side-bar { - background: linear-gradient(135deg, #ffffff 0%, rgba(20, 176, 20, 0.01) 25%, rgba(20, 176, 20, 0.04) 40%, rgba(220, 230, 255, 0.4) 60%, rgba(205, 218, 255, 0.4) 80%, #F5F6FA 100%); + background-color: #121212 !important; + background: #121212 !important; } @media (max-width: 50em) { diff --git a/cecli/website/_includes/leaderboard_table.js b/cecli/website/_includes/leaderboard_table.js index 039d6e1217f..6f43b1bec98 100644 --- a/cecli/website/_includes/leaderboard_table.js +++ b/cecli/website/_includes/leaderboard_table.js @@ -88,8 +88,8 @@ document.addEventListener('DOMContentLoaded', function() { else if (mode === 'detail') activeButton = modeDetailButton; activeButton.classList.add('active'); - activeButton.style.backgroundColor = '#e7f3ff'; // Use selected row highlight blue - activeButton.style.color = '#495057'; // Use dark text for contrast on light blue + activeButton.style.backgroundColor = 'rgba(76, 110, 245, 0.2)'; // Use selected row highlight blue + activeButton.style.color = 'var(--gray)'; // Use dark text for contrast on light blue // Get the first header cell (for the toggle/checkbox column) const firstHeaderCell = document.querySelector('table thead th:first-child'); @@ -374,8 +374,8 @@ document.addEventListener('DOMContentLoaded', function() { }); this.classList.add('active'); // Apply active styles directly as inline styles might interfere - this.style.backgroundColor = '#e7f3ff'; // Use selected row highlight blue - this.style.color = '#495057'; // Use dark text for contrast on light blue + this.style.backgroundColor = 'rgba(76, 110, 245, 0.2)'; // Use selected row highlight blue + this.style.color = 'var(--gray)'; // Use dark text for contrast on light blue // Update table view and apply filters updateTableView(newMode); diff --git a/cecli/website/_includes/nav_footer_custom.html b/cecli/website/_includes/nav_footer_custom.html index cc90b33044e..8b137891791 100644 --- a/cecli/website/_includes/nav_footer_custom.html +++ b/cecli/website/_includes/nav_footer_custom.html @@ -1,7 +1 @@ -
- cecli is AI pair programming in your terminal. - cecli is on - GitHub - and - Discord. -
+ diff --git a/cecli/website/_includes/recording.css b/cecli/website/_includes/recording.css index 292407d2aef..2805c6bcc3f 100644 --- a/cecli/website/_includes/recording.css +++ b/cecli/website/_includes/recording.css @@ -81,7 +81,6 @@ /* macOS backdrop styling */ .macos-backdrop { - background: linear-gradient(135deg, #ff9966, #ff5e62, #6666ff, #0066ff); border-radius: 12px; padding: clamp(5px, 5vw, 50px) clamp(5px, 2.5vw, 50px); margin: 20px 0; diff --git a/cecli/website/_sass/_hero.scss b/cecli/website/_sass/_hero.scss index afff3fe3604..d24c005eee6 100644 --- a/cecli/website/_sass/_hero.scss +++ b/cecli/website/_sass/_hero.scss @@ -2,7 +2,6 @@ .hero { padding: 80px 0; - background: linear-gradient(135deg, rgba(20, 176, 20, 0.1) 0%, rgba(20, 176, 20, 0.05) 25%, rgba(76, 110, 245, 0.2) 50%, rgba(76, 110, 245, 0.3) 75%, rgba(76, 110, 245, 0.4) 100%); position: relative; overflow: hidden; } diff --git a/cecli/website/_sass/_testimonials.scss b/cecli/website/_sass/_testimonials.scss index f0d1fb32516..479ff4cb9e0 100644 --- a/cecli/website/_sass/_testimonials.scss +++ b/cecli/website/_sass/_testimonials.scss @@ -11,7 +11,6 @@ } .testimonial-card { - background: linear-gradient(135deg, rgba(20, 176, 20, 0.1) 0%, rgba(76, 110, 245, 0.2) 100%); border-radius: 8px; padding: 30px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); @@ -58,9 +57,7 @@ } .testimonial-card:hover { - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); - background: linear-gradient(135deg, rgba(76, 110, 245, 0.12) 0%, rgba(20, 176, 20, 0.08) 50%, rgba(76, 110, 245, 0.2) 100%); -} + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15);} .testimonial-author { font-weight: 600; diff --git a/cecli/website/_sass/global-overrides.scss b/cecli/website/_sass/global-overrides.scss index a46ea2c1f2c..aa6d800a412 100644 --- a/cecli/website/_sass/global-overrides.scss +++ b/cecli/website/_sass/global-overrides.scss @@ -121,6 +121,8 @@ body:has(.side-bar):has(.main) { } .side-bar { + background-color: #121212 !important; + .site-nav, .site-header, .site-footer { width: 100% !important; } @@ -174,6 +176,16 @@ body:has(.side-bar):has(.main) { display: none !important; } +th, td { + background-color: #121212; +} + +.main-content code { + background-color: #1e1e1e; + color: inherit !important; + padding: 0.25rem; +} + // ============================================ // RESPONSIVE UTILITIES // ============================================ diff --git a/cecli/website/assets/home.css b/cecli/website/assets/home.css index 94a82a97d85..a566f660836 100644 --- a/cecli/website/assets/home.css +++ b/cecli/website/assets/home.css @@ -135,7 +135,6 @@ nav { .hero { padding: 80px 0; - background: linear-gradient(135deg, rgba(20, 176, 20, 0.15) 0%, rgba(20, 176, 20, 0.1) 25%, rgba(201, 214, 255, 0.7) 50%, rgba(179, 198, 255, 0.8) 75%, rgba(163, 189, 255, 0.9) 100%); position: relative; overflow: hidden; } @@ -413,7 +412,6 @@ code, pre, .code-block { } .testimonial-card { - background: linear-gradient(135deg, rgba(20, 176, 20, 0.05) 0%, rgba(76, 110, 245, 0.15) 100%); border-radius: 8px; padding: 30px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); @@ -461,7 +459,6 @@ code, pre, .code-block { .testimonial-card:hover { box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); - background: linear-gradient(135deg, rgba(76, 110, 245, 0.12) 0%, rgba(20, 176, 20, 0.08) 50%, rgba(76, 110, 245, 0.2) 100%); } .testimonial-author { diff --git a/cecli/website/assets/icons/browserconfig.xml b/cecli/website/assets/icons/browserconfig.xml index bfefbbfc233..cccb26530e0 100644 --- a/cecli/website/assets/icons/browserconfig.xml +++ b/cecli/website/assets/icons/browserconfig.xml @@ -2,7 +2,7 @@ - + #da532c diff --git a/cecli/website/docs/config/aider_conf.md b/cecli/website/docs/config/aider_conf.md index 3354be9a602..b4d0c31aa47 100644 --- a/cecli/website/docs/config/aider_conf.md +++ b/cecli/website/docs/config/aider_conf.md @@ -400,9 +400,6 @@ cog.outl("```") ## Specify a file containing the message to send the LLM, process reply, then exit (disables chat mode) #message-file: xxx -## Run cecli in your browser (default: False) -#gui: false - ## Enable automatic copy/paste of chat between cecli and web UI (default: False) #copy-paste: false diff --git a/cecli/website/docs/config/dotenv.md b/cecli/website/docs/config/dotenv.md index 07595ddb4ca..089b02c9a17 100644 --- a/cecli/website/docs/config/dotenv.md +++ b/cecli/website/docs/config/dotenv.md @@ -358,9 +358,6 @@ cog.outl("```") ## Specify a file containing the message to send the LLM, process reply, then exit (disables chat mode) #CECLI_MESSAGE_FILE= -## Run cecli in your browser (default: False) -#CECLI_GUI=false - ## Enable automatic copy/paste of chat between cecli and web UI (default: False) #CECLI_COPY_PASTE=false diff --git a/cecli/website/docs/config/hooks.md b/cecli/website/docs/config/hooks.md index e95acf557ab..e610ab27dc6 100644 --- a/cecli/website/docs/config/hooks.md +++ b/cecli/website/docs/config/hooks.md @@ -58,10 +58,10 @@ Command hooks are simple shell commands. You can use placeholders in the command | Hook Type | Available Placeholders | |-----------|------------------------| -| `start`, `end` | `{timestamp}`, `{coder_type}` | -| `on_message`, `end_message` | `{timestamp}`, `{message}`, `{message_length}` | -| `pre_tool` | `{timestamp}`, `{tool_name}`, `{arg_string}` | -| `post_tool` | `{timestamp}`, `{tool_name}`, `{arg_string}`, `{output}` | +| `start`, `end` | `{timestamp}` `{coder_type}` | +| `on_message` `end_message` | `{timestamp}` `{message}` `{message_length}` | +| `pre_tool` | `{timestamp}` `{tool_name}` `{arg_string}` | +| `post_tool` | `{timestamp}` `{tool_name}` `{arg_string}` `{output}` | ### Example: Aborting Tool Execution If a `pre_tool` command hook returns a non-zero exit code, the tool execution will be aborted. diff --git a/cecli/website/docs/config/options.md b/cecli/website/docs/config/options.md index 50b572e59ab..f462120ede7 100644 --- a/cecli/website/docs/config/options.md +++ b/cecli/website/docs/config/options.md @@ -648,16 +648,6 @@ Aliases: - `--message-file MESSAGE_FILE` - `-f MESSAGE_FILE` -### `--gui` -Run cecli in your browser (default: False) -Default: False -Environment variable: `CECLI_GUI` -Aliases: - - `--gui` - - `--no-gui` - - `--browser` - - `--no-browser` - ### `--copy-paste` Enable automatic copy/paste of chat between cecli and web UI (default: False) Default: False diff --git a/cecli/website/docs/index.md b/cecli/website/docs/index.md index 647c3dd2457..678560b299c 100644 --- a/cecli/website/docs/index.md +++ b/cecli/website/docs/index.md @@ -2,9 +2,9 @@ nav_exclude: true --- -# cecli Documentation +# Documentation -cecli is AI pair programming in your terminal. This documentation will help you get the most out of cecli. +This documentation will help you get the most out of cecli.
{% assign pages_list = site.html_pages | sort: "nav_order" %} diff --git a/cecli/website/docs/leaderboards/by-release-date.md b/cecli/website/docs/leaderboards/by-release-date.md index f121802bbce..9d0b471781c 100644 --- a/cecli/website/docs/leaderboards/by-release-date.md +++ b/cecli/website/docs/leaderboards/by-release-date.md @@ -1,6 +1,6 @@ --- title: Scores by release date -parent: cecli LLM Leaderboards +parent: LLM Leaderboards nav_order: 200 --- diff --git a/cecli/website/docs/leaderboards/contrib.md b/cecli/website/docs/leaderboards/contrib.md index eb0a6fd2ba4..63763765b87 100644 --- a/cecli/website/docs/leaderboards/contrib.md +++ b/cecli/website/docs/leaderboards/contrib.md @@ -1,5 +1,5 @@ --- -parent: cecli LLM Leaderboards +parent: LLM Leaderboards nav_order: 900 --- diff --git a/cecli/website/docs/leaderboards/edit.md b/cecli/website/docs/leaderboards/edit.md index b9c369c2195..434b6f16a63 100644 --- a/cecli/website/docs/leaderboards/edit.md +++ b/cecli/website/docs/leaderboards/edit.md @@ -1,5 +1,5 @@ --- -parent: cecli LLM Leaderboards +parent: LLM Leaderboards highlight_image: /assets/leaderboard.jpg nav_order: 50 description: Quantitative benchmark of basic LLM code editing skill. @@ -21,10 +21,10 @@ This measures the LLM's coding ability, and whether it can write new code that integrates into existing code. The model also has to successfully apply all its changes to the source file without human intervention. - + - + @@ -36,7 +36,7 @@ The model also has to successfully apply all its changes to the source file with {% assign edit_sorted = site.data.edit_leaderboard | sort: 'pass_rate_2' | reverse %} {% for row in edit_sorted %} - + @@ -57,7 +57,7 @@ The model also has to successfully apply all its changes to the source file with - -Use cecli's new experimental browser UI to collaborate with LLMs -to edit code in your local git repo. -cecli will directly edit the code in your local source files, -and [git commit the changes](https://cecli.dev/docs/git.html) -with sensible commit messages. -You can start a new project or work with an existing git repo. -cecli works well with -GPT-4o, Sonnet 3.7, and DeepSeek Chat V3 & R1. -It also supports [connecting to almost any LLM](https://cecli.dev/docs/llms.html). - -Use the `--browser` switch to launch the browser version of cecli: - -``` -python -m pip install -U cecli-dev - -export OPENAI_API_KEY= # Mac/Linux -setx OPENAI_API_KEY # Windows, restart shell after setx - -cecli --browser -``` diff --git a/cecli/website/docs/usage/caching.md b/cecli/website/docs/usage/caching.md deleted file mode 100644 index 73239d71e6a..00000000000 --- a/cecli/website/docs/usage/caching.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Prompt caching -highlight_image: /assets/prompt-caching.jpg -parent: Usage -nav_order: 750 -description: cecli supports prompt caching for cost savings and faster coding. ---- - -# Prompt caching - -cecli supports prompt caching for cost savings and faster coding. -Currently Anthropic provides caching for Sonnet and Haiku, -and DeepSeek provides caching for Chat. - -cecli organizes the chat history to try and cache: - -- The system prompt. -- Read only files added with `--read` or `/read-only`. -- The repository map. -- The editable files that have been added to the chat. - -![Prompt caching](/assets/prompt-caching.jpg) - - -## Usage - -Run cecli with `--cache-prompts` or add that setting to your -[configuration files](/docs/config.html). - -Due to limitations in the provider APIs, caching statistics and costs -are not available when streaming responses. -To turn off streaming, use `--no-stream`. - -When caching is enabled, it will be noted for the main model when cecli launches: - -``` -Main model: claude-3-5-sonnet-20240620 with diff edit format, prompt cache, infinite output -``` - -## Preventing cache expiration - -cecli can ping the provider to keep your prompt cache warm and prevent -it from expiring. -By default, Anthropic keeps your cache for 5 minutes. -Use `--cache-keepalive-pings N` to tell cecli to ping -every 5 minutes to keep the cache warm. -cecli will ping up to `N` times over a period of `N*5` minutes -after each message you send. - diff --git a/cecli/website/docs/usage/conventions.md b/cecli/website/docs/usage/conventions.md index b348195823c..64c8601ea46 100644 --- a/cecli/website/docs/usage/conventions.md +++ b/cecli/website/docs/usage/conventions.md @@ -5,7 +5,7 @@ description: Tell cecli to follow your coding conventions when it works on your --- # Specifying coding conventions -Sometimes you want GPT to be aware of certain coding guidelines, +Sometimes you want LLMs to be aware of certain coding guidelines, like whether to provide type hints, which libraries or packages to prefer, etc. @@ -23,15 +23,9 @@ We would simply create a file like `CONVENTIONS.md` with those lines and then we can add it to the cecli chat, along with the file(s) that we want to edit. -It's best to load the conventions file with `/read CONVENTIONS.md` -or `cecli --read CONVENTIONS.md`. -This way it is marked as read-only, and cached if prompt caching -is enabled. +It's best to load the conventions file with `/rules CONVENTIONS.md` +or `cecli --rules CONVENTIONS.md`. -## Community contributed conventions - -You can check the [cecli conventions repository](https://github.com/cecli-AI/conventions) -to find or contribute conventions files. ## Always load conventions @@ -41,79 +35,8 @@ in the [`.cecli.conf.yml` config file](https://cecli.dev/docs/config/cecli_conf. ```yaml # alone -read: CONVENTIONS.md +rules: CONVENTIONS.md # multiple files -read: [CONVENTIONS.md, anotherfile.txt] -``` - - -## Example - -See below for an example of how the conventions can affect the code -that cecli writes. - -
-> $ cecli --read CONVENTIONS.md useragent.py -> -> cecli v0.24.2-dev -> Added CONVENTIONS.md to the chat. -> Added useragent.py to the chat. - -#### write a function that uses httpbin to check the user agent on the request. return the ua. - -To fulfill your request, I will: - -1. Add an import for `httpx`. -2. Write a function named `check_user_agent` that makes a GET request to `httpbin.org/user-agent` and returns the user agent string. - -Here is the diff for the `useragent.py` file: - -```diff ---- useragent.py -+++ useragent.py -@@ ... @@ -+import httpx -+ -+def check_user_agent() -> str: -+ response = httpx.get('https://httpbin.org/user-agent') -+ return response.json()['user-agent'] +rules: [CONVENTIONS.md, AGENTS.md] ``` - -This function uses `httpx` to make a GET request to `httpbin.org/user-agent`, which returns a JSON response containing the user agent of the HTTP client. The function then extracts the user agent string from the JSON response and returns it. - -> Applied edit to useragent.py -
- -GPT correctly used `httpx` and provided type hints. -See below for the transcript of this same -coding task conducted *without* -using `CONVENTIONS.md`. -In this case, GPT used `requests` and skipped types, -which is perhaps more typical in small python scripts. - -
-> $ cecli useragent.py -> -> cecli v0.24.2-dev -> Added useragent.py to the chat. - - -#### write a function that uses httpbin to check the user agent on the request. return the ua. - -```diff ---- /dev/null -+++ useragent.py -@@ ... @@ -+import requests -+ -+def check_user_agent(): -+ response = requests.get('https://httpbin.org/user-agent') -+ user_agent = response.json().get('user-agent') -+ return user_agent -``` - -> Applied edit to useragent.py -
- - diff --git a/cecli/website/docs/sessions.md b/cecli/website/docs/usage/sessions.md similarity index 97% rename from cecli/website/docs/sessions.md rename to cecli/website/docs/usage/sessions.md index 9173b4dd51a..ada211cea95 100644 --- a/cecli/website/docs/sessions.md +++ b/cecli/website/docs/usage/sessions.md @@ -1,3 +1,9 @@ +--- +parent: Usage +nav_order: 850 +description: Session management utilities to save and load work across multiple application runs +--- + # Session Management cecli provides session management commands that allow you to save, load, and manage your chat sessions. This is particularly useful for: diff --git a/cecli/website/examples/2048-game.md b/cecli/website/examples/2048-game.md deleted file mode 100644 index 4fa7e4ab289..00000000000 --- a/cecli/website/examples/2048-game.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -parent: Example chat transcripts -nav_order: 2 ---- - -# Modify an open source 2048 game with aider - -In this chat transcript, the user explores and modifies an open source [javascript 2048 game repo](https://github.com/gabrielecirulli/2048). - -Notice that the LLM asks to see a particular source file, and aider automatically includes it in the chat session after getting permission from the user. - -
- -> $ git clone https://github.com/gabrielecirulli/2048 -> $ cd 2048 -> $ aider - -#### what is this repo? - -Based on the file names you provided, it seems like this repository is for a web-based game, possibly a 2048 game clone. The files include JavaScript files for game logic, HTML and CSS files for the user interface, and various meta files for icons and touch startup images. - -If you need help with any specific part of the code or want to add new features, please let me know, and I'll be happy to assist you. - - -
-
-#### how does the scoring work? - -To help you with the scoring mechanism, I need to see the relevant code. Please provide the contents of the `js/game_manager.js` file, as it is likely to contain the scoring logic. - - -> js/game_manager.js -> Add these files to the chat? y - - -Apologies for the oversight. I see the contents of the `js/game_manager.js` file now. - -The scoring in this 2048 game works as follows: - -1. When two tiles with the same value merge, they create a new tile with a value equal to the sum of the merged tiles' values. -2. The score is updated by adding the value of the new merged tile. - -Here's the relevant code snippet from the `js/game_manager.js` file: - -```javascript -if (next && next.value === tile.value && !next.mergedFrom) { - var merged = new Tile(positions.next, tile.value * 2); - merged.mergedFrom = [tile, next]; - - self.grid.insertTile(merged); - self.grid.removeTile(tile); - - // Converge the two tiles' positions - tile.updatePosition(positions.next); - - // Update the score - self.score += merged.value; - - // The mighty 2048 tile - if (merged.value === 2048) self.won = true; -} -``` - -In this code snippet, when two tiles with the same value (`tile` and `next`) are found, they are merged into a new tile (`merged`). The value of the new tile is added to the current score (`self.score += merged.value`). If the merged tile's value is 2048, the game is considered won (`self.won = true`). - - -
-
-#### can we add some randomness? maybe 10% of the time give the user a 10x bonus? - -Sure, we can add a 10% chance of giving the user a 10x bonus when two tiles merge. To implement this, we'll modify the scoring logic in the `js/game_manager.js` file. Here's the step-by-step plan: - -1. Generate a random number between 0 and 1. -2. Check if the random number is less than or equal to 0.1 (10% chance). -3. If the condition is met, multiply the merged tile's value by 10 before adding it to the score. - -Here's the code change for the `js/game_manager.js` file: - -```javascript -js/game_manager.js -<<<<<<< ORIGINAL - // Update the score - self.score += merged.value; -======= - // Update the score with a 10% chance of 10x bonus - var bonus = Math.random() <= 0.1 ? 10 : 1; - self.score += merged.value * bonus; ->>>>>>> UPDATED -``` - -This change will give the user a 10x bonus 10% of the time when two tiles merge. - -> Applied edit to js/game_manager.js -> Commit eb9d882 aider: Added a 10% chance of giving the user a 10x bonus when two tiles merge. - -
diff --git a/cecli/website/examples/README.md b/cecli/website/examples/README.md deleted file mode 100644 index 0c51f80212b..00000000000 --- a/cecli/website/examples/README.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Example chat transcripts -has_children: true -nav_order: 80 -has_toc: false ---- - -# Example chat transcripts - -Below are some chat transcripts showing what it's like to code with aider. -In the chats, you'll see a variety of coding tasks like generating new code, editing existing code, debugging, exploring unfamiliar code, etc. - -* [**Hello World Flask App**](https://cecli.dev/examples/hello-world-flask.html): Start from scratch and have aider create a simple Flask app with various endpoints, such as adding two numbers and calculating the Fibonacci sequence. - -* [**Javascript Game Modification**](https://cecli.dev/examples/2048-game.html): Dive into an existing open-source repo, and get aider's help to understand it and make modifications. - -* [**Complex Multi-file Change with Debugging**](https://cecli.dev/examples/complex-change.html): Aider makes a complex code change that is coordinated across multiple source files, and resolves bugs by reviewing error output and doc snippets. - -* [**Create a Black Box Test Case**](https://cecli.dev/examples/add-test.html): Aider creates a "black box" test case without access to the source of the method being tested, using only a [high level map of the repository based on ctags](https://cecli.dev/docs/ctags.html). - -* [**Honor the NO_COLOR env var**](https://cecli.dev/examples/no-color.html): The user pastes the NO_COLOR spec from no-color.org into the chat, and aider modifies the application to conform. - -* [**Download, analyze and plot US Census data**](https://cecli.dev/examples/census.html): Aider downloads census data, suggests some hypotheses to test, tests one and then summarizes and plots a graph of the results. - -* [**Semantic Search & Replace**](semantic-search-replace.md): Updating a collection of function calls, which requires dealing with various formatting and semantic differences in the various function call sites. - -* [**Pong Game with Pygame**](pong.md): Creating a simple Pong game using the Pygame library, with customizations for paddle size and color, and ball speed adjustments. - -* [**CSS Exercise: Animation Dropdown Menu**](css-exercises.md): A small CSS exercise involving adding animation to a dropdown menu. - -* [**Automatically Update Docs**](update-docs.md): Automatically updating documentation based on the latest version of the main() function. - -* [**Editing an Asciinema Cast File**](asciinema.md): Editing escape sequences in an `asciinema` screencast file. - -## What's happening in these chats? - -To better understand the chat transcripts, it's worth knowing that: - - - Each time the LLM suggests a code change, `aider` automatically applies it to the source files. - - After applying the edits, `aider` commits them to git with a descriptive commit message. - - The LLM can only see and edit files which have been "added to the chat session". The user adds files either via the command line or the in-chat `/add` command. If the LLM asks to see specific files, `aider` asks the user for permission to add them to the chat. The transcripts contain notifications from `aider` whenever a file is added or dropped from the session. - -## Transcript formatting - -
- -> This is output from the aider tool. - -#### These are chat messages written by the user. - -Chat responses from the LLM are in a blue font like this, and often include colorized "edit blocks" that specify edits to the code. -Here's a sample edit block that switches from printing "hello" to "goodbye": - -```python -hello.py -<<<<<<< ORIGINAL -print("hello") -======= -print("goodbye") ->>>>>>> UPDATED -``` - -
diff --git a/cecli/website/examples/add-test.md b/cecli/website/examples/add-test.md deleted file mode 100644 index 3db295d4248..00000000000 --- a/cecli/website/examples/add-test.md +++ /dev/null @@ -1,181 +0,0 @@ ---- -parent: Example chat transcripts -nav_order: 4 ---- - -# Create a "black box" test case - -This transcript shows aider creating a black box test case, -**without being given -access to the source code of the function being tested or any of the -other code in the repo.** - -Instead, aider is operating entirely off a -[high level map of the repository based on ctags](https://cecli.dev/docs/ctags.html), -which provides details on all the symbols and function signatures in the repo. -Using only the meta-data in the map, the LLM is able to figure out how to call the method to be tested, as well as how to instantiate multiple class objects that are required to prepare for the test. - -In detail, aider is able to use the repo map to: - - - Find the function signature of the `cmd_add()` function which the user wants a test case for. - - Determine that it is a method of the `Command` class, so the test case will need to instantiate an instance to conduct the test. - - Identify that creating a `Command` instance requires passing in `InputOutput` and `Coder` instances. - - Figure out the arguments required to instantiate the `InputOutput` instance. - - Decide that the `Coder` class looks complex enough to use a `MagickMock`. - -The `cmd_add()` method is defined without type annotations, so aider makes a -reasonable but incorrect guess that it accepts a `list` of files. The -user invokes the `/run` command to run the new unit test, which throws an -error based on this misunderstanding. - -The LLM reviews the error message and fixes the call to `cmd_add()` to -correctly pass a `str` with a space-separated list of filenames -instead. The test then passes on the next `/run`. - -
- -> $ aider tests/test_commands.py -> Creating empty file tests/test_commands.py -> Added tests/test_commands.py to the chat -> Files not tracked in .git: -> - tests/test_commands.py -> -> Add them? y -> Added tests/test_commands.py to the git repo -> Commit b9cea2c Added new files to the git repo: tests/test_commands.py -> Using git repo: .git - -#### add a test for cmd_add that passes in 'foo.txt' and 'bar.txt' and ensures they have both been created after the test. run the test in a tmp dir. - -I will add a new test method called `test_cmd_add` in the `tests/test_commands.py` file. This test will create a temporary directory, change the current working directory to the temporary directory, and then call the `cmd_add` method with 'foo.txt' and 'bar.txt' as arguments. After that, it will check if both files have been created in the temporary directory. - -Here's the code change: - -```python -tests/test_commands.py -<<<<<<< ORIGINAL -======= -import os -import tempfile -from unittest import TestCase -from unittest.mock import MagicMock -from aider.commands import Commands -from aider.io import InputOutput - -class TestCommands(TestCase): - def test_cmd_add(self): - # Create a temporary directory and change the current working directory - with tempfile.TemporaryDirectory() as tmpdir: - os.chdir(tmpdir) - - # Initialize the Commands and InputOutput objects - io = InputOutput(pretty=False, yes=True) - coder = MagicMock() - commands = Commands(io, coder) - - # Call the cmd_add method with 'foo.txt' and 'bar.txt' - commands.cmd_add(["foo.txt", "bar.txt"]) - - # Check if both files have been created in the temporary directory - self.assertTrue(os.path.exists("foo.txt")) - self.assertTrue(os.path.exists("bar.txt")) ->>>>>>> UPDATED -``` - -This test will ensure that the `cmd_add` method creates the specified files in the current working directory. - -> Applied edit to tests/test_commands.py -> Commit e2d5212 aider: Added a test method `test_cmd_add` that creates a temporary directory, calls the `cmd_add` method with 'foo.txt' and 'bar.txt', and checks if both files have been created in the temporary directory. - -
-
-#### /run pytest tests/test_commands.py - -> ``` -> ============================= test session starts ============================== -> platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 -> rootdir: /Users/gauthier/Projects/aider -> collected 1 item -> -> tests/test_commands.py F [100%] -> -> =================================== FAILURES =================================== -> __________________________ TestCommands.test_cmd_add ___________________________ -> -> self = -> -> def test_cmd_add(self): -> # Create a temporary directory and change the current working directory -> with tempfile.TemporaryDirectory() as tmpdir: -> os.chdir(tmpdir) -> -> # Initialize the Commands and InputOutput objects -> io = InputOutput(pretty=False, yes=True) -> coder = MagicMock() -> commands = Commands(io, coder) -> -> # Call the cmd_add method with 'foo.txt' and 'bar.txt' -> > commands.cmd_add(["foo.txt", "bar.txt"]) -> -> /Users/gauthier/Projects/aider/tests/test_commands.py:20: -> _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ -> -> self = -> args = ['foo.txt', 'bar.txt'] -> -> def cmd_add(self, args): -> "Add matching files to the chat session" -> -> added_fnames = [] -> files = self.coder.get_all_relative_files() -> > for word in args.split(): -> E AttributeError: 'list' object has no attribute 'split' -> -> /Users/gauthier/Projects/aider/aider/commands.py:161: AttributeError -> =========================== short test summary info ============================ -> FAILED tests/test_commands.py::TestCommands::test_cmd_add - AttributeError: '... -> ============================== 1 failed in 0.34s =============================== -> ``` -> -> Add the output to the chat? [y/n] (y): y - -It seems that the `cmd_add` method in `aider/commands.py` is expecting a string as input, but in the test, we are passing a list. I will update the test to pass a string instead of a list. - -Here's the code change: - -```python -tests/test_commands.py -<<<<<<< ORIGINAL - # Call the cmd_add method with 'foo.txt' and 'bar.txt' - commands.cmd_add(["foo.txt", "bar.txt"]) -======= - # Call the cmd_add method with 'foo.txt' and 'bar.txt' as a single string - commands.cmd_add("foo.txt bar.txt") ->>>>>>> UPDATED -``` - -Now, run the test again with the updated test method: - -``` -pytest tests/test_commands.py -``` - -> Applied edit to tests/test_commands.py -> Commit d1551b4 aider: Updated test to pass a string instead of a list when calling `cmd_add` method. - -
-
-#### /run pytest tests/test_commands.py - -> ``` -> ============================= test session starts ============================== -> platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 -> rootdir: /Users/gauthier/Projects/aider -> collected 1 item -> -> tests/test_commands.py . [100%] -> -> ============================== 1 passed in 0.38s =============================== -> ``` - -
diff --git a/cecli/website/examples/asciinema.md b/cecli/website/examples/asciinema.md deleted file mode 100644 index f0e5899c68a..00000000000 --- a/cecli/website/examples/asciinema.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Editing an asciinema cast file with aider - -The user needs to make some tedious edits to the escape sequences in an `asciinema` screencast file. - -
- -> $ aider hello.cast -> Added hello.cast to the chat - -#### when the "hello.py> " prompt is displayed, it's including some escape codes to add a few blank lines below the prompt and then bringing the cursor back up. remove all that. just show the prompt followed by a space and leave the cursor showing right after that. - -```python -hello.cast -<<<<<<< ORIGINAL -[19.534013, "o", "\u001b[?2004h\u001b[?1l\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mhello.py> \u001b[0m\r\r\n\r\r\n\r\r\n\u001b[0;38;5;28m \r\u001b[64C \r\u001b[3A\u001b[10C\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] -======= -[19.534013, "o", "\u001b[?2004h\u001b[?1l\u001b[?25l\u001b[0m\u001b[?7l\u001b[0m\u001b[J\u001b[0;38;5;28mhello.py> \u001b[0m\u001b[?7h\u001b[0m\u001b[?12l\u001b[?25h"] ->>>>>>> UPDATED -``` - -> Applied edit to hello.cast -> Commit a3fa4d2 aider: Removed escape codes to display prompt and cursor correctly. - - -
diff --git a/cecli/website/examples/census.md b/cecli/website/examples/census.md deleted file mode 100644 index 340dd6690e8..00000000000 --- a/cecli/website/examples/census.md +++ /dev/null @@ -1,326 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Download, analyze and plot US Census data - -Inspired by -[Ethan Mollick's recent blog article about ChatGPT plugins](https://www.oneusefulthing.org/p/it-is-starting-to-get-strange), -I used `aider` and GPT-4 to do data analysis of some US Census data. -I asked aider how to download census data and to suggest some -hypotheses to test. I asked it to write the code to get the data, test one of the -hypotheses and provide a summary and plot of the results. - -
- -> $ aider -> Using git repo: .git - -#### what is a good basic us census dataset to analyze for interesting patterns? - -A good basic US Census dataset to analyze for interesting patterns is the American Community Survey (ACS) dataset. The ACS is an ongoing survey conducted by the US Census Bureau that provides vital information on a yearly basis about the US population and its characteristics. The dataset includes information on demographics, social, economic, and housing characteristics. - -You can find the ACS dataset on the US Census Bureau's website: https://www.census.gov/programs-surveys/acs/data.html - -Some interesting patterns to analyze in the ACS dataset could include: - -1. Population growth and decline in different regions. -2. Changes in racial and ethnic composition. -3. Income inequality and poverty levels. -4. Educational attainment and its relationship with income. -5. Housing affordability and homeownership rates. - -Remember to consider the dataset's documentation and guidelines to understand the data and its limitations. - - -
-
-#### do you know how to download American Community Survey data? - -Yes, I can guide you on how to download American Community Survey (ACS) data. You can use the Census Bureau's API to access the data. To do this, you'll need an API key, which you can obtain from the Census Bureau's website. - -Here's a Python example using the `requests` library to download ACS data: - -1. Install the `requests` library if you haven't already: - -``` -pip install requests -``` - -2. Use the following code to download ACS data: - -```python -import requests - -# Replace 'your_api_key' with your actual API key -api_key = 'your_api_key' - -# Define the API endpoint and parameters -url = 'https://api.census.gov/data/2019/acs/acs5' -params = { - 'get': 'NAME,B01001_001E', # Variables to retrieve (NAME: Geographic area name, B01001_001E: Total population) - 'for': 'state:*', # Geographic area (all states) - 'key': api_key -} - -# Send the request and parse the response -response = requests.get(url, params=params) -data = response.json() - -# Print the data -for row in data: - print(row) -``` - -Replace `'your_api_key'` with the API key you obtained from the Census Bureau's website. This example retrieves the total population (variable `B01001_001E`) for all states. You can modify the `get` and `for` parameters to download different variables and geographic areas. - -For more information on available variables and geographic areas, refer to the ACS documentation: - -- Variables: https://api.census.gov/data/2019/acs/acs5/variables.html -- Geographic Areas: https://api.census.gov/data/2019/acs/acs5/geography.html - - -
-
-#### how do i get an api key? - -To obtain an API key from the Census Bureau, follow these steps: - -1. Go to the Census Bureau's API key request page: https://api.census.gov/data/key_signup.html - -2. Fill out the form with your name, email address, organization (if applicable), and a brief description of your intended use of the API. - -3. Click the "Request Key" button. - -4. You will receive an email containing your API key. It may take a few minutes for the email to arrive. Make sure to check your spam folder if you don't see it in your inbox. - -Once you have your API key, you can use it in your Python code to access the American Community Survey data, as shown in the previous example. Replace `'your_api_key'` with the API key you received in the email. - - -
-
-#### please save your download script as `getdata.py` - -Here's the `getdata.py` file with the download script: - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -======= -import requests - -# Replace 'your_api_key' with your actual API key -api_key = 'your_api_key' - -# Define the API endpoint and parameters -url = 'https://api.census.gov/data/2019/acs/acs5' -params = { - 'get': 'NAME,B01001_001E', # Variables to retrieve (NAME: Geographic area name, B01001_001E: Total population) - 'for': 'state:*', # Geographic area (all states) - 'key': api_key -} - -# Send the request and parse the response -response = requests.get(url, params=params) -data = response.json() - -# Print the data -for row in data: - print(row) ->>>>>>> UPDATED -``` - -Save this code in a new file named `getdata.py`. Remember to replace `'your_api_key'` with your actual API key obtained from the Census Bureau. - -> Allow creation of new file new_file/getdata.py? y -> Add new_file/getdata.py to git? y -> Applied edit to new_file/getdata.py -> Commit afcb9d1 aider: Added `getdata.py` file with download script. - -
-
-#### add my api key: 4ae31ed2a0f0dd62609e51cd95deadbeefe7774f - -I will update the API key in the file. - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -api_key = 'your_api_key' -======= -api_key = '4ae31ed2a0f0dd62609e51cd95deadbeefe7774f' ->>>>>>> UPDATED -``` - -> Applied edit to new_file/getdata.py -> Commit 1d8b4a6 aider: Updated the API key in new_file/getdata.py. - - -
-
-#### what are some interesting hypothesis that you might be able to test using ACS data? - -Here are some interesting hypotheses that you can test using ACS (American Community Survey) data: - -1. Relationship between income and education level: Higher education levels lead to higher income. -2. Impact of population density on housing prices: Higher population density areas have higher housing prices. -3. Relationship between employment rate and crime rate: Higher employment rates lead to lower crime rates. -4. Effect of public transportation availability on car ownership: Areas with better public transportation have lower car ownership rates. -5. Relationship between age distribution and healthcare facilities: Areas with a higher percentage of elderly population have more healthcare facilities. - -These are just a few examples. ACS data provides a wealth of information on various demographic, social, economic, and housing characteristics, allowing you to explore many different hypotheses. - - -
-
-#### can you download the data to test #2 Impact of population density on housing prices: Higher population density areas have higher housing prices. - -As an AI language model, I cannot directly download or access data. However, I can help you with the code to download the required data for testing hypothesis #2 using the ACS data. - -To test the hypothesis, you'll need to obtain population density and housing price data. You can use the following ACS variables: - -- B01003_001E: Total population -- B25077_001E: Median value of owner-occupied housing units - -Here's the updated code to fetch the required data: - -```python -new_file/getdata.py -<<<<<<< ORIGINAL - 'get': 'NAME,B01001_001E', # Variables to retrieve (NAME: Geographic area name, B01001_001E: Total population) -======= - 'get': 'NAME,B01003_001E,B25077_001E', # Variables to retrieve (NAME: Geographic area name, B01003_001E: Total population, B25077_001E: Median value of owner-occupied housing units) ->>>>>>> UPDATED -``` - -After running the updated code, you'll have the population and housing price data for each geographic area. You can then analyze the data to test the hypothesis. - -> Applied edit to new_file/getdata.py -> Commit 81549ab aider: Updated code to fetch population and housing price data for testing hypothesis #2. - -
-
-#### instead of printing all the data, print a summary table that shows whether the data supports the hypothesis - -To print a summary table, you can use the pandas library to analyze the data and calculate the correlation between population density and housing prices. Here's how to modify the code: - -1. Import pandas. -2. Convert the JSON data to a pandas DataFrame. -3. Calculate the correlation between population density and housing prices. -4. Print the correlation as a summary table. - -Here are the changes to the code: - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -import requests -======= -import requests -import pandas as pd ->>>>>>> UPDATED -``` - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -data = response.json() -======= -data = response.json() -# Convert the JSON data to a pandas DataFrame -df = pd.DataFrame(data[1:], columns=data[0]) -df[['B01003_001E', 'B25077_001E']] = df[['B01003_001E', 'B25077_001E']].apply(pd.to_numeric) ->>>>>>> UPDATED -``` - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -for row in data: - print(row) -======= -# Calculate the correlation between population density and housing prices -correlation = df['B01003_001E'].corr(df['B25077_001E']) - -# Print the correlation as a summary table -print("Summary Table:") -print("Correlation between population density and housing prices:", correlation) ->>>>>>> UPDATED -``` - -After applying these changes, the code will print a summary table showing the correlation between population density and housing prices. A positive correlation value indicates that higher population density areas have higher housing prices, supporting the hypothesis. - -> Applied edit to new_file/getdata.py -> Applied edit to new_file/getdata.py -> Applied edit to new_file/getdata.py -> Commit 2f4fcd4 aider: Added pandas library to analyze data and calculate correlation between population density and housing prices, and printed the correlation as a summary table. - - -
-
-#### /run python3 new_file/getdata.py ->``` ->Summary Table: ->Correlation between population density and housing prices: 0.16020298445808737 ->``` -> -> Add the output to the chat? y - -Great! The script seems to be working as expected. The correlation between population density and housing prices is 0.1602. If you need any further assistance or modifications to the code, please let me know. - - -
-
-#### can you also plot a graph which would show any correlation? - -To plot a graph showing the correlation between population density and housing prices, we'll need to modify the `new_file/getdata.py` file. We'll use the `matplotlib` library to create the plot. If you don't have it installed, you can install it using `pip install matplotlib`. - -Here are the changes to make: - -1. Import the `matplotlib.pyplot` module. -2. Create a scatter plot of population density (x-axis) and housing prices (y-axis). -3. Add labels to the axes and a title to the plot. -4. Display the plot. - -Here's the updated code: - -```python -new_file/getdata.py -<<<<<<< ORIGINAL -import requests -import pandas as pd -======= -import requests -import pandas as pd -import matplotlib.pyplot as plt ->>>>>>> UPDATED - -... - -new_file/getdata.py -<<<<<<< ORIGINAL -print("Summary Table:") -print("Correlation between population density and housing prices:", correlation) -======= -print("Summary Table:") -print("Correlation between population density and housing prices:", correlation) - -# Create a scatter plot of population density and housing prices -plt.scatter(df['B01003_001E'], df['B25077_001E']) -plt.xlabel('Population Density') -plt.ylabel('Housing Prices') -plt.title('Correlation between Population Density and Housing Prices') -plt.show() ->>>>>>> UPDATED -``` - -This will create a scatter plot of population density and housing prices, which will help visualize the correlation between the two variables. - -> Applied edit to new_file/getdata.py -> Applied edit to new_file/getdata.py -> Commit 9ff3666 aider: Added scatter plot of population density and housing prices to visualize correlation. - - -
- -![aider screenshot](../assets/figure.png) diff --git a/cecli/website/examples/chat-transcript-css.md b/cecli/website/examples/chat-transcript-css.md deleted file mode 100644 index 37a2a6e6273..00000000000 --- a/cecli/website/examples/chat-transcript-css.md +++ /dev/null @@ -1,451 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Improve css styling of chat transcripts - -
- -> $ aider index.html assets/css/style.css -> Added index.html to the chat -> Added assets/css/style.css to the chat - - -#### add a rounded border to all sides of the boxquote - -```css -assets/css/style.css -<<<<<<< ORIGINAL -.main-content blockquote{padding:0 1rem;margin-left:0;color:#819198;border-left:0.3rem solid #dce6f0} -======= -.main-content blockquote{ - padding: 0 1rem; - margin -``` - -> ^C KeyboardInterrupt -> ^C again to quit - - -
-
-#### make all the edits to the embedded style tag in the html - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit e7a5cb5 aider: Added rounded border to all sides of the boxquote. - - - -
-
-#### make the blockquote font color the same as the p color -> Git repo has uncommitted changes. -> Commit before the chat proceeds [y/n/commit message]? Added note about missing method and incorrect method calls in index.html. -> Commit 2d31f5b Added note about missing method and incorrect method calls in index.html. - -```css -assets/css/style.css -<<<<<<< ORIGINAL -.main-content blockquote{padding:0 1rem;margin-left:0;color:#819198;border-left -``` - -> ^C KeyboardInterrupt - - -#### only edit index.html - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit 8645217 aider: Changed blockquote font color to match

color in index.html. - - -

-
-#### make the blockquote background a lighter shade of the same green as the border - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit 0b0e332 aider: Added label "aider" to the top of the blockquote box using style. - - -
-
-#### add more padding on the top of the box and bring the label into the box so it doesn't intersect the border - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit eee902e aider: Added more padding on the top of the box and brought the label into the box so it doesn't intersect the border. - - -
-
-#### change all the blockquote colors to be shades of blue instead of green. choose the blue based on the blue in the header gradient - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit a26c135 aider: Changed blockquote colors to shades of blue based on the blue in the header gradient. - -
-
-#### can you style the "aider" label on the blockquote as a "tab" that docks onto the top of the box? - -```html -index.html -<<<<<<< ORIGINAL - .main-content blockquote { - border: 0.1rem solid #155799; - border-radius: 0.5rem; - background-color: #e0ecf4; - padding: 0.75rem; - position: relative; - margin-top: 2rem; - } - .main-content blockquote::before { - content: "aider"; - display: inline-block; - position: absolute; - top: -1.75rem; - left: -0.5rem; - padding: 0 0.5rem; - font-weight: bold; - color: #155799; - } -======= - .main-content blockquote { - border: 0.1rem solid #155799; - border-radius: 0.5rem; - background-color: #e0ecf4; - padding: 0.75rem; - position: relative; - margin-top: 2rem; - } - .main-content blockquote::before { - content: "aider"; - display: inline-block; - position: absolute; - top: -0.5rem; - left: 0; - padding: 0.25rem 0.5rem; - font-weight: bold; - color: #155799; - background-color: #e0ecf4; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem; - border: 0.1rem solid #155799; - border-bottom: none; - } ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit 292eda3 aider: Styled the "aider" label on the blockquote as a "tab" that docks onto the top of the box. - - -
-
-#### apply the same box around the h4 element - -```html -index.html -<<<<<<< ORIGINAL - -======= - ->>>>>>> UPDATED -``` - -> Applied edit to index.html -> Commit 6a8cd27 aider: Added box around h4 element. - - -
diff --git a/cecli/website/examples/complex-change.md b/cecli/website/examples/complex-change.md deleted file mode 100644 index 68eb45f67be..00000000000 --- a/cecli/website/examples/complex-change.md +++ /dev/null @@ -1,371 +0,0 @@ ---- -parent: Example chat transcripts -nav_order: 3 ---- - -# A complex multi-file change, with debugging - -This is a chat transcript of a fairly complex code change. The user is trying to update tests to use an input mock provided by `prompt_toolkit`. This requires changes to multiple source files. - -Some things to notice: - - The initial changes don't work, so the user provides error messages and snippets from the `prompt_toolkit` docs to collaboratively debug and solve the problem. - - The user edits a file outside the chat using their editor. `aider` notices these out-of-band changes and offers to commit them. - - At one point the user hits `^C` to interrupt the LLM response, since it's not a useful response. The user gives clarifications and gets a better result on the next reply. - -
- -> $ aider tests/test_main.py aider/getinput.py -> Added tests/test_main.py to the chat -> Added aider/getinput.py to the chat - -#### instead of using /dev/null to send no input in the main tests, can we use the prompt_toolkit input mocking functions? - -Yes, we can use the `prompt_toolkit.input` module to mock the input in the tests. Here's how to modify the `tests/test_main.py` file: - -```python -tests/test_main.py -<<<<<<< ORIGINAL -import os -import sys -import tempfile -from unittest import TestCase -from aider.main import main -import subprocess -======= -import os -import tempfile -from unittest import TestCase -from aider.main import main -import subprocess -from prompt_toolkit.input import create_pipe_input -from prompt_toolkit.output import DummyOutput ->>>>>>> UPDATED -``` - -Now, update the tests to use the `create_pipe_input` and `DummyOutput`: - -```python -tests/test_main.py -<<<<<<< ORIGINAL - with open(os.devnull, "r") as dev_null: - save_stdin = sys.stdin - sys.stdin = dev_null - main([]) - sys.stdin = save_stdin -======= - pipe_input = create_pipe_input() - save_stdin = sys.stdin - sys.stdin = pipe_input - main([], input=pipe_input, output=DummyOutput()) - sys.stdin = save_stdin - pipe_input.close() ->>>>>>> UPDATED -``` - -```python -tests/test_main.py -<<<<<<< ORIGINAL - with open(os.devnull, "r") as dev_null: - save_stdin = sys.stdin - sys.stdin = dev_null - main(["foo.txt"]) - sys.stdin = save_stdin - self.assertTrue(os.path.exists("foo.txt")) -======= - pipe_input = create_pipe_input() - save_stdin = sys.stdin - sys.stdin = pipe_input - main(["foo.txt"], input=pipe_input, output=DummyOutput()) - sys.stdin = save_stdin - pipe_input.close() - self.assertTrue(os.path.exists("foo.txt")) ->>>>>>> UPDATED -``` - -```python -tests/test_main.py -<<<<<<< ORIGINAL - with open(os.devnull, "r") as dev_null: - save_stdin = sys.stdin - sys.stdin = dev_null - main(["--yes", "foo.txt"]) - sys.stdin = save_stdin - self.assertTrue(os.path.exists("foo.txt")) -======= - pipe_input = create_pipe_input() - save_stdin = sys.stdin - sys.stdin = pipe_input - main(["--yes", "foo.txt"], input=pipe_input, output=DummyOutput()) - sys.stdin = save_stdin - pipe_input.close() - self.assertTrue(os.path.exists("foo.txt")) ->>>>>>> UPDATED -``` - -These changes will replace the usage of `/dev/null` with `prompt_toolkit`'s input mocking functions. - -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Commit c177e29 aider: Replaced usage of /dev/null with prompt_toolkit's input mocking functions in tests. - - -> Git repo has uncommitted changes. -> Suggested commit message: -> Added import statement for sys module in test_main.py. -> Commit before the chat proceeds [y/n/commit message]? y -> Commit 6724fcd Added import statement for sys module in test_main.py. - - -
-
-#### /add aider/main.py - -> Added aider/main.py to the chat - - -#### I am getting this error: FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: main() got an unexpected keyword argument 'input' - -I apologize for the confusion. It seems I made a mistake in the `main()` function definition in `aider/main.py`. The `main()` function should accept `input` and `output` keyword arguments. Here's the fix: - -```python -aider/main.py -<<<<<<< ORIGINAL -def main(args=None): - if args is None: - args = sys.argv[1:] -======= -def main(args=None, input=None, output=None): - if args is None: - args = sys.argv[1:] - if input is None: - input = sys.stdin - if output is None: - output = sys.stdout ->>>>>>> UPDATED -``` - -Now, the `main()` function will accept the `input` and `output` keyword arguments, and the tests should pass. - -> Applied edit to aider/main.py -> Commit 79dfba9 aider: Fixed main() function to accept input and output keyword arguments. - -
-
-#### we need to pass those into InputOutput and to prompt_toolkit right? - -Yes, you're right. We need to pass the `input` and `output` arguments to the `InputOutput` class and then to the `prompt_toolkit.prompt()` function. Here's how to modify the `aider/getinput.py` and `aider/main.py` files: - -In `aider/getinput.py`, update the `InputOutput` class to accept `input` and `output` arguments: - -```python -aider/getinput.py -<<<<<<< ORIGINAL -class InputOutput: - def __init__(self, pretty, yes, input_history_file, chat_history_file): -======= -class InputOutput: - def __init__(self, pretty, yes, input_history_file, chat_history_file, input=None, output=None): - self.input = input - self.output = output ->>>>>>> UPDATED -``` - -Now, update the `prompt()` function calls in the `get_input()` method to use the `input` and `output` arguments: - -```python -aider/getinput.py -<<<<<<< ORIGINAL - line = prompt( - show, - completer=completer_instance, - history=FileHistory(self.input_history_file), - style=style, - reserve_space_for_menu=4, - complete_style=CompleteStyle.MULTI_COLUMN, - ) -======= - line = prompt( - show, - completer=completer_instance, - history=FileHistory(self.input_history_file), - style=style, - reserve_space_for_menu=4, - complete_style=CompleteStyle.MULTI_COLUMN, - input=self.input, - output=self.output, - ) ->>>>>>> UPDATED -``` - -In `aider/main.py`, pass the `input` and `output` arguments to the `InputOutput` class: - -```python -aider/main.py -<<<<<<< ORIGINAL - io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file) -======= - io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file, input=input, output=output) ->>>>>>> UPDATED -``` - -These changes will ensure that the `input` and `output` arguments are passed to the `InputOutput` class and then to the `prompt_toolkit.prompt()` function. - -> Applied edit to aider/getinput.py -> Applied edit to aider/getinput.py -> Applied edit to aider/main.py -> Commit cea9d2a aider: Updated InputOutput class to accept input and output arguments and passed them to prompt_toolkit.prompt() function. - -
-
-#### FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: prompt() got an unexpected keyword argument 'input' - -I apologize for the confusion. It seems I made a mistake in the `prompt_toolkit.prompt()` function call. The `prompt()` function accepts `stdin` and `stdout` arguments instead of `input` and `output`. Here's the fix: - -Update the `prompt()` function call in the `get_input()` method in `aider/getinput.py`: - -```python -aider/getinput.py -<<<<<<< ORIGINAL - input=self.input, - output=self.output, -======= - stdin=self.input, - stdout=self.output, ->>>>>>> UPDATED -``` - -These changes will ensure that the `input` and `output` arguments are passed correctly to the `prompt_toolkit.prompt()` function. - -> Applied edit to aider/getinput.py -> Commit 55d3540 aider: Fixed `prompt()` function call in `get_input()` method to pass `stdin` and `stdout` arguments instead of `input` and `output`. - -
-
-#### prompt_toolkit.shortcuts.prompt(message: AnyFormattedText | None = None, *, history: History | None = None, editing_mode: EditingMode | None = None, refresh_interval: float | None = None, vi_mode: bool | None = None, lexer: Lexer | None = None, completer: Completer | None = None, complete_in_thread: bool | None = None, is_password: bool | None = None, key_bindings: KeyBindingsBase | None = None, bottom_toolbar: AnyFormattedText | None = None, style: BaseStyle | None = None, color_depth: ColorDepth | None = None, cursor: AnyCursorShapeConfig = None, include_default_pygments_style: FilterOrBool | None = None, style_transformation: StyleTransformation | None = None, swap_light_and_dark_colors: FilterOrBool | None = None, rprompt: AnyFormattedText | None = None, multiline: FilterOrBool | None = None, prompt_continuation: PromptContinuationText | None = None, wrap_lines: FilterOrBool | None = None, enable_history_search: FilterOrBool | None = None, search_ignore_case: FilterOrBool | None = None, complete_while_typing: FilterOrBool | None = None, validate_while_typing: FilterOrBool | None = None, complete_style: CompleteStyle | None = None, auto_suggest: AutoSuggest | None = None, validator: Validator | None = None, clipboard: Clipboard | None = None, mouse_support: FilterOrBool | None = None, input_processors: list[Processor] | None = None, placeholder: AnyFormattedText | None = None, reserve_space_for_menu: int | None = None, enable_system_prompt: FilterOrBool | None = None, enable_suspend: FilterOrBool | None = None, enable_open_in_editor: FilterOrBool | None = None, tempfile_suffix: str | Callable[[], str] | None = None, tempfile: str | Callable[[], str] | None = None, default: str = '', accept_default: bool = False, pre_run: Callable[[], None] | None = None) → str - -I apologize for the confusion earlier. It seems that the `prompt_toolkit.prompt()` function does not accept `stdin` and `stdout` arguments directly. Instead, we need to use the `Application` class from `prompt_toolkit` to create a custom application with the desired input and output. Here's how to modify the `aider/getinput.py` file: - -First, import the necessary classes and functions from `prompt_tool - -> ^C KeyboardInterrupt - -
-
-#### class prompt_toolkit.shortcuts.PromptSession(message: AnyFormattedText = '', *, multiline: FilterOrBool = False, wrap_lines: FilterOrBool = True, is_password: FilterOrBool = False, vi_mode: bool = False, editing_mode: EditingMode = , complete_while_typing: FilterOrBool = True, validate_while_typing: FilterOrBool = True, enable_history_search: FilterOrBool = False, search_ignore_case: FilterOrBool = False, lexer: Lexer | None = None, enable_system_prompt: FilterOrBool = False, enable_suspend: FilterOrBool = False, enable_open_in_editor: FilterOrBool = False, validator: Validator | None = None, completer: Completer | None = None, complete_in_thread: bool = False, reserve_space_for_menu: int = 8, complete_style: CompleteStyle = , auto_suggest: AutoSuggest | None = None, style: BaseStyle | None = None, style_transformation: StyleTransformation | None = None, swap_light_and_dark_colors: FilterOrBool = False, color_depth: ColorDepth | None = None, cursor: AnyCursorShapeConfig = None, include_default_pygments_style: FilterOrBool = True, history: History | None = None, clipboard: Clipboard | None = None, prompt_continuation: PromptContinuationText | None = None, rprompt: AnyFormattedText = None, bottom_toolbar: AnyFormattedText = None, mouse_support: FilterOrBool = False, input_processors: list[Processor] | None = None, placeholder: AnyFormattedText | None = None, key_bindings: KeyBindingsBase | None = None, erase_when_done: bool = False, tempfile_suffix: str | Callable[[], str] | None = '.txt', tempfile: str | Callable[[], str] | None = None, refresh_interval: float = 0, input: Input | None = None, output: Output | None = None) - -Thank you for providing the `PromptSession` class information. We can use the `PromptSession` class to create a custom session with the desired input and output. Here's how to modify the `aider/getinput.py` file: - -First, import the `PromptSession` class from `prompt_toolkit.shortcuts`: - -```python -aider/getinput.py -<<<<<<< ORIGINAL -from prompt_toolkit import prompt -======= -from prompt_toolkit.shortcuts import PromptSession ->>>>>>> UPDATED -``` - -Now, update the `get_input()` method to use the `PromptSession` class: - -```python -aider/getinput.py -<<<<<<< ORIGINAL - line = prompt( - show, - completer=completer_instance, - history=FileHistory(self.input_history_file), - style=style, - reserve_space_for_menu=4, - complete_style=CompleteStyle.MULTI_COLUMN, - input=self.input, - output=self.output, - ) -======= - session = PromptSession( - message=show, - completer=completer_instance, - history=FileHistory(self.input_history_file), - style=style, - reserve_space_for_menu=4, - complete_style=CompleteStyle.MULTI_COLUMN, - input=self.input, - output=self.output, - ) - line = session.prompt() ->>>>>>> UPDATED -``` - -These changes will ensure that the `input` and `output` arguments are passed correctly to the `prompt_toolkit`'s `PromptSession` class. - -> Applied edit to aider/getinput.py -> Applied edit to aider/getinput.py -> Commit ac92cca aider: Replaced `prompt()` with `PromptSession()` to correctly pass input and output arguments. - -
-
-#### try using create_input(StringIO('')) in the tests - - -I understand that you want to use `create_input(StringIO(''))` in the tests. However, I need more information about which test cases you want to apply this change to and how you want to use it. Please provide more details or examples. - - -#### the ones that currently use create_pipe_input() - -I see that you want to replace `create_pipe_input()` with `create_input(StringIO(''))` in the test cases. Here are the changes for the `tests/test_main.py` file: - -```python -tests/test_main.py -<<<<<<< ORIGINAL -from prompt_toolkit.input import create_pipe_input -======= -from prompt_toolkit.input import create_input -from io import StringIO ->>>>>>> UPDATED -``` - -```python -tests/test_main.py -<<<<<<< ORIGINAL - pipe_input = create_pipe_input() -======= - pipe_input = create_input(StringIO('')) ->>>>>>> UPDATED -``` - -```python -tests/test_main.py -<<<<<<< ORIGINAL - pipe_input = create_pipe_input() -======= - pipe_input = create_input(StringIO('')) ->>>>>>> UPDATED -``` - -```python -tests/test_main.py -<<<<<<< ORIGINAL - pipe_input = create_pipe_input() -======= - pipe_input = create_input(StringIO('')) ->>>>>>> UPDATED -``` - -These changes will replace the usage of `create_pipe_input()` with `create_input(StringIO(''))` in the test cases. - -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Applied edit to tests/test_main.py -> Commit 4bb043f aider: Replaced the usage of `create_pipe_input()` with `create_input(StringIO(''))` in the test cases. - -
-
-#### nice, that worked! - -I'm glad the changes worked for you! If you have any more requests or need further assistance, feel free to ask. - - -
diff --git a/cecli/website/examples/css-exercises.md b/cecli/website/examples/css-exercises.md deleted file mode 100644 index 76d6f4bed53..00000000000 --- a/cecli/website/examples/css-exercises.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Complete a css exercise with aider - -In this chat transcript, the user asks about the open source [CSS Exercises repo](https://github.com/TheOdinProject/css-exercises) and completes an animation exercise. - -
- -> $ aider - -#### what is this repo? - -Based on the file list provided, it seems like this repository contains a collection of CSS and HTML exercises, solutions, and examples. The exercises cover various topics such as Flexbox, Grid, Animation, Foundations, and Margin & Padding. The repository also includes images and other assets used in the exercises. - - -
-
-#### /add animation/03-dropdown-menu/README.md - -> Added animation/03-dropdown-menu/README.md to the chat - -Yes, I see the content of the `animation/03-dropdown-menu/README.md` file. It describes an exercise to add animation to a dropdown menu, creating a bounce illusion when the dropdown expands close to its final end state. The desired outcome is shown in a GIF file, and the hints provided suggest using the `transform-origin` property and adding an intermediate step to the keyframe at-rule to achieve the bounce effect. - -Is there any specific change or improvement you would like me to make to this file or any other file in the repository? - - -
-
-#### can you make the changes suggested in the readme? - -To make the changes suggested in the `animation/03-dropdown-menu/README.md`, I need to see the contents of the following files: - -1. `animation/03-dropdown-menu/style.css` -2. `animation/03-dropdown-menu/script.js` - -Please provide the contents of these files so I can suggest the necessary changes. - - -> animation/03-dropdown-menu/style.css -> animation/03-dropdown-menu/script.js -> Add these files to the chat? y - - -Apologies for the oversight. I see the content of the `animation/03-dropdown-menu/style.css` and `animation/03-dropdown-menu/script.js` files now. I will suggest the necessary changes to add the bounce animation to the dropdown menu. - -```css -animation/03-dropdown-menu/style.css -<<<<<<< ORIGINAL -.visible { - display: block; -} -======= -.visible { - display: block; - animation: expand 0.3s ease-out; - transform-origin: top; -} - -@keyframes expand { - 0% { - transform: scaleY(0); - } - 80% { - transform: scaleY(1.1); - } - 100% { - transform: scaleY(1); - } -} ->>>>>>> UPDATED -``` - -These changes add the bounce animation to the dropdown menu by creating a new keyframe animation called `expand`. The `transform-origin` property is set to `top` to make the dropdown menu start transforming from the top. The intermediate step at 80% of the animation creates the bounce illusion. - -> Applied edit to animation/03-dropdown-menu/style.css -> Commit bb3a813 aider: Added bounce animation to dropdown menu. - -
-
-#### that worked! - -I'm glad the changes worked for you! If you have any more requests or need assistance with other parts of the code, feel free to ask. I'm here to help! - -
diff --git a/cecli/website/examples/hello-world-flask.md b/cecli/website/examples/hello-world-flask.md deleted file mode 100644 index 2a3ff276913..00000000000 --- a/cecli/website/examples/hello-world-flask.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -parent: Example chat transcripts -nav_order: 1 ---- - -# Create a simple flask app with aider - -In this chat transcript, the user asks to make a simple flask app with various endpoints. - -
- -> $ aider app.py -> Creating empty file app.py -> Added app.py to the chat - -#### make a flask app with a /hello endpoint that returns hello world - -To create a Flask app with a `/hello` endpoint that returns "Hello, World!", we need to install Flask, import it, create an app instance, and define a route for the `/hello` endpoint. Here's how to update the `app.py` file: - -```python -app.py -<<<<<<< ORIGINAL - -======= -from flask import Flask - -app = Flask(__name__) - -@app.route('/hello') -def hello(): - return "Hello, World!" - -if __name__ == '__main__': - app.run() ->>>>>>> UPDATED -``` - -Make sure to install Flask in your project environment using `pip install flask` before running the app. - -> Applied edit to app.py -> Commit 414c394 aider: Added a `/hello` endpoint that returns "Hello, World!" to the Flask app. - -
-
-#### add an endpoint like /add/3/5 which returns the sum of the 2 numbers - -To add an endpoint like `/add/3/5` that returns the sum of the two numbers, you can create a new route with two URL parameters. Update the `app.py` file as follows: - -```python -app.py -<<<<<<< ORIGINAL -@app.route('/hello') -def hello(): - return "Hello, World!" -======= -@app.route('/add//') -def add(num1, num2): - return str(num1 + num2) - -@app.route('/hello') -def hello(): - return "Hello, World!" ->>>>>>> UPDATED -``` - -Now, when you visit `/add/3/5`, it will return the sum of the two numbers, which is `8`. - -> Applied edit to app.py -> Commit 0872c51 aider: Added an endpoint `/add//` that returns the sum of the two numbers. - -
-
-#### add /fibonacci/X - -To add a `/fibonacci/X` endpoint that returns the Xth number in the Fibonacci sequence, you can create a new route with a URL parameter for X and a function to calculate the Fibonacci number. Update the `app.py` file as follows: - -```python -app.py -<<<<<<< ORIGINAL -@app.route('/hello') -def hello(): - return "Hello, World!" -======= -def fibonacci(n): - if n <= 0: - return "Invalid input. Please enter a positive integer." - elif n == 1: - return 0 - elif n == 2: - return 1 - else: - a, b = 0, 1 - for _ in range(n - 2): - a, b = b, a + b - return b - -@app.route('/fibonacci/') -def fibonacci_x(x): - return str(fibonacci(x)) - -@app.route('/hello') -def hello(): - return "Hello, World!" ->>>>>>> UPDATED -``` - -Now, when you visit `/fibonacci/X`, it will return the Xth number in the Fibonacci sequence. For example, `/fibonacci/5` will return `3`. - -> Applied edit to app.py -> Commit 163fba8 aider: Added a `/fibonacci/X` endpoint that returns the Xth number in the Fibonacci sequence. - -
-
-#### remove the hello endpoint - -To remove the `/hello` endpoint, you can simply delete the corresponding route and function in the `app.py` file. Here's the update: - -```python -app.py -<<<<<<< ORIGINAL -@app.route('/hello') -def hello(): - return "Hello, World!" -======= ->>>>>>> UPDATED -``` - -Now, the `/hello` endpoint has been removed from the Flask app. - -> Applied edit to app.py -> Commit eab6620 aider: Removed the `/hello` endpoint from the Flask app. - - -
diff --git a/cecli/website/examples/hello.md b/cecli/website/examples/hello.md deleted file mode 100644 index 9c0c06a672d..00000000000 --- a/cecli/website/examples/hello.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Hello aider! - -Here is the simplest possible "hello world" example of using aider: - -
-
-
-
- -#### change hello to goodbye - -To change the output from "hello" to "goodbye", we need to modify the `hello.py` file. - -Here is the change: - -```python -hello.py -<<<<<<< ORIGINAL -print("hello") -======= -print("goodbye") ->>>>>>> UPDATED -``` - -> Applied edit to hello.py -> Commit 672ae42 aider: Changed output from "hello" to "goodbye" in hello.py. - -
-
-
-
diff --git a/cecli/website/examples/no-color.md b/cecli/website/examples/no-color.md deleted file mode 100644 index 90f4fb10fbc..00000000000 --- a/cecli/website/examples/no-color.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Honor the NO_COLOR environment variable - -The user pastes the specification of the NO_COLOR environment variable protocol -from [no-color.org](https://no-color.org) into the aider chat. - -Aider figures out which files need to be edited, and asks permission to make changes. -It then disables all the color and pretty-printing functions if NO_COLOR is -set, so the tool conforms to the specification. - -The user then asks for a test case, which aider adds as well. - -
- -> $ aider -> Using git repo: .git -> Use /help to see in-chat commands. - -#### Command-line software which adds ANSI color to its output by default should check for a NO_COLOR environment variable that, when present and not an empty string (regardless of its value), prevents the addition of ANSI color. - -To implement this feature, we need to modify the `aider/io.py` file. Please make it *read-write* so I can provide the necessary changes. - -> aider/io.py -> Add these files to the chat? y - -Great! Now, I will make the necessary changes to check for the `NO_COLOR` environment variable and disable ANSI color when it is present and not an empty string. - -```python -aider/io.py -<<<<<<< ORIGINAL -import os -======= -import os -import os.path ->>>>>>> UPDATED -``` - -```python -aider/io.py -<<<<<<< ORIGINAL - def __init__( - self, - pretty=True, - yes=False, - input_history_file=None, - chat_history_file=None, - input=None, - output=None, - user_input_color="blue", - tool_output_color=None, - tool_error_color="red", - ): - self.user_input_color = user_input_color - self.tool_output_color = tool_output_color - self.tool_error_color = tool_error_color - self.input = input - self.output = output - self.pretty = pretty - self.yes = yes - self.input_history_file = input_history_file - if chat_history_file is not None: - self.chat_history_file = Path(chat_history_file) - else: - self.chat_history_file = None - - if pretty: - self.console = Console() - else: - self.console = Console(force_terminal=True, no_color=True) -======= - def __init__( - self, - pretty=True, - yes=False, - input_history_file=None, - chat_history_file=None, - input=None, - output=None, - user_input_color="blue", - tool_output_color=None, - tool_error_color="red", - ): - no_color = os.environ.get("NO_COLOR") - if no_color is not None and no_color != "": - pretty = False - - self.user_input_color = user_input_color if pretty else None - self.tool_output_color = tool_output_color if pretty else None - self.tool_error_color = tool_error_color if pretty else None - self.input = input - self.output = output - self.pretty = pretty - self.yes = yes - self.input_history_file = input_history_file - if chat_history_file is not None: - self.chat_history_file = Path(chat_history_file) - else: - self.chat_history_file = None - - self.console = Console(force_terminal=not pretty, no_color=not pretty) ->>>>>>> UPDATED -``` - -These changes will make the software check for the `NO_COLOR` environment variable and disable ANSI color when it is present and not an empty string. - -> Applied edit to aider/io.py -> Applied edit to aider/io.py -> Commit 3218242 aider: Added check for NO_COLOR environment variable to disable ANSI color. - -
-
-#### add a test - -To add a test for the `NO_COLOR` environment variable, we will create a new test function in the `tests/test_io.py` file. Since this file does not exist, we will create it first. - -```python -tests/test_io.py -<<<<<<< ORIGINAL -======= -import os -import unittest -from unittest.mock import patch -from aider.io import InputOutput - -class TestInputOutput(unittest.TestCase): - def test_no_color_environment_variable(self): - with patch.dict(os.environ, {"NO_COLOR": "1"}): - io = InputOutput() - self.assertFalse(io.pretty) - -if __name__ == "__main__": - unittest.main() ->>>>>>> UPDATED -``` - -This test function checks if the `pretty` attribute of the `InputOutput` class is set to `False` when the `NO_COLOR` environment variable is set. - -> Allow creation of new file tests/test_io.py? y -> Add tests/test_io.py to git? y -> Applied edit to tests/test_io.py -> Commit 52a9df5 aider: Added a test function to check if the `pretty` attribute of the `InputOutput` class is set to `False` when the `NO_COLOR` environment variable is set. - -
-
-#### /run pytest - -> ============================= test session starts ============================== -> platform darwin -- Python 3.11.3, pytest-7.3.1, pluggy-1.0.0 -> rootdir: /Users/gauthier/Projects/aider -> collected 25 items -> -> tests/test_coder.py ...... [ 24%] -> tests/test_commands.py . [ 28%] -> tests/test_io.py . [ 32%] -> tests/test_main.py .... [ 48%] -> tests/test_repomap.py ... [ 60%] -> tests/test_utils.py .......... [100%] -> -> ============================== 25 passed in 1.41s ============================== -> -> Add the output to the chat? y - - -
diff --git a/cecli/website/examples/pong.md b/cecli/website/examples/pong.md deleted file mode 100644 index f4441bc1b65..00000000000 --- a/cecli/website/examples/pong.md +++ /dev/null @@ -1,315 +0,0 @@ ---- -parent: Example chat transcripts ---- - -# Build pong with aider and pygame. - -In this chat transcript, the user asks to make a pong game using pygame. -After setting up a basic game, the user asks for some customization of the colors and game mechanics. - -
- -> $ aider - -#### can you make a simple game of pong with pygame? - -Sure, I can help you create a simple Pong game using Pygame. First, let's break down the steps needed to create the game: - -1. Install Pygame library. -2. Initialize Pygame and create a game window. -3. Create Paddle and Ball classes. -4. Initialize the paddles and the ball. -5. Create the game loop. -6. Handle user input for paddle movement. -7. Update the ball position and check for collisions. -8. Draw the paddles and ball on the screen. -9. Check for scoring and update the score. -10. Display the score on the screen. - -Now, let's start with the code. I'll provide you with the code changes in an *EDIT BLOCK*. Since we are creating a new file, I'll name it `pong_game.py`. - -```python -pong_game.py -<<<<<<< ORIGINAL -======= -import pygame -import sys - -# Initialize Pygame -pygame.init() - -# Set up the game window -screen_width = 640 -screen_height = 480 -screen = pygame.display.set_mode((screen_width, screen_height)) -pygame.display.set_caption("Pong") - -# Game loop -running = True -while running: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - - # Update game objects - - # Draw game objects - - # Update the display - pygame.display.flip() - -# Quit Pygame -pygame.quit() -sys.exit() ->>>>>>> UPDATED -``` - -This is the basic structure of the game. Now, let's add the Paddle and Ball classes, handle user input, update the ball position, draw the objects, and handle scoring. I'll provide the code changes in the next message. - -> Allow creation of new file pong_game.py? y -> Add pong_game.py to git? y -> Applied edit to pong_game.py -> Commit 14799cb aider: Added basic Pygame initialization and game loop structure to `pong_game.py`. - -
-
-#### go ahead - -Now, let's add the Paddle and Ball classes, handle user input, update the ball position, draw the objects, and handle scoring. I'll provide the code changes in an *EDIT BLOCK*. - -```python -pong_game.py -<<<<<<< ORIGINAL -import pygame -import sys - -# Initialize Pygame -pygame.init() - -# Set up the game window -screen_width = 640 -screen_height = 480 -screen = pygame.display.set_mode((screen_width, screen_height)) -pygame.display.set_caption("Pong") -======= -import pygame -import sys -import random - -# Initialize Pygame -pygame.init() - -# Set up the game window -screen_width = 640 -screen_height = 480 -screen = pygame.display.set_mode((screen_width, screen_height)) -pygame.display.set_caption("Pong") - -# Paddle and Ball classes -class Paddle(pygame.sprite.Sprite): - def __init__(self, x, y): - super().__init__() - self.width = 10 - self.height = 60 - self.image = pygame.Surface((self.width, self.height)) - self.image.fill((255, 255, 255)) - self.rect = self.image.get_rect() - self.rect.x = x - self.rect.y = y - - def move(self, dy): - if self.rect.y + dy >= 0 and self.rect.y + dy <= screen_height - self.height: - self.rect.y += dy - -class Ball(pygame.sprite.Sprite): - def __init__(self): - super().__init__() - self.width = 10 - self.height = 10 - self.image = pygame.Surface((self.width, self.height)) - self.image.fill((255, 255, 255)) - self.rect = self.image.get_rect() - self.rect.x = screen_width // 2 - self.width // 2 - self.rect.y = screen_height // 2 - self.height // 2 - self.dx = random.choice([-1, 1]) * 4 - self.dy = random.choice([-1, 1]) * 4 - - def update(self): - self.rect.x += self.dx - self.rect.y += self.dy - - # Collision with top and bottom - if self.rect.y <= 0 or self.rect.y >= screen_height - self.height: - self.dy = -self.dy - - # Scoring - if self.rect.x <= 0 or self.rect.x >= screen_width - self.width: - self.reset() - - def reset(self): - self.rect.x = screen_width // 2 - self.width // 2 - self.rect.y = screen_height // 2 - self.height // 2 - self.dx = random.choice([-1, 1]) * 4 - self.dy = random.choice([-1, 1]) * 4 - - def collide_with(self, paddle): - if self.rect.colliderect(paddle.rect): - self.dx = -self.dx ->>>>>>> UPDATED -``` - -Now, let's initialize the paddles and the ball, handle user input, update the ball position, draw the objects, and handle scoring. - -```python -pong_game.py -<<<<<<< ORIGINAL -# Game loop -running = True -while running: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - - # Update game objects - - # Draw game objects - - # Update the display - pygame.display.flip() -======= -# Initialize paddles and ball -left_paddle = Paddle(10, screen_height // 2 - 30) -right_paddle = Paddle(screen_width - 20, screen_height // 2 - 30) -ball = Ball() - -# Game loop -running = True -while running: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - - # Handle user input - keys = pygame.key.get_pressed() - if keys[pygame.K_w]: - left_paddle.move(-5) - if keys[pygame.K_s]: - left_paddle.move(5) - if keys[pygame.K_UP]: - right_paddle.move(-5) - if keys[pygame.K_DOWN]: - right_paddle.move(5) - - # Update game objects - ball.update() - ball.collide_with(left_paddle) - ball.collide_with(right_paddle) - - # Draw game objects - screen.fill((0, 0, 0)) - screen.blit(left_paddle.image, left_paddle.rect) - screen.blit(right_paddle.image, right_paddle.rect) - screen.blit(ball.image, ball.rect) - - # Update the display - pygame.display.flip() - pygame.time.delay(16) ->>>>>>> UPDATED -``` - -Now, you should have a simple Pong game using Pygame. You can further improve the game by adding a scoring system and displaying the score on the screen. - -> Applied edit to pong_game.py -> Applied edit to pong_game.py -> Commit b20bcee aider: Added Paddle and Ball classes, handled user input, updated ball position, drew objects, and handled scoring. - -
-
-#### nice, it works. but the ball moves way too fast. - -I'm glad it works! Let's slow down the ball by reducing its speed. We can do this by changing the values of `self.dx` and `self.dy` in the `Ball` class. - -```python -pong_game.py -<<<<<<< ORIGINAL - self.dx = random.choice([-1, 1]) * 4 - self.dy = random.choice([-1, 1]) * 4 -======= - self.dx = random.choice([-1, 1]) * 2 - self.dy = random.choice([-1, 1]) * 2 ->>>>>>> UPDATED -``` - -This change will reduce the ball's speed by half. You can adjust the values further if you want the ball to move even slower. - -> Applied edit to pong_game.py -> Commit f4ceed4 aider: Reduced ball speed by half. - -
-
-#### can you make the paddles a bit bigger? and make one paddle red and one blue? - -Sure, let's make the paddles bigger and change their colors. We can do this by modifying the `Paddle` class in the `pong_game.py` file. - -```python -pong_game.py -<<<<<<< ORIGINAL - self.width = 10 - self.height = 60 - self.image = pygame.Surface((self.width, self.height)) - self.image.fill((255, 255, 255)) -======= - self.width = 15 - self.height = 80 - self.image = pygame.Surface((self.width, self.height)) - self.color = (255, 255, 255) if x < screen_width // 2 else (0, 0, 255) - self.image.fill(self.color) ->>>>>>> UPDATED -``` - -Now, let's change the color of the left paddle to red. - -```python -pong_game.py -<<<<<<< ORIGINAL -left_paddle = Paddle(10, screen_height // 2 - 30) -======= -left_paddle = Paddle(10, screen_height // 2 - 40) -left_paddle.color = (255, 0, 0) -left_paddle.image.fill(left_paddle.color) ->>>>>>> UPDATED -``` - -With these changes, the paddles will be larger, and the left paddle will be red while the right paddle will be blue. - -> Applied edit to pong_game.py -> Applied edit to pong_game.py -> Commit 2c645c9 aider: Changed paddle size and color, and set left paddle to red. - -
-
-#### each time the ball bounces off the paddle, can you make it go 10% faster? - -Sure, let's increase the ball's speed by 10% each time it bounces off a paddle. We can do this by modifying the `collide_with` method in the `Ball` class. - -```python -pong_game.py -<<<<<<< ORIGINAL - def collide_with(self, paddle): - if self.rect.colliderect(paddle.rect): - self.dx = -self.dx -======= - def collide_with(self, paddle): - if self.rect.colliderect(paddle.rect): - self.dx = -self.dx * 1.1 - self.dy *= 1.1 ->>>>>>> UPDATED -``` - -With this change, the ball's speed will increase by 10% in both the x and y directions each time it bounces off a paddle. - -> Applied edit to pong_game.py -> Commit 693faf4 aider: Increased ball speed by 10% in both x and y directions each time it bounces off a paddle. - - -
diff --git a/cecli/website/examples/semantic-search-replace.md b/cecli/website/examples/semantic-search-replace.md deleted file mode 100644 index 1f08cd5a24a..00000000000 --- a/cecli/website/examples/semantic-search-replace.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -parent: Example chat transcripts ---- -# Semantic search & replace code with aider - -In this chat transcript, the user asks to modify a series of function calls. - -Notice that it isn't replacing exact strings. -Making the change requires dealing with various formatting and semantic differences in the calls that it is updating. - -Some edits were elided from this transcript for brevity. - -
- -> $ aider aider/coder.py -> Added aider/coder.py to the chat - -#### replace all the self.console.print() calls that contain [red] with calls to self.io.tool_error() with [red] removed from the string - -```python -aider/coder.py -<<<<<<< ORIGINAL - self.console.print( - f"[red]This tool will almost certainly fail to work with {main_model}" - ) -======= - self.io.tool_error(f"This tool will almost certainly fail to work with {main_model}") ->>>>>>> UPDATED -``` - -```python -aider/coder.py -<<<<<<< ORIGINAL - self.console.print("[red]Files are not in a git repo.") -======= - self.io.tool_error("Files are not in a git repo.") ->>>>>>> UPDATED -``` - -```python -aider/coder.py -<<<<<<< ORIGINAL - self.console.print("[red]Malformed ORIGINAL/UPDATE blocks, retrying...") - self.console.print("[red]", Text(err)) -======= - self.io.tool_error("Malformed ORIGINAL/UPDATE blocks, retrying...") - self.io.tool_error(Text(err)) ->>>>>>> UPDATED -``` - -Please note that I couldn't find the `self.io.tool_error()` method in the provided code. You may need to create this method in the appropriate class or provide the correct method name to replace the `self.console.print()` calls. - -> Applied edit to aider/coder.py -> Applied edit to aider/coder.py -> Applied edit to aider/coder.py -> Commit 919cfc2 aider: Replaced all self.console.print() calls with self.io.tool_error() calls with [red] removed from the string. - - -
diff --git a/cecli/website/examples/update-docs.md b/cecli/website/examples/update-docs.md deleted file mode 100644 index 9616ceb6c32..00000000000 --- a/cecli/website/examples/update-docs.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -parent: Example chat transcripts ---- -# Automatically update docs with aider - -In this chat transcript, the user asks to automatically update the Usage docs based on the latest version of the main() function in the code. - -
- -> $ aider ./README.md aider/main.py -> Added README.md to the chat -> Added aider/main.py to the chat - -#### the cmd line args in main() have been updated. update the readme to accurately describe them. - -```markdown -README.md -<<<<<<< ORIGINAL -- `--history-file HISTORY_FILE`: Specify the chat input history file (default: .cecli.history). Override the default with the environment variable `CECLI_HISTORY_FILE`. -- `--model MODEL`: Specify the model to use for the main chat (default: gpt-4). Override the default with the environment variable `CECLI_MODEL`. -- `-3`: Use gpt-3.5-turbo model for the main chat (basically won't work). No environment variable override. -- `--no-pretty`: Disable pretty, colorized output. Override the default with the environment variable `CECLI_PRETTY` (default: 1 for enabled, 0 for disabled). -- `--show-diffs`: Show diffs when committing changes (default: False). Override the default with the environment variable `CECLI_SHOW_DIFFS` (default: 0 for False, 1 for True). -- `--no-auto-commits`: Disable auto commit of changes. Override the default with the environment variable `CECLI_AUTO_COMMITS` (default: 1 for enabled, 0 for disabled). -======= -- `--input-history-file INPUT_HISTORY_FILE`: Specify the chat input history file (default: .cecli.input.history). Override the default with the environment variable `CECLI_INPUT_HISTORY_FILE`. -- `--chat-history-file CHAT_HISTORY_FILE`: Specify the chat history file (default: .cecli.chat.history.md). Override the default with the environment variable `CECLI_CHAT_HISTORY_FILE`. -- `--model MODEL`: Specify the model to use for the main chat (default: gpt-4). Override the default with the environment variable `CECLI_MODEL`. -- `-3`: Use gpt-3.5-turbo model for the main chat (basically won't work). No environment variable override. -- `--no-pretty`: Disable pretty, colorized output. Override the default with the environment variable `CECLI_PRETTY` (default: 1 for enabled, 0 for disabled). -- `--apply FILE`: Apply the changes from the given file instead of running the chat (debug). -- `--no-auto-commits`: Disable auto commit of changes. Override the default with the environment variable `CECLI_AUTO_COMMITS` (default: 1 for enabled, 0 for disabled). -- `--show-diffs`: Show diffs when committing changes (default: False). Override the default with the environment variable `CECLI_SHOW_DIFFS` (default: 0 for False, 1 for True). -- `--yes`: Always say yes to every confirmation (default: False). ->>>>>>> UPDATED -``` - -> Applied edit to README.md -> Commit 024f45e aider: Updated command line arguments in main() and updated README.md to accurately describe them. - -
diff --git a/cecli/website/index.html b/cecli/website/index.html index 9095b5e0f43..35a42807404 100644 --- a/cecli/website/index.html +++ b/cecli/website/index.html @@ -6,7 +6,7 @@ - cecli - AI Pair Programming in Your Terminal + cecli - a terminal agent diff --git a/requirements.txt b/requirements.txt index f1c3371c9ff..4b23fbf7c98 100644 --- a/requirements.txt +++ b/requirements.txt @@ -453,12 +453,16 @@ truststore==0.10.4 typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # aiosignal + # anyio # beautifulsoup4 # huggingface-hub # mcp # openai # pydantic # pydantic-core + # referencing + # starlette # textual # typing-inspection typing-inspection==0.4.2 diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index fe131426c2b..b86916c6337 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -165,6 +165,7 @@ jinja2==3.1.6 # via # banks # litellm + # memray # torch jiter==0.12.0 # via openai @@ -216,6 +217,8 @@ mdit-py-plugins==0.5.0 # via textual mdurl==0.1.2 # via markdown-it-py +memray==1.19.2 + # via -r requirements/requirements-dev.in mpmath==1.3.0 # via sympy mslex==1.3.0 @@ -287,6 +290,8 @@ nvidia-nvshmem-cu12==3.3.20 # via torch nvidia-nvtx-cu12==12.8.90 # via torch +objgraph==3.6.2 + # via -r requirements/requirements-dev.in openai==2.8.1 # via litellm oslex==0.1.3 @@ -430,6 +435,7 @@ requests==2.32.5 rich==14.2.0 # via # -r requirements/requirements.in + # memray # textual # typer rpds-py==0.29.0 @@ -487,7 +493,9 @@ sympy==1.14.0 tenacity==9.1.2 # via llama-index-core textual==6.8.0 - # via -r requirements/requirements.in + # via + # -r requirements/requirements.in + # memray threadpoolctl==3.6.0 # via scikit-learn tiktoken==0.12.0 @@ -532,7 +540,9 @@ typer==0.20.0 # via -r requirements/requirements-dev.in typing-extensions==4.15.0 # via + # aiosignal # aiosqlite + # anyio # beautifulsoup4 # huggingface-hub # llama-index-core @@ -542,8 +552,11 @@ typing-extensions==4.15.0 # pydantic # pydantic-core # pyee + # pytest-asyncio + # referencing # sentence-transformers # sqlalchemy + # starlette # textual # torch # typer diff --git a/requirements/requirements-dev.in b/requirements/requirements-dev.in index e0c1ab29136..80e721de972 100644 --- a/requirements/requirements-dev.in +++ b/requirements/requirements-dev.in @@ -13,3 +13,5 @@ cogapp semver codespell uv +memray +objgraph diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt index 33ca4ea3643..a9c64454b87 100644 --- a/requirements/requirements-dev.txt +++ b/requirements/requirements-dev.txt @@ -53,26 +53,48 @@ iniconfig==2.3.0 # via # -c requirements/common-constraints.txt # pytest +jinja2==3.1.6 + # via + # -c requirements/common-constraints.txt + # memray kiwisolver==1.4.9 # via # -c requirements/common-constraints.txt # matplotlib +linkify-it-py==2.0.3 + # via + # -c requirements/common-constraints.txt + # markdown-it-py lox==1.0.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements-dev.in -markdown-it-py==4.0.0 +markdown-it-py[linkify]==4.0.0 # via # -c requirements/common-constraints.txt + # mdit-py-plugins # rich + # textual +markupsafe==3.0.3 + # via + # -c requirements/common-constraints.txt + # jinja2 matplotlib==3.10.7 # via # -c requirements/common-constraints.txt # -r requirements/requirements-dev.in +mdit-py-plugins==0.5.0 + # via + # -c requirements/common-constraints.txt + # textual mdurl==0.1.2 # via # -c requirements/common-constraints.txt # markdown-it-py +memray==1.19.2 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements-dev.in nodeenv==1.9.1 # via # -c requirements/common-constraints.txt @@ -83,6 +105,10 @@ numpy==2.3.5 # contourpy # matplotlib # pandas +objgraph==3.6.2 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements-dev.in packaging==25.0 # via # -c requirements/common-constraints.txt @@ -108,6 +134,7 @@ pip-tools==7.5.2 platformdirs==4.5.0 # via # -c requirements/common-constraints.txt + # textual # virtualenv pluggy==1.6.0 # via @@ -122,6 +149,7 @@ pygments==2.19.2 # -c requirements/common-constraints.txt # pytest # rich + # textual pyparsing==3.2.5 # via # -c requirements/common-constraints.txt @@ -166,6 +194,8 @@ pyyaml==6.0.3 rich==14.2.0 # via # -c requirements/common-constraints.txt + # memray + # textual # typer semver==3.0.4 # via @@ -183,6 +213,10 @@ six==1.17.0 # via # -c requirements/common-constraints.txt # python-dateutil +textual==6.8.0 + # via + # -c requirements/common-constraints.txt + # memray typer==0.20.0 # via # -c requirements/common-constraints.txt @@ -190,11 +224,17 @@ typer==0.20.0 typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # pytest-asyncio + # textual # typer tzdata==2025.2 # via # -c requirements/common-constraints.txt # pandas +uc-micro-py==1.0.3 + # via + # -c requirements/common-constraints.txt + # linkify-it-py uv==0.9.11 # via # -c requirements/common-constraints.txt diff --git a/requirements/requirements-help.txt b/requirements/requirements-help.txt index 21afcbcd2b5..1b1b4ce392d 100644 --- a/requirements/requirements-help.txt +++ b/requirements/requirements-help.txt @@ -382,7 +382,9 @@ triton==3.5.1 typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # aiosignal # aiosqlite + # anyio # huggingface-hub # llama-index-core # llama-index-workflows diff --git a/requirements/requirements.in b/requirements/requirements.in index ef6b1783c88..8c577e88be5 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -15,7 +15,7 @@ beautifulsoup4>=4.13.4 PyYAML>=6.0.2 diff-match-patch>=20241021 pypandoc>=1.15 -litellm>=1.80.11 +litellm>=1.80.11,!=1.82.7,!=1.82.8 flake8>=7.3.0 importlib_resources pyperclip>=1.9.0 diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 031440b8476..45fa8f1c59f 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -1,6 +1,7 @@ import base64 import os import tempfile +import uuid from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -11,8 +12,7 @@ from cecli.coders.base_coder import FinishReasonLength, UnknownEditFormat from cecli.commands import SwitchCoderSignal from cecli.dump import dump # noqa: F401 -from cecli.helpers.conversation import ConversationChunks -from cecli.helpers.conversation.manager import ConversationManager +from cecli.helpers.conversation import ConversationService from cecli.helpers.conversation.tags import MessageTag from cecli.io import InputOutput from cecli.mcp import McpServerManager @@ -22,21 +22,28 @@ from cecli.utils import GitTemporaryDirectory +class MockCoder: + """Simple mock coder class for tests.""" + + def __init__(self): + self.uuid = uuid.uuid4() + + class TestCoder: @pytest.fixture(autouse=True) def setup(self, gpt35_model): + self.uuid = uuid.uuid4() self.GPT35 = gpt35_model self.webbrowser_patcher = patch("cecli.io.webbrowser.open") self.mock_webbrowser = self.webbrowser_patcher.start() # Reset conversation system before each test - ConversationChunks.reset() - + ConversationService.get_chunks(self).reset() yield # Cleanup after each test self.webbrowser_patcher.stop() # Reset conversation system after each test as well - ConversationChunks.reset() + ConversationService.get_chunks(self).reset() async def test_allowed_to_edit(self): with GitTemporaryDirectory(): @@ -1164,10 +1171,10 @@ async def test_show_exhausted_error(self): # Add messages to ConversationManager for msg in done_messages: - ConversationManager.add_message(msg, MessageTag.DONE) + ConversationService.get_manager(coder).add_message(msg, MessageTag.DONE) for msg in cur_messages: - ConversationManager.add_message(msg, MessageTag.CUR) + ConversationService.get_manager(coder).add_message(msg, MessageTag.CUR) # Set up real values for the main model coder.main_model.info = { @@ -1698,7 +1705,7 @@ async def test_auto_commit_with_none_content_message(self): # Add messages to ConversationManager for msg in cur_messages: - ConversationManager.add_message(msg, MessageTag.CUR) + ConversationService.get_manager(coder).add_message(msg, MessageTag.CUR) # The context for commit message will be generated from cur_messages. # This call should not raise an exception due to `content: None`. diff --git a/tests/basic/test_hashline.py b/tests/basic/test_hashline.py index 4965b1b61d5..4943e75cf6c 100644 --- a/tests/basic/test_hashline.py +++ b/tests/basic/test_hashline.py @@ -15,18 +15,17 @@ def test_hashline_basic(): lines = result.splitlines() assert len(lines) == 3 - # Check each line has the format "[{4-char-hash}]content" (new HashPos format) + # Check each line has the format "{4-char-hash}::content" (new HashPos format) for i, line in enumerate(lines, start=1): - # Format should be "[{4-char-hash}]content" - assert line.startswith("[") - assert line[5] == "]" # 4-char hash + 1 for opening bracket - # Extract hash fragment - hash_fragment = line[1:5] + # Format should be "{4-char-hash}::content" + assert "::" in line + # Extract hash fragment (everything before "::") + hash_fragment = line.split("::", 1)[0] # Check hash fragment is 4 characters assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, -, _, @) + # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_@" + assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" def test_hashline_with_start_line(): @@ -36,22 +35,18 @@ def test_hashline_with_start_line(): lines = result.splitlines() assert len(lines) == 2 - # Check format is [{4-char-hash}]content (new HashPos format) + # Check format is {4-char-hash}::content (new HashPos format) # Note: start_line parameter is ignored by HashPos but kept for compatibility for line in lines: - # Format should be "[{4-char-hash}]content" - assert line.startswith("[") - assert line[5] == "]" # 4-char hash + 1 for opening bracket - # Extract hash fragment - hash_fragment = line[1:5] + # Format should be "{4-char-hash}::content" + assert "::" in line + # Extract hash fragment (everything before "::") + hash_fragment = line.split("::", 1)[0] # Check hash fragment is 4 characters assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, -, _, @) + # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_@" - - -def test_hashline_empty_string(): + assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" """Test hashline with empty string.""" result = hashline("") assert result == "" @@ -63,46 +58,43 @@ def test_hashline_single_line(): result = hashline(text) lines = result.splitlines() assert len(lines) == 1 - # Check format is [{4-char-hash}]content (new HashPos format) + # Check format is {4-char-hash}::content (new HashPos format) line = lines[0] - assert line.startswith("[") - assert line[5] == "]" # 4-char hash + 1 for opening bracket - assert line.endswith("]Single line") - # Extract hash fragment - hash_fragment = line[1:5] + assert "::" in line + # Extract hash fragment (everything before "::") + hash_fragment = line.split("::", 1)[0] # Check hash fragment is 4 characters assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, -, _, @) + # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_@" + assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" def test_hashline_preserves_newlines(): """Test that hashline preserves newline characters.""" text = "Line 1\nLine 2\n" result = hashline(text) - # HashPos format: [{4-char-hash}]content on each line + # HashPos format: {4-char-hash}::content on each line # The result should have hashes on each line but no trailing newline lines = result.splitlines() assert len(lines) == 2 # Check each line has the correct format for line in lines: - assert line.startswith("[") - assert line[5] == "]" # 4-char hash + 1 for opening bracket - # Extract hash fragment - hash_fragment = line[1:5] + assert "::" in line + # Extract hash fragment (everything before "::") + hash_fragment = line.split("::", 1)[0] assert len(hash_fragment) == 4 - # Check all hash characters are valid base64 (A-Z, a-z, 0-9, -, _, @) + # Check all hash characters are valid base64 (A-Z, a-z, 0-9, ~, _, @) for char in hash_fragment: - assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_@" + assert char in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~_@" # HashPos doesn't preserve trailing newlines in the formatted output # The splitlines() above verifies we have the right number of lines def test_strip_hashline_basic(): """Test basic strip_hashline functionality.""" - # Create a hashline-formatted text with correct HashPos format: [{4-char-hash}]content - text = "[abcd]Hello\n[efgh]World\n[ijkl]Test" + # Create a hashline-formatted text with correct HashPos format: {4-char-hash}::content + text = "abcd::Hello\nefgh::World\nijkl::Test" stripped = strip_hashline(text) assert stripped == "Hello\nWorld\nTest" @@ -111,24 +103,24 @@ def test_strip_hashline_with_negative_line_numbers(): """Test strip_hashline with negative line numbers.""" # HashPos format doesn't support negative line numbers in the prefix # Test with standard HashPos format - text = "[abcd]Hello\n[efgh]World\n[ijkl]Test" + text = "abcd::Hello\nefgh::World\nijkl::Test" stripped = strip_hashline(text) assert stripped == "Hello\nWorld\nTest" def test_strip_hashline_mixed_lines(): """Test strip_hashline with mixed hashline and non-hashline lines.""" - # HashPos format: [{4-char-hash}]content + # HashPos format: {4-char-hash}::content # Plain lines without hashes should be left unchanged - text = "[abcd]Hello\nPlain line\n[efgh]World" + text = "abcd::Hello\nPlain line\nefgh::World" stripped = strip_hashline(text) assert stripped == "Hello\nPlain line\nWorld" def test_strip_hashline_preserves_newlines(): """Test that strip_hashline preserves newline characters.""" - # HashPos format: [{4-char-hash}]content - text = "[abcd]Line 1\n[efgh]Line 2\n" + # HashPos format: {4-char-hash}::content + text = "abcd::Line 1\nefgh::Line 2\n" stripped = strip_hashline(text) # strip_hashline should preserve newlines assert stripped == "Line 1\nLine 2\n" @@ -177,14 +169,14 @@ def test_hashline_different_inputs(): def test_parse_hashline(): """Test parse_hashline function.""" - # Test basic parsing (HashPos format: [{4-char-hash}]) - hash_fragment, line_num_str, line_num = parse_hashline("[abcd]") + # Test basic parsing (HashPos format: {4-char-hash}) + hash_fragment, line_num_str, line_num = parse_hashline("abcd") assert hash_fragment == "abcd" assert line_num_str is None # HashPos doesn't include line numbers assert line_num is None # Test with content after hash - hash_fragment, line_num_str, line_num = parse_hashline("[efgh]Hello World") + hash_fragment, line_num_str, line_num = parse_hashline("efgh::Hello World") assert hash_fragment == "efgh" assert line_num_str is None assert line_num is None diff --git a/tests/basic/test_main.py b/tests/basic/test_main.py index 3fa6730c035..4bd8456e842 100644 --- a/tests/basic/test_main.py +++ b/tests/basic/test_main.py @@ -1373,7 +1373,7 @@ def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): mcp_file = Path("mcp_servers.json") mcp_content = {"mcpServers": {"git": {"command": "uvx", "args": ["mcp-server-git"]}}} mcp_file.write_text(json.dumps(mcp_content)) - main(["--mcp-servers-file", str(mcp_file), "--exit", "--yes-always"], **dummy_io) + main(["--mcp-servers-files", str(mcp_file), "--exit", "--yes-always"], **dummy_io) mock_coder_create.assert_called_once() _, kwargs = mock_coder_create.call_args @@ -1381,3 +1381,43 @@ def test_mcp_servers_parsing(dummy_io, git_temp_dir, mocker): assert kwargs["mcp_manager"] is not None assert len(kwargs["mcp_manager"].servers) > 0 assert hasattr(kwargs["mcp_manager"].servers[0], "name") + + +def test_mcp_servers_file_multiple(dummy_io, git_temp_dir, mocker): + mocker.patch("cecli.mcp.server.McpServer.connect", new_callable=AsyncMock) + mock_coder_create = mocker.patch("cecli.coders.Coder.create") + mock_coder_instance = MagicMock() + mock_coder_instance.mcp_manager = False + mock_coder_instance._autosave_future = mock_autosave_future() + mock_coder_create.return_value = mock_coder_instance + + mcp_file1 = Path("mcp_servers1.json") + mcp_content1 = {"mcpServers": {"server1": {"command": "cmd1"}}} + mcp_file1.write_text(json.dumps(mcp_content1)) + + mcp_file2 = Path("mcp_servers2.json") + mcp_content2 = {"mcpServers": {"server2": {"command": "cmd2"}}} + mcp_file2.write_text(json.dumps(mcp_content2)) + + main( + [ + "--mcp-servers-files", + str(mcp_file1), + "--mcp-servers-files", + str(mcp_file2), + "--exit", + "--yes-always", + ], + **dummy_io, + ) + + mock_coder_create.assert_called_once() + _, kwargs = mock_coder_create.call_args + + assert "mcp_manager" in kwargs + mcp_manager = kwargs["mcp_manager"] + assert mcp_manager is not None + assert len(mcp_manager.servers) == 2 + server_names = {server.name for server in mcp_manager.servers} + assert "server1" in server_names + assert "server2" in server_names diff --git a/tests/basic/test_repomap.py b/tests/basic/test_repomap.py index a8bfa9e29df..0b6543c64e0 100644 --- a/tests/basic/test_repomap.py +++ b/tests/basic/test_repomap.py @@ -52,7 +52,7 @@ def test_get_repo_map(self): def test_repo_map_refresh_files(self): with GitTemporaryDirectory() as temp_dir: - repo = git.Repo(temp_dir) + repo = git.Repo(temp_dir, odbt=git.GitCmdObjectDB) # Create three source files with one function each file1_content = "def function1():\n return 'Hello from file1'\n" @@ -130,7 +130,7 @@ def test_repo_map_refresh_files(self): def test_repo_map_refresh_auto(self): with GitTemporaryDirectory() as temp_dir: - repo = git.Repo(temp_dir) + repo = git.Repo(temp_dir, odbt=git.GitCmdObjectDB) # Create two source files with one function each file1_content = "def function1():\n return 'Hello from file1'\n" diff --git a/tests/basic/test_wholefile.py b/tests/basic/test_wholefile.py index 2dee1c90a0f..e48aaa8a883 100644 --- a/tests/basic/test_wholefile.py +++ b/tests/basic/test_wholefile.py @@ -9,7 +9,6 @@ from cecli.coders import Coder from cecli.coders.wholefile_coder import WholeFileCoder from cecli.dump import dump # noqa: F401 -from cecli.helpers.conversation import ConversationChunks from cecli.io import InputOutput @@ -21,14 +20,15 @@ def setup_and_teardown(self, gpt35_model): os.chdir(self.tempdir) self.GPT35 = gpt35_model # Reset conversation system before each test - ConversationChunks.reset() - + # Note: We can't reset here because we don't have a coder object yet + # ConversationService.get_chunks(self.tempdir).reset() yield os.chdir(self.original_cwd) shutil.rmtree(self.tempdir, ignore_errors=True) # Reset conversation system after each test - ConversationChunks.reset() + # Note: We can't reset here because we don't have a coder object + # ConversationService.get_chunks(self.tempdir).reset() async def test_no_files(self): # Initialize WholeFileCoder with the temporary directory diff --git a/tests/hooks/test_base.py b/tests/hooks/test_base.py index 83897d5164b..9af8f9138ce 100644 --- a/tests/hooks/test_base.py +++ b/tests/hooks/test_base.py @@ -6,8 +6,8 @@ from cecli.hooks import BaseHook, CommandHook, HookType -class TestHook(BaseHook): - """Test hook for unit testing.""" +class MockHook(BaseHook): + """Mock hook for unit testing.""" type = HookType.START @@ -16,8 +16,8 @@ async def execute(self, coder, metadata): return True -class TestHookWithReturn(BaseHook): - """Test hook that returns a specific value.""" +class MockHookWithReturn(BaseHook): + """Mock hook that returns a specific value.""" type = HookType.PRE_TOOL @@ -30,8 +30,8 @@ async def execute(self, coder, metadata): return self.return_value -class TestHookWithException(BaseHook): - """Test hook that raises an exception.""" +class MockHookWithException(BaseHook): + """Mock hook that raises an exception.""" type = HookType.START @@ -45,7 +45,7 @@ class TestBaseHook: def test_hook_creation(self): """Test basic hook creation.""" - hook = TestHook(name="test_hook", priority=5, enabled=True) + hook = MockHook(name="test_hook", priority=5, enabled=True) assert hook.name == "test_hook" assert hook.priority == 5 @@ -54,8 +54,8 @@ def test_hook_creation(self): def test_hook_default_name(self): """Test hook uses class name as default.""" - hook = TestHook() - assert hook.name == "TestHook" + hook = MockHook() + assert hook.name == "MockHook" def test_hook_validation(self): """Test hook type validation.""" @@ -72,10 +72,10 @@ async def execute(self, coder, metadata): def test_hook_repr(self): """Test hook string representation.""" - hook = TestHook(name="test_hook", priority=10, enabled=False) + hook = MockHook(name="test_hook", priority=10, enabled=False) repr_str = repr(hook) - assert "TestHook" in repr_str + assert "MockHook" in repr_str assert "name='test_hook'" in repr_str assert "type=HookType.START" in repr_str or "type=START" in repr_str assert "priority=10" in repr_str @@ -84,7 +84,7 @@ def test_hook_repr(self): @pytest.mark.asyncio async def test_hook_execution(self): """Test hook execution.""" - hook = TestHook() + hook = MockHook() result = await hook.execute(None, {}) assert result is True @@ -92,7 +92,7 @@ async def test_hook_execution(self): @pytest.mark.asyncio async def test_hook_with_return_value(self): """Test hook with specific return value.""" - hook = TestHookWithReturn(return_value=False) + hook = MockHookWithReturn(return_value=False) result = await hook.execute(None, {}) assert result is False @@ -100,7 +100,7 @@ async def test_hook_with_return_value(self): @pytest.mark.asyncio async def test_hook_with_exception(self): """Test hook that raises exception.""" - hook = TestHookWithException() + hook = MockHookWithException() with pytest.raises(ValueError, match="Test exception"): await hook.execute(None, {}) diff --git a/tests/hooks/test_manager.py b/tests/hooks/test_manager.py index 073ee50034c..ad1113ef99d 100644 --- a/tests/hooks/test_manager.py +++ b/tests/hooks/test_manager.py @@ -7,8 +7,8 @@ from cecli.hooks import BaseHook, HookManager, HookType -class TestHook(BaseHook): - """Test hook for unit testing.""" +class MockHook(BaseHook): + """Mock hook for unit testing.""" type = HookType.START @@ -17,8 +17,8 @@ async def execute(self, coder, metadata): return True -class TestPreToolHook(BaseHook): - """Test hook for pre_tool type.""" +class MockPreToolHook(BaseHook): + """Mock hook for pre_tool type.""" type = HookType.PRE_TOOL @@ -47,7 +47,7 @@ def test_singleton_pattern(self): def test_register_hook(self): """Test hook registration.""" - hook = TestHook(name="test_hook") + hook = MockHook(name="test_hook") self.manager.register_hook(hook) assert self.manager.hook_exists("test_hook") is True @@ -56,8 +56,8 @@ def test_register_hook(self): def test_register_duplicate_hook(self): """Test duplicate hook registration fails.""" - hook1 = TestHook(name="test_hook") - hook2 = TestHook(name="test_hook") # Same name + hook1 = MockHook(name="test_hook") + hook2 = MockHook(name="test_hook") # Same name self.manager.register_hook(hook1) @@ -66,9 +66,9 @@ def test_register_duplicate_hook(self): def test_get_hooks(self): """Test getting hooks by type.""" - hook1 = TestHook(name="hook1", priority=10) - hook2 = TestHook(name="hook2", priority=5) # Higher priority - hook3 = TestPreToolHook(name="hook3", priority=10) + hook1 = MockHook(name="hook1", priority=10) + hook2 = MockHook(name="hook2", priority=5) # Higher priority + hook3 = MockPreToolHook(name="hook3", priority=10) self.manager.register_hook(hook1) self.manager.register_hook(hook2) @@ -93,9 +93,9 @@ def test_get_hooks(self): def test_get_all_hooks(self): """Test getting all hooks grouped by type.""" - hook1 = TestHook(name="hook1") - hook2 = TestHook(name="hook2") - hook3 = TestPreToolHook(name="hook3") + hook1 = MockHook(name="hook1") + hook2 = MockHook(name="hook2") + hook3 = MockPreToolHook(name="hook3") self.manager.register_hook(hook1) self.manager.register_hook(hook2) @@ -110,7 +110,7 @@ def test_get_all_hooks(self): def test_hook_exists(self): """Test checking if hook exists.""" - hook = TestHook(name="test_hook") + hook = MockHook(name="test_hook") assert self.manager.hook_exists("test_hook") is False @@ -121,7 +121,7 @@ def test_hook_exists(self): def test_enable_disable_hook(self): """Test enabling and disabling hooks.""" - hook = TestHook(name="test_hook", enabled=False) + hook = MockHook(name="test_hook", enabled=False) self.manager.register_hook(hook) # Initially disabled @@ -163,8 +163,8 @@ def test_state_persistence(self, tmp_path): self.manager._state_file = state_file # Create and register hooks - hook1 = TestHook(name="hook1", enabled=True) - hook2 = TestHook(name="hook2", enabled=False) + hook1 = MockHook(name="hook1", enabled=True) + hook2 = MockHook(name="hook2", enabled=False) self.manager.register_hook(hook1) self.manager.register_hook(hook2) @@ -188,8 +188,8 @@ def test_state_persistence(self, tmp_path): new_manager._state_file = state_file # Register hooks with new manager - new_hook1 = TestHook(name="hook1", enabled=False) # Default disabled - new_hook2 = TestHook(name="hook2", enabled=True) # Default enabled + new_hook1 = MockHook(name="hook1", enabled=False) # Default disabled + new_hook2 = MockHook(name="hook2", enabled=True) # Default enabled new_manager.register_hook(new_hook1) new_manager.register_hook(new_hook2) @@ -202,8 +202,8 @@ def test_state_persistence(self, tmp_path): def test_clear(self): """Test clearing all hooks.""" - hook1 = TestHook(name="hook1") - hook2 = TestHook(name="hook2") + hook1 = MockHook(name="hook1") + hook2 = MockHook(name="hook2") self.manager.register_hook(hook1) self.manager.register_hook(hook2) diff --git a/tests/hooks/test_registry.py b/tests/hooks/test_registry.py index 3a10432a8c0..3bbca0d19b6 100644 --- a/tests/hooks/test_registry.py +++ b/tests/hooks/test_registry.py @@ -8,8 +8,8 @@ from cecli.hooks import BaseHook, HookManager, HookRegistry, HookType -class TestHook(BaseHook): - """Test hook for unit testing.""" +class MockHook(BaseHook): + """Mock hook for unit testing.""" type = HookType.START @@ -18,8 +18,8 @@ async def execute(self, coder, metadata): return True -class AnotherTestHook(BaseHook): - """Another test hook.""" +class AnotherMockHook(BaseHook): + """Another mock hook.""" type = HookType.END diff --git a/tests/test_conversation_integration.py b/tests/test_conversation_integration.py index 6ab965f5eb6..c30d9596a63 100644 --- a/tests/test_conversation_integration.py +++ b/tests/test_conversation_integration.py @@ -3,8 +3,14 @@ """ import unittest +import uuid -from cecli.helpers.conversation import ConversationManager, MessageTag +from cecli.helpers.conversation import ConversationService, MessageTag + + +class MockCoder: + def __init__(self): + self.uuid = uuid.uuid4() class TestConversationIntegration(unittest.TestCase): @@ -12,48 +18,49 @@ class TestConversationIntegration(unittest.TestCase): def setUp(self): """Set up test environment.""" - # Reset conversation manager - ConversationManager.reset() + self.coder = MockCoder() + self.manager = ConversationService.get_manager(self.coder) + self.manager.reset() def test_conversation_manager_methods(self): """Test that conversation manager methods work correctly.""" # Test adding a message message_dict = {"role": "user", "content": "Hello"} - message = ConversationManager.add_message( + message = self.manager.add_message( message_dict=message_dict, tag=MessageTag.CUR, ) self.assertIsNotNone(message) - self.assertEqual(len(ConversationManager.get_messages()), 1) + self.assertEqual(len(self.manager.get_messages()), 1) # Test getting messages as dict - messages_dict = ConversationManager.get_messages_dict() + messages_dict = self.manager.get_messages_dict() self.assertEqual(len(messages_dict), 1) self.assertEqual(messages_dict[0]["role"], "user") self.assertEqual(messages_dict[0]["content"], "Hello") # Test clearing tag - ConversationManager.clear_tag(MessageTag.CUR) - self.assertEqual(len(ConversationManager.get_messages()), 0) + self.manager.clear_tag(MessageTag.CUR) + self.assertEqual(len(self.manager.get_messages()), 0) def test_message_ordering_with_tags(self): """Test message ordering with different tags.""" # Add messages with different tags (different default priorities) - ConversationManager.add_message( + self.manager.add_message( message_dict={"role": "system", "content": "System"}, tag=MessageTag.SYSTEM, # Priority 0 ) - ConversationManager.add_message( + self.manager.add_message( message_dict={"role": "user", "content": "User"}, tag=MessageTag.CUR, # Priority 200 ) - ConversationManager.add_message( + self.manager.add_message( message_dict={"role": "assistant", "content": "Assistant"}, tag=MessageTag.EXAMPLES, # Priority 75 ) - messages = ConversationManager.get_messages() + messages = self.manager.get_messages() # Check ordering: SYSTEM (0), EXAMPLES (75), CUR (200) self.assertEqual(len(messages), 3) @@ -64,25 +71,25 @@ def test_message_ordering_with_tags(self): def test_mark_for_delete_lifecycle(self): """Test mark_for_delete lifecycle.""" # Add message with mark_for_delete - ConversationManager.add_message( + self.manager.add_message( message_dict={"role": "user", "content": "Temp"}, tag=MessageTag.CUR, mark_for_delete=2, # Will expire after 2 decrements ) - self.assertEqual(len(ConversationManager.get_messages()), 1) + self.assertEqual(len(self.manager.get_messages()), 1) # First decrement: mark_for_delete = 1 (not expired) - ConversationManager.decrement_mark_for_delete() - self.assertEqual(len(ConversationManager.get_messages()), 1) + self.manager.decrement_message_markers() + self.assertEqual(len(self.manager.get_messages()), 1) # Second decrement: mark_for_delete = 0 (not expired) - ConversationManager.decrement_mark_for_delete() - self.assertEqual(len(ConversationManager.get_messages()), 1) + self.manager.decrement_message_markers() + self.assertEqual(len(self.manager.get_messages()), 1) # Third decrement: mark_for_delete = -1 (expired, should be removed) - ConversationManager.decrement_mark_for_delete() - self.assertEqual(len(ConversationManager.get_messages()), 0) + self.manager.decrement_message_markers() + self.assertEqual(len(self.manager.get_messages()), 0) if __name__ == "__main__": diff --git a/tests/test_conversation_system.py b/tests/test_conversation_system.py index 7f0cfe3d34d..6410e71369a 100644 --- a/tests/test_conversation_system.py +++ b/tests/test_conversation_system.py @@ -2,39 +2,38 @@ Unit tests for the conversation system. """ +import uuid + import pytest -from cecli.helpers.conversation import ( - BaseMessage, - ConversationFiles, - ConversationManager, - MessageTag, - initialize_conversation_system, -) +from cecli.helpers.conversation import BaseMessage, ConversationService, MessageTag from cecli.io import InputOutput -class TestCoder: - """Simple test coder class for conversation system tests.""" +class MockCoder: + """Simple mock coder class for conversation system tests.""" def __init__(self, io=None): + self.uuid = uuid.uuid4() self.abs_fnames = set() self.abs_read_only_fnames = set() self.edit_format = None self.context_management_enabled = False self.large_file_token_threshold = 1000 self.io = io or InputOutput(yes=False) + self.add_cache_headers = False + self.hashlines = False self.add_cache_headers = False # Default to False for tests @property def done_messages(self): """Get DONE messages from ConversationManager.""" - return ConversationManager.get_messages_dict(MessageTag.DONE) + return ConversationService.get_manager(self).get_messages_dict(MessageTag.DONE) @property def cur_messages(self): """Get CUR messages from ConversationManager.""" - return ConversationManager.get_messages_dict(MessageTag.CUR) + return ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR) class TestBaseMessage: @@ -43,11 +42,12 @@ class TestBaseMessage: @pytest.fixture(autouse=True) def setup(self): """Reset conversation manager before each test.""" - ConversationManager.reset() - ConversationFiles.reset() + self.test_coder = MockCoder() + ConversationService.get_manager(self.test_coder).reset() + ConversationService.get_files(self.test_coder).reset() yield - ConversationManager.reset() - ConversationFiles.reset() + ConversationService.get_manager(self.test_coder).reset() + ConversationService.get_files(self.test_coder).reset() def test_base_message_creation(self): """Test creating a BaseMessage instance.""" @@ -117,506 +117,127 @@ class TestConversationManager: @pytest.fixture(autouse=True) def setup(self): """Reset conversation manager before each test.""" - ConversationManager.reset() - # Create a test coder with real InputOutput - self.test_coder = TestCoder() + self.test_coder = MockCoder() + self.manager = ConversationService.get_manager(self.test_coder) + self.chunks = ConversationService.get_chunks(self.test_coder) + self.manager.reset() # Initialize conversation system - initialize_conversation_system(self.test_coder) + self.chunks.initialize_conversation_system() yield - ConversationManager.reset() + self.manager.reset() def test_add_message(self): """Test adding messages to conversation manager.""" message_dict = {"role": "user", "content": "Hello"} # Add first message - message1 = ConversationManager.add_message( + message1 = self.manager.add_message( message_dict=message_dict, tag=MessageTag.CUR, ) + assert len(self.manager.get_messages()) == 1 + assert self.manager.get_messages()[0].message_dict["content"] == "Hello" - assert len(ConversationManager.get_messages()) == 1 - assert ConversationManager.get_messages()[0] == message1 - - # Add same message again (should be idempotent) - message2 = ConversationManager.add_message( + # Add same message (should be idempotent by default) + message2 = self.manager.add_message( message_dict=message_dict, tag=MessageTag.CUR, ) + assert len(self.manager.get_messages()) == 1 + assert message1.message_id == message2.message_id - assert message1 == message2 # Should return same message - assert len(ConversationManager.get_messages()) == 1 # Still only one - - # Add different message - message_dict2 = {"role": "assistant", "content": "Hi there!"} - ConversationManager.add_message( - message_dict=message_dict2, - tag=MessageTag.CUR, + # Add message with different tag + self.manager.add_message( + message_dict={"role": "assistant", "content": "Hi"}, + tag=MessageTag.EXAMPLES, ) - - assert len(ConversationManager.get_messages()) == 2 + assert len(self.manager.get_messages()) == 2 def test_add_message_with_force(self): """Test adding messages with force=True.""" message_dict = {"role": "user", "content": "Hello"} - - # Add first message - message1 = ConversationManager.add_message( + message1 = self.manager.add_message( message_dict=message_dict, tag=MessageTag.CUR, - priority=100, + hash_key=("test", "message"), ) - # Add same message with force=True and different priority - message2 = ConversationManager.add_message( - message_dict=message_dict, + # Update message with force=True using same hash_key + updated_dict = {"role": "user", "content": "Updated"} + message2 = self.manager.add_message( + message_dict=updated_dict, tag=MessageTag.CUR, - priority=200, + hash_key=("test", "message"), force=True, ) - assert message1 == message2 # Should be same object - assert message2.priority == 200 # Priority should be updated - - def test_message_ordering(self): - """Test that messages are ordered by priority and timestamp.""" - # Add messages with different priorities - ConversationManager.add_message( - message_dict={"role": "system", "content": "System message"}, - tag=MessageTag.SYSTEM, - priority=0, # Lowest priority = first - ) - - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message"}, - tag=MessageTag.CUR, - priority=200, # Higher priority = later - ) - - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message"}, - tag=MessageTag.CUR, - priority=100, # Medium priority = middle - ) - - messages = ConversationManager.get_messages() - - # Check ordering - assert messages[0].message_dict["content"] == "System message" - assert messages[1].message_dict["content"] == "Assistant message" - assert messages[2].message_dict["content"] == "User message" - - def test_clear_tag(self): - """Test clearing messages by tag.""" - # Add messages with different tags - ConversationManager.add_message( - message_dict={"role": "system", "content": "System"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User 1"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User 2"}, - tag=MessageTag.CUR, - ) - - assert len(ConversationManager.get_messages()) == 3 - - # Clear CUR messages - ConversationManager.clear_tag(MessageTag.CUR) - - messages = ConversationManager.get_messages() - assert len(messages) == 1 - assert messages[0].message_dict["content"] == "System" - - def test_get_tag_messages(self): - """Test getting messages by tag.""" - # Add messages with different tags - ConversationManager.add_message( - message_dict={"role": "system", "content": "System"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User 1"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User 2"}, - tag=MessageTag.CUR, - ) - - cur_messages = ConversationManager.get_tag_messages(MessageTag.CUR) - assert len(cur_messages) == 2 - - system_messages = ConversationManager.get_tag_messages(MessageTag.SYSTEM) - assert len(system_messages) == 1 - - def test_decrement_mark_for_delete(self): - """Test decrementing mark_for_delete values.""" - # Add message with mark_for_delete - ConversationManager.add_message( - message_dict={"role": "user", "content": "Temp message"}, - tag=MessageTag.CUR, - mark_for_delete=0, # Will expire after one decrement (0 -> -1) - ) - - assert len(ConversationManager.get_messages()) == 1 - - # Decrement once - ConversationManager.decrement_mark_for_delete() - - # Message should be removed - assert len(ConversationManager.get_messages()) == 0 - - def test_get_messages_dict(self): - """Test getting message dictionaries for LLM consumption.""" - ConversationManager.add_message( - message_dict={"role": "user", "content": "Hello"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Hi!"}, - tag=MessageTag.CUR, - ) - - messages_dict = ConversationManager.get_messages_dict() - - assert len(messages_dict) == 2 - assert messages_dict[0]["role"] == "user" - assert messages_dict[0]["content"] == "Hello" - assert messages_dict[1]["role"] == "assistant" - assert messages_dict[1]["content"] == "Hi!" - - def test_get_messages_dict_with_tag_filter(self): - """Test getting message dictionaries filtered by tag.""" - # Add messages with different tags - ConversationManager.add_message( - message_dict={"role": "system", "content": "System message"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 1"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message 1"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 2"}, - tag=MessageTag.DONE, - ) - - # Test getting all messages (no tag filter) - all_messages = ConversationManager.get_messages_dict() - assert len(all_messages) == 4 - - # Test filtering by CUR tag - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages) == 2 - assert all(msg["role"] in ["user", "assistant"] for msg in cur_messages) - assert any(msg["content"] == "User message 1" for msg in cur_messages) - assert any(msg["content"] == "Assistant message 1" for msg in cur_messages) - - # Test filtering by SYSTEM tag - system_messages = ConversationManager.get_messages_dict(MessageTag.SYSTEM) - assert len(system_messages) == 1 - assert system_messages[0]["role"] == "system" - assert system_messages[0]["content"] == "System message" - - # Test filtering by DONE tag - done_messages = ConversationManager.get_messages_dict(MessageTag.DONE) - assert len(done_messages) == 1 - assert done_messages[0]["role"] == "user" - assert done_messages[0]["content"] == "User message 2" - - # Test filtering by tag string (not enum) - cur_messages_str = ConversationManager.get_messages_dict("cur") - assert len(cur_messages_str) == 2 - - # Test invalid tag handling - with pytest.raises(ValueError): - ConversationManager.get_messages_dict("invalid_tag") - - def test_debug_functionality(self): - """Test debug mode and message comparison functionality.""" - # First, disable debug to test enabling it - ConversationManager.set_debug_enabled(False) - - # Add a message with debug disabled - ConversationManager.add_message( - message_dict={"role": "user", "content": "Test message 1"}, - tag=MessageTag.CUR, - ) - - # Get messages dict (should not trigger debug comparison) - messages_dict1 = ConversationManager.get_messages_dict() - assert len(messages_dict1) == 1 - - # Enable debug mode - ConversationManager.set_debug_enabled(True) - - # Add another message - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Test response 1"}, - tag=MessageTag.CUR, - ) - - # Get messages dict again (should trigger debug comparison) - messages_dict2 = ConversationManager.get_messages_dict() - assert len(messages_dict2) == 2 - - # Disable debug mode again - ConversationManager.set_debug_enabled(False) - - # Add one more message - ConversationManager.add_message( - message_dict={"role": "user", "content": "Test message 2"}, - tag=MessageTag.CUR, - ) + assert len(self.manager.get_messages()) == 1 + # When using hash_key + force, the existing message object is updated + assert message1.message_id == message2.message_id + assert self.manager.get_messages()[0].message_dict["content"] == "Updated" - # Get final messages dict - messages_dict3 = ConversationManager.get_messages_dict() - assert len(messages_dict3) == 3 - - # Test debug_validate_state method - assert ConversationManager.debug_validate_state() - - # Test debug_get_stream_info method - stream_info = ConversationManager.debug_get_stream_info() - assert "stream_length" in stream_info - assert stream_info["stream_length"] == 3 - assert "hashes" in stream_info - assert len(stream_info["hashes"]) == 3 - assert "tags" in stream_info - assert "priorities" in stream_info - - def test_caching_functionality(self): - """Test caching for tagged message dict queries.""" - # Clear any existing cache - ConversationManager.clear_cache() - - # Add messages with different tags - ConversationManager.add_message( - message_dict={"role": "system", "content": "System message"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 1"}, - tag=MessageTag.CUR, + # Normal priority messages + self.manager.add_message( + message_dict={"role": "user", "content": "Low priority"}, + tag=MessageTag.CUR, # priority 200 ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message 1"}, - tag=MessageTag.CUR, + self.manager.add_message( + message_dict={"role": "system", "content": "High priority"}, + tag=MessageTag.SYSTEM, # priority 0 ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 2"}, - tag=MessageTag.DONE, - ) - - # First call to get CUR messages - should compute and cache - cur_messages1 = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages1) == 2 - - # Second call to get CUR messages - should use cache - cur_messages2 = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages2) == 2 - assert cur_messages1 == cur_messages2 # Should be same object from cache - - # Call with reload=True - should bypass cache - cur_messages3 = ConversationManager.get_messages_dict(MessageTag.CUR, reload=True) - assert len(cur_messages3) == 2 - assert cur_messages1 == cur_messages3 # Content should be same - - # Get DONE messages - should compute and cache - done_messages1 = ConversationManager.get_messages_dict(MessageTag.DONE) - assert len(done_messages1) == 1 - - # Get SYSTEM messages - should compute and cache - system_messages1 = ConversationManager.get_messages_dict(MessageTag.SYSTEM) - assert len(system_messages1) == 1 - - # Add a new CUR message - should invalidate CUR cache - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 3"}, - tag=MessageTag.CUR, - ) - - # Get CUR messages again - should recompute (cache was invalidated) - cur_messages4 = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages4) == 3 # Now has 3 messages - - # Clear tag should clear cache for that tag - ConversationManager.clear_tag(MessageTag.CUR) - - # Get CUR messages after clear - should recompute - cur_messages5 = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages5) == 0 # All CUR messages cleared - - # Test clear_cache method - # Get DONE messages to populate cache - done_messages2 = ConversationManager.get_messages_dict(MessageTag.DONE) - assert len(done_messages2) == 1 - - # Clear all cache - ConversationManager.clear_cache() - - # Get DONE messages again - should recompute after cache clear - done_messages3 = ConversationManager.get_messages_dict(MessageTag.DONE) - assert len(done_messages3) == 1 - # Test reset also clears cache - # Get SYSTEM messages to populate cache - system_messages2 = ConversationManager.get_messages_dict(MessageTag.SYSTEM) - assert len(system_messages2) == 1 - - # Reset should clear everything including cache - ConversationManager.reset() - - # Get SYSTEM messages after reset - should be empty - system_messages3 = ConversationManager.get_messages_dict(MessageTag.SYSTEM) - assert len(system_messages3) == 0 - - def test_coder_properties(self): - """Test that coder.done_messages and coder.cur_messages properties work.""" - # Create a test coder - coder = TestCoder() - - # Initialize conversation system - initialize_conversation_system(coder) - - # Add messages with different tags - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 1"}, + # Promoted message (should be between SYSTEM and CUR) + self.manager.add_message( + message_dict={"role": "assistant", "content": "Promoted"}, tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message 1"}, + promotion=100, + mark_for_demotion=1, # Required for is_promoted() + ) + + messages = self.manager.get_messages() + assert len(messages) == 4 + assert messages[0].tag == MessageTag.SYSTEM.value + assert messages[1].message_dict["content"] == "Promoted" + assert messages[2].message_dict["content"] == "Updated" + assert messages[3].message_dict["content"] == "Low priority" + + def test_is_promoted_method(self): + """Test that is_promoted() method works correctly.""" + # Not promoted (no mark_for_demotion) + message1 = self.manager.add_message( + message_dict={"role": "user", "content": "Not promoted"}, tag=MessageTag.CUR, + promotion=100, ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message 2"}, - tag=MessageTag.DONE, - ) + assert not message1.is_promoted() - # Test coder.cur_messages property - cur_messages = coder.cur_messages - assert len(cur_messages) == 2 - assert cur_messages[0]["content"] == "User message 1" - assert cur_messages[1]["content"] == "Assistant message 1" - - # Test coder.done_messages property - done_messages = coder.done_messages - assert len(done_messages) == 1 - assert done_messages[0]["content"] == "User message 2" - - # Test that properties return the same as direct ConversationManager calls - assert cur_messages == ConversationManager.get_messages_dict(MessageTag.CUR) - assert done_messages == ConversationManager.get_messages_dict(MessageTag.DONE) - - def test_cache_control_headers(self): - """Test that cache control headers are only added when coder.add_cache_headers = True.""" - # Create a test coder with add_cache_headers = False (default) - coder_false = TestCoder() - coder_false.add_cache_headers = False - initialize_conversation_system(coder_false) - - # Add some messages - ConversationManager.add_message( - message_dict={"role": "system", "content": "System message"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message"}, - tag=MessageTag.CUR, - ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message"}, + # Promoted (has mark_for_demotion >= 0) + message2 = self.manager.add_message( + message_dict={"role": "user", "content": "Promoted"}, tag=MessageTag.CUR, + promotion=100, + mark_for_demotion=0, ) + assert message2.is_promoted() - # Get all messages (no tag filter) - should NOT add cache control headers - messages_dict_false = ConversationManager.get_messages_dict() - assert len(messages_dict_false) == 3 - - # Check that no cache control headers were added - for msg in messages_dict_false: - content = msg.get("content") - if isinstance(content, list): - # If content is a list, check that no element has cache_control - for element in content: - if isinstance(element, dict): - assert "cache_control" not in element - elif isinstance(content, dict): - # If content is a dict, check it doesn't have cache_control - assert "cache_control" not in content - - # Reset and test with add_cache_headers = True - ConversationManager.reset() - - coder_true = TestCoder() - coder_true.add_cache_headers = True - initialize_conversation_system(coder_true) - - # Add the same messages - ConversationManager.add_message( - message_dict={"role": "system", "content": "System message"}, - tag=MessageTag.SYSTEM, - ) - ConversationManager.add_message( - message_dict={"role": "user", "content": "User message"}, - tag=MessageTag.CUR, + def test_base_sort_with_promotion(self): + """Test that base_sort correctly handles promoted and non-promoted messages.""" + m1 = BaseMessage({"role": "user", "content": "M1"}, tag=MessageTag.CUR.value, priority=200) + m2 = BaseMessage( + {"role": "system", "content": "M2"}, tag=MessageTag.SYSTEM.value, priority=0 ) - ConversationManager.add_message( - message_dict={"role": "assistant", "content": "Assistant message"}, - tag=MessageTag.CUR, + m3 = BaseMessage( + {"role": "assistant", "content": "M3"}, + tag=MessageTag.CUR.value, + priority=200, + promotion=100, + mark_for_demotion=1, # Required for is_promoted() ) - # Get all messages (no tag filter) - SHOULD add cache control headers - messages_dict_true = ConversationManager.get_messages_dict() - assert len(messages_dict_true) == 3 - - # Check that cache control headers were added to specific messages - # The system message (first) and last 2 messages should have cache control - # In this case: system message (index 0), assistant message (index 2), user message (index 1) - # Note: The last system message before the last 2 non-system messages gets cache control - # Since we have system at index 0, and non-system at indices 1 and 2, system at index 0 gets cache control - - # Check system message (index 0) has cache control - system_msg = messages_dict_true[0] - assert isinstance(system_msg.get("content"), list) - assert len(system_msg["content"]) == 1 - assert isinstance(system_msg["content"][0], dict) - assert "cache_control" in system_msg["content"][0] - - # Check last message (index 2) has cache control - last_msg = messages_dict_true[2] - assert isinstance(last_msg.get("content"), list) - assert len(last_msg["content"]) == 1 - assert isinstance(last_msg["content"][0], dict) - assert "cache_control" in last_msg["content"][0] - - # Check second-to-last message (index 1) has cache control - second_last_msg = messages_dict_true[1] - assert isinstance(second_last_msg.get("content"), list) - assert len(second_last_msg["content"]) == 1 - assert isinstance(second_last_msg["content"][0], dict) - assert "cache_control" in second_last_msg["content"][0] - - # Test that filtered messages (with tag) don't get cache control headers - cur_messages = ConversationManager.get_messages_dict(MessageTag.CUR) - assert len(cur_messages) == 2 - # CUR messages should not have cache control when filtered by tag - for msg in cur_messages: - content = msg.get("content") - # When filtered by tag, cache control should not be added - # Content should be string, not list with cache control dict - assert isinstance(content, str) - assert content in ["User message", "Assistant message"] + sorted_msgs = self.manager.base_sort([m1, m2, m3]) + assert sorted_msgs == [m2, m3, m1] class TestConversationFiles: @@ -625,48 +246,46 @@ class TestConversationFiles: @pytest.fixture(autouse=True) def setup(self): """Reset conversation files before each test.""" - ConversationFiles.reset() - # Create a test coder with real InputOutput - self.test_coder = TestCoder() + self.test_coder = MockCoder() + self.files = ConversationService.get_files(self.test_coder) + self.chunks = ConversationService.get_chunks(self.test_coder) # Initialize conversation system - initialize_conversation_system(self.test_coder) + self.chunks.initialize_conversation_system() + yield - ConversationFiles.reset() + self.files.reset() def test_add_and_get_file_content(self, mocker): """Test adding and getting file content.""" import os import tempfile - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write("Test content") - temp_file = f.name - + # Create a temporary file + fd, temp_file = tempfile.mkstemp() try: - # Mock read_text to return file content - def mock_read_text(filename, silent=False): - try: - with open(filename, "r", encoding="utf-8") as f: - return f.read() - except Exception: - return None + with os.fdopen(fd, "w") as f: + f.write("Test content") - # Patch the read_text method on the coder's io - mocker.patch.object(self.test_coder.io, "read_text", side_effect=mock_read_text) + # Patch the read_text method to return content only for the temp file + def side_effect(fname, **kwargs): + if fname == temp_file: + return "Test content" + return None + mocker.patch.object(self.test_coder.io, "read_text", side_effect=side_effect) # Add file to cache - content = ConversationFiles.add_file(temp_file) + content = self.files.add_file(temp_file) assert content == "Test content" # Get file content from cache - cached_content = ConversationFiles.get_file_content(temp_file) + cached_content = self.files.get_file_content(temp_file) assert cached_content == "Test content" # Get content for non-existent file - non_existent_content = ConversationFiles.get_file_content("/non/existent/file") - assert non_existent_content is None + non_existent_content = self.files.get_file_content("/non/existent/file") + assert non_existent_content == "" finally: # Clean up os.unlink(temp_file) @@ -677,12 +296,13 @@ def test_has_file_changed(self, mocker): import tempfile import time - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write("Initial content") - temp_file = f.name - + # Create a temporary file + fd, temp_file = tempfile.mkstemp() try: - # Mock read_text to return file content + with os.fdopen(fd, "w") as f: + f.write("Original content") + + # Patch the read_text method on the coder's io def mock_read_text(filename, silent=False): try: with open(filename, "r", encoding="utf-8") as f: @@ -690,14 +310,13 @@ def mock_read_text(filename, silent=False): except Exception: return None - # Patch the read_text method on the coder's io mocker.patch.object(self.test_coder.io, "read_text", side_effect=mock_read_text) # Add file to cache - ConversationFiles.add_file(temp_file) + self.files.add_file(temp_file) # File should not have changed yet - assert not ConversationFiles.has_file_changed(temp_file) + assert not self.files.has_file_changed(temp_file) # Modify the file time.sleep(0.01) # Ensure different mtime @@ -705,6 +324,7 @@ def mock_read_text(filename, silent=False): f.write("Modified content") # File should now be detected as changed - assert ConversationFiles.has_file_changed(temp_file) + assert self.files.has_file_changed(temp_file) finally: - os.unlink(temp_file) + if os.path.exists(temp_file): + os.unlink(temp_file) diff --git a/tests/tools/test_insert_block.py b/tests/tools/test_insert_block.py index 7742fae059c..cd6940f4916 100644 --- a/tests/tools/test_insert_block.py +++ b/tests/tools/test_insert_block.py @@ -51,6 +51,7 @@ def __init__(self, root): self.files_edited_by_tools = set() self.abs_read_only_fnames = set() self.abs_fnames = set() + self.edit_allowed = True def abs_root_path(self, file_path): path = Path(file_path) @@ -79,10 +80,10 @@ def test_position_top_succeeds_with_no_patterns(coder_with_file): hashed_content = hashline(content) lines = hashed_content.splitlines() line1_hashline = lines[0] # Index 0 is line 1 - # HashPos format: [{4-char-hash}]content - # Extract hash fragment from [hash]content format - hash_fragment = line1_hashline[1:5] # Characters after '[' and before ']' - start_line = f"[{hash_fragment}]" + # HashPos format: {4-char-hash}::content + # Extract hash fragment from {hash}::content format + hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" + start_line = hash_fragment # Just the hash fragment, no brackets result = insert_text.Tool.execute( coder, @@ -122,10 +123,10 @@ def test_trailing_newline_preservation(coder_with_file): hashed_content = hashline(content) lines = hashed_content.splitlines() line1_hashline = lines[0] # Index 0 is line 1 - # HashPos format: [{4-char-hash}]content - # Extract hash fragment from [hash]content format - hash_fragment = line1_hashline[1:5] # Characters after '[' and before ']' - start_line = f"[{hash_fragment}]" + # HashPos format: {4-char-hash}::content + # Extract hash fragment from {hash}::content format + hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" + start_line = hash_fragment # Just the hash fragment, no brackets insert_text.Tool.execute( coder, @@ -136,11 +137,11 @@ def test_trailing_newline_preservation(coder_with_file): content = file_path.read_text() # When inserting in middle of file with HashPos system, - # trailing newlines are not preserved for insert operations - # The behavior is different from append operations - assert not content.endswith( + # trailing newlines should be preserved for insert operations + # just like they are for other operations + assert content.endswith( "\n" - ), "HashPos insert operation does not preserve trailing newlines when inserting in middle" + ), "HashPos insert operation should preserve trailing newlines when inserting in middle" coder.io.tool_error.assert_not_called() @@ -155,11 +156,10 @@ def test_no_trailing_newline_preservation(coder_with_file): hashed_content = hashline(content) lines = hashed_content.splitlines() line1_hashline = lines[0] # Index 0 is line 1 - # HashPos format: [{4-char-hash}]content - # Extract hash fragment from [hash]content format - hash_fragment = line1_hashline[1:5] # Characters after '[' and before ']' - start_line = f"[{hash_fragment}]" - + # HashPos format: {4-char-hash}::content + # Extract hash fragment from {hash}::content format + hash_fragment = line1_hashline.split("::", 1)[0] # Everything before "::" + start_line = hash_fragment # Just the hash fragment, no brackets insert_text.Tool.execute( coder, file_path="example.txt", @@ -182,13 +182,12 @@ def test_line_number_beyond_file_length_appends(coder_with_file): content = file_path.read_text() hashed_content = hashline(content) # Extract hash fragment for line 2 - # HashPos format: [{4-char-hash}]content + # HashPos format: {4-char-hash}::content lines = hashed_content.splitlines() line2_hashline = lines[1] # Index 1 is line 2 (0-indexed) - # Extract hash fragment from [hash]content format - hash_fragment = line2_hashline[1:5] # Characters after '[' and before ']' - start_line = f"[{hash_fragment}]" - + # Extract hash fragment from {hash}::content format + hash_fragment = line2_hashline.split("::", 1)[0] # Everything before "::" + start_line = hash_fragment # Just the hash fragment, no brackets result = insert_text.Tool.execute( coder, file_path="example.txt", @@ -212,10 +211,10 @@ def test_line_number_beyond_file_length_appends_no_trailing_newline(coder_with_f # Extract hash fragment for line 2 lines = hashed_content.splitlines() line2_hashline = lines[1] # Index 1 is line 2 (0-indexed) - # HashPos format: [{4-char-hash}]content - # Extract hash fragment from [hash]content format - hash_fragment = line2_hashline[1:5] # Characters after '[' and before ']' - start_line = f"[{hash_fragment}]" + # HashPos format: {4-char-hash}::content + # Extract hash fragment from {hash}::content format + hash_fragment = line2_hashline.split("::", 1)[0] # Everything before "::" + start_line = hash_fragment # Just the hash fragment, no brackets insert_text.Tool.execute( coder, diff --git a/tests/tools/test_show_numbered_context.py b/tests/tools/test_show_context.py similarity index 76% rename from tests/tools/test_show_numbered_context.py rename to tests/tools/test_show_context.py index 419956e6aa4..1dc0d194f18 100644 --- a/tests/tools/test_show_numbered_context.py +++ b/tests/tools/test_show_context.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools import show_numbered_context +from cecli.tools import show_context class DummyIO: @@ -25,6 +25,9 @@ def __init__(self, root): self.root = str(root) self.repo = SimpleNamespace(root=str(root)) self.io = DummyIO() + import uuid + + self.uuid = str(uuid.uuid4()) # Generate unique UUID for each instance def abs_root_path(self, file_path): path = Path(file_path) @@ -47,59 +50,59 @@ def coder_with_file(tmp_path): def test_pattern_with_zero_line_number_is_allowed(coder_with_file): coder, file_path = coder_with_file - result = show_numbered_context.Tool.execute( + result = show_context.Tool.execute( coder, show=[ { "file_path": "example.txt", - "pattern": "beta", - "line_number": 0, - "context_lines": 0, + "start_text": "beta", + "end_text": "beta", + "padding": 0, } ], ) # show_numbered_context now returns a static success message - assert "Successfully retrieved context" in result + assert "Successfully retrieved most recent context" in result coder.io.tool_error.assert_not_called() def test_empty_pattern_uses_line_number(coder_with_file): coder, file_path = coder_with_file - result = show_numbered_context.Tool.execute( + result = show_context.Tool.execute( coder, show=[ { "file_path": "example.txt", - "pattern": "", - "line_number": 2, - "context_lines": 0, + "start_text": "beta", + "end_text": "beta", + "padding": 0, } ], ) # show_numbered_context now returns a static success message - assert "Successfully retrieved context" in result + assert "Successfully retrieved most recent context" in result coder.io.tool_error.assert_not_called() def test_conflicting_pattern_and_line_number_raise(coder_with_file): coder, file_path = coder_with_file - result = show_numbered_context.Tool.execute( + # Test that missing start_text raises an error + result = show_context.Tool.execute( coder, show=[ { "file_path": "example.txt", - "pattern": "beta", - "line_number": 2, - "context_lines": 0, + "end_text": "beta", + "padding": 0, } ], ) - assert result.startswith("Error: Show operation 1: Provide exactly one of") + assert "Provide both 'start_text' and 'end_text'" in result coder.io.tool_error.assert_called()
Model Percent completed correctly
{{ row.model }} {{ row.pass_rate_2 }}% {{ row.percent_cases_well_formed }}%