code-puppy 0.0.84__py3-none-any.whl → 0.0.86__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +1 -0
- code_puppy/agent.py +1 -1
- code_puppy/agent_prompts.py +1 -3
- code_puppy/command_line/motd.py +1 -1
- code_puppy/main.py +23 -12
- code_puppy/message_history_processor.py +126 -36
- code_puppy/model_factory.py +20 -14
- code_puppy/state_management.py +9 -5
- code_puppy/summarization_agent.py +2 -4
- code_puppy/tools/__init__.py +4 -1
- code_puppy/tools/command_runner.py +355 -116
- code_puppy/tools/file_modifications.py +3 -1
- code_puppy/tools/file_operations.py +30 -23
- {code_puppy-0.0.84.dist-info → code_puppy-0.0.86.dist-info}/METADATA +1 -1
- code_puppy-0.0.86.dist-info/RECORD +30 -0
- code_puppy-0.0.84.dist-info/RECORD +0 -30
- {code_puppy-0.0.84.data → code_puppy-0.0.86.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.84.dist-info → code_puppy-0.0.86.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.84.dist-info → code_puppy-0.0.86.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.84.dist-info → code_puppy-0.0.86.dist-info}/licenses/LICENSE +0 -0
    
        code_puppy/__init__.py
    CHANGED
    
    
    
        code_puppy/agent.py
    CHANGED
    
    | @@ -79,7 +79,7 @@ def reload_code_generation_agent(): | |
| 79 79 | 
             
                    instructions=instructions,
         | 
| 80 80 | 
             
                    output_type=str,
         | 
| 81 81 | 
             
                    retries=3,
         | 
| 82 | 
            -
                    history_processors=[message_history_accumulator]
         | 
| 82 | 
            +
                    history_processors=[message_history_accumulator],
         | 
| 83 83 | 
             
                )
         | 
| 84 84 | 
             
                register_all_tools(agent)
         | 
| 85 85 | 
             
                _code_generation_agent = agent
         | 
    
        code_puppy/agent_prompts.py
    CHANGED
    
    | @@ -101,9 +101,7 @@ Important rules: | |
| 101 101 |  | 
| 102 102 | 
             
            Your solutions should be production-ready, maintainable, and follow best practices for the chosen language.
         | 
| 103 103 |  | 
| 104 | 
            -
            Return your final response as a  | 
| 105 | 
            -
             * output_message: The final output message to display to the user
         | 
| 106 | 
            -
             * awaiting_user_input: True if user input is needed to continue the task. If you get an error, you might consider asking the user for help.
         | 
| 104 | 
            +
            Return your final response as a string output
         | 
| 107 105 | 
             
            """
         | 
| 108 106 |  | 
| 109 107 |  | 
    
        code_puppy/command_line/motd.py
    CHANGED
    
    
    
        code_puppy/main.py
    CHANGED
    
    | @@ -21,7 +21,8 @@ from code_puppy.state_management import get_message_history, set_message_history | |
| 21 21 | 
             
            # Initialize rich console for pretty output
         | 
| 22 22 | 
             
            from code_puppy.tools.common import console
         | 
| 23 23 | 
             
            from code_puppy.version_checker import fetch_latest_version
         | 
| 24 | 
            -
            from code_puppy.message_history_processor import message_history_processor
         | 
| 24 | 
            +
            from code_puppy.message_history_processor import message_history_processor, prune_interrupted_tool_calls
         | 
| 25 | 
            +
             | 
| 25 26 |  | 
| 26 27 | 
             
            # from code_puppy.tools import *  # noqa: F403
         | 
| 27 28 |  | 
| @@ -193,13 +194,13 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 193 194 | 
             
                        try:
         | 
| 194 195 | 
             
                            prettier_code_blocks()
         | 
| 195 196 | 
             
                            local_cancelled = False
         | 
| 197 | 
            +
             | 
| 196 198 | 
             
                            async def run_agent_task():
         | 
| 197 199 | 
             
                                try:
         | 
| 198 200 | 
             
                                    agent = get_code_generation_agent()
         | 
| 199 201 | 
             
                                    async with agent.run_mcp_servers():
         | 
| 200 202 | 
             
                                        return await agent.run(
         | 
| 201 | 
            -
                                            task,
         | 
| 202 | 
            -
                                            message_history=get_message_history()
         | 
| 203 | 
            +
                                            task, message_history=get_message_history()
         | 
| 203 204 | 
             
                                        )
         | 
| 204 205 | 
             
                                except Exception as e:
         | 
| 205 206 | 
             
                                    console.log("Task failed", e)
         | 
| @@ -207,20 +208,30 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 207 208 | 
             
                            agent_task = asyncio.create_task(run_agent_task())
         | 
| 208 209 |  | 
| 209 210 | 
             
                            import signal
         | 
| 211 | 
            +
                            from code_puppy.tools import kill_all_running_shell_processes
         | 
| 210 212 |  | 
| 211 213 | 
             
                            original_handler = None
         | 
| 212 214 |  | 
| 215 | 
            +
                            # Ensure the interrupt handler only acts once per task
         | 
| 216 | 
            +
                            handled = False
         | 
| 213 217 | 
             
                            def keyboard_interrupt_handler(sig, frame):
         | 
| 214 218 | 
             
                                nonlocal local_cancelled
         | 
| 215 | 
            -
                                 | 
| 216 | 
            -
             | 
| 217 | 
            -
             | 
| 218 | 
            -
             | 
| 219 | 
            -
             | 
| 220 | 
            -
             | 
| 221 | 
            -
                                     | 
| 222 | 
            -
                                     | 
| 223 | 
            -
             | 
| 219 | 
            +
                                nonlocal handled
         | 
| 220 | 
            +
                                if handled:
         | 
| 221 | 
            +
                                    return
         | 
| 222 | 
            +
                                handled = True
         | 
| 223 | 
            +
                                # First, nuke any running shell processes triggered by tools
         | 
| 224 | 
            +
                                try:
         | 
| 225 | 
            +
                                    killed = kill_all_running_shell_processes()
         | 
| 226 | 
            +
                                    if killed:
         | 
| 227 | 
            +
                                        console.print(f"[yellow]Cancelled {killed} running shell process(es).[/yellow]")
         | 
| 228 | 
            +
                                    else:
         | 
| 229 | 
            +
                                        # Then cancel the agent task
         | 
| 230 | 
            +
                                        if not agent_task.done():
         | 
| 231 | 
            +
                                            agent_task.cancel()
         | 
| 232 | 
            +
                                            local_cancelled = True
         | 
| 233 | 
            +
                                except Exception as e:
         | 
| 234 | 
            +
                                    console.print(f"[dim]Shell kill error: {e}[/dim]")
         | 
| 224 235 | 
             
                            try:
         | 
| 225 236 | 
             
                                original_handler = signal.getsignal(signal.SIGINT)
         | 
| 226 237 | 
             
                                signal.signal(signal.SIGINT, keyboard_interrupt_handler)
         | 
| @@ -1,30 +1,40 @@ | |
| 1 1 | 
             
            import json
         | 
| 2 | 
            -
            import  | 
| 3 | 
            -
            from typing import List
         | 
| 2 | 
            +
            from typing import List, Set
         | 
| 4 3 | 
             
            import os
         | 
| 5 4 | 
             
            from pathlib import Path
         | 
| 6 5 |  | 
| 7 6 | 
             
            import pydantic
         | 
| 8 7 | 
             
            import tiktoken
         | 
| 9 | 
            -
            from pydantic_ai.messages import  | 
| 8 | 
            +
            from pydantic_ai.messages import (
         | 
| 9 | 
            +
                ModelMessage,
         | 
| 10 | 
            +
                TextPart,
         | 
| 11 | 
            +
                ModelResponse,
         | 
| 12 | 
            +
                ModelRequest,
         | 
| 13 | 
            +
                ToolCallPart,
         | 
| 14 | 
            +
            )
         | 
| 10 15 |  | 
| 11 | 
            -
            from code_puppy.config import get_message_history_limit
         | 
| 12 16 | 
             
            from code_puppy.tools.common import console
         | 
| 13 17 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 14 18 | 
             
            from code_puppy.config import get_model_name
         | 
| 15 19 |  | 
| 16 20 | 
             
            # Import summarization agent
         | 
| 17 21 | 
             
            try:
         | 
| 18 | 
            -
                from code_puppy.summarization_agent import  | 
| 22 | 
            +
                from code_puppy.summarization_agent import (
         | 
| 23 | 
            +
                    get_summarization_agent as _get_summarization_agent,
         | 
| 24 | 
            +
                )
         | 
| 25 | 
            +
             | 
| 19 26 | 
             
                SUMMARIZATION_AVAILABLE = True
         | 
| 20 | 
            -
             | 
| 27 | 
            +
             | 
| 21 28 | 
             
                # Make the function available in this module's namespace for mocking
         | 
| 22 29 | 
             
                def get_summarization_agent():
         | 
| 23 30 | 
             
                    return _get_summarization_agent()
         | 
| 24 | 
            -
             | 
| 31 | 
            +
             | 
| 25 32 | 
             
            except ImportError:
         | 
| 26 33 | 
             
                SUMMARIZATION_AVAILABLE = False
         | 
| 27 | 
            -
                console.print( | 
| 34 | 
            +
                console.print(
         | 
| 35 | 
            +
                    "[yellow]Warning: Summarization agent not available. Message history will be truncated instead of summarized.[/yellow]"
         | 
| 36 | 
            +
                )
         | 
| 37 | 
            +
             | 
| 28 38 | 
             
                def get_summarization_agent():
         | 
| 29 39 | 
             
                    return None
         | 
| 30 40 |  | 
| @@ -40,10 +50,10 @@ def get_tokenizer_for_model(model_name: str): | |
| 40 50 | 
             
            def stringify_message_part(part) -> str:
         | 
| 41 51 | 
             
                """
         | 
| 42 52 | 
             
                Convert a message part to a string representation for token estimation or other uses.
         | 
| 43 | 
            -
             | 
| 53 | 
            +
             | 
| 44 54 | 
             
                Args:
         | 
| 45 55 | 
             
                    part: A message part that may contain content or be a tool call
         | 
| 46 | 
            -
             | 
| 56 | 
            +
             | 
| 47 57 | 
             
                Returns:
         | 
| 48 58 | 
             
                    String representation of the message part
         | 
| 49 59 | 
             
                """
         | 
| @@ -54,7 +64,7 @@ def stringify_message_part(part) -> str: | |
| 54 64 | 
             
                    result += str(type(part)) + ": "
         | 
| 55 65 |  | 
| 56 66 | 
             
                # Handle content
         | 
| 57 | 
            -
                if hasattr(part,  | 
| 67 | 
            +
                if hasattr(part, "content") and part.content:
         | 
| 58 68 | 
             
                    # Handle different content types
         | 
| 59 69 | 
             
                    if isinstance(part.content, str):
         | 
| 60 70 | 
             
                        result = part.content
         | 
| @@ -64,16 +74,16 @@ def stringify_message_part(part) -> str: | |
| 64 74 | 
             
                        result = json.dumps(part.content)
         | 
| 65 75 | 
             
                    else:
         | 
| 66 76 | 
             
                        result = str(part.content)
         | 
| 67 | 
            -
             | 
| 77 | 
            +
             | 
| 68 78 | 
             
                # Handle tool calls which may have additional token costs
         | 
| 69 79 | 
             
                # If part also has content, we'll process tool calls separately
         | 
| 70 | 
            -
                if hasattr(part,  | 
| 80 | 
            +
                if hasattr(part, "tool_name") and part.tool_name:
         | 
| 71 81 | 
             
                    # Estimate tokens for tool name and parameters
         | 
| 72 82 | 
             
                    tool_text = part.tool_name
         | 
| 73 83 | 
             
                    if hasattr(part, "args"):
         | 
| 74 84 | 
             
                        tool_text += f" {str(part.args)}"
         | 
| 75 85 | 
             
                    result += tool_text
         | 
| 76 | 
            -
             | 
| 86 | 
            +
             | 
| 77 87 | 
             
                return result
         | 
| 78 88 |  | 
| 79 89 |  | 
| @@ -84,27 +94,22 @@ def estimate_tokens_for_message(message: ModelMessage) -> int: | |
| 84 94 | 
             
                """
         | 
| 85 95 | 
             
                tokenizer = get_tokenizer_for_model(get_model_name())
         | 
| 86 96 | 
             
                total_tokens = 0
         | 
| 87 | 
            -
             | 
| 97 | 
            +
             | 
| 88 98 | 
             
                for part in message.parts:
         | 
| 89 99 | 
             
                    part_str = stringify_message_part(part)
         | 
| 90 100 | 
             
                    if part_str:
         | 
| 91 101 | 
             
                        tokens = tokenizer.encode(part_str)
         | 
| 92 102 | 
             
                        total_tokens += len(tokens)
         | 
| 93 | 
            -
             | 
| 103 | 
            +
             | 
| 94 104 | 
             
                return max(1, total_tokens)
         | 
| 95 105 |  | 
| 96 106 |  | 
| 97 107 | 
             
            def summarize_messages(messages: List[ModelMessage]) -> ModelMessage:
         | 
| 98 | 
            -
             | 
| 99 | 
            -
                # Get the summarization agent
         | 
| 100 108 | 
             
                summarization_agent = get_summarization_agent()
         | 
| 101 | 
            -
                message_strings = []
         | 
| 102 | 
            -
             | 
| 109 | 
            +
                message_strings: List[str] = []
         | 
| 103 110 | 
             
                for message in messages:
         | 
| 104 111 | 
             
                    for part in message.parts:
         | 
| 105 112 | 
             
                        message_strings.append(stringify_message_part(part))
         | 
| 106 | 
            -
             | 
| 107 | 
            -
             | 
| 108 113 | 
             
                summary_string = "\n".join(message_strings)
         | 
| 109 114 | 
             
                instructions = (
         | 
| 110 115 | 
             
                    "Above I've given you a log of Agentic AI steps that have been taken"
         | 
| @@ -116,19 +121,53 @@ def summarize_messages(messages: List[ModelMessage]) -> ModelMessage: | |
| 116 121 | 
             
                    "\n Make sure your result is a bulleted list of all steps and interactions."
         | 
| 117 122 | 
             
                )
         | 
| 118 123 | 
             
                try:
         | 
| 119 | 
            -
                    # Run the summarization agent
         | 
| 120 124 | 
             
                    result = summarization_agent.run_sync(f"{summary_string}\n{instructions}")
         | 
| 121 | 
            -
                    
         | 
| 122 | 
            -
                    # Create a new message with the summarized content
         | 
| 123 | 
            -
                    summarized_parts = [TextPart(result.output)]
         | 
| 124 | 
            -
                    summarized_message = ModelResponse(parts=summarized_parts)
         | 
| 125 | 
            -
                    return summarized_message
         | 
| 125 | 
            +
                    return ModelResponse(parts=[TextPart(result.output)])
         | 
| 126 126 | 
             
                except Exception as e:
         | 
| 127 127 | 
             
                    console.print(f"Summarization failed during compaction: {e}")
         | 
| 128 | 
            -
                    # Return original message if summarization fails
         | 
| 129 128 | 
             
                    return None
         | 
| 130 129 |  | 
| 131 130 |  | 
| 131 | 
            +
            # New: single-message summarization helper used by tests
         | 
| 132 | 
            +
            # - If the message has a ToolCallPart, return original message (no summarization)
         | 
| 133 | 
            +
            # - If the message has system/instructions, return original message
         | 
| 134 | 
            +
            # - Otherwise, summarize and return a new ModelRequest with the summarized content
         | 
| 135 | 
            +
            # - On any error, return the original message
         | 
| 136 | 
            +
             | 
| 137 | 
            +
             | 
| 138 | 
            +
            def summarize_message(message: ModelMessage) -> ModelMessage:
         | 
| 139 | 
            +
                if not SUMMARIZATION_AVAILABLE:
         | 
| 140 | 
            +
                    return message
         | 
| 141 | 
            +
                try:
         | 
| 142 | 
            +
                    # If the message looks like a system/instructions message, skip summarization
         | 
| 143 | 
            +
                    instructions = getattr(message, "instructions", None)
         | 
| 144 | 
            +
                    if instructions:
         | 
| 145 | 
            +
                        return message
         | 
| 146 | 
            +
                    # If any part is a tool call, skip summarization
         | 
| 147 | 
            +
                    for part in message.parts:
         | 
| 148 | 
            +
                        if isinstance(part, ToolCallPart) or getattr(part, "tool_name", None):
         | 
| 149 | 
            +
                            return message
         | 
| 150 | 
            +
                    # Build prompt from textual content parts
         | 
| 151 | 
            +
                    content_bits: List[str] = []
         | 
| 152 | 
            +
                    for part in message.parts:
         | 
| 153 | 
            +
                        s = stringify_message_part(part)
         | 
| 154 | 
            +
                        if s:
         | 
| 155 | 
            +
                            content_bits.append(s)
         | 
| 156 | 
            +
                    if not content_bits:
         | 
| 157 | 
            +
                        return message
         | 
| 158 | 
            +
                    prompt = (
         | 
| 159 | 
            +
                        "Please summarize the following user message:\n"
         | 
| 160 | 
            +
                        + "\n".join(content_bits)
         | 
| 161 | 
            +
                    )
         | 
| 162 | 
            +
                    agent = get_summarization_agent()
         | 
| 163 | 
            +
                    result = agent.run_sync(prompt)
         | 
| 164 | 
            +
                    summarized = ModelRequest([TextPart(result.output)])
         | 
| 165 | 
            +
                    return summarized
         | 
| 166 | 
            +
                except Exception as e:
         | 
| 167 | 
            +
                    console.print(f"Summarization failed: {e}")
         | 
| 168 | 
            +
                    return message
         | 
| 169 | 
            +
             | 
| 170 | 
            +
             | 
| 132 171 | 
             
            def get_model_context_length() -> int:
         | 
| 133 172 | 
             
                """
         | 
| 134 173 | 
             
                Get the context length for the currently configured model from models.json
         | 
| @@ -139,33 +178,84 @@ def get_model_context_length() -> int: | |
| 139 178 | 
             
                    models_path = Path(__file__).parent / "models.json"
         | 
| 140 179 | 
             
                else:
         | 
| 141 180 | 
             
                    models_path = Path(models_path)
         | 
| 142 | 
            -
             | 
| 181 | 
            +
             | 
| 143 182 | 
             
                model_configs = ModelFactory.load_config(str(models_path))
         | 
| 144 183 | 
             
                model_name = get_model_name()
         | 
| 145 | 
            -
             | 
| 184 | 
            +
             | 
| 146 185 | 
             
                # Get context length from model config
         | 
| 147 186 | 
             
                model_config = model_configs.get(model_name, {})
         | 
| 148 187 | 
             
                context_length = model_config.get("context_length", 128000)  # Default value
         | 
| 149 | 
            -
             | 
| 188 | 
            +
             | 
| 150 189 | 
             
                # Reserve 10% of context for response
         | 
| 151 190 | 
             
                return int(context_length)
         | 
| 152 191 |  | 
| 192 | 
            +
            def prune_interrupted_tool_calls(messages: List[ModelMessage]) -> List[ModelMessage]:
         | 
| 193 | 
            +
                """
         | 
| 194 | 
            +
                Remove any messages that participate in mismatched tool call sequences.
         | 
| 195 | 
            +
             | 
| 196 | 
            +
                A mismatched tool call id is one that appears in a ToolCall (model/tool request)
         | 
| 197 | 
            +
                without a corresponding tool return, or vice versa. We preserve original order
         | 
| 198 | 
            +
                and only drop messages that contain parts referencing mismatched tool_call_ids.
         | 
| 199 | 
            +
                """
         | 
| 200 | 
            +
                if not messages:
         | 
| 201 | 
            +
                    return messages
         | 
| 153 202 |  | 
| 154 | 
            -
             | 
| 203 | 
            +
                tool_call_ids: Set[str] = set()
         | 
| 204 | 
            +
                tool_return_ids: Set[str] = set()
         | 
| 205 | 
            +
             | 
| 206 | 
            +
                # First pass: collect ids for calls vs returns
         | 
| 207 | 
            +
                for msg in messages:
         | 
| 208 | 
            +
                    for part in getattr(msg, "parts", []) or []:
         | 
| 209 | 
            +
                        tool_call_id = getattr(part, "tool_call_id", None)
         | 
| 210 | 
            +
                        if not tool_call_id:
         | 
| 211 | 
            +
                            continue
         | 
| 212 | 
            +
                        # Heuristic: if it's an explicit ToolCallPart or has a tool_name/args,
         | 
| 213 | 
            +
                        # consider it a call; otherwise it's a return/result.
         | 
| 214 | 
            +
                        if part.part_kind == "tool-call":
         | 
| 215 | 
            +
                            tool_call_ids.add(tool_call_id)
         | 
| 216 | 
            +
                        else:
         | 
| 217 | 
            +
                            tool_return_ids.add(tool_call_id)
         | 
| 218 | 
            +
             | 
| 219 | 
            +
                mismatched: Set[str] = tool_call_ids.symmetric_difference(tool_return_ids)
         | 
| 220 | 
            +
                if not mismatched:
         | 
| 221 | 
            +
                    return messages
         | 
| 155 222 |  | 
| 223 | 
            +
                pruned: List[ModelMessage] = []
         | 
| 224 | 
            +
                dropped_count = 0
         | 
| 225 | 
            +
                for msg in messages:
         | 
| 226 | 
            +
                    has_mismatched = False
         | 
| 227 | 
            +
                    for part in getattr(msg, "parts", []) or []:
         | 
| 228 | 
            +
                        tcid = getattr(part, "tool_call_id", None)
         | 
| 229 | 
            +
                        if tcid and tcid in mismatched:
         | 
| 230 | 
            +
                            has_mismatched = True
         | 
| 231 | 
            +
                            break
         | 
| 232 | 
            +
                    if has_mismatched:
         | 
| 233 | 
            +
                        dropped_count += 1
         | 
| 234 | 
            +
                        continue
         | 
| 235 | 
            +
                    pruned.append(msg)
         | 
| 236 | 
            +
             | 
| 237 | 
            +
                if dropped_count:
         | 
| 238 | 
            +
                    console.print(f"[yellow]Pruned {dropped_count} message(s) with mismatched tool_call_id pairs[/yellow]")
         | 
| 239 | 
            +
                return pruned
         | 
| 240 | 
            +
             | 
| 241 | 
            +
             | 
| 242 | 
            +
            def message_history_processor(messages: List[ModelMessage]) -> List[ModelMessage]:
         | 
| 243 | 
            +
                # First, prune any interrupted/mismatched tool-call conversations
         | 
| 156 244 | 
             
                total_current_tokens = sum(estimate_tokens_for_message(msg) for msg in messages)
         | 
| 157 245 |  | 
| 158 246 | 
             
                model_max = get_model_context_length()
         | 
| 159 247 |  | 
| 160 248 | 
             
                proportion_used = total_current_tokens / model_max
         | 
| 161 249 | 
             
                console.print(f"""
         | 
| 162 | 
            -
            [bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used}
         | 
| 250 | 
            +
            [bold white on blue] Tokens in context: {total_current_tokens}, total model capacity: {model_max}, proportion used: {proportion_used:.2f}
         | 
| 163 251 | 
             
            """)
         | 
| 164 252 |  | 
| 165 253 | 
             
                if proportion_used > 0.9:
         | 
| 166 254 | 
             
                    summary = summarize_messages(messages)
         | 
| 167 255 | 
             
                    result_messages = [messages[0], summary]
         | 
| 168 | 
            -
                    final_token_count = sum( | 
| 256 | 
            +
                    final_token_count = sum(
         | 
| 257 | 
            +
                        estimate_tokens_for_message(msg) for msg in result_messages
         | 
| 258 | 
            +
                    )
         | 
| 169 259 | 
             
                    console.print(f"Final token count after processing: {final_token_count}")
         | 
| 170 260 | 
             
                    return result_messages
         | 
| 171 | 
            -
                return messages
         | 
| 261 | 
            +
                return messages
         | 
    
        code_puppy/model_factory.py
    CHANGED
    
    | @@ -42,15 +42,17 @@ def build_httpx_proxy(proxy): | |
| 42 42 | 
             
                """Build an httpx.Proxy object from a proxy string in format ip:port:username:password"""
         | 
| 43 43 | 
             
                proxy_tokens = proxy.split(":")
         | 
| 44 44 | 
             
                if len(proxy_tokens) != 4:
         | 
| 45 | 
            -
                    raise ValueError( | 
| 46 | 
            -
             | 
| 45 | 
            +
                    raise ValueError(
         | 
| 46 | 
            +
                        f"Invalid proxy format: {proxy}. Expected format: ip:port:username:password"
         | 
| 47 | 
            +
                    )
         | 
| 48 | 
            +
             | 
| 47 49 | 
             
                ip, port, username, password = proxy_tokens
         | 
| 48 50 | 
             
                proxy_url = f"http://{ip}:{port}"
         | 
| 49 51 | 
             
                proxy_auth = (username, password)
         | 
| 50 | 
            -
             | 
| 52 | 
            +
             | 
| 51 53 | 
             
                # Log the proxy being used
         | 
| 52 54 | 
             
                console.log(f"Using proxy: {proxy_url} with username: {username}")
         | 
| 53 | 
            -
             | 
| 55 | 
            +
             | 
| 54 56 | 
             
                return httpx.Proxy(url=proxy_url, auth=proxy_auth)
         | 
| 55 57 |  | 
| 56 58 |  | 
| @@ -58,18 +60,22 @@ def get_random_proxy_from_file(file_path): | |
| 58 60 | 
             
                """Reads proxy file and returns a random proxy formatted for httpx.AsyncClient"""
         | 
| 59 61 | 
             
                if not os.path.exists(file_path):
         | 
| 60 62 | 
             
                    raise ValueError(f"Proxy file '{file_path}' not found.")
         | 
| 61 | 
            -
             | 
| 63 | 
            +
             | 
| 62 64 | 
             
                with open(file_path, "r") as f:
         | 
| 63 65 | 
             
                    proxies = [line.strip() for line in f.readlines() if line.strip()]
         | 
| 64 | 
            -
             | 
| 66 | 
            +
             | 
| 65 67 | 
             
                if not proxies:
         | 
| 66 | 
            -
                    raise ValueError( | 
| 67 | 
            -
             | 
| 68 | 
            +
                    raise ValueError(
         | 
| 69 | 
            +
                        f"Proxy file '{file_path}' is empty or contains only whitespace."
         | 
| 70 | 
            +
                    )
         | 
| 71 | 
            +
             | 
| 68 72 | 
             
                selected_proxy = random.choice(proxies)
         | 
| 69 73 | 
             
                try:
         | 
| 70 74 | 
             
                    return build_httpx_proxy(selected_proxy)
         | 
| 71 | 
            -
                except ValueError | 
| 72 | 
            -
                    console.log( | 
| 75 | 
            +
                except ValueError:
         | 
| 76 | 
            +
                    console.log(
         | 
| 77 | 
            +
                        f"Warning: Malformed proxy '{selected_proxy}' found in file '{file_path}', ignoring and continuing without proxy."
         | 
| 78 | 
            +
                    )
         | 
| 73 79 | 
             
                    return None
         | 
| 74 80 |  | 
| 75 81 |  | 
| @@ -147,13 +153,13 @@ class ModelFactory: | |
| 147 153 |  | 
| 148 154 | 
             
                    elif model_type == "custom_anthropic":
         | 
| 149 155 | 
             
                        url, headers, ca_certs_path, api_key = get_custom_config(model_config)
         | 
| 150 | 
            -
             | 
| 156 | 
            +
             | 
| 151 157 | 
             
                        # Check for proxy configuration
         | 
| 152 158 | 
             
                        proxy_file_path = os.environ.get("CODE_PUPPY_PROXIES")
         | 
| 153 159 | 
             
                        proxy = None
         | 
| 154 160 | 
             
                        if proxy_file_path:
         | 
| 155 161 | 
             
                            proxy = get_random_proxy_from_file(proxy_file_path)
         | 
| 156 | 
            -
             | 
| 162 | 
            +
             | 
| 157 163 | 
             
                        # Only pass proxy to client if it's valid
         | 
| 158 164 | 
             
                        client_args = {"headers": headers, "verify": ca_certs_path}
         | 
| 159 165 | 
             
                        if proxy is not None:
         | 
| @@ -223,13 +229,13 @@ class ModelFactory: | |
| 223 229 |  | 
| 224 230 | 
             
                    elif model_type == "custom_openai":
         | 
| 225 231 | 
             
                        url, headers, ca_certs_path, api_key = get_custom_config(model_config)
         | 
| 226 | 
            -
             | 
| 232 | 
            +
             | 
| 227 233 | 
             
                        # Check for proxy configuration
         | 
| 228 234 | 
             
                        proxy_file_path = os.environ.get("CODE_PUPPY_PROXIES")
         | 
| 229 235 | 
             
                        proxy = None
         | 
| 230 236 | 
             
                        if proxy_file_path:
         | 
| 231 237 | 
             
                            proxy = get_random_proxy_from_file(proxy_file_path)
         | 
| 232 | 
            -
             | 
| 238 | 
            +
             | 
| 233 239 | 
             
                        # Only pass proxy to client if it's valid
         | 
| 234 240 | 
             
                        client_args = {"headers": headers, "verify": ca_certs_path}
         | 
| 235 241 | 
             
                        if proxy is not None:
         | 
    
        code_puppy/state_management.py
    CHANGED
    
    | @@ -1,24 +1,28 @@ | |
| 1 1 | 
             
            from typing import Any, List
         | 
| 2 2 |  | 
| 3 | 
            -
            from code_puppy.tools.common import console
         | 
| 4 3 | 
             
            from code_puppy.message_history_processor import message_history_processor
         | 
| 5 4 |  | 
| 6 5 | 
             
            _message_history: List[Any] = []
         | 
| 7 6 |  | 
| 7 | 
            +
             | 
| 8 8 | 
             
            def get_message_history() -> List[Any]:
         | 
| 9 9 | 
             
                return _message_history
         | 
| 10 10 |  | 
| 11 | 
            +
             | 
| 11 12 | 
             
            def set_message_history(history: List[Any]) -> None:
         | 
| 12 13 | 
             
                global _message_history
         | 
| 13 14 | 
             
                _message_history = history
         | 
| 14 15 |  | 
| 16 | 
            +
             | 
| 15 17 | 
             
            def clear_message_history() -> None:
         | 
| 16 18 | 
             
                global _message_history
         | 
| 17 19 | 
             
                _message_history = []
         | 
| 18 20 |  | 
| 21 | 
            +
             | 
| 19 22 | 
             
            def append_to_message_history(message: Any) -> None:
         | 
| 20 23 | 
             
                _message_history.append(message)
         | 
| 21 24 |  | 
| 25 | 
            +
             | 
| 22 26 | 
             
            def extend_message_history(history: List[Any]) -> None:
         | 
| 23 27 | 
             
                _message_history.extend(history)
         | 
| 24 28 |  | 
| @@ -37,18 +41,18 @@ def hash_message(message): | |
| 37 41 |  | 
| 38 42 | 
             
            def message_history_accumulator(messages: List[Any]):
         | 
| 39 43 | 
             
                global _message_history
         | 
| 40 | 
            -
             | 
| 44 | 
            +
             | 
| 41 45 | 
             
                message_history_hashes = set([hash_message(m) for m in _message_history])
         | 
| 42 46 | 
             
                for msg in messages:
         | 
| 43 47 | 
             
                    if hash_message(msg) not in message_history_hashes:
         | 
| 44 48 | 
             
                        _message_history.append(msg)
         | 
| 45 | 
            -
             | 
| 49 | 
            +
             | 
| 46 50 | 
             
                # Apply message history trimming using the main processor
         | 
| 47 51 | 
             
                # This ensures we maintain global state while still managing context limits
         | 
| 48 52 | 
             
                trimmed_messages = message_history_processor(_message_history)
         | 
| 49 | 
            -
             | 
| 53 | 
            +
             | 
| 50 54 | 
             
                # Update our global state with the trimmed version
         | 
| 51 55 | 
             
                # This preserves the state but keeps us within token limits
         | 
| 52 56 | 
             
                _message_history = trimmed_messages
         | 
| 53 | 
            -
             | 
| 57 | 
            +
             | 
| 54 58 | 
             
                return _message_history
         | 
| @@ -1,9 +1,7 @@ | |
| 1 1 | 
             
            import os
         | 
| 2 2 | 
             
            from pathlib import Path
         | 
| 3 3 |  | 
| 4 | 
            -
            import pydantic
         | 
| 5 4 | 
             
            from pydantic_ai import Agent
         | 
| 6 | 
            -
            from pydantic_ai.mcp import MCPServerSSE
         | 
| 7 5 |  | 
| 8 6 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 9 7 | 
             
            from code_puppy.tools.common import console
         | 
| @@ -33,7 +31,7 @@ def reload_summarization_agent(): | |
| 33 31 | 
             
                    else Path(__file__).parent / "models.json"
         | 
| 34 32 | 
             
                )
         | 
| 35 33 | 
             
                model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
         | 
| 36 | 
            -
             | 
| 34 | 
            +
             | 
| 37 35 | 
             
                # Specialized instructions for summarization
         | 
| 38 36 | 
             
                instructions = """You are a message summarization expert. Your task is to summarize conversation messages 
         | 
| 39 37 | 
             
            while preserving important context and information. The summaries should be concise but capture the essential 
         | 
| @@ -51,7 +49,7 @@ When summarizing: | |
| 51 49 | 
             
                    model=model,
         | 
| 52 50 | 
             
                    instructions=instructions,
         | 
| 53 51 | 
             
                    output_type=str,
         | 
| 54 | 
            -
                    retries=1  # Fewer retries for summarization
         | 
| 52 | 
            +
                    retries=1,  # Fewer retries for summarization
         | 
| 55 53 | 
             
                )
         | 
| 56 54 | 
             
                _summarization_agent = agent
         | 
| 57 55 | 
             
                _LAST_MODEL_NAME = model_name
         | 
    
        code_puppy/tools/__init__.py
    CHANGED
    
    | @@ -1,4 +1,7 @@ | |
| 1 | 
            -
            from code_puppy.tools.command_runner import  | 
| 1 | 
            +
            from code_puppy.tools.command_runner import (
         | 
| 2 | 
            +
                register_command_runner_tools,
         | 
| 3 | 
            +
                kill_all_running_shell_processes,
         | 
| 4 | 
            +
            )
         | 
| 2 5 | 
             
            from code_puppy.tools.file_modifications import register_file_modifications_tools
         | 
| 3 6 | 
             
            from code_puppy.tools.file_operations import register_file_operations_tools
         | 
| 4 7 |  | 
| @@ -1,14 +1,117 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import signal
         | 
| 1 3 | 
             
            import subprocess
         | 
| 4 | 
            +
            import threading
         | 
| 2 5 | 
             
            import time
         | 
| 3 | 
            -
             | 
| 6 | 
            +
            import traceback
         | 
| 7 | 
            +
            import sys
         | 
| 8 | 
            +
            from typing import Set
         | 
| 4 9 |  | 
| 5 10 | 
             
            from pydantic import BaseModel
         | 
| 6 11 | 
             
            from pydantic_ai import RunContext
         | 
| 7 12 | 
             
            from rich.markdown import Markdown
         | 
| 8 | 
            -
            from rich. | 
| 13 | 
            +
            from rich.text import Text
         | 
| 9 14 |  | 
| 10 15 | 
             
            from code_puppy.tools.common import console
         | 
| 11 16 |  | 
| 17 | 
            +
            _AWAITING_USER_INPUT = False
         | 
| 18 | 
            +
             | 
| 19 | 
            +
            _CONFIRMATION_LOCK = threading.Lock()
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            # Track running shell processes so we can kill them on Ctrl-C from the UI
         | 
| 22 | 
            +
            _RUNNING_PROCESSES: Set[subprocess.Popen] = set()
         | 
| 23 | 
            +
            _RUNNING_PROCESSES_LOCK = threading.Lock()
         | 
| 24 | 
            +
            _USER_KILLED_PROCESSES = set()
         | 
| 25 | 
            +
             | 
| 26 | 
            +
            def _register_process(proc: subprocess.Popen) -> None:
         | 
| 27 | 
            +
                with _RUNNING_PROCESSES_LOCK:
         | 
| 28 | 
            +
                    _RUNNING_PROCESSES.add(proc)
         | 
| 29 | 
            +
             | 
| 30 | 
            +
             | 
| 31 | 
            +
            def _unregister_process(proc: subprocess.Popen) -> None:
         | 
| 32 | 
            +
                with _RUNNING_PROCESSES_LOCK:
         | 
| 33 | 
            +
                    _RUNNING_PROCESSES.discard(proc)
         | 
| 34 | 
            +
             | 
| 35 | 
            +
             | 
| 36 | 
            +
            def _kill_process_group(proc: subprocess.Popen) -> None:
         | 
| 37 | 
            +
                """Attempt to aggressively terminate a process and its group.
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                Cross-platform best-effort. On POSIX, uses process groups. On Windows, tries CTRL_BREAK_EVENT, then terminate().
         | 
| 40 | 
            +
                """
         | 
| 41 | 
            +
                try:
         | 
| 42 | 
            +
                    if sys.platform.startswith("win"):
         | 
| 43 | 
            +
                        try:
         | 
| 44 | 
            +
                            # Try a soft break first if the group exists
         | 
| 45 | 
            +
                            proc.send_signal(signal.CTRL_BREAK_EVENT)  # type: ignore[attr-defined]
         | 
| 46 | 
            +
                            time.sleep(0.8)
         | 
| 47 | 
            +
                        except Exception:
         | 
| 48 | 
            +
                            pass
         | 
| 49 | 
            +
                        if proc.poll() is None:
         | 
| 50 | 
            +
                            try:
         | 
| 51 | 
            +
                                proc.terminate()
         | 
| 52 | 
            +
                                time.sleep(0.8)
         | 
| 53 | 
            +
                            except Exception:
         | 
| 54 | 
            +
                                pass
         | 
| 55 | 
            +
                        if proc.poll() is None:
         | 
| 56 | 
            +
                            try:
         | 
| 57 | 
            +
                                proc.kill()
         | 
| 58 | 
            +
                            except Exception:
         | 
| 59 | 
            +
                                pass
         | 
| 60 | 
            +
                        return
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                    # POSIX
         | 
| 63 | 
            +
                    pid = proc.pid
         | 
| 64 | 
            +
                    try:
         | 
| 65 | 
            +
                        pgid = os.getpgid(pid)
         | 
| 66 | 
            +
                        os.killpg(pgid, signal.SIGTERM)
         | 
| 67 | 
            +
                        time.sleep(1.0)
         | 
| 68 | 
            +
                        if proc.poll() is None:
         | 
| 69 | 
            +
                            os.killpg(pgid, signal.SIGINT)
         | 
| 70 | 
            +
                            time.sleep(0.6)
         | 
| 71 | 
            +
                        if proc.poll() is None:
         | 
| 72 | 
            +
                            os.killpg(pgid, signal.SIGKILL)
         | 
| 73 | 
            +
                            time.sleep(0.5)
         | 
| 74 | 
            +
                    except (OSError, ProcessLookupError):
         | 
| 75 | 
            +
                        # Fall back to direct kill of the process
         | 
| 76 | 
            +
                        try:
         | 
| 77 | 
            +
                            if proc.poll() is None:
         | 
| 78 | 
            +
                                proc.kill()
         | 
| 79 | 
            +
                        except (OSError, ProcessLookupError):
         | 
| 80 | 
            +
                            pass
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                    if proc.poll() is None:
         | 
| 83 | 
            +
                        # Last ditch attempt; may be unkillable zombie
         | 
| 84 | 
            +
                        try:
         | 
| 85 | 
            +
                            for _ in range(3):
         | 
| 86 | 
            +
                                os.kill(proc.pid, signal.SIGKILL)
         | 
| 87 | 
            +
                                time.sleep(0.2)
         | 
| 88 | 
            +
                                if proc.poll() is not None:
         | 
| 89 | 
            +
                                    break
         | 
| 90 | 
            +
                        except Exception:
         | 
| 91 | 
            +
                            pass
         | 
| 92 | 
            +
                except Exception as e:
         | 
| 93 | 
            +
                    console.print(f"Kill process error: {e}")
         | 
| 94 | 
            +
             | 
| 95 | 
            +
             | 
| 96 | 
            +
            def kill_all_running_shell_processes() -> int:
         | 
| 97 | 
            +
                """Kill all currently tracked running shell processes.
         | 
| 98 | 
            +
             | 
| 99 | 
            +
                Returns the number of processes signaled.
         | 
| 100 | 
            +
                """
         | 
| 101 | 
            +
                procs: list[subprocess.Popen]
         | 
| 102 | 
            +
                with _RUNNING_PROCESSES_LOCK:
         | 
| 103 | 
            +
                    procs = list(_RUNNING_PROCESSES)
         | 
| 104 | 
            +
                count = 0
         | 
| 105 | 
            +
                for p in procs:
         | 
| 106 | 
            +
                    try:
         | 
| 107 | 
            +
                        if p.poll() is None:
         | 
| 108 | 
            +
                            _kill_process_group(p)
         | 
| 109 | 
            +
                            count += 1
         | 
| 110 | 
            +
                            _USER_KILLED_PROCESSES.add(p.pid)
         | 
| 111 | 
            +
                    finally:
         | 
| 112 | 
            +
                        _unregister_process(p)
         | 
| 113 | 
            +
                return count
         | 
| 114 | 
            +
             | 
| 12 115 |  | 
| 13 116 | 
             
            class ShellCommandOutput(BaseModel):
         | 
| 14 117 | 
             
                success: bool
         | 
| @@ -19,35 +122,250 @@ class ShellCommandOutput(BaseModel): | |
| 19 122 | 
             
                exit_code: int | None
         | 
| 20 123 | 
             
                execution_time: float | None
         | 
| 21 124 | 
             
                timeout: bool | None = False
         | 
| 125 | 
            +
                user_interrupted: bool | None = False
         | 
| 126 | 
            +
             | 
| 127 | 
            +
             | 
| 128 | 
            +
            def run_shell_command_streaming(
         | 
| 129 | 
            +
                process: subprocess.Popen, timeout: int = 60, command: str = ""
         | 
| 130 | 
            +
            ):
         | 
| 131 | 
            +
                start_time = time.time()
         | 
| 132 | 
            +
                last_output_time = [start_time]
         | 
| 133 | 
            +
             | 
| 134 | 
            +
                ABSOLUTE_TIMEOUT_SECONDS = 270
         | 
| 135 | 
            +
             | 
| 136 | 
            +
                stdout_lines = []
         | 
| 137 | 
            +
                stderr_lines = []
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                stdout_thread = None
         | 
| 140 | 
            +
                stderr_thread = None
         | 
| 141 | 
            +
             | 
| 142 | 
            +
                def read_stdout():
         | 
| 143 | 
            +
                    try:
         | 
| 144 | 
            +
                        for line in iter(process.stdout.readline, ""):
         | 
| 145 | 
            +
                            if line:
         | 
| 146 | 
            +
                                line = line.rstrip("\n\r")
         | 
| 147 | 
            +
                                stdout_lines.append(line)
         | 
| 148 | 
            +
                                console.print(line)
         | 
| 149 | 
            +
                                last_output_time[0] = time.time()
         | 
| 150 | 
            +
                    except Exception:
         | 
| 151 | 
            +
                        pass
         | 
| 152 | 
            +
             | 
| 153 | 
            +
                def read_stderr():
         | 
| 154 | 
            +
                    try:
         | 
| 155 | 
            +
                        for line in iter(process.stderr.readline, ""):
         | 
| 156 | 
            +
                            if line:
         | 
| 157 | 
            +
                                line = line.rstrip("\n\r")
         | 
| 158 | 
            +
                                stderr_lines.append(line)
         | 
| 159 | 
            +
                                console.print(line)
         | 
| 160 | 
            +
                                last_output_time[0] = time.time()
         | 
| 161 | 
            +
                    except Exception:
         | 
| 162 | 
            +
                        pass
         | 
| 163 | 
            +
             | 
| 164 | 
            +
                def cleanup_process_and_threads(timeout_type: str = "unknown"):
         | 
| 165 | 
            +
                    nonlocal stdout_thread, stderr_thread
         | 
| 166 | 
            +
             | 
| 167 | 
            +
                    def nuclear_kill(proc):
         | 
| 168 | 
            +
                        _kill_process_group(proc)
         | 
| 169 | 
            +
             | 
| 170 | 
            +
                    try:
         | 
| 171 | 
            +
                        if process.poll() is None:
         | 
| 172 | 
            +
                            nuclear_kill(process)
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                        try:
         | 
| 175 | 
            +
                            if process.stdout and not process.stdout.closed:
         | 
| 176 | 
            +
                                process.stdout.close()
         | 
| 177 | 
            +
                            if process.stderr and not process.stderr.closed:
         | 
| 178 | 
            +
                                process.stderr.close()
         | 
| 179 | 
            +
                            if process.stdin and not process.stdin.closed:
         | 
| 180 | 
            +
                                process.stdin.close()
         | 
| 181 | 
            +
                        except (OSError, ValueError):
         | 
| 182 | 
            +
                            pass
         | 
| 183 | 
            +
             | 
| 184 | 
            +
                        # Unregister once we're done cleaning up
         | 
| 185 | 
            +
                        _unregister_process(process)
         | 
| 186 | 
            +
             | 
| 187 | 
            +
                        if stdout_thread and stdout_thread.is_alive():
         | 
| 188 | 
            +
                            stdout_thread.join(timeout=3)
         | 
| 189 | 
            +
                            if stdout_thread.is_alive():
         | 
| 190 | 
            +
                                console.print(
         | 
| 191 | 
            +
                                    f"stdout reader thread failed to terminate after {timeout_type} seconds"
         | 
| 192 | 
            +
                                )
         | 
| 193 | 
            +
             | 
| 194 | 
            +
                        if stderr_thread and stderr_thread.is_alive():
         | 
| 195 | 
            +
                            stderr_thread.join(timeout=3)
         | 
| 196 | 
            +
                            if stderr_thread.is_alive():
         | 
| 197 | 
            +
                                console.print(
         | 
| 198 | 
            +
                                    f"stderr reader thread failed to terminate after {timeout_type} seconds"
         | 
| 199 | 
            +
                                )
         | 
| 200 | 
            +
             | 
| 201 | 
            +
                    except Exception as e:
         | 
| 202 | 
            +
                        console.log(f"Error during process cleanup {e}")
         | 
| 203 | 
            +
             | 
| 204 | 
            +
                    execution_time = time.time() - start_time
         | 
| 205 | 
            +
                    return ShellCommandOutput(
         | 
| 206 | 
            +
                        **{
         | 
| 207 | 
            +
                            "success": False,
         | 
| 208 | 
            +
                            "command": command,
         | 
| 209 | 
            +
                            "stdout": "\n".join(stdout_lines[-1000:]),
         | 
| 210 | 
            +
                            "stderr": "\n".join(stderr_lines[-1000:]),
         | 
| 211 | 
            +
                            "exit_code": -9,
         | 
| 212 | 
            +
                            "execution_time": execution_time,
         | 
| 213 | 
            +
                            "timeout": True,
         | 
| 214 | 
            +
                            "error": f"Command timed out after {timeout} seconds",
         | 
| 215 | 
            +
                        }
         | 
| 216 | 
            +
                    )
         | 
| 217 | 
            +
             | 
| 218 | 
            +
                try:
         | 
| 219 | 
            +
                    stdout_thread = threading.Thread(target=read_stdout, daemon=True)
         | 
| 220 | 
            +
                    stderr_thread = threading.Thread(target=read_stderr, daemon=True)
         | 
| 221 | 
            +
             | 
| 222 | 
            +
                    stdout_thread.start()
         | 
| 223 | 
            +
                    stderr_thread.start()
         | 
| 224 | 
            +
             | 
| 225 | 
            +
                    while process.poll() is None:
         | 
| 226 | 
            +
                        current_time = time.time()
         | 
| 227 | 
            +
             | 
| 228 | 
            +
                        if current_time - start_time > ABSOLUTE_TIMEOUT_SECONDS:
         | 
| 229 | 
            +
                            error_msg = Text()
         | 
| 230 | 
            +
                            error_msg.append(
         | 
| 231 | 
            +
                                "Process killed: inactivity timeout reached", style="bold red"
         | 
| 232 | 
            +
                            )
         | 
| 233 | 
            +
                            console.print(error_msg)
         | 
| 234 | 
            +
                            return cleanup_process_and_threads("absolute")
         | 
| 235 | 
            +
             | 
| 236 | 
            +
                        if current_time - last_output_time[0] > timeout:
         | 
| 237 | 
            +
                            error_msg = Text()
         | 
| 238 | 
            +
                            error_msg.append(
         | 
| 239 | 
            +
                                "Process killed: inactivity timeout reached", style="bold red"
         | 
| 240 | 
            +
                            )
         | 
| 241 | 
            +
                            console.print(error_msg)
         | 
| 242 | 
            +
                            return cleanup_process_and_threads("inactivity")
         | 
| 243 | 
            +
             | 
| 244 | 
            +
                        time.sleep(0.1)
         | 
| 245 | 
            +
             | 
| 246 | 
            +
                    if stdout_thread:
         | 
| 247 | 
            +
                        stdout_thread.join(timeout=5)
         | 
| 248 | 
            +
                    if stderr_thread:
         | 
| 249 | 
            +
                        stderr_thread.join(timeout=5)
         | 
| 250 | 
            +
             | 
| 251 | 
            +
                    exit_code = process.returncode
         | 
| 252 | 
            +
                    execution_time = time.time() - start_time
         | 
| 253 | 
            +
             | 
| 254 | 
            +
                    try:
         | 
| 255 | 
            +
                        if process.stdout and not process.stdout.closed:
         | 
| 256 | 
            +
                            process.stdout.close()
         | 
| 257 | 
            +
                        if process.stderr and not process.stderr.closed:
         | 
| 258 | 
            +
                            process.stderr.close()
         | 
| 259 | 
            +
                        if process.stdin and not process.stdin.closed:
         | 
| 260 | 
            +
                            process.stdin.close()
         | 
| 261 | 
            +
                    except (OSError, ValueError):
         | 
| 262 | 
            +
                        pass
         | 
| 263 | 
            +
             | 
| 264 | 
            +
                    _unregister_process(process)
         | 
| 265 | 
            +
             | 
| 266 | 
            +
                    if exit_code != 0:
         | 
| 267 | 
            +
                        console.print(
         | 
| 268 | 
            +
                            f"Command failed with exit code {exit_code}", style="bold red"
         | 
| 269 | 
            +
                        )
         | 
| 270 | 
            +
                        console.print(f"Took {execution_time:.2f}s", style="dim")
         | 
| 271 | 
            +
                        time.sleep(1)
         | 
| 272 | 
            +
                        return ShellCommandOutput(
         | 
| 273 | 
            +
                            success=False,
         | 
| 274 | 
            +
                            command=command,
         | 
| 275 | 
            +
                            error="""The process didn't exit cleanly! If the user_interrupted flag is true,
         | 
| 276 | 
            +
                            please stop all execution and ask the user for clarification!""",
         | 
| 277 | 
            +
                            stdout="\n".join(stdout_lines[-1000:]),
         | 
| 278 | 
            +
                            stderr="\n".join(stderr_lines[-1000:]),
         | 
| 279 | 
            +
                            exit_code=exit_code,
         | 
| 280 | 
            +
                            execution_time=execution_time,
         | 
| 281 | 
            +
                            timeout=False,
         | 
| 282 | 
            +
                            user_interrupted=process.pid in _USER_KILLED_PROCESSES
         | 
| 283 | 
            +
                        )
         | 
| 284 | 
            +
                    return ShellCommandOutput(
         | 
| 285 | 
            +
                        success=exit_code == 0,
         | 
| 286 | 
            +
                        command=command,
         | 
| 287 | 
            +
                        stdout="\n".join(stdout_lines[-1000:]),
         | 
| 288 | 
            +
                        stderr="\n".join(stderr_lines[-1000:]),
         | 
| 289 | 
            +
                        exit_code=exit_code,
         | 
| 290 | 
            +
                        execution_time=execution_time,
         | 
| 291 | 
            +
                        timeout=False,
         | 
| 292 | 
            +
                    )
         | 
| 293 | 
            +
             | 
| 294 | 
            +
                except Exception as e:
         | 
| 295 | 
            +
                    return ShellCommandOutput(
         | 
| 296 | 
            +
                        success=False,
         | 
| 297 | 
            +
                        command=command,
         | 
| 298 | 
            +
                        error=f"Error durign streaming execution {str(e)}",
         | 
| 299 | 
            +
                        stdout="\n".join(stdout_lines[-1000:]),
         | 
| 300 | 
            +
                        stderr="\n".join(stderr_lines[-1000:]),
         | 
| 301 | 
            +
                        exit_code=-1,
         | 
| 302 | 
            +
                        timeout=False,
         | 
| 303 | 
            +
                    )
         | 
| 304 | 
            +
             | 
| 22 305 |  | 
| 23 306 | 
             
            def run_shell_command(
         | 
| 24 307 | 
             
                context: RunContext, command: str, cwd: str = None, timeout: int = 60
         | 
| 25 308 | 
             
            ) -> ShellCommandOutput:
         | 
| 309 | 
            +
                command_displayed = False
         | 
| 26 310 | 
             
                if not command or not command.strip():
         | 
| 27 311 | 
             
                    console.print("[bold red]Error:[/bold red] Command cannot be empty")
         | 
| 28 | 
            -
                    return ShellCommandOutput( | 
| 312 | 
            +
                    return ShellCommandOutput(
         | 
| 313 | 
            +
                        **{"success": False, "error": "Command cannot be empty"}
         | 
| 314 | 
            +
                    )
         | 
| 29 315 | 
             
                console.print(
         | 
| 30 316 | 
             
                    f"\n[bold white on blue] SHELL COMMAND [/bold white on blue] \U0001f4c2 [bold green]$ {command}[/bold green]"
         | 
| 31 317 | 
             
                )
         | 
| 32 | 
            -
                if cwd:
         | 
| 33 | 
            -
                    console.print(f"[dim]Working directory: {cwd}[/dim]")
         | 
| 34 | 
            -
                console.print("[dim]" + "-" * 60 + "[/dim]")
         | 
| 35 318 | 
             
                from code_puppy.config import get_yolo_mode
         | 
| 36 319 |  | 
| 37 320 | 
             
                yolo_mode = get_yolo_mode()
         | 
| 38 | 
            -
             | 
| 39 | 
            -
             | 
| 40 | 
            -
             | 
| 41 | 
            -
             | 
| 42 | 
            -
             | 
| 321 | 
            +
             | 
| 322 | 
            +
                confirmation_lock_acquired = False
         | 
| 323 | 
            +
             | 
| 324 | 
            +
                # Only ask for confirmation if we're in an interactive TTY and not in yolo mode.
         | 
| 325 | 
            +
                if not yolo_mode and sys.stdin.isatty():
         | 
| 326 | 
            +
                    confirmation_lock_acquired = _CONFIRMATION_LOCK.acquire(blocking=False)
         | 
| 327 | 
            +
                    if not confirmation_lock_acquired:
         | 
| 328 | 
            +
                        return ShellCommandOutput(
         | 
| 329 | 
            +
                            success=False,
         | 
| 330 | 
            +
                            command=command,
         | 
| 331 | 
            +
                            error="Another command is currently awaiting confirmation",
         | 
| 43 332 | 
             
                        )
         | 
| 44 | 
            -
             | 
| 45 | 
            -
             | 
| 46 | 
            -
             | 
| 47 | 
            -
             | 
| 48 | 
            -
                        })
         | 
| 49 | 
            -
             | 
| 333 | 
            +
             | 
| 334 | 
            +
                    command_displayed = True
         | 
| 335 | 
            +
             | 
| 336 | 
            +
                    if cwd:
         | 
| 337 | 
            +
                        console.print(f"[dim] Working directory: {cwd} [/dim]")
         | 
| 338 | 
            +
                    time.sleep(0.2)
         | 
| 339 | 
            +
                    sys.stdout.write("Are you sure you want to run this command? (y(es)/n(o))\n")
         | 
| 340 | 
            +
                    sys.stdout.flush()
         | 
| 341 | 
            +
             | 
| 342 | 
            +
                    try:
         | 
| 343 | 
            +
                        user_input = input()
         | 
| 344 | 
            +
                        confirmed = user_input.strip().lower() in {"yes", "y"}
         | 
| 345 | 
            +
                    except (KeyboardInterrupt, EOFError):
         | 
| 346 | 
            +
                        console.print("\n Cancelled by user")
         | 
| 347 | 
            +
                        confirmed = False
         | 
| 348 | 
            +
                    finally:
         | 
| 349 | 
            +
                        if confirmation_lock_acquired:
         | 
| 350 | 
            +
                            _CONFIRMATION_LOCK.release()
         | 
| 351 | 
            +
             | 
| 352 | 
            +
                    if not confirmed:
         | 
| 353 | 
            +
                        result = ShellCommandOutput(
         | 
| 354 | 
            +
                            success=False, command=command, error="User rejected the command!"
         | 
| 355 | 
            +
                        )
         | 
| 356 | 
            +
                        return result
         | 
| 357 | 
            +
                else:
         | 
| 50 358 | 
             
                    start_time = time.time()
         | 
| 359 | 
            +
                try:
         | 
| 360 | 
            +
                    creationflags = 0
         | 
| 361 | 
            +
                    preexec_fn = None
         | 
| 362 | 
            +
                    if sys.platform.startswith("win"):
         | 
| 363 | 
            +
                        try:
         | 
| 364 | 
            +
                            creationflags = subprocess.CREATE_NEW_PROCESS_GROUP  # type: ignore[attr-defined]
         | 
| 365 | 
            +
                        except Exception:
         | 
| 366 | 
            +
                            creationflags = 0
         | 
| 367 | 
            +
                    else:
         | 
| 368 | 
            +
                        preexec_fn = os.setsid if hasattr(os, "setsid") else None
         | 
| 51 369 | 
             
                    process = subprocess.Popen(
         | 
| 52 370 | 
             
                        command,
         | 
| 53 371 | 
             
                        shell=True,
         | 
| @@ -55,112 +373,33 @@ def run_shell_command( | |
| 55 373 | 
             
                        stderr=subprocess.PIPE,
         | 
| 56 374 | 
             
                        text=True,
         | 
| 57 375 | 
             
                        cwd=cwd,
         | 
| 376 | 
            +
                        bufsize=1,
         | 
| 377 | 
            +
                        universal_newlines=True,
         | 
| 378 | 
            +
                        preexec_fn=preexec_fn,
         | 
| 379 | 
            +
                        creationflags=creationflags,
         | 
| 58 380 | 
             
                    )
         | 
| 381 | 
            +
                    _register_process(process)
         | 
| 59 382 | 
             
                    try:
         | 
| 60 | 
            -
                         | 
| 61 | 
            -
             | 
| 62 | 
            -
                         | 
| 63 | 
            -
                         | 
| 64 | 
            -
                            console.print("[bold white]STDOUT:[/bold white]")
         | 
| 65 | 
            -
                            console.print(
         | 
| 66 | 
            -
                                Syntax(
         | 
| 67 | 
            -
                                    stdout.strip(),
         | 
| 68 | 
            -
                                    "bash",
         | 
| 69 | 
            -
                                    theme="monokai",
         | 
| 70 | 
            -
                                    background_color="default",
         | 
| 71 | 
            -
                                )
         | 
| 72 | 
            -
                            )
         | 
| 73 | 
            -
                        else:
         | 
| 74 | 
            -
                            console.print("[yellow]No STDOUT output[/yellow]")
         | 
| 75 | 
            -
                        if stderr.strip():
         | 
| 76 | 
            -
                            console.print("[bold yellow]STDERR:[/bold yellow]")
         | 
| 77 | 
            -
                            console.print(
         | 
| 78 | 
            -
                                Syntax(
         | 
| 79 | 
            -
                                    stderr.strip(),
         | 
| 80 | 
            -
                                    "bash",
         | 
| 81 | 
            -
                                    theme="monokai",
         | 
| 82 | 
            -
                                    background_color="default",
         | 
| 83 | 
            -
                                )
         | 
| 84 | 
            -
                            )
         | 
| 85 | 
            -
                        if exit_code == 0:
         | 
| 86 | 
            -
                            console.print(
         | 
| 87 | 
            -
                                f"[bold green]✓ Command completed successfully[/bold green] [dim](took {execution_time:.2f}s)[/dim]"
         | 
| 88 | 
            -
                            )
         | 
| 89 | 
            -
                        else:
         | 
| 90 | 
            -
                            console.print(
         | 
| 91 | 
            -
                                f"[bold red]✗ Command failed with exit code {exit_code}[/bold red] [dim](took {execution_time:.2f}s)[/dim]"
         | 
| 92 | 
            -
                            )
         | 
| 93 | 
            -
                        if not stdout.strip() and not stderr.strip():
         | 
| 94 | 
            -
                            console.print(
         | 
| 95 | 
            -
                                "[bold yellow]This command produced no output at all![/bold yellow]"
         | 
| 96 | 
            -
                            )
         | 
| 97 | 
            -
                        console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 98 | 
            -
                        return ShellCommandOutput(**{
         | 
| 99 | 
            -
                            "success": exit_code == 0,
         | 
| 100 | 
            -
                            "command": command,
         | 
| 101 | 
            -
                            "stdout": stdout,
         | 
| 102 | 
            -
                            "stderr": stderr,
         | 
| 103 | 
            -
                            "exit_code": exit_code,
         | 
| 104 | 
            -
                            "execution_time": execution_time,
         | 
| 105 | 
            -
                            "timeout": False,
         | 
| 106 | 
            -
                        })
         | 
| 107 | 
            -
                    except subprocess.TimeoutExpired:
         | 
| 108 | 
            -
                        process.kill()
         | 
| 109 | 
            -
                        stdout, stderr = process.communicate()
         | 
| 110 | 
            -
                        execution_time = time.time() - start_time
         | 
| 111 | 
            -
                        if stdout.strip():
         | 
| 112 | 
            -
                            console.print(
         | 
| 113 | 
            -
                                "[bold white]STDOUT (incomplete due to timeout):[/bold white]"
         | 
| 114 | 
            -
                            )
         | 
| 115 | 
            -
                            console.print(
         | 
| 116 | 
            -
                                Syntax(
         | 
| 117 | 
            -
                                    stdout.strip(),
         | 
| 118 | 
            -
                                    "bash",
         | 
| 119 | 
            -
                                    theme="monokai",
         | 
| 120 | 
            -
                                    background_color="default",
         | 
| 121 | 
            -
                                )
         | 
| 122 | 
            -
                            )
         | 
| 123 | 
            -
                        if stderr.strip():
         | 
| 124 | 
            -
                            console.print("[bold yellow]STDERR:[/bold yellow]")
         | 
| 125 | 
            -
                            console.print(
         | 
| 126 | 
            -
                                Syntax(
         | 
| 127 | 
            -
                                    stderr.strip(),
         | 
| 128 | 
            -
                                    "bash",
         | 
| 129 | 
            -
                                    theme="monokai",
         | 
| 130 | 
            -
                                    background_color="default",
         | 
| 131 | 
            -
                                )
         | 
| 132 | 
            -
                            )
         | 
| 133 | 
            -
                        console.print(
         | 
| 134 | 
            -
                            f"[bold red]⏱ Command timed out after {timeout} seconds[/bold red] [dim](ran for {execution_time:.2f}s)[/dim]"
         | 
| 135 | 
            -
                        )
         | 
| 136 | 
            -
                        console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 137 | 
            -
                        return ShellCommandOutput(**{
         | 
| 138 | 
            -
                            "success": False,
         | 
| 139 | 
            -
                            "command": command,
         | 
| 140 | 
            -
                            "stdout": stdout[-1000:],
         | 
| 141 | 
            -
                            "stderr": stderr[-1000:],
         | 
| 142 | 
            -
                            "exit_code": None,
         | 
| 143 | 
            -
                            "execution_time": execution_time,
         | 
| 144 | 
            -
                            "timeout": True,
         | 
| 145 | 
            -
                            "error": f"Command timed out after {timeout} seconds",
         | 
| 146 | 
            -
                        })
         | 
| 383 | 
            +
                        return run_shell_command_streaming(process, timeout=timeout, command=command)
         | 
| 384 | 
            +
                    finally:
         | 
| 385 | 
            +
                        # Ensure unregistration in case streaming returned early or raised
         | 
| 386 | 
            +
                        _unregister_process(process)
         | 
| 147 387 | 
             
                except Exception as e:
         | 
| 148 | 
            -
                    console. | 
| 149 | 
            -
                    console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 150 | 
            -
                    # Ensure stdout and stderr are always defined
         | 
| 388 | 
            +
                    console.print(traceback.format_exc())
         | 
| 151 389 | 
             
                    if "stdout" not in locals():
         | 
| 152 390 | 
             
                        stdout = None
         | 
| 153 391 | 
             
                    if "stderr" not in locals():
         | 
| 154 392 | 
             
                        stderr = None
         | 
| 155 | 
            -
                    return ShellCommandOutput( | 
| 156 | 
            -
                         | 
| 157 | 
            -
                         | 
| 158 | 
            -
                         | 
| 159 | 
            -
                         | 
| 160 | 
            -
                         | 
| 161 | 
            -
                         | 
| 162 | 
            -
                         | 
| 163 | 
            -
                     | 
| 393 | 
            +
                    return ShellCommandOutput(
         | 
| 394 | 
            +
                        success=False,
         | 
| 395 | 
            +
                        command=command,
         | 
| 396 | 
            +
                        error=f"Error executing command {str(e)}",
         | 
| 397 | 
            +
                        stdout="\n".join(stdout[-1000:]) if stdout else None,
         | 
| 398 | 
            +
                        stderr="\n".join(stderr[-1000:]) if stderr else None,
         | 
| 399 | 
            +
                        exit_code=-1,
         | 
| 400 | 
            +
                        timeout=False,
         | 
| 401 | 
            +
                    )
         | 
| 402 | 
            +
             | 
| 164 403 |  | 
| 165 404 | 
             
            class ReasoningOutput(BaseModel):
         | 
| 166 405 | 
             
                success: bool = True
         | 
| @@ -378,7 +378,9 @@ def register_file_modifications_tools(agent): | |
| 378 378 | 
             
                """Attach file-editing tools to *agent* with mandatory diff rendering."""
         | 
| 379 379 |  | 
| 380 380 | 
             
                @agent.tool(retries=5)
         | 
| 381 | 
            -
                def edit_file( | 
| 381 | 
            +
                def edit_file(
         | 
| 382 | 
            +
                    context: RunContext, path: str = "", diff: str = ""
         | 
| 383 | 
            +
                ) -> EditFileOutput:
         | 
| 382 384 | 
             
                    return EditFileOutput(**_edit_file(context, path, diff))
         | 
| 383 385 |  | 
| 384 386 | 
             
                @agent.tool(retries=5)
         | 
| @@ -1,9 +1,9 @@ | |
| 1 1 | 
             
            # file_operations.py
         | 
| 2 2 |  | 
| 3 3 | 
             
            import os
         | 
| 4 | 
            -
            from typing import  | 
| 4 | 
            +
            from typing import List
         | 
| 5 5 |  | 
| 6 | 
            -
            from pydantic import BaseModel | 
| 6 | 
            +
            from pydantic import BaseModel
         | 
| 7 7 | 
             
            from pydantic_ai import RunContext
         | 
| 8 8 |  | 
| 9 9 | 
             
            from code_puppy.tools.common import console
         | 
| @@ -41,11 +41,15 @@ def _list_files( | |
| 41 41 | 
             
                        f"[bold red]Error:[/bold red] Directory '{directory}' does not exist"
         | 
| 42 42 | 
             
                    )
         | 
| 43 43 | 
             
                    console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 44 | 
            -
                    return ListFileOutput( | 
| 44 | 
            +
                    return ListFileOutput(
         | 
| 45 | 
            +
                        files=[ListedFile(path=None, type=None, full_path=None, depth=None)]
         | 
| 46 | 
            +
                    )
         | 
| 45 47 | 
             
                if not os.path.isdir(directory):
         | 
| 46 48 | 
             
                    console.print(f"[bold red]Error:[/bold red] '{directory}' is not a directory")
         | 
| 47 49 | 
             
                    console.print("[dim]" + "-" * 60 + "[/dim]\n")
         | 
| 48 | 
            -
                    return ListFileOutput( | 
| 50 | 
            +
                    return ListFileOutput(
         | 
| 51 | 
            +
                        files=[ListedFile(path=None, type=None, full_path=None, depth=None)]
         | 
| 52 | 
            +
                    )
         | 
| 49 53 | 
             
                folder_structure = {}
         | 
| 50 54 | 
             
                file_list = []
         | 
| 51 55 | 
             
                for root, dirs, files in os.walk(directory):
         | 
| @@ -57,13 +61,15 @@ def _list_files( | |
| 57 61 | 
             
                    if rel_path:
         | 
| 58 62 | 
             
                        dir_path = os.path.join(directory, rel_path)
         | 
| 59 63 | 
             
                        results.append(
         | 
| 60 | 
            -
                            ListedFile( | 
| 61 | 
            -
                                 | 
| 62 | 
            -
             | 
| 63 | 
            -
             | 
| 64 | 
            -
             | 
| 65 | 
            -
             | 
| 66 | 
            -
             | 
| 64 | 
            +
                            ListedFile(
         | 
| 65 | 
            +
                                **{
         | 
| 66 | 
            +
                                    "path": rel_path,
         | 
| 67 | 
            +
                                    "type": "directory",
         | 
| 68 | 
            +
                                    "size": 0,
         | 
| 69 | 
            +
                                    "full_path": dir_path,
         | 
| 70 | 
            +
                                    "depth": depth,
         | 
| 71 | 
            +
                                }
         | 
| 72 | 
            +
                            )
         | 
| 67 73 | 
             
                        )
         | 
| 68 74 | 
             
                        folder_structure[rel_path] = {
         | 
| 69 75 | 
             
                            "path": rel_path,
         | 
| @@ -131,9 +137,7 @@ def _list_files( | |
| 131 137 | 
             
                        return "\U0001f4c4"
         | 
| 132 138 |  | 
| 133 139 | 
             
                if results:
         | 
| 134 | 
            -
                    files = sorted(
         | 
| 135 | 
            -
                        [f for f in results if f.type == "file"], key=lambda x: x.path
         | 
| 136 | 
            -
                    )
         | 
| 140 | 
            +
                    files = sorted([f for f in results if f.type == "file"], key=lambda x: x.path)
         | 
| 137 141 | 
             
                    console.print(
         | 
| 138 142 | 
             
                        f"\U0001f4c1 [bold blue]{os.path.basename(directory) or directory}[/bold blue]"
         | 
| 139 143 | 
             
                    )
         | 
| @@ -177,6 +181,7 @@ def _list_files( | |
| 177 181 | 
             
            class ReadFileOutput(BaseModel):
         | 
| 178 182 | 
             
                content: str | None
         | 
| 179 183 |  | 
| 184 | 
            +
             | 
| 180 185 | 
             
            def _read_file(context: RunContext, file_path: str) -> ReadFileOutput:
         | 
| 181 186 | 
             
                file_path = os.path.abspath(file_path)
         | 
| 182 187 | 
             
                console.print(
         | 
| @@ -191,7 +196,7 @@ def _read_file(context: RunContext, file_path: str) -> ReadFileOutput: | |
| 191 196 | 
             
                    with open(file_path, "r", encoding="utf-8") as f:
         | 
| 192 197 | 
             
                        content = f.read()
         | 
| 193 198 | 
             
                    return ReadFileOutput(content=content)
         | 
| 194 | 
            -
                except Exception | 
| 199 | 
            +
                except Exception:
         | 
| 195 200 | 
             
                    return ReadFileOutput(content="FILE NOT FOUND")
         | 
| 196 201 |  | 
| 197 202 |  | 
| @@ -200,12 +205,12 @@ class MatchInfo(BaseModel): | |
| 200 205 | 
             
                line_number: int | None
         | 
| 201 206 | 
             
                line_content: str | None
         | 
| 202 207 |  | 
| 208 | 
            +
             | 
| 203 209 | 
             
            class GrepOutput(BaseModel):
         | 
| 204 210 | 
             
                matches: List[MatchInfo]
         | 
| 205 211 |  | 
| 206 | 
            -
             | 
| 207 | 
            -
             | 
| 208 | 
            -
            ) -> GrepOutput:
         | 
| 212 | 
            +
             | 
| 213 | 
            +
            def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
         | 
| 209 214 | 
             
                matches: List[MatchInfo] = []
         | 
| 210 215 | 
             
                directory = os.path.abspath(directory)
         | 
| 211 216 | 
             
                console.print(
         | 
| @@ -229,11 +234,13 @@ def _grep( | |
| 229 234 | 
             
                            with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
         | 
| 230 235 | 
             
                                for line_number, line_content in enumerate(fh, 1):
         | 
| 231 236 | 
             
                                    if search_string in line_content:
         | 
| 232 | 
            -
                                        match_info = MatchInfo( | 
| 233 | 
            -
                                             | 
| 234 | 
            -
             | 
| 235 | 
            -
             | 
| 236 | 
            -
             | 
| 237 | 
            +
                                        match_info = MatchInfo(
         | 
| 238 | 
            +
                                            **{
         | 
| 239 | 
            +
                                                "file_path": file_path,
         | 
| 240 | 
            +
                                                "line_number": line_number,
         | 
| 241 | 
            +
                                                "line_content": line_content.strip(),
         | 
| 242 | 
            +
                                            }
         | 
| 243 | 
            +
                                        )
         | 
| 237 244 | 
             
                                        matches.append(match_info)
         | 
| 238 245 | 
             
                                        # console.print(
         | 
| 239 246 | 
             
                                        #     f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}"
         | 
| @@ -0,0 +1,30 @@ | |
| 1 | 
            +
            code_puppy/__init__.py,sha256=CWH46ZAmJRmHAbOiAhG07OrWYEcEt4yvDTkZU341Wag,169
         | 
| 2 | 
            +
            code_puppy/agent.py,sha256=7_1FpGPnw8U632OXP0hLmFIozfVvllF491q8gCpaa8c,3284
         | 
| 3 | 
            +
            code_puppy/agent_prompts.py,sha256=wTah_TvakCMhkb_KwuWCsw4_UR1QsjTZeOT1I8at_nc,6593
         | 
| 4 | 
            +
            code_puppy/config.py,sha256=r5nw5ChOP8xd_K5yo8U5OtO2gy2bFhARiyNtDp1JrwQ,5013
         | 
| 5 | 
            +
            code_puppy/main.py,sha256=qtBokZZfPQRGF91KNl_n_ywutONwF2f-zTwmt_ROsEU,11080
         | 
| 6 | 
            +
            code_puppy/message_history_processor.py,sha256=jpoxGE6emBX3MAkeeezPZ4QEOzONRhbN6mZe_efG9vI,9248
         | 
| 7 | 
            +
            code_puppy/model_factory.py,sha256=HXuFHNkVjkCcorAd3ScFmSvBILO932UTq6OmNAqisT8,10898
         | 
| 8 | 
            +
            code_puppy/models.json,sha256=jr0-LW87aJS79GosVwoZdHeeq5eflPzgdPoMbcqpVA8,2728
         | 
| 9 | 
            +
            code_puppy/state_management.py,sha256=JkTkmq6f9rl_RHPDoBqJvbAzgaMsIkJf-k38ragItIo,1692
         | 
| 10 | 
            +
            code_puppy/summarization_agent.py,sha256=jHUQe6iYJsMT0ywEwO7CrhUIKEamO5imhAsDwvNuvow,2684
         | 
| 11 | 
            +
            code_puppy/version_checker.py,sha256=aRGulzuY4C4CdFvU1rITduyL-1xTFsn4GiD1uSfOl_Y,396
         | 
| 12 | 
            +
            code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZv_YRmU,45
         | 
| 13 | 
            +
            code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
         | 
| 14 | 
            +
            code_puppy/command_line/meta_command_handler.py,sha256=L7qP2g0Faz0V7bMH4YK3s03OWWuQFtK7Sh-Kt2zmmEQ,6182
         | 
| 15 | 
            +
            code_puppy/command_line/model_picker_completion.py,sha256=NkyZZG7IhcVWSJ3ADytwCA5f8DpNeVs759Qtqs4fQtY,3733
         | 
| 16 | 
            +
            code_puppy/command_line/motd.py,sha256=FoZsiVpXGF8WpAmEJX4O895W7MDuzCtNWvFAOShxUXY,1572
         | 
| 17 | 
            +
            code_puppy/command_line/prompt_toolkit_completion.py,sha256=_gP0FIOgHDNHTTWLNL0XNzr6sO0ISe7Mec1uQNo9kcM,8337
         | 
| 18 | 
            +
            code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
         | 
| 19 | 
            +
            code_puppy/tools/__init__.py,sha256=WTHYIfRk2KMmk6o45TELpbB3GIiAm8s7GmfJ7Zy_tww,503
         | 
| 20 | 
            +
            code_puppy/tools/command_runner.py,sha256=9UWCSPpuEndaPx8Ecc8TRsn3rMHNd2AqerirvYPGRIw,14358
         | 
| 21 | 
            +
            code_puppy/tools/common.py,sha256=M53zhiXZAmPdvi1Y_bzCxgvEmifOvRRJvYPARYRZqHw,2253
         | 
| 22 | 
            +
            code_puppy/tools/file_modifications.py,sha256=BzQrGEacS2NZr2ru9N30x_Qd70JDudBKOAPO1XjBohg,13861
         | 
| 23 | 
            +
            code_puppy/tools/file_operations.py,sha256=ypk4yL90LDSVRr0xyWafttzt956J_nXhhenCXhOOit8,11326
         | 
| 24 | 
            +
            code_puppy/tools/ts_code_map.py,sha256=o-u8p5vsYwitfDtVEoPS-7MwWn2xHzwtIQLo1_WMhQs,17647
         | 
| 25 | 
            +
            code_puppy-0.0.86.data/data/code_puppy/models.json,sha256=jr0-LW87aJS79GosVwoZdHeeq5eflPzgdPoMbcqpVA8,2728
         | 
| 26 | 
            +
            code_puppy-0.0.86.dist-info/METADATA,sha256=MhZ1w27mLtvfIsjEztFH9BuAGabfD_uCS4ZQQnDVSwg,6351
         | 
| 27 | 
            +
            code_puppy-0.0.86.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
         | 
| 28 | 
            +
            code_puppy-0.0.86.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
         | 
| 29 | 
            +
            code_puppy-0.0.86.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
         | 
| 30 | 
            +
            code_puppy-0.0.86.dist-info/RECORD,,
         | 
| @@ -1,30 +0,0 @@ | |
| 1 | 
            -
            code_puppy/__init__.py,sha256=oDE4GhaqOHsYi9XCGp6A2-PqhDqxJiYP_XmxmoKWoPU,168
         | 
| 2 | 
            -
            code_puppy/agent.py,sha256=7On9mo4RWRaoINJr4Rv4c7Ot751FdQfSnTg0grrxzp8,3283
         | 
| 3 | 
            -
            code_puppy/agent_prompts.py,sha256=13YIpTZa3R3lg60-fdkll7t7hgSBtQL0M53wcE1gzyQ,6834
         | 
| 4 | 
            -
            code_puppy/config.py,sha256=r5nw5ChOP8xd_K5yo8U5OtO2gy2bFhARiyNtDp1JrwQ,5013
         | 
| 5 | 
            -
            code_puppy/main.py,sha256=WL1EGw86u_yQJDrkvnZbabldRKcYgFMuWQPQDz8zUgY,10428
         | 
| 6 | 
            -
            code_puppy/message_history_processor.py,sha256=QOAQqxOJ2MSe6sTnZ6F4PMBqrtmV8RBQ0LmwQKh-o1A,6158
         | 
| 7 | 
            -
            code_puppy/model_factory.py,sha256=3j7AcJfZAHbx_plL9oOxjGJO0MMTRaQFThCErg8VpH8,10909
         | 
| 8 | 
            -
            code_puppy/models.json,sha256=jr0-LW87aJS79GosVwoZdHeeq5eflPzgdPoMbcqpVA8,2728
         | 
| 9 | 
            -
            code_puppy/state_management.py,sha256=1QycApDBbXjayxXsYRecJib8TQ-MYMTeYvN5P_1Ipdg,1747
         | 
| 10 | 
            -
            code_puppy/summarization_agent.py,sha256=N1UZg_R3wJFb7ZdVexDqx7L_8yxQ5m5nMOwGsLNfvKM,2744
         | 
| 11 | 
            -
            code_puppy/version_checker.py,sha256=aRGulzuY4C4CdFvU1rITduyL-1xTFsn4GiD1uSfOl_Y,396
         | 
| 12 | 
            -
            code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZv_YRmU,45
         | 
| 13 | 
            -
            code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
         | 
| 14 | 
            -
            code_puppy/command_line/meta_command_handler.py,sha256=L7qP2g0Faz0V7bMH4YK3s03OWWuQFtK7Sh-Kt2zmmEQ,6182
         | 
| 15 | 
            -
            code_puppy/command_line/model_picker_completion.py,sha256=NkyZZG7IhcVWSJ3ADytwCA5f8DpNeVs759Qtqs4fQtY,3733
         | 
| 16 | 
            -
            code_puppy/command_line/motd.py,sha256=7ICNgfL4EgSrmCAHIsCK72R19obSQXkK8l7XGJBkvrQ,1571
         | 
| 17 | 
            -
            code_puppy/command_line/prompt_toolkit_completion.py,sha256=_gP0FIOgHDNHTTWLNL0XNzr6sO0ISe7Mec1uQNo9kcM,8337
         | 
| 18 | 
            -
            code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
         | 
| 19 | 
            -
            code_puppy/tools/__init__.py,sha256=ozIGpLM7pKSjH4UeojkTodhfVYZeNzMsLtK_oyw41HA,456
         | 
| 20 | 
            -
            code_puppy/tools/command_runner.py,sha256=NFCL35x44McMzSUNHQyg5q4Zx7wkvqD-nH4_YAU8N2s,7229
         | 
| 21 | 
            -
            code_puppy/tools/common.py,sha256=M53zhiXZAmPdvi1Y_bzCxgvEmifOvRRJvYPARYRZqHw,2253
         | 
| 22 | 
            -
            code_puppy/tools/file_modifications.py,sha256=nGI8gRD6Vtkg8EzBkErsv3khE3VI-_M1z_PdQLvjfLo,13847
         | 
| 23 | 
            -
            code_puppy/tools/file_operations.py,sha256=eftkN-MxsRGQc8c1iIoNmN5r-Ppld5YJRT7a89kxpkM,11207
         | 
| 24 | 
            -
            code_puppy/tools/ts_code_map.py,sha256=o-u8p5vsYwitfDtVEoPS-7MwWn2xHzwtIQLo1_WMhQs,17647
         | 
| 25 | 
            -
            code_puppy-0.0.84.data/data/code_puppy/models.json,sha256=jr0-LW87aJS79GosVwoZdHeeq5eflPzgdPoMbcqpVA8,2728
         | 
| 26 | 
            -
            code_puppy-0.0.84.dist-info/METADATA,sha256=NWnTj01yQNciyZLXZDYgWbU8St84wCea4wE4fLn27lE,6351
         | 
| 27 | 
            -
            code_puppy-0.0.84.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
         | 
| 28 | 
            -
            code_puppy-0.0.84.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
         | 
| 29 | 
            -
            code_puppy-0.0.84.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
         | 
| 30 | 
            -
            code_puppy-0.0.84.dist-info/RECORD,,
         | 
| 
            File without changes
         | 
| 
            File without changes
         | 
| 
            File without changes
         | 
| 
            File without changes
         |