diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 94d61738442..7366d2f859e 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -157,9 +157,11 @@ def _get_agent_config(self): config["large_file_token_threshold"] = nested.getter( config, "large_file_token_threshold", 8192 ) + config["show_lint_errors"] = nested.getter(config, "show_lint_errors", False) config["skip_cli_confirmations"] = nested.getter( config, "skip_cli_confirmations", nested.getter(config, "yolo", []) ) + config["command_timeout"] = nested.getter(config, "command_timeout", 30) config["allowed_commands"] = nested.getter(config, "allowed_commands", []) config["hot_reload"] = nested.getter(config, "hot_reload", False) @@ -816,6 +818,11 @@ async def gather_and_await(): if interrupted: raise KeyboardInterrupt("Interrupted during linting") + has_errors = False + + if self.lint_outcome is False: + has_errors = True + self.lint_outcome = not lint_errors if lint_errors: @@ -824,21 +831,35 @@ async def gather_and_await(): "# Fix any linting errors below, if possible and then continue with your task.", 1, ) - ConversationService.get_manager(self).remove_message_by_hash_key( - ("lint_errors", "agent") - ) ConversationService.get_manager(self).add_message( message_dict=dict(role="user", content=lint_errors), - tag=MessageTag.CUR, - hash_key=("lint_errors", "agent"), + tag=MessageTag.LINT, + hash_key=("lint_errors", "agent", lint_errors), + ) + ConversationService.get_manager(self).add_message( + message_dict=dict( + role="user", content="Please address the latest linting errors." + ), + tag=MessageTag.LINT, + hash_key=("lint_errors", "agent", lint_errors, "cta"), promotion=ConversationService.get_manager(self).DEFAULT_TAG_PROMOTION_VALUE, mark_for_demotion=1, - force=True, + mark_for_delete=0, ) else: - ConversationService.get_manager(self).remove_message_by_hash_key( - ("lint_errors", "agent") - ) + if has_errors: + ConversationService.get_manager(self).add_message( + message_dict=dict( + role="user", + content=( + '' + "All linting errors resolved." + "" + ), + ), + tag=MessageTag.LINT, + hash_key=("lint_errors", "agent", str(time.monotonic_ns())), + ) return tool_responses @@ -1666,6 +1687,11 @@ def get_background_command_output(self): command_str = command_info.get(command_key, {}).get("command", command_key) output += f"\n[bg: {command_str}]\n{cmd_output}\n" + # Clean up stale (finished) background commands after reading their output + for command_key, info in command_info.items(): + if not info.get("running", False): + BackgroundCommandManager.stop_background_command(command_key) + return output def get_git_status(self): diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ceeb4862a3d..e206fdae6de 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -491,6 +491,7 @@ def __init__( self.mcp_manager = mcp_manager self.enable_context_compaction = enable_context_compaction + self.context_compaction_current_ratio = 0 self.context_compaction_max_tokens = context_compaction_max_tokens self.context_compaction_summary_tokens = context_compaction_summary_tokens self.max_reflections = nested.getter(self.args, "max_reflections", 3) @@ -876,9 +877,8 @@ def get_announcements(self): env_items.append(f"{rel_repo_dir} ({num_files:,} files)") if num_files > 1000: env_items.append( - "Warning: For large repos, consider using --subtree-only and .cecli_ignore" + "Warning: For large repos, consider using --subtree-only and .cecli.ignore" ) - env_items.append(f"See: {urls.large_repos}") else: env_items.append("no git repo") @@ -1440,7 +1440,10 @@ def get_images_message(self, fnames): if mime_type.startswith("image/") and supports_images: content = [ {"type": "text", "text": f"Image file: {rel_fname}"}, - {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}}, + { + "type": "image_url", + "image_url": {"url": image_url, "detail": "high", "format": mime_type}, + }, ] elif mime_type == "application/pdf" and supports_pdfs: content = [ @@ -2001,12 +2004,15 @@ async def compact_context_if_needed(self, force=False, message=""): combined_tokens = done_tokens + cur_tokens + diff_tokens + self.context_compaction_current_ratio = all_tokens / self.context_compaction_max_tokens + if force or ( all_tokens >= self.context_compaction_max_tokens * 0.9 - and ConversationService.get_chunks(self).last_clear_count > 10 + and ConversationService.get_chunks(self).last_clear_count > 20 ): - manager.clear_tag(MessageTag.DIFFS) - manager.clear_tag(MessageTag.FILE_CONTEXTS) + manager.clear_tag(MessageTag.LINT, ratio=0.33) + manager.clear_tag(MessageTag.DIFFS, ratio=0.33) + manager.clear_tag(MessageTag.FILE_CONTEXTS, ratio=0.33) ConversationService.get_files(self).clear_file_cache() ConversationService.get_chunks(self).flush_removals() @@ -3249,6 +3255,12 @@ async def lint_edited(self, fnames, show_output=True): res += errors res += "\n" + if self.edit_format in ("agent", "subagent"): + if self.agent_config.get("show_lint_errors"): + show_output = True + else: + show_output = False + if res and show_output: self.io.tool_warning(res) @@ -4024,7 +4036,9 @@ def compute_costs_from_tokens( 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.get_active_model().info.get("input_cost_per_token_cache_hit") or 0 + self.get_active_model().info.get("input_cost_per_token_cache_hit") + or self.get_active_model().info.get("cache_read_input_token_cost") + or 0 ) # deepseek @@ -4036,14 +4050,13 @@ def compute_costs_from_tokens( # == total tokens that were if input_cost_per_token_cache_hit: - # must be deepseek - cost += input_cost_per_token_cache_hit * cache_hit_tokens - cost += (prompt_tokens - input_cost_per_token_cache_hit) * input_cost_per_token + cost += cache_hit_tokens * input_cost_per_token_cache_hit + cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token else: # hard code the anthropic adjustments, no-ops for other models since cache_x_tokens==0 cost += cache_write_tokens * input_cost_per_token * 1.25 cost += cache_hit_tokens * input_cost_per_token * 0.10 - cost += prompt_tokens * input_cost_per_token + cost += (prompt_tokens - cache_hit_tokens) * input_cost_per_token cost += completion_tokens * output_cost_per_token return cost diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py index 2789fec0ebc..825dd09ad50 100644 --- a/cecli/helpers/background_commands.py +++ b/cecli/helpers/background_commands.py @@ -5,7 +5,6 @@ in the background and capturing their output for injection into chat streams. """ -import codecs import os import platform import subprocess @@ -171,31 +170,86 @@ def _start_output_reader(self) -> None: """Start thread to read process output.""" def reader(): - try: - # Simple approach: read lines when available - # This will block on readline(), but that's OK because - # we're in a separate thread and the buffer will capture - # output as soon as it's available + import re + + # Regex to strip ANSI terminal escape sequences + _ansi_escape = re.compile( + r"\x1b\[[0-9;]*[a-zA-Z]|\x1b[\]^_]|[\x1b\x9b][][()#;?]*" + r"(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PRZcf-nqry=><~]" + ) + def _strip_ansi(text: str) -> str: + return _ansi_escape.sub("", text) + + try: if self.master_fd is not None: while not self._stop_event.is_set(): try: data = os.read(self.master_fd, 4096).decode(errors="replace") if not data: break - self.buffer.append(data) + # Strip ANSI escape sequences from PTY output + self.buffer.append(_strip_ansi(data)) except (OSError, EOFError): break else: - # Read stdout - for line in iter(self.process.stdout.readline, ""): - if line: - self.buffer.append(line) + has_stdout_fileno = hasattr(self.process.stdout, "fileno") + if has_stdout_fileno: + os.set_blocking(self.process.stdout.fileno(), False) + while not self._stop_event.is_set(): + try: + if has_stdout_fileno: + # Use os.read() instead of readline() to capture + # partial line output (e.g. REPL prompts without newlines) + data = os.read(self.process.stdout.fileno(), 4096).decode( + errors="replace" + ) + else: + # Fallback to readline when fileno() is unavailable + # (e.g. mock objects in tests) + data = self.process.stdout.readline() + if data: + self.buffer.append(data) + else: + # Check if process died + if not self.is_alive(): + if has_stdout_fileno: + # Read any remaining data + try: + remaining = os.read( + self.process.stdout.fileno(), 4096 + ).decode(errors="replace") + if remaining: + self.buffer.append(remaining) + except (OSError, EOFError): + pass + break + import time + + time.sleep(0.05) + except (OSError, EOFError, ValueError): + if not self.is_alive(): + break + import time + + time.sleep(0.05) - # Read stderr - for line in iter(self.process.stderr.readline, ""): - if line: - self.buffer.append(line) + # Also capture stderr (best-effort, non-blocking) + if hasattr(self.process.stderr, "fileno"): + try: + os.set_blocking(self.process.stderr.fileno(), False) + while True: + try: + err_data = os.read(self.process.stderr.fileno(), 4096).decode( + errors="replace" + ) + if not err_data: + break + self.buffer.append(err_data) + except (OSError, EOFError): + break + except Exception: + pass except Exception as e: self.buffer.append(f"\n[Error reading process output: {str(e)}]\n") @@ -369,6 +423,7 @@ def start_background_command( persist: bool = False, existing_input_buffer: Optional[InputBuffer] = None, use_pty: bool = False, + master_fd: Optional[int] = None, ) -> str: """ Start a command in background. @@ -390,9 +445,16 @@ def start_background_command( buffer = existing_buffer or CircularBuffer(max_size=max_buffer_size) # Use existing process or start new one - master_fd = None - if use_pty and HAS_PTY and platform.system() != "Windows": - master_fd, slave_fd = pty.openpty() + # Use provided master_fd (e.g., from _execute_with_timeout) or default to None + final_master_fd = master_fd + + # Only create a new PTY if no external master_fd was provided + # (prevents overwriting an externally-created PTY fd) + can_use_pty = ( + use_pty and master_fd is None and HAS_PTY and platform.system() != "Windows" + ) + if can_use_pty: + final_master_fd, slave_fd = pty.openpty() # Disable echo on the slave PTY attr = termios.tcgetattr(slave_fd) @@ -415,8 +477,13 @@ def start_background_command( elif existing_process: process = existing_process else: + # When PTY was requested but isn't available (e.g. Windows), + # wrap with stdbuf -oL to force line-buffered output at the libc level + resolved_command = ( + cls._wrap_line_buffered(command) if use_pty and not HAS_PTY else command + ) process = subprocess.Popen( - command, + resolved_command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -434,7 +501,7 @@ def start_background_command( buffer, persist=persist, input_buffer=existing_input_buffer, - master_fd=master_fd, + master_fd=final_master_fd, ) # Generate unique key and store @@ -519,13 +586,8 @@ def send_command_input(cls, command_key: str, text: str) -> bool: bg_process = cls._background_commands.get(command_key) if not bg_process: return False - # Decode escape sequences (like \x1b) if present in the string - try: - text = codecs.decode(text, "unicode_escape") - except Exception: - pass - bg_process.send_input(text) - return True + bg_process.send_input(text) + return True @classmethod def get_all_command_outputs(cls, clear: bool = False) -> Dict[str, str]: @@ -625,6 +687,61 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]: } return result + _line_buffered_tool = None + + @staticmethod + def _detect_line_buffered_tool() -> str: + """ + Detect the best available tool for forcing line-buffered stdout. + + Checks for `stdbuf` (GNU coreutils, most Linux distros) and falls + back to `unbuffer` (expect package, available on many systems). + If neither is available, returns None. + + Returns: + The tool command (e.g., 'stdbuf -oL' or 'unbuffer'), or None + """ + import shutil + + if shutil.which("stdbuf"): + return "stdbuf -oL" + + if shutil.which("unbuffer"): + return "unbuffer" + + return None + + @staticmethod + def _wrap_line_buffered(command: str) -> str: + """ + Wrap a command to force line-buffered stdout when PTY is unavailable. + + When stdout is connected to a pipe instead of a TTY, most programs + (Python, C, Ruby, etc.) use full buffering. The stdbuf command uses + LD_PRELOAD to override buffering at the libc level. The unbuffer + command (from expect) creates a PTY wrapper. + + The detected tool is cached after first check to avoid repeated + subprocess/shell calls. + + Args: + command: The shell command string + + Returns: + Command wrapped with line-buffering prefix, or the original + command if no tool is available + """ + if BackgroundCommandManager._line_buffered_tool is None: + BackgroundCommandManager._line_buffered_tool = ( + BackgroundCommandManager._detect_line_buffered_tool() + ) + + tool = BackgroundCommandManager._line_buffered_tool + if tool: + return f"{tool} {command}" + + return command + @staticmethod def save_paginated_output( output: str, diff --git a/cecli/helpers/conversation/files.py b/cecli/helpers/conversation/files.py index 8b703e674ae..db52dca4b2e 100644 --- a/cecli/helpers/conversation/files.py +++ b/cecli/helpers/conversation/files.py @@ -1,10 +1,14 @@ +import json import os import weakref from typing import Any, Dict, List, Optional, Tuple import xxhash -from cecli.helpers.hashline import get_hashline_content_diff, hashline +from cecli.helpers.hashline import ( + get_hashline_content_diff, + hashline_formatted, +) from cecli.repomap import RepoMap from .service import ConversationService @@ -25,8 +29,11 @@ def __init__(self, coder): self.uuid = coder.uuid self._file_contents_original: Dict[str, str] = {} self._file_contents_snapshot: Dict[str, str] = {} + self._file_json_contents: Dict[str, str] = {} self._file_timestamps: Dict[str, float] = {} self._file_diffs: Dict[str, str] = {} + self._file_diff_versions: Dict[str, int] = {} + self._file_context_versions: Dict[str, int] = {} self._file_stub_contents: Dict[str, str] = {} self._file_to_message_id: Dict[str, str] = {} self._image_files: Dict[str, bool] = {} @@ -109,7 +116,10 @@ def add_file( try: content = coder.io.read_text(abs_fname, silent=True) if coder.hashlines: - content = hashline(content) + content, json_str = hashline_formatted( + content, file_name=abs_fname, partial=False + ) + self._file_json_contents[abs_fname] = json_str except Exception: content = "" # Empty content for unreadable files @@ -226,7 +236,9 @@ def generate_diff(self, fname: str) -> Optional[str]: try: current_content = coder.io.read_text(abs_fname, silent=True) if coder.hashlines: - current_content = hashline(current_content) + current_content, _ = hashline_formatted( + current_content, file_name=abs_fname, partial=False + ) except Exception: return None @@ -239,6 +251,9 @@ def generate_diff(self, fname: str) -> Optional[str]: abs_fname, self._file_contents_original[abs_fname] ) + if not snapshot_content: + return None + # Generate diff between snapshot and current content using hashline helper diff_text = get_hashline_content_diff( old_content=snapshot_content, @@ -265,10 +280,14 @@ def update_file_diff(self, fname: str) -> Optional[str]: """ coder = self.get_coder() diff = self.generate_diff(fname) + diff_output = diff if diff: - # Store diff + # Increment diff version counter abs_fname = os.path.abspath(fname) + self._file_diff_versions[abs_fname] = self._file_diff_versions.get(abs_fname, 0) + 1 + + # Store diff self._file_diffs[abs_fname] = diff rel_fname = fname @@ -278,6 +297,16 @@ def update_file_diff(self, fname: str) -> Optional[str]: rel_fname = coder.get_rel_fname(fname) prefix_str = "content ID prefixed " if getattr(coder, "hashlines") else "" + if coder.hashlines: + # Wrap diff in JSON structure + diff_output = json.dumps( + { + "file_name": rel_fname, + "prefixed_diff": diff, + }, + ensure_ascii=False, + ) + # Add diff message to conversation content_hash = xxhash.xxh3_128_hexdigest(diff.encode("utf-8")) @@ -285,15 +314,7 @@ def update_file_diff(self, fname: str) -> Optional[str]: "role": "user", "content": ( f"{rel_fname} has been updated. Review this {prefix_str}diff of the changes to" - f" ensure all modifications are appropriate:\n\n{diff}" - ), - } - - assistant_msg = { - "role": "assistant", - "content": ( - f"Thank you for sharing this {prefix_str}diff of the updates to {rel_fname}." - " I will review their contents." + f" ensure all modifications are appropriate:\n\n{diff_output}" ), } @@ -303,12 +324,6 @@ def update_file_diff(self, fname: str) -> Optional[str]: hash_key=("file_diff_user", rel_fname, content_hash), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.DIFFS, - hash_key=("file_diff_assistant", rel_fname, content_hash), - ) - return diff def get_file_stub(self, fname: str) -> str: @@ -342,6 +357,22 @@ def get_file_stub(self, fname: str) -> str: return content or "" + def get_file_json(self, fname: str) -> Optional[str]: + """ + Get the cached JSON metadata for a file, if available. + + The JSON is generated and cached during add_file() and contains + file_name, start_line, end_line, partial, and prefixed_contents. + + Args: + fname: Absolute file path + + Returns: + JSON-formatted string, or None if not cached + """ + abs_fname = os.path.abspath(fname) + return self._file_json_contents.get(abs_fname) + def clear_file_cache(self, fname: Optional[str] = None, clear_contexts=True) -> None: """ Clear cache for specific file or all files. @@ -354,7 +385,10 @@ def clear_file_cache(self, fname: Optional[str] = None, clear_contexts=True) -> self._file_contents_snapshot.clear() self._file_timestamps.clear() self._file_diffs.clear() + self._file_diff_versions.clear() + self._file_context_versions.clear() self._file_stub_contents.clear() + self._file_json_contents.clear() self._file_to_message_id.clear() if clear_contexts: self._numbered_contexts.clear() @@ -364,7 +398,10 @@ def clear_file_cache(self, fname: Optional[str] = None, clear_contexts=True) -> self._file_contents_snapshot.pop(abs_fname, None) self._file_timestamps.pop(abs_fname, None) self._file_diffs.pop(abs_fname, None) + self._file_diff_versions.pop(abs_fname, None) + self._file_context_versions.pop(abs_fname, None) self._file_stub_contents.pop(abs_fname, None) + self._file_json_contents.pop(abs_fname, None) self._file_to_message_id.pop(abs_fname, None) self._image_files.pop(abs_fname, None) if clear_contexts: @@ -416,6 +453,8 @@ def update_file_context( The merged range (start_line, end_line) that contains the input range. """ abs_fname = os.path.abspath(file_path) + diff_version = self._file_diff_versions.get(abs_fname, 0) + context_version = self._file_context_versions.get(abs_fname, 0) # Validate range if start_line > end_line: @@ -424,6 +463,11 @@ def update_file_context( # Get existing ranges existing_ranges = self._numbered_contexts.get(abs_fname, []) + if diff_version != context_version: + self._file_context_versions[abs_fname] = diff_version + # auto clear on edit + # existing_ranges = [] + # Add new range new_range = (start_line, end_line) all_ranges = existing_ranges + [new_range] @@ -495,7 +539,7 @@ def clear_ranges(self, file_path: Optional[str] = None) -> None: abs_fname = os.path.abspath(file_path) self._last_merged_ranges.pop(abs_fname, None) - def get_file_context(self, file_path: str, all_ranges=False) -> str: + def get_file_context(self, file_path: str, all_ranges=False, check_versions=True) -> str: """ Generate hashline representation of cached context ranges. @@ -503,7 +547,8 @@ def get_file_context(self, file_path: str, all_ranges=False) -> str: file_path: Absolute file path Returns: - Hashline representation of cached ranges, or empty string if no ranges + JSON-formatted string with file_path, version, and results array, + or empty string if no ranges """ abs_fname = os.path.abspath(file_path) @@ -532,8 +577,10 @@ def get_file_context(self, file_path: str, all_ranges=False) -> str: return "" # Generate hashline representations for each range - context_parts = [] + results = [] content_lines = content.splitlines() + diff_version = self._file_diff_versions.get(abs_fname, 0) + context_version = self._file_context_versions.get(abs_fname, 0) for i, (start_line, end_line) in enumerate(ranges): # Note: hashline uses 1-based line numbers, so no conversion needed @@ -546,15 +593,34 @@ def get_file_context(self, file_path: str, all_ranges=False) -> str: # Extract lines for this range (0-based indexing for list) lines = content_lines[start_line_adj - 1 : end_line_adj] - # Generate hashline representation using the hashline() function - # Join lines back with newlines for hashline() + # Generate JSON for this range using hashline_formatted() range_content = "\n".join(lines) - hashline_content = hashline(range_content, start_line=start_line_adj) - context_parts.append(hashline_content.strip()) + # Don't duplicate whole contents that are already in context + if ( + check_versions + and range_content == content + and (abs_fname in coder.abs_fnames or abs_fname in coder.abs_read_only_fnames) + and diff_version == context_version + ): + continue + + _, json_str = hashline_formatted( + range_content, file_name=abs_fname, partial=True, start_line=start_line_adj + ) + results.append(json.loads(json_str)) + + # Build and return the top-level JSON structure + # version = self._file_diff_versions.get(abs_fname, 0) + if results: + result = { + "file_path": file_path, + "results": results, + # "version": version, + } + return json.dumps(result, ensure_ascii=False) - # Join with ellipsis separator - return "\n...\n\n".join(context_parts) + return None def remove_file_context(self, file_path: str) -> None: """ diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index a9381d4ecbb..7510976817d 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -168,8 +168,8 @@ def add_randomized_cta(self) -> None: if not coder: return - # if self._cancel_post_message_injections(): - # return + if self._cancel_post_message_injections(): + return message = random.choice( [ @@ -192,7 +192,7 @@ def add_randomized_cta(self) -> None: " the current data." ), ( - "You’ve got what you need, please invoke the right tools to keep making" + "Based on what you know, please invoke the right tools to keep making" " progress towards our goal." ), ( @@ -286,13 +286,14 @@ def cleanup_files(self) -> None: if ( should_clear + and coder.context_compaction_current_ratio > 0.8 and self.last_clear_count >= 20 and diff_tokens + other_tokens > coder.context_compaction_max_tokens * 0.5 ): self.last_clear_count = 0 # Clear all diff messages - ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS) + ConversationService.get_manager(coder).clear_tag(MessageTag.DIFFS, 0.33) # Clear ConversationFiles caches to force regeneration ConversationService.get_files(coder).clear_file_cache(clear_contexts=False) @@ -527,10 +528,6 @@ def add_repo_map_messages(self) -> List[Dict[str, Any]]: # Create repository map messages dict_repo_messages = [ dict(role="user", content=repo_content), - dict( - role="assistant", - content="Thank you, these files will help with navigating the codebase.", - ), ] # Add messages to conversation manager with appropriate priority @@ -579,11 +576,6 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: "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( @@ -594,15 +586,7 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: update_timestamp=False, ) - 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]) + messages.extend([user_msg]) return messages @@ -640,7 +624,16 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: ConversationService.get_files(coder).add_file(fname, force_refresh=refresh) # Get file content (with proper caching and stub generation) - content = ConversationService.get_files(coder).get_file_stub(fname) + content = None + if coder.edit_format in ("agent", "subagent"): + content = ConversationService.get_files(coder).get_file_json(fname) + else: + content = ConversationService.get_files(coder).get_file_stub(fname) + + if not content: + ConversationService.get_files(coder).clear_file_cache(fname) + continue + if content: # Add user message with file path as hash_key rel_fname = coder.get_rel_fname(fname) @@ -667,20 +660,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: ) messages.append(user_msg) - # Add assistant message with file path as hash_key - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the file contents for {rel_fname}.", - } - 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 - force=True, - update_timestamp=False, - ) - messages.append(assistant_msg) - # Check if file has changed and add diff message if needed if ConversationService.get_files(coder).has_file_changed(fname): ConversationService.get_files(coder).update_file_diff(fname) @@ -692,13 +671,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: # Add individual image message to result messages.append(img_msg) - # Add individual assistant acknowledgment for each image - assistant_msg = { - "role": "assistant", - "content": "Ok, I will use this image as a reference.", - } - messages.append(assistant_msg) - # Get the file name from the message (stored in image_file key) fname = img_msg.get("image_file") if fname: @@ -708,11 +680,6 @@ def add_readonly_files_messages(self) -> List[Dict[str, Any]]: tag=MessageTag.READONLY_FILES, hash_key=("image_user", fname), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.READONLY_FILES, - hash_key=("image_assistant", fname), - ) return messages @@ -747,7 +714,12 @@ def add_chat_files_messages(self) -> Dict[str, Any]: ConversationService.get_files(coder).add_file(fname, force_refresh=refresh) # Get file content (with proper caching and stub generation) - content = ConversationService.get_files(coder).get_file_stub(fname) + content = None + if coder.edit_format in ("agent", "subagent"): + content = ConversationService.get_files(coder).get_file_json(fname) + else: + content = ConversationService.get_files(coder).get_file_stub(fname) + if not content: ConversationService.get_files(coder).clear_file_cache(fname) continue @@ -767,15 +739,9 @@ def add_chat_files_messages(self) -> Dict[str, Any]: "content": f"{file_preamble}\n{rel_fname}\n\n{content}\n\n{file_postamble}", } - # Create assistant message - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the file contents for {rel_fname}.", - } - # Determine tag based on editability tag = MessageTag.CHAT_FILES - result["chat_files"].extend([user_msg, assistant_msg]) + result["chat_files"].extend([user_msg]) # Add user message to ConversationManager with file path as hash_key ConversationService.get_manager(coder).add_message( @@ -786,15 +752,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: update_timestamp=False, ) - # Add assistant message to ConversationManager with file path as hash_key - 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 - force=True, - update_timestamp=False, - ) - # Check if file has changed and add diff message if needed if ConversationService.get_files(coder).has_file_changed(fname): ConversationService.get_files(coder).update_file_diff(fname) @@ -806,13 +763,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: # Add individual image message to result result["chat_files"].append(img_msg) - # Add individual assistant acknowledgment for each image - assistant_msg = { - "role": "assistant", - "content": "Ok, I will use this image as a reference.", - } - result["chat_files"].append(assistant_msg) - # Get the file name from the message (stored in image_file key) fname = img_msg.get("image_file") if fname: @@ -822,11 +772,6 @@ def add_chat_files_messages(self) -> Dict[str, Any]: tag=MessageTag.CHAT_FILES, hash_key=("image_user", fname), ) - ConversationService.get_manager(coder).add_message( - message_dict=assistant_msg, - tag=MessageTag.CHAT_FILES, - hash_key=("image_assistant", fname), - ) return result @@ -858,11 +803,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: "content": f"ID-Prefixed Context For:\n{rel_fname}\n\n{context_content}", } - assistant_msg = { - "role": "assistant", - "content": f"Thank you for sharing the prefixed file contents for {rel_fname}.", - } - # Add to conversation manager content_hash = xxhash.xxh3_128_hexdigest(context_content.encode("utf-8")) ConversationService.get_manager(coder).queue_message( @@ -871,12 +811,6 @@ def add_file_context_messages(self, promote_messages=True) -> None: hash_key=("file_context_user", file_path, content_hash), ) - ConversationService.get_manager(coder).queue_message( - message_dict=assistant_msg, - tag=MessageTag.FILE_CONTEXTS, - hash_key=("file_context_assistant", file_path, content_hash), - ) - def reset(self) -> None: """ Reset the entire conversation system to initial state. @@ -1079,14 +1013,25 @@ def defer_removal(self, file_path: str): def flush_removals(self): self._deferred_removals.clear() - def _cancel_post_message_injections(self): + def _cancel_post_message_injections(self, modulus=10): coder = self.get_coder() if not coder: return False # Add system reminder as a pre-prompt context block - if coder.edit_format in ("agent", "subagent") and coder.turn_count % 5 != 0: - return True + if coder.edit_format in ("agent", "subagent"): + if coder.turn_count < modulus: + return True + + if coder.edit_allowed and not coder._get_repetitive_tools(): + return True + + if coder.turn_count % modulus != 0: + # and coder.turn_count % modulus != 0 + if not coder.edit_allowed: + return False + else: + return True return False diff --git a/cecli/helpers/conversation/manager.py b/cecli/helpers/conversation/manager.py index 7c7b5738772..3337d0b8949 100644 --- a/cecli/helpers/conversation/manager.py +++ b/cecli/helpers/conversation/manager.py @@ -370,8 +370,14 @@ def get_messages_dict( messages_dict = self._add_cache_control(messages_dict) return messages_dict - def clear_tag(self, tag: str) -> None: - """Remove all messages with given tag.""" + def clear_tag(self, tag: str, ratio: float = 0) -> None: + """Remove all messages with given tag. + + Args: + tag: The tag to remove messages for + ratio: If > 0, only consider the newest ratio portion of messages + (e.g., 0.25 = newest 25%) + """ if not isinstance(tag, MessageTag): try: tag = MessageTag(tag) @@ -381,7 +387,13 @@ def clear_tag(self, tag: str) -> None: tag_str = tag.value messages_to_remove = [] - for message in self._messages: + # If ratio is set, only look at the newest portion of messages + messages = self._messages + if ratio > 0: + count = max(1, int(len(messages) * ratio)) + messages = messages[-count:] + + for message in messages: if message.tag == tag_str: messages_to_remove.append(message) @@ -394,17 +406,25 @@ def clear_tag(self, tag: str) -> None: self._tag_cache.pop(tag_str, None) self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) - def remove_messages_by_hash_key_pattern(self, pattern_checker) -> None: + def remove_messages_by_hash_key_pattern(self, pattern_checker, ratio: float = 0) -> None: """ Remove messages whose hash_key matches a pattern. Args: pattern_checker: A function that takes a hash_key (tuple) and returns True if the message should be removed + ratio: If > 0, only consider the newest ratio portion of messages + (e.g., 0.25 = newest 25%) """ messages_to_remove = [] - for message in self._messages: + # If ratio is set, only look at the newest portion of messages + messages = self._messages + if ratio > 0: + count = max(1, int(len(messages) * ratio)) + messages = messages[-count:] + + for message in messages: if message.hash_key and pattern_checker(message.hash_key): messages_to_remove.append(message) @@ -421,17 +441,25 @@ def remove_messages_by_hash_key_pattern(self, pattern_checker) -> None: self._tag_cache.pop(tag, None) self._tag_cache.pop(self._ALL_MESSAGES_CACHE_KEY, None) - def remove_message_by_hash_key(self, hash_key: Tuple[str, ...]) -> bool: + def remove_message_by_hash_key(self, hash_key: Tuple[str, ...], ratio: float = 0) -> bool: """ Remove a message by its exact hash key. Args: hash_key: The exact hash key to match + ratio: If > 0, only consider the newest ratio portion of messages + (e.g., 0.25 = newest 25%) Returns: True if a message was removed, False otherwise """ - messages_to_remove = [m for m in self._messages if m.hash_key == hash_key] + # If ratio is set, only look at the newest portion of messages + messages = self._messages + if ratio > 0: + count = max(1, int(len(messages) * ratio)) + messages = messages[-count:] + + messages_to_remove = [m for m in messages if m.hash_key == hash_key] if not messages_to_remove: return False diff --git a/cecli/helpers/conversation/tags.py b/cecli/helpers/conversation/tags.py index 1d259821b83..58ccbf86e25 100644 --- a/cecli/helpers/conversation/tags.py +++ b/cecli/helpers/conversation/tags.py @@ -19,6 +19,7 @@ class MessageTag(str, Enum): CHAT_FILES = "chat_files" EDIT_FILES = "edit_files" DIFFS = "diffs" + LINT = "lint" FILE_CONTEXTS = "file_contexts" CUR = "cur" DONE = "done" @@ -37,6 +38,7 @@ class MessageTag(str, Enum): MessageTag.CHAT_FILES: 200, MessageTag.EDIT_FILES: 200, MessageTag.DIFFS: 200, + MessageTag.LINT: 200, MessageTag.FILE_CONTEXTS: 200, MessageTag.DONE: 200, MessageTag.CUR: 200, @@ -56,6 +58,7 @@ class MessageTag(str, Enum): MessageTag.CHAT_FILES: 0, MessageTag.EDIT_FILES: 0, MessageTag.DIFFS: 0, + MessageTag.LINT: 0, MessageTag.FILE_CONTEXTS: 0, MessageTag.DONE: 0, MessageTag.CUR: 0, diff --git a/cecli/helpers/conversation/utils.py b/cecli/helpers/conversation/utils.py index af7bbcbd6cb..908a54a465b 100644 --- a/cecli/helpers/conversation/utils.py +++ b/cecli/helpers/conversation/utils.py @@ -1,7 +1,8 @@ -import hashlib import json from typing import Any, Dict, Optional, Tuple +import xxhash + def generate_message_hash( role: str, @@ -35,7 +36,7 @@ def generate_message_hash( else: key_data = f"{role}:{content or ''}" - return hashlib.md5(key_data.encode("utf-8")).hexdigest() + return xxhash.xxh3_128_hexdigest(key_data.encode("utf-8")) def validate_message_dict(message_dict: Dict[str, Any]) -> bool: diff --git a/cecli/helpers/hashline.py b/cecli/helpers/hashline.py index c6904ef3d23..359fc2ca3a8 100644 --- a/cecli/helpers/hashline.py +++ b/cecli/helpers/hashline.py @@ -1,4 +1,5 @@ import difflib +import json import re from cecli.helpers.hashpos.hashpos import HashPos @@ -27,6 +28,39 @@ def hashline(text: str, start_line: int = 1) -> str: return hp.format_content(start_line=start_line) +def hashline_formatted( + text: str, file_name: str, partial: bool, start_line: int = 1 +) -> tuple[str, str]: + """ + Generate hashline-formatted content and return it as both raw hashline text and a JSON structure. + + Args: + text: Input text + file_name: The file name or path + partial: Whether the content represents a partial file + start_line: Starting line number (default 1) + + Returns: + A tuple of (raw_hashline_text, json_text): + - raw_hashline_text: The HashPos-prefixed content (same as hashline()) + - json_text: JSON-formatted string with file_name, start_line, end_line, partial, and + prefixed_contents + """ + hp = HashPos(text) + prefixed = hp.format_content(start_line=start_line) + end_line = start_line + hp.total - 1 + + result = { + "file_name": file_name, + "start_line": start_line, + "end_line": end_line, + "partial": partial, + "prefixed_contents": prefixed, + } + + return prefixed, json.dumps(result, ensure_ascii=False) + + # int_to_2digit_52 removed as it is no longer used by the HashPos engine. diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index 280e3394ec4..8077e900e3d 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -119,7 +119,11 @@ def format_content(self, use_private_ids: bool = False, start_line: int = 1) -> if use_private_ids else self.generate_public_id(line, i) ) - formatted_lines.append(f"{prefix}::{line}") + if line.strip(): + formatted_lines.append(f"{prefix}::{line}") + else: + formatted_lines.append(f"{line}") + return "\n".join(formatted_lines) def resolve_to_lines(self, public_id: str, start_line: int = 1) -> list[int]: diff --git a/cecli/helpers/requests.py b/cecli/helpers/requests.py index 607c8f50887..b092cc842ba 100644 --- a/cecli/helpers/requests.py +++ b/cecli/helpers/requests.py @@ -107,7 +107,7 @@ def get_text(c): def flush_user_messages(): if user_messages_to_concat: - concatenated_content = "\n".join(get_text(c) for c in user_messages_to_concat) + concatenated_content = "\n\n---\n".join(get_text(c) for c in user_messages_to_concat) result.append({"role": "user", "content": concatenated_content}) user_messages_to_concat.clear() diff --git a/cecli/io.py b/cecli/io.py index 4a703eaa22f..a140bd516a9 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -514,7 +514,6 @@ def __init__( "lexer": PygmentsLexer(MarkdownLexer), "editing_mode": self.editingmode, "bottom_toolbar": self.get_bottom_toolbar, - "refresh_interval": 0.1, } if self.editingmode == EditingMode.VI: session_kwargs["cursor"] = ModalCursorShapeConfig() diff --git a/cecli/linter.py b/cecli/linter.py index 45003c6b01d..3d3df7d9601 100644 --- a/cecli/linter.py +++ b/cecli/linter.py @@ -157,6 +157,7 @@ async def flake8_lint(self, rel_fname): self.interrupt_event, cwd=self.root, encoding=self.encoding, + should_print=False, ) if stdout == "Interrupted": return diff --git a/cecli/models.py b/cecli/models.py index 3f593cb7b75..b87dee088e2 100644 --- a/cecli/models.py +++ b/cecli/models.py @@ -256,6 +256,8 @@ def get_model_from_cached_json_db(self, model): def get_model_info(self, model): cached_info = self.get_model_from_cached_json_db(model) + if cached_info: + return cached_info litellm_info = None if litellm._lazy_module or not cached_info: try: @@ -268,6 +270,10 @@ def get_model_info(self, model): return provider_info if litellm_info: return litellm_info + if not cached_info and model.startswith("openai/"): + stripped = model[7:] + if stripped: + return self.get_model_info(stripped) return cached_info def _resolve_via_provider(self, model, cached_info): @@ -682,6 +688,13 @@ def configure_model_settings(self, model): f"override_kwargs '{key}' must be a dict, got {type(value)}" ) self.info = {**self.info, **value} + + if not litellm.model_cost.get(model): + litellm.model_cost[model] = {} + + litellm.model_cost[model].update(self.info) + litellm.utils._invalidate_model_cost_lowercase_map() + litellm.add_known_models(model_cost_map=litellm.model_cost) elif isinstance(value, dict) and isinstance(self.extra_params.get(key), dict): self.extra_params[key] = {**self.extra_params[key], **value} else: @@ -1324,6 +1337,16 @@ async def send_completion( "Connection": "close", } + if "GITHUB_COPILOT_TOKEN" in os.environ or self.name.startswith("github_copilot/"): + kwargs["extra_headers"] = kwargs.get("extra_headers", {}) or {} + + kwargs["extra_headers"].update( + { + "editor-version": "vscode/1.126.0", + "editor-plugin-version": "copilot/1.155.0", + } + ) + litellm_ex = LiteLLMExceptions() retry_delay = 0.125 @@ -1497,7 +1520,9 @@ def _log_messages(self, messages, name="message"): """ os.makedirs(".cecli/logs/messages", exist_ok=True) with open(f".cecli/logs/messages/{name}-{time.time()}.log", "w") as f: - json.dump(messages, f, indent=4, default=lambda o: "") + json.dump( + messages, f, indent=4, ensure_ascii=False, default=lambda o: "" + ) def _log_request(self, model_call_dict): """ @@ -1507,7 +1532,13 @@ def _log_request(self, model_call_dict): log_file_path = f".cecli/logs/litellm/request-{time.time()}.log" with open(log_file_path, "a", encoding="utf-8") as f: - json.dump(model_call_dict, f, indent=4, default=lambda o: "") + json.dump( + model_call_dict, + f, + indent=4, + ensure_ascii=False, + default=lambda o: "", + ) f.write(",\n") diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 646fd46d5ba..8fe49a4d4d6 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -3,6 +3,15 @@ import os import platform +# PTY support for interactive commands (avoids pipe buffering issues) +try: + import pty + import termios + + HAS_PTY = True +except ImportError: + HAS_PTY = False + import xxhash from cecli.helpers.background_commands import BackgroundCommandManager @@ -21,15 +30,15 @@ class Tool(BaseTool): "type": "function", "function": { "name": "Command", - "description": "Execute a shell command.", + "description": "Execute a shell command or interact with background processes.", "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": ( - "The shell command to execute. To send stdin to an existing background" - " command, use the format 'command_key::{key}'." + "The shell command to execute. " + "Required unless background_key is provided." ), }, "background": { @@ -37,42 +46,44 @@ class Tool(BaseTool): "description": "Run command in background (non-blocking).", "default": False, }, - "stop": { - "type": "boolean", + "background_key": { + "type": "string", "description": ( - "If true, stop the background command specified in the 'command'" - " parameter (format 'command_key::{key}')." + "Key of an existing background command to interact with. " + "Use with 'action' (stdin/stop)." ), - "default": False, }, - "pty": { - "type": "boolean", + "action": { + "type": "string", + "enum": ["stdin", "stop"], "description": ( - "Run the command in a pseudo-terminal (PTY). Useful for interactive" - " programs like 'vi' or 'top'." + "Action on a background command. Requires background_key: " + "'stdin' to send input, 'stop' to terminate." ), - "default": False, }, "stdin": { "type": "string", "description": ( - "Input to send to the command's stdin. Supports escape sequences like" - " \\n, \\r, \\t, and hex escapes like \\x1b." + "Input to send. Use with background=True to send at " + "start time, or with background_key + action='stdin'." + ), + }, + "pty": { + "type": "boolean", + "description": ( + "Use a pseudo-terminal (PTY). Auto-enabled on Unix for " + "background commands. Useful for interactive programs " + "like 'vi' or 'top'." ), + "default": False, }, }, - "required": ["command"], + "required": [], }, }, } @staticmethod - def _parse_command_key(command): - """Extract command key from command string if it follows the pattern.""" - if command and command.startswith("command_key::"): - return command.split("::", 1)[1].strip() - return None - @staticmethod def _hash_command(command): """Compute an xxhash of the full command text for session tracking.""" @@ -83,38 +94,41 @@ def _hash_command(command): @classmethod async def execute( - cls, coder, command, background=False, stop=None, stdin=None, pty=False, **kwargs + cls, + coder, + command=None, + background=False, + background_key=None, + action=None, + stdin=None, + pty=False, + **kwargs, ): """ - Execute a shell command, optionally in background. - Commands run with timeout based on agent_config['command_timeout'] (default: 30 seconds). - """ - command_key = cls._parse_command_key(command) + Execute a shell command or interact with background processes. - # Handle stopping background commands - if stop: - if not command_key: - return ( - "Error: 'command' in format 'command_key::{key}' is required when 'stop' is" - " true." - ) - return await cls._stop_background_command(coder, command_key) + For new commands: provide 'command' (and optionally 'background', 'stdin', 'pty'). + For background interactions: provide 'background_key' + 'action' (stdin/stop). - # Handle sending stdin to an existing background command - if stdin: - if not command_key: - return ( - "Error: 'command' in format 'command_key::{key}' is required when using" - " 'stdin'." - ) + Commands run with timeout based on agent_config['command_timeout'] (default: 30 seconds). + """ + # Handle interactions with an existing background command + if background_key: + if action == "stdin": + if not stdin: + return "Error: 'stdin' is required when action='stdin'." + cls.clear_invocation_cache() + success = BackgroundCommandManager.send_command_input(background_key, stdin) + if success: + return f"Sent input to background command {background_key}: {stdin}" + else: + return f"Error: Background command {background_key} not found or not running." - cls.clear_invocation_cache() + elif action == "stop": + return await cls._stop_background_command(coder, background_key) - success = BackgroundCommandManager.send_command_input(command_key, stdin) - if success: - return f"Sent input to background command {command_key}: {stdin}" else: - return f"Error: Background command {command_key} not found or not running." + return f"Error: Unknown action '{action}'. " "Use one of: stdin, stop." if not command: return "Error: 'command' must be provided." @@ -143,7 +157,7 @@ async def execute( timeout = coder.agent_config.get("command_timeout", 30) if background: - return await cls._execute_background(coder, command, use_pty=pty) + return await cls._execute_background(coder, command, use_pty=pty, stdin=stdin) elif timeout > 0: return await cls._execute_with_timeout(coder, command, timeout, use_pty=pty) else: @@ -199,12 +213,20 @@ async def _get_confirmation(cls, coder, command_string, background): return confirmed @classmethod - async def _execute_background(cls, coder, command_string, use_pty=False): + async def _execute_background(cls, coder, command_string, use_pty=None, stdin=None): """ Execute command in background. + + Args: + stdin: Optional text to send to the command's stdin after starting """ coder.io.tool_output(f"⛭ Starting background command: {command_string}", type="tool-result") + # Default to PTY on Unix platforms for proper line-buffered output + # (Python and other programs buffer output aggressively on pipes) + if use_pty is None: + use_pty = platform.system() != "Windows" + # Use static manager to start background command command_key = BackgroundCommandManager.start_background_command( command_string, @@ -214,6 +236,10 @@ async def _execute_background(cls, coder, command_string, use_pty=False): use_pty=use_pty, ) + # Send stdin to the background command if provided + if stdin: + BackgroundCommandManager.send_command_input(command_key, stdin) + return ( f"Background command started: {command_string}\n" f"Command key: {command_key}\n" @@ -221,13 +247,13 @@ async def _execute_background(cls, coder, command_string, use_pty=False): ) @classmethod - async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=False): + async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=None): """ Execute command with timeout. If timeout elapses, move to background. - IMPORTANT: We use a different approach to avoid pipe conflicts. - Instead of reading pipes directly, we let BackgroundCommandManager - handle all pipe reading from the start. + When use_pty is True (or auto-defaulted on Unix), a pseudo-terminal + is used to avoid the full-buffering issue that occurs when stdout is + connected to a pipe instead of a TTY. """ import asyncio import subprocess @@ -239,24 +265,59 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal f"⛭ Executing shell command with {timeout}s timeout.", type="tool-result" ) - shell = os.environ.get("SHELL", "/bin/sh") + # Auto-default to PTY on Unix unless explicitly set otherwise + if use_pty is None: + use_pty = platform.system() != "Windows" # Create output buffer buffer = CircularBuffer(max_size=4096) - # Start process with pipes for output capture - process = subprocess.Popen( - command_string, - shell=True, - executable=shell if platform.system() != "Windows" else None, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - stdin=subprocess.PIPE, - cwd=coder.root, - text=True, - bufsize=1, - universal_newlines=True, - ) + # Decide whether to use PTY + master_fd = None + + if use_pty and HAS_PTY and platform.system() != "Windows": + master_fd, slave_fd = pty.openpty() + + # Disable echo on the slave PTY + attr = termios.tcgetattr(slave_fd) + attr[3] = attr[3] & ~termios.ECHO + termios.tcsetattr(slave_fd, termios.TCSANOW, attr) + + process = subprocess.Popen( + command_string, + shell=True, + executable=os.environ.get("SHELL", "/bin/sh"), + stdout=slave_fd, + stderr=slave_fd, + stdin=slave_fd, + cwd=coder.root, + close_fds=True, + text=True, + bufsize=1, + universal_newlines=True, + ) + os.close(slave_fd) + else: + # Start process with pipes for output capture + # When PTY was requested but unavailable, wrap with stdbuf for line-buffered output + resolved_cmd = ( + BackgroundCommandManager._wrap_line_buffered(command_string) + if use_pty and not HAS_PTY + else command_string + ) + shell = os.environ.get("SHELL", "/bin/sh") + process = subprocess.Popen( + resolved_cmd, + shell=True, + executable=shell if platform.system() != "Windows" else None, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + cwd=coder.root, + text=True, + bufsize=1, + universal_newlines=True, + ) # Immediately register with background manager to handle pipe reading command_key = BackgroundCommandManager.start_background_command( @@ -267,6 +328,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal existing_process=process, existing_buffer=buffer, persist=True, + master_fd=master_fd, ) # Now monitor the process with timeout @@ -340,7 +402,7 @@ async def _execute_with_timeout(cls, coder, command_string, timeout, use_pty=Fal if elapsed >= timeout: # Timeout elapsed, process continues in background coder.io.tool_output( - f"⏱️ Command exceeded {timeout}s timeout, continuing in background...", + f"\u23f1\ufe0f Command exceeded {timeout}s timeout, continuing in background...", type="tool-result", ) @@ -428,6 +490,7 @@ async def _stop_background_command(cls, coder, command_key): else: return output # Error message from manager + @classmethod async def _handle_errors(cls, coder, command_string, e): """Handle errors during command execution.""" coder.io.tool_error(f"Error executing shell command: {str(e)}") @@ -451,7 +514,8 @@ def format_output(cls, coder, mcp_server, tool_response): command = params.get("command", "") background = params.get("background", False) - stop = params.get("stop", False) + background_key = params.get("background_key") + action = params.get("action") stdin = params.get("stdin") pty = params.get("pty", False) @@ -461,8 +525,8 @@ def format_output(cls, coder, mcp_server, tool_response): extras = [] if background: extras.append("background=True") - if stop: - extras.append("stop=True") + if action: + extras.append(f"action={action}") if pty: extras.append("pty=True") @@ -473,8 +537,13 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_output(f"{color_start}Stdin:{color_end}") coder.io.tool_output(stdin) - coder.io.tool_output(f"{color_start}Command:{color_end}") - coder.io.tool_output(command) + if background_key and action: + coder.io.tool_output(f"{color_start}Background Key:{color_end} {background_key}") + coder.io.tool_output(f"{color_start}Action:{color_end} {action}") + elif command: + coder.io.tool_output(f"{color_start}Command:{color_end}") + coder.io.tool_output(command) + coder.io.tool_output("") # Output footer diff --git a/cecli/tools/edit_text.py b/cecli/tools/edit_text.py index 15dfb18730f..0e44efc753a 100644 --- a/cecli/tools/edit_text.py +++ b/cecli/tools/edit_text.py @@ -466,8 +466,9 @@ def format_output(cls, coder, mcp_server, tool_response): operation=operation, text=strip_hashline(text), ) - except ContentHashError as e: - diff_output = f"content ID verification failed: {str(e)}" + except ContentHashError: + # diff_output = f"content ID verification failed: {str(e)}" + diff_output = "Preview Unavailable: Content ID Verification Failed" except Exception: pass diff --git a/cecli/tools/ls.py b/cecli/tools/ls.py index 89c5b791b5f..e5e12c9915b 100644 --- a/cecli/tools/ls.py +++ b/cecli/tools/ls.py @@ -77,7 +77,7 @@ def execute(cls, coder, path=None, **kwargs): if contents: coder.io.tool_output( - f"📋 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" + f"🗐 Listed {len(contents)} file(s) in '{dir_path}'", type="tool-result" ) sorted_contents = sorted(contents) if len(sorted_contents) > 10: @@ -87,7 +87,7 @@ def execute(cls, coder, path=None, **kwargs): else: return f"Found {len(sorted_contents)} files: {', '.join(sorted_contents)}" else: - coder.io.tool_output(f"📋 No files found in '{dir_path}'", type="tool-result") + coder.io.tool_output(f"🗐 No files found in '{dir_path}'", type="tool-result") return "No files found in directory" except Exception as e: coder.io.tool_error(f"Error in ls: {str(e)}") diff --git a/cecli/tools/read_range.py b/cecli/tools/read_range.py index 67a46090c4b..fe5be2e7228 100644 --- a/cecli/tools/read_range.py +++ b/cecli/tools/read_range.py @@ -1,7 +1,8 @@ +import json import os from typing import Dict, List -from cecli.helpers.hashline import hashline, strip_hashline +from cecli.helpers.hashline import hashline_formatted, strip_hashline from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ( ToolError, @@ -510,7 +511,7 @@ def _is_valid_int(s): # output_lines = [f"Displaying context around {found_by} in {rel_path}:"] # Generate hashline for the entire file - hashed_content = hashline(content) + hashed_content, _ = hashline_formatted(content, file_name=abs_path, partial=False) hashed_lines = hashed_content.splitlines() # Extract the context window from hashed lines @@ -533,6 +534,7 @@ def _is_valid_int(s): original_context_content = ConversationService.get_files(coder).get_file_context( abs_path, all_ranges=True, + check_versions=False, ) update_tuple = ConversationService.get_files(coder).update_file_context( abs_path, start_line, end_line, auto_remove=False @@ -540,6 +542,7 @@ def _is_valid_int(s): new_context_content = ConversationService.get_files(coder).get_file_context( abs_path, all_ranges=True, + check_versions=False, ) is_already_up_to_date = False @@ -572,19 +575,15 @@ def _is_valid_int(s): and e_idx < len(hashed_lines) ): # hashed_slice = hashed_lines[s_idx : e_idx + 1] - if is_already_up_to_date: - model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_lines, current=True - ) + model_response = cls.format_model_response( + coder, rel_path, s_idx, e_idx, hashed_lines, current=is_already_up_to_date + ) + if is_already_up_to_date: if model_response not in already_up_to_set: already_up_to_set.add(model_response) already_up_to_details.append(model_response) else: - model_response = cls.format_model_response( - coder, rel_path, s_idx, e_idx, hashed_lines - ) - if model_response not in new_context_set: new_context_set.add(model_response) new_context_details.append(model_response) @@ -605,7 +604,12 @@ def _is_valid_int(s): ConversationService.get_files(coder).push_range(abs_path, tuples) ConversationService.get_chunks(coder).add_file_context_messages() - cls.clear_old_messages(coder) + + # if ( + # ConversationService.get_chunks(coder).last_clear_count > 20 + # and coder.context_compaction_current_ratio > 0.8 + # ): + # cls.clear_old_messages(coder) # Log success and return the formatted context directly coder.edit_allowed = True @@ -682,11 +686,9 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, curr except Exception: pass - lines = [] - # Try to return structural stub information instead of raw hashed lines try: - if hashed_lines and current and coder.turn_count - last_turn >= 2: + if hashed_lines and current and coder.turn_count - last_turn <= 2: num_lines = len(hashed_lines) start_stub_s, start_stub_e = cls._extend_range_with_stub( @@ -709,46 +711,151 @@ def format_model_response(cls, coder, rel_path, s_idx, e_idx, hashed_lines, curr end_found = False if start_found or end_found: + snapshot_parts = [] if start_found: - lines.append( - f"File {rel_path} Current Snapshot (Lines {start_stub_s + 1} - {start_stub_e + 1}):" - ) - lines.extend(hashed_lines[start_stub_s:start_stub_e]) + snapshot_parts.extend(hashed_lines[start_stub_s:start_stub_e]) - if ( + has_second_range = ( end_found and start_stub_s != end_stub_s and start_stub_e != end_stub_e and end_stub_e != e_idx - ): - lines.append("...⋮...") - lines.append( - f"File {rel_path} Current Snapshot (Lines {end_stub_s + 1} - {end_stub_e + 1}):" - ) - lines.extend(hashed_lines[end_stub_s:end_stub_e]) - - lines.append("") - return "\n".join(lines) + ) + if has_second_range: + snapshot_parts.append("...⋮...\n") + snapshot_parts.extend(hashed_lines[end_stub_s:end_stub_e]) + + prefixed = "".join(snapshot_parts) + result = { + "file_path": rel_path, + "start_line": start_stub_s + 1, + "end_line": end_stub_e if has_second_range else start_stub_e, + "partial": True, + "prefixed_contents": prefixed, + } + return json.dumps(result, ensure_ascii=False) except Exception: pass - lines = [f"File {rel_path} Current Snapshot (Lines {s_idx + 1} - {e_idx + 1}):"] - total = e_idx - s_idx hashed_content = "\n".join(hashed_lines[s_idx : e_idx + 1]) token_count = coder.main_model.token_count(hashed_content) if token_count <= min(coder.large_file_token_threshold / 16, 512): - lines.extend(hashed_lines[s_idx : e_idx + 1]) + prefixed = hashed_content else: + total = e_idx - s_idx if total <= 15: - lines.extend(hashed_lines[s_idx : e_idx + 1]) + prefixed = hashed_content else: - lines.extend(hashed_lines[s_idx : s_idx + 5]) - lines.append("...⋮...") - lines.extend(hashed_lines[e_idx - 4 : e_idx + 1]) + prefixed = cls.content_splitter(coder, hashed_lines, s_idx, e_idx) - lines.append("") - return "\n".join(lines) + result = { + "file_path": rel_path, + "start_line": s_idx + 1, + "end_line": e_idx + 1, + "partial": True, + "prefixed_contents": prefixed, + } + return json.dumps(result, ensure_ascii=False) + + @classmethod + def content_splitter(cls, coder, hashed_lines, s_idx, e_idx): + """Edges in, middle out: progressively selects lines from edges + inward and middle outward, tracking token budget until exhausted. + + Returns a string with hashed lines joined by newlines, with + "...⋮..." separators between non-contiguous groups. + """ + total_lines = e_idx - s_idx + 1 + max_tokens = min(coder.large_file_token_threshold / 16, 512) + + selected = set() + + # Round 0: first 2 lines + selected.add(s_idx) + if s_idx + 1 <= e_idx: + selected.add(s_idx + 1) + + # Round 0: middle 1 or 2 lines + if total_lines % 2 == 1: # odd + mid_start = s_idx + total_lines // 2 + selected.add(mid_start) + mid_end = mid_start + else: # even + mid_start = s_idx + total_lines // 2 - 1 + mid_end = s_idx + total_lines // 2 + selected.add(mid_start) + selected.add(mid_end) + + # Round 0: last 2 lines + if e_idx - 1 >= s_idx: + selected.add(e_idx - 1) + selected.add(e_idx) + + round_num = 1 + while True: + next_selected = selected.copy() + + # Add 2 lines to the top + new_top_1 = s_idx + 2 * round_num + if new_top_1 <= e_idx: + next_selected.add(new_top_1) + new_top_2 = s_idx + 2 * round_num + 1 + if new_top_2 <= e_idx: + next_selected.add(new_top_2) + + # Add 1 line on either end of the middle + left_mid = mid_start - round_num + if left_mid >= s_idx: + next_selected.add(left_mid) + right_mid = mid_end + round_num + if right_mid <= e_idx: + next_selected.add(right_mid) + + # Add 2 lines before the bottom + new_bottom_1 = e_idx - 1 - 2 * round_num + if new_bottom_1 >= s_idx: + next_selected.add(new_bottom_1) + new_bottom_2 = e_idx - 2 * round_num + if new_bottom_2 >= s_idx: + next_selected.add(new_bottom_2) + + # Check token count + sorted_indices = sorted(next_selected) + candidate_lines = [hashed_lines[i] for i in sorted_indices] + candidate_content = "\n".join(candidate_lines) + candidate_tokens = coder.main_model.token_count(candidate_content) + + if candidate_tokens > max_tokens: + break + + selected = next_selected + round_num += 1 + + if len(selected) == total_lines: + break + + # Build output with "...⋮..." between non-contiguous ranges + sorted_indices = sorted(selected) + output_parts = [] + current_chunk = [sorted_indices[0]] + + for i in range(1, len(sorted_indices)): + if sorted_indices[i] == sorted_indices[i - 1] + 1: + current_chunk.append(sorted_indices[i]) + else: + output_parts.append(current_chunk) + current_chunk = [sorted_indices[i]] + output_parts.append(current_chunk) + + output_lines = [] + for chunk_idx, chunk in enumerate(output_parts): + if chunk_idx > 0: + output_lines.append("...⋮...") + for idx in chunk: + output_lines.append(hashed_lines[idx]) + + return "\n".join(output_lines) @classmethod def _reposition_indices( @@ -969,16 +1076,16 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr abs_path, io, start_line=start_idx, end_line=end_idx, line_numbers=line_numbers ) - # If get_file_stub returned a useful structural outline, wrap it with headers + # If get_file_stub returned a useful structural outline, wrap it as JSON if stub and stub != "# No outline available": - total_lines = end_idx - start_idx + 1 - parts = [ - f"Showing structural information for {rel_path}:", - "Use this information to further narrow your search", - "", - stub, - ] - return "\n".join(parts), True + result = json.dumps( + { + "file_path": rel_path, + "outline": stub, + }, + ensure_ascii=False, + ) + return result, True content = io.read_text(abs_path) if not content: @@ -1019,8 +1126,21 @@ def _get_range_preview(cls, coder, abs_path, start_idx, end_idx, line_numbers=Tr f"Showing {len(sample_lines)} equally-spaced lines from the range:", "", ] + + file_contents = [] for idx, line_content in sample_lines: line_num = idx + 1 - parts.append(f" {line_num:>5} | {line_content}") + file_contents.append(f"{line_num}|{line_content}") + file_contents.append("...") + + parts.append( + json.dumps( + { + "file_path": rel_path, + "truncated": "\n".join(file_contents), + }, + ensure_ascii=False, + ) + ) return "\n".join(parts), False diff --git a/cecli/tools/resource_manager.py b/cecli/tools/resource_manager.py index f9c634ffc01..a58a232f12e 100644 --- a/cecli/tools/resource_manager.py +++ b/cecli/tools/resource_manager.py @@ -167,7 +167,7 @@ async def execute( "load_skill, remove_skill, load_mcp, remove_mcp, or actions" ) - coder.io.tool_output("\u2b6d Modifying Context", type="tool-result") + coder.io.tool_output("⛭ Modifying Context", type="tool-result") messages = [] # Expand wildcards for MCP operations diff --git a/cecli/urls.py b/cecli/urls.py index d2e30182dc9..9a5c44c0593 100644 --- a/cecli/urls.py +++ b/cecli/urls.py @@ -1,16 +1,14 @@ website = "https://cecli.dev/" -add_all_files = "https://cecli.dev/docs/faq.html#how-can-i-add-all-the-files-to-the-chat" edit_errors = "https://cecli.dev/docs/troubleshooting/edit-errors.html" git = "https://cecli.dev/docs/git.html" -enable_playwright = "https://cecli.dev/docs/install/optional.html#enable-playwright" +enable_playwright = "https://cecli.dev/docs/usage/optional.html#enable-playwright" favicon = "https://cecli.dev/assets/cecli-temp-logo-favicon.svg" model_warnings = "https://cecli.dev/docs/llms/warnings.html" token_limits = "https://cecli.dev/docs/troubleshooting/token-limits.html" llms = "https://cecli.dev/docs/llms.html" -large_repos = "https://cecli.dev/docs/faq.html#can-i-use-cecli-in-a-large-mono-repo" -github_issues = "https://github.com/dwash96/cecli/issues/new" +github_issues = "https://github.com/cecli-dev/cecli/issues/new" git_index_version = "https://github.com/Aider-AI/aider/issues/211" install_properly = "https://cecli.dev/docs/troubleshooting/imports.html" -release_notes = "https://github.com/dwash96/cecli/releases/latest" +release_notes = "https://github.com/cecli-dev/cecli/releases/latest" edit_formats = "https://cecli.dev/docs/more/edit-formats.html" models_and_keys = "https://cecli.dev/docs/troubleshooting/models-and-keys.html" diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 05d13fe964f..95ff02b32b7 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -21,7 +21,7 @@ async def install_from_main_branch(io): io, None, "Install the development version of cecli from the main branch?", - ["git+https://github.com/dwash96/cecli.git"], + ["git+https://github.com/cecli-dev/cecli.git"], self_update=True, ) diff --git a/cecli/website/_config.yml b/cecli/website/_config.yml index f88ca4c1b1f..5b664e866ae 100644 --- a/cecli/website/_config.yml +++ b/cecli/website/_config.yml @@ -24,7 +24,7 @@ exclude: aux_links: "GitHub": - - "https://github.com/dwash96/cecli" + - "https://github.com/cecli-dev/cecli" "Discord": - "https://discord.gg/AX9ZEA7nJn" "Support": @@ -32,7 +32,7 @@ aux_links: nav_external_links: - title: "GitHub" - url: "https://github.com/dwash96/cecli" + url: "https://github.com/cecli-dev/cecli" - title: "Discord" url: "https://discord.gg/AX9ZEA7nJn" - title: "Support" diff --git a/cecli/website/_includes/help.md b/cecli/website/_includes/help.md index 89955bac765..2999741f739 100644 --- a/cecli/website/_includes/help.md +++ b/cecli/website/_includes/help.md @@ -1,5 +1,5 @@ If you need more help, please check our -[GitHub issues](https://github.com/dwash96/cecli/issues) +[GitHub issues](https://github.com/cecli-dev/cecli/issues) and file a new issue if your problem isn't discussed. Or drop into our [Discord](https://discord.gg/g4bF53fSWF) diff --git a/cecli/website/docs/benchmarks-1106.md b/cecli/website/docs/benchmarks-1106.md index 1555ef3bd55..a66892d1351 100644 --- a/cecli/website/docs/benchmarks-1106.md +++ b/cecli/website/docs/benchmarks-1106.md @@ -19,7 +19,7 @@ and there's a lot of interest about their ability to code compared to the previous versions. With that in mind, I've been benchmarking the new models. -[cecli](https://github.com/dwash96/cecli) +[cecli](https://github.com/cecli-dev/cecli) is an open source command line chat tool that lets you work with GPT to edit code in your local git repo. To do this, cecli needs to be able to reliably recognize when GPT wants to edit diff --git a/cecli/website/docs/benchmarks-speed-1106.md b/cecli/website/docs/benchmarks-speed-1106.md index 36c3edd8c52..db91a9e7959 100644 --- a/cecli/website/docs/benchmarks-speed-1106.md +++ b/cecli/website/docs/benchmarks-speed-1106.md @@ -20,7 +20,7 @@ and there's a lot of interest about their capabilities and performance. With that in mind, I've been benchmarking the new models. -[cecli](https://github.com/dwash96/cecli) +[cecli](https://github.com/cecli-dev/cecli) is an open source command line chat tool that lets you work with GPT to edit code in your local git repo. cecli relies on a diff --git a/cecli/website/docs/benchmarks.md b/cecli/website/docs/benchmarks.md index 108cc67ca3c..8226a276266 100644 --- a/cecli/website/docs/benchmarks.md +++ b/cecli/website/docs/benchmarks.md @@ -168,7 +168,7 @@ requests: ### whole The -[whole](https://github.com/dwash96/cecli/blob/main/cecli/coders/wholefile_prompts.py) +[whole](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/wholefile_prompts.py) format asks GPT to return an updated copy of the entire file, including any changes. The file should be formatted with normal markdown triple-backtick fences, inlined with the rest of its response text. @@ -187,7 +187,7 @@ def main(): ### diff -The [diff](https://github.com/dwash96/cecli/blob/main/cecli/coders/editblock_prompts.py) +The [diff](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/editblock_prompts.py) format also asks GPT to return edits as part of the normal response text, in a simple diff format. Each edit is a fenced code block that @@ -209,7 +209,7 @@ demo.py ### whole-func -The [whole-func](https://github.com/dwash96/cecli/blob/main/cecli/coders/wholefile_func_coder.py) +The [whole-func](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/wholefile_func_coder.py) format requests updated copies of whole files to be returned using the function call API. @@ -227,7 +227,7 @@ format requests updated copies of whole files to be returned using the function ### diff-func The -[diff-func](https://github.com/dwash96/cecli/blob/main/cecli/coders/editblock_func_coder.py) +[diff-func](https://github.com/cecli-dev/cecli/blob/main/cecli/coders/editblock_func_coder.py) format requests a list of original/updated style edits to be returned using the function call API. diff --git a/cecli/website/docs/config/adv-model-settings.md b/cecli/website/docs/config/adv-model-settings.md index a96d8b80cbd..e2d4c6c5997 100644 --- a/cecli/website/docs/config/adv-model-settings.md +++ b/cecli/website/docs/config/adv-model-settings.md @@ -122,7 +122,7 @@ These settings will be merged with any model-specific settings, with the Below are all the pre-configured model settings to give a sense for the settings which are supported. You can also look at the `ModelSettings` class in -[models.py](https://github.com/dwash96/cecli/blob/main/cecli/models.py) +[models.py](https://github.com/cecli-dev/cecli/blob/main/cecli/models.py) file for more details about all of the model setting that cecli supports. The first entry shows all the settings, with their default values. diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index f0d7e01fa02..fffe26c748d 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -156,11 +156,13 @@ Agent Mode can also be configured directly in your configuration file. See the [ - **`tools_paths`**: Array of directories or Python files containing custom tools to load - **`servers_includelist`**: Array of MCP server names to allow (only these servers will be available) - **`servers_excludelist`**: Array of MCP server names to exclude (these servers will be disabled) +- **`show_lint_errors`**: When enabled, linting errors found during editing will be displayed in the tool output, allowing the LLM to see and address them (default: false) - **`subagent_paths`**: Array of directories to search for sub-agent definition `.md` files - **`max_sub_agents`**: Maximum number of concurrent sub-agents (default: 3) - **`allow_nested_delegation`**: Allow sub-agents to delegate tasks to further sub-agents (default: `false`). When enabled, the `Delegate` tool is made available in sub-agent tool schemas. - **`include_context_blocks`**: Array of context block names to include (overrides default set) - **`exclude_context_blocks`**: Array of context block names to exclude from default set +- **`hot_reload`**: When enabled, skills configuration is hot-reloaded automatically, reflecting changes to skills without requiring a restart (default: false) - **`command_timeout`**: Time in seconds to wait for shell commands to finish before automatic backgrounding occurs (default: None) #### Essential Tools @@ -283,6 +285,8 @@ agent-config: large_file_token_threshold: 32768 # Token threshold for large file warnings (default: 32768) skip_cli_confirmations: false # YOLO mode - be brave and let the LLM cook allowed_commands: ["wc -l*"] # Commands matching these glob patterns will not prompt for confirmation + show_lint_errors: false # When enabled, linting errors are shown in tool output (default: false) + hot_reload: false # When enabled, skills configuration is hot-reloaded automatically (default: false) # Skills configuration (see Skills documentation for details) skills_paths: ["~/my-skills", "./project-skills"] # Directories to search for skills skills_includelist: ["python-refactoring", "react-components"] # Optional: Whitelist of skills to include @@ -306,7 +310,7 @@ agent-config: skills_init: ["python-refactoring"] ``` -For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/dwash96/cecli/blob/main/cecli/website/docs/config/skills.md). +For complete documentation on creating and using skills, including skill directory structure, SKILL.md format, and best practices, see the [Skills documentation](https://github.com/cecli-dev/cecli/blob/main/cecli/website/docs/config/skills.md). ### Benefits - **Autonomous operation**: Reduces need for manual file management diff --git a/cecli/website/docs/config/aider_conf.md b/cecli/website/docs/config/conf.md similarity index 92% rename from cecli/website/docs/config/aider_conf.md rename to cecli/website/docs/config/conf.md index b4d0c31aa47..efcb199eb1e 100644 --- a/cecli/website/docs/config/aider_conf.md +++ b/cecli/website/docs/config/conf.md @@ -40,7 +40,7 @@ read: [CONVENTIONS.md, anotherfile.txt, thirdfile.py] Below is a sample of the YAML config file, which you can also -[download from GitHub](https://github.com/dwash96/cecli/blob/main/cecli/website/assets/sample.cecli.conf.yml). +[download from GitHub](https://github.com/cecli-dev/cecli/blob/main/cecli/website/assets/sample.cecli.conf.yml). diff --git a/cecli/website/docs/config/dotenv.md b/cecli/website/docs/config/dotenv.md index 089b02c9a17..f987d477701 100644 --- a/cecli/website/docs/config/dotenv.md +++ b/cecli/website/docs/config/dotenv.md @@ -26,7 +26,7 @@ If the files above exist, they will be loaded in that order. Files loaded last w Below is a sample `.env` file, which you can also -[download from GitHub](https://github.com/dwash96/cecli/blob/main/cecli/website/assets/sample.env). +[download from GitHub](https://github.com/cecli-dev/cecli/blob/main/cecli/website/assets/sample.env).