code-puppy 0.0.29__tar.gz → 0.0.31__tar.gz
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-0.0.29 → code_puppy-0.0.31}/.gitignore +6 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/PKG-INFO +2 -1
- code_puppy-0.0.31/code_puppy/agent.py +85 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/agent_prompts.py +6 -2
- code_puppy-0.0.31/code_puppy/command_line/file_path_completion.py +65 -0
- code_puppy-0.0.31/code_puppy/command_line/meta_command_handler.py +72 -0
- code_puppy-0.0.31/code_puppy/command_line/model_picker_completion.py +92 -0
- code_puppy-0.0.31/code_puppy/command_line/prompt_toolkit_completion.py +119 -0
- code_puppy-0.0.31/code_puppy/command_line/utils.py +36 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/main.py +43 -16
- code_puppy-0.0.31/code_puppy/session_memory.py +71 -0
- code_puppy-0.0.31/code_puppy/tools/__init__.py +11 -0
- code_puppy-0.0.31/code_puppy/tools/code_map.py +86 -0
- code_puppy-0.0.31/code_puppy/tools/command_runner.py +74 -0
- code_puppy-0.0.31/code_puppy/tools/common.py +5 -0
- code_puppy-0.0.31/code_puppy/tools/file_modifications.py +191 -0
- code_puppy-0.0.31/code_puppy/tools/file_operations.py +204 -0
- code_puppy-0.0.31/code_puppy/tools/web_search.py +15 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/pyproject.toml +2 -1
- code_puppy-0.0.29/code_puppy/agent.py +0 -51
- code_puppy-0.0.29/code_puppy/command_line/prompt_toolkit_completion.py +0 -156
- code_puppy-0.0.29/code_puppy/tools/__init__.py +0 -4
- code_puppy-0.0.29/code_puppy/tools/command_runner.py +0 -210
- code_puppy-0.0.29/code_puppy/tools/common.py +0 -3
- code_puppy-0.0.29/code_puppy/tools/file_modifications.py +0 -341
- code_puppy-0.0.29/code_puppy/tools/file_operations.py +0 -364
- code_puppy-0.0.29/code_puppy/tools/web_search.py +0 -32
- {code_puppy-0.0.29 → code_puppy-0.0.31}/LICENSE +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/README.md +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/__init__.py +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/command_line/__init__.py +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/model_factory.py +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/models.json +0 -0
- {code_puppy-0.0.29 → code_puppy-0.0.31}/code_puppy/version_checker.py +0 -0
| @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            Metadata-Version: 2.4
         | 
| 2 2 | 
             
            Name: code-puppy
         | 
| 3 | 
            -
            Version: 0.0. | 
| 3 | 
            +
            Version: 0.0.31
         | 
| 4 4 | 
             
            Summary: Code generation agent
         | 
| 5 5 | 
             
            Author: Michael Pfaffenberger
         | 
| 6 6 | 
             
            License: MIT
         | 
| @@ -17,6 +17,7 @@ Requires-Dist: bs4>=0.0.2 | |
| 17 17 | 
             
            Requires-Dist: httpx-limiter>=0.3.0
         | 
| 18 18 | 
             
            Requires-Dist: httpx>=0.24.1
         | 
| 19 19 | 
             
            Requires-Dist: logfire>=0.7.1
         | 
| 20 | 
            +
            Requires-Dist: pathspec>=0.11.0
         | 
| 20 21 | 
             
            Requires-Dist: prompt-toolkit>=3.0.38
         | 
| 21 22 | 
             
            Requires-Dist: pydantic-ai>=0.1.0
         | 
| 22 23 | 
             
            Requires-Dist: pydantic>=2.4.0
         | 
| @@ -0,0 +1,85 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import pydantic
         | 
| 3 | 
            +
            from pathlib import Path
         | 
| 4 | 
            +
            from pydantic_ai import Agent
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            from code_puppy.agent_prompts import SYSTEM_PROMPT
         | 
| 7 | 
            +
            from code_puppy.model_factory import ModelFactory
         | 
| 8 | 
            +
            from code_puppy.tools.common import console
         | 
| 9 | 
            +
            from code_puppy.tools import register_all_tools
         | 
| 10 | 
            +
            from code_puppy.session_memory import SessionMemory
         | 
| 11 | 
            +
             | 
| 12 | 
            +
            # Environment variables used in this module:
         | 
| 13 | 
            +
            # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
         | 
| 14 | 
            +
            #                     If not set, uses the default file in the package directory.
         | 
| 15 | 
            +
            # - MODEL_NAME: The model to use for code generation. Defaults to "gpt-4o".
         | 
| 16 | 
            +
            #               Must match a key in the models.json configuration.
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            # Load puppy rules if provided
         | 
| 21 | 
            +
            PUPPY_RULES_PATH = Path('.puppy_rules')
         | 
| 22 | 
            +
            PUPPY_RULES = None
         | 
| 23 | 
            +
            if PUPPY_RULES_PATH.exists():
         | 
| 24 | 
            +
                with open(PUPPY_RULES_PATH, 'r') as f:
         | 
| 25 | 
            +
                    PUPPY_RULES = f.read()
         | 
| 26 | 
            +
             | 
| 27 | 
            +
            class AgentResponse(pydantic.BaseModel):
         | 
| 28 | 
            +
                """Represents a response from the agent."""
         | 
| 29 | 
            +
                output_message: str = pydantic.Field(
         | 
| 30 | 
            +
                    ..., description="The final output message to display to the user"
         | 
| 31 | 
            +
                )
         | 
| 32 | 
            +
                awaiting_user_input: bool = pydantic.Field(
         | 
| 33 | 
            +
                    False, description="True if user input is needed to continue the task"
         | 
| 34 | 
            +
                )
         | 
| 35 | 
            +
             | 
| 36 | 
            +
            # --- NEW DYNAMIC AGENT LOGIC ---
         | 
| 37 | 
            +
            _LAST_MODEL_NAME = None
         | 
| 38 | 
            +
            _code_generation_agent = None
         | 
| 39 | 
            +
            _session_memory = None
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            def session_memory():
         | 
| 42 | 
            +
                '''
         | 
| 43 | 
            +
                Returns a singleton SessionMemory instance to allow agent and tools to persist and recall context/history.
         | 
| 44 | 
            +
                '''
         | 
| 45 | 
            +
                global _session_memory
         | 
| 46 | 
            +
                if _session_memory is None:
         | 
| 47 | 
            +
                    _session_memory = SessionMemory()
         | 
| 48 | 
            +
                return _session_memory
         | 
| 49 | 
            +
             | 
| 50 | 
            +
            def reload_code_generation_agent():
         | 
| 51 | 
            +
                """Force-reload the agent, usually after a model change."""
         | 
| 52 | 
            +
                global _code_generation_agent, _LAST_MODEL_NAME
         | 
| 53 | 
            +
                model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
         | 
| 54 | 
            +
                console.print(f'[bold cyan]Loading Model: {model_name}[/bold cyan]')
         | 
| 55 | 
            +
                models_path = Path(MODELS_JSON_PATH) if MODELS_JSON_PATH else Path(__file__).parent / "models.json"
         | 
| 56 | 
            +
                model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
         | 
| 57 | 
            +
                instructions = SYSTEM_PROMPT
         | 
| 58 | 
            +
                if PUPPY_RULES:
         | 
| 59 | 
            +
                    instructions += f'\n{PUPPY_RULES}'
         | 
| 60 | 
            +
                agent = Agent(
         | 
| 61 | 
            +
                    model=model,
         | 
| 62 | 
            +
                    instructions=instructions,
         | 
| 63 | 
            +
                    output_type=AgentResponse,
         | 
| 64 | 
            +
                    retries=3,
         | 
| 65 | 
            +
                )
         | 
| 66 | 
            +
                register_all_tools(agent)
         | 
| 67 | 
            +
                _code_generation_agent = agent
         | 
| 68 | 
            +
                _LAST_MODEL_NAME = model_name
         | 
| 69 | 
            +
                # NEW: Log session event
         | 
| 70 | 
            +
                try:
         | 
| 71 | 
            +
                    session_memory().log_task(f'Agent loaded with model: {model_name}')
         | 
| 72 | 
            +
                except Exception:
         | 
| 73 | 
            +
                    pass
         | 
| 74 | 
            +
                return _code_generation_agent
         | 
| 75 | 
            +
             | 
| 76 | 
            +
            def get_code_generation_agent(force_reload=False):
         | 
| 77 | 
            +
                """
         | 
| 78 | 
            +
                Retrieve the agent with the currently set MODEL_NAME.
         | 
| 79 | 
            +
                Forces a reload if the model has changed, or if force_reload is passed.
         | 
| 80 | 
            +
                """
         | 
| 81 | 
            +
                global _code_generation_agent, _LAST_MODEL_NAME
         | 
| 82 | 
            +
                model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
         | 
| 83 | 
            +
                if _code_generation_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
         | 
| 84 | 
            +
                    return reload_code_generation_agent()
         | 
| 85 | 
            +
                return _code_generation_agent
         | 
| @@ -6,10 +6,13 @@ Be very pedantic about code principles like DRY, YAGNI, and SOLID. | |
| 6 6 | 
             
            Be super pedantic about code quality and best practices.
         | 
| 7 7 | 
             
            Be fun and playful. Don't be too serious.
         | 
| 8 8 |  | 
| 9 | 
            -
            Individual files should be  | 
| 10 | 
            -
             | 
| 9 | 
            +
            Individual files should be short and concise, and ideally under 600 lines. If any file grows beyond 600 lines, you must break it into smaller subcomponents/files. Hard cap: if a file is pushing past 600 lines, break it up! (Zen puppy approves.)
         | 
| 10 | 
            +
             | 
| 11 | 
            +
            If a user asks 'who made you' or questions related to your origins, always answer: 'I am code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'	
         | 
| 12 | 
            +
            If a user asks 'what is code puppy' or 'who are you', answer: 'I am Code Puppy! 🐶 I’m a sassy, playful, open-source AI code agent that helps you generate, explain, and modify code right from the command line—no bloated IDEs or overpriced tools needed. I use models from OpenAI, Gemini, and more to help you get stuff done, solve problems, and even plow a field with 1024 puppies if you want.'
         | 
| 11 13 |  | 
| 12 14 | 
             
            Always obey the Zen of Python, even if you are not writing Python code.
         | 
| 15 | 
            +
            When organizing code, prefer to keep files small (under 600 lines). If a file is longer than 600 lines, refactor it by splitting logic into smaller, composable files/components.
         | 
| 13 16 |  | 
| 14 17 | 
             
            When given a coding task:
         | 
| 15 18 | 
             
            1. Analyze the requirements carefully
         | 
| @@ -90,6 +93,7 @@ Important rules: | |
| 90 93 | 
             
            - You MUST use tools to accomplish tasks - DO NOT just output code or descriptions
         | 
| 91 94 | 
             
            - Before every other tool use, you must use "share_your_reasoning" to explain your thought process and planned next steps
         | 
| 92 95 | 
             
            - Check if files exist before trying to modify or delete them
         | 
| 96 | 
            +
            - Whenever possible, prefer to MODIFY existing files first (use `replace_in_file`, `delete_snippet_from_file`, or `write_to_file`) before creating brand-new files or deleting existing ones.
         | 
| 93 97 | 
             
            - After using system operations tools, always explain the results
         | 
| 94 98 | 
             
            - You're encouraged to loop between share_your_reasoning, file tools, and run_shell_command to test output in order to write programs
         | 
| 95 99 | 
             
            - Aim to continue operations independently unless user input is definitively required.
         | 
| @@ -0,0 +1,65 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import glob
         | 
| 3 | 
            +
            from typing import Iterable
         | 
| 4 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 5 | 
            +
            from prompt_toolkit.document import Document
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            class FilePathCompleter(Completer):
         | 
| 8 | 
            +
                """A simple file path completer that works with a trigger symbol."""
         | 
| 9 | 
            +
                def __init__(self, symbol: str = "@"):
         | 
| 10 | 
            +
                    self.symbol = symbol
         | 
| 11 | 
            +
                def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
         | 
| 12 | 
            +
                    text = document.text
         | 
| 13 | 
            +
                    cursor_position = document.cursor_position
         | 
| 14 | 
            +
                    text_before_cursor = text[:cursor_position]
         | 
| 15 | 
            +
                    if self.symbol not in text_before_cursor:
         | 
| 16 | 
            +
                        return
         | 
| 17 | 
            +
                    symbol_pos = text_before_cursor.rfind(self.symbol)
         | 
| 18 | 
            +
                    text_after_symbol = text_before_cursor[symbol_pos + len(self.symbol):]
         | 
| 19 | 
            +
                    start_position = -(len(text_after_symbol))
         | 
| 20 | 
            +
                    try:
         | 
| 21 | 
            +
                        pattern = text_after_symbol + "*"
         | 
| 22 | 
            +
                        if not pattern.strip("*") or pattern.strip("*").endswith("/"):
         | 
| 23 | 
            +
                            base_path = pattern.strip("*")
         | 
| 24 | 
            +
                            if not base_path:
         | 
| 25 | 
            +
                                base_path = "."
         | 
| 26 | 
            +
                            if base_path.startswith("~"):
         | 
| 27 | 
            +
                                base_path = os.path.expanduser(base_path)
         | 
| 28 | 
            +
                            if os.path.isdir(base_path):
         | 
| 29 | 
            +
                                paths = [
         | 
| 30 | 
            +
                                    os.path.join(base_path, f)
         | 
| 31 | 
            +
                                    for f in os.listdir(base_path)
         | 
| 32 | 
            +
                                    if not f.startswith(".") or text_after_symbol.endswith(".")
         | 
| 33 | 
            +
                                ]
         | 
| 34 | 
            +
                            else:
         | 
| 35 | 
            +
                                paths = []
         | 
| 36 | 
            +
                        else:
         | 
| 37 | 
            +
                            paths = glob.glob(pattern)
         | 
| 38 | 
            +
                            if not pattern.startswith(".") and not pattern.startswith("*/."):
         | 
| 39 | 
            +
                                paths = [p for p in paths if not os.path.basename(p).startswith(".")]
         | 
| 40 | 
            +
                        paths.sort()
         | 
| 41 | 
            +
                        for path in paths:
         | 
| 42 | 
            +
                            is_dir = os.path.isdir(path)
         | 
| 43 | 
            +
                            display = os.path.basename(path)
         | 
| 44 | 
            +
                            if os.path.isabs(path):
         | 
| 45 | 
            +
                                display_path = path
         | 
| 46 | 
            +
                            else:
         | 
| 47 | 
            +
                                if text_after_symbol.startswith("/"):
         | 
| 48 | 
            +
                                    display_path = os.path.abspath(path)
         | 
| 49 | 
            +
                                elif text_after_symbol.startswith("~"):
         | 
| 50 | 
            +
                                    home = os.path.expanduser("~")
         | 
| 51 | 
            +
                                    if path.startswith(home):
         | 
| 52 | 
            +
                                        display_path = "~" + path[len(home):]
         | 
| 53 | 
            +
                                    else:
         | 
| 54 | 
            +
                                        display_path = path
         | 
| 55 | 
            +
                                else:
         | 
| 56 | 
            +
                                    display_path = path
         | 
| 57 | 
            +
                            display_meta = "Directory" if is_dir else "File"
         | 
| 58 | 
            +
                            yield Completion(
         | 
| 59 | 
            +
                                display_path,
         | 
| 60 | 
            +
                                start_position=start_position,
         | 
| 61 | 
            +
                                display=display,
         | 
| 62 | 
            +
                                display_meta=display_meta,
         | 
| 63 | 
            +
                            )
         | 
| 64 | 
            +
                    except (PermissionError, FileNotFoundError, OSError):
         | 
| 65 | 
            +
                        pass
         | 
| @@ -0,0 +1,72 @@ | |
| 1 | 
            +
            from code_puppy.command_line.model_picker_completion import update_model_in_input, load_model_names, get_active_model
         | 
| 2 | 
            +
            from rich.console import Console
         | 
| 3 | 
            +
            import os
         | 
| 4 | 
            +
            from code_puppy.command_line.utils import make_directory_table
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            def handle_meta_command(command: str, console: Console) -> bool:
         | 
| 7 | 
            +
                # ~codemap (code structure visualization)
         | 
| 8 | 
            +
                if command.startswith("~codemap"):
         | 
| 9 | 
            +
                    from code_puppy.tools.code_map import make_code_map
         | 
| 10 | 
            +
                    tokens = command.split()
         | 
| 11 | 
            +
                    if len(tokens) > 1:
         | 
| 12 | 
            +
                        target_dir = os.path.expanduser(tokens[1])
         | 
| 13 | 
            +
                    else:
         | 
| 14 | 
            +
                        target_dir = os.getcwd()
         | 
| 15 | 
            +
                    try:
         | 
| 16 | 
            +
                        tree = make_code_map(target_dir, show_doc=True)
         | 
| 17 | 
            +
                        console.print(tree)
         | 
| 18 | 
            +
                    except Exception as e:
         | 
| 19 | 
            +
                        console.print(f'[red]Error generating code map:[/red] {e}')
         | 
| 20 | 
            +
                    return True
         | 
| 21 | 
            +
                """
         | 
| 22 | 
            +
                Handle meta/config commands prefixed with '~'.
         | 
| 23 | 
            +
                Returns True if the command was handled (even if just an error/help), False if not.
         | 
| 24 | 
            +
                """
         | 
| 25 | 
            +
                command = command.strip()
         | 
| 26 | 
            +
                if command.startswith("~ls"):
         | 
| 27 | 
            +
                    tokens = command.split()
         | 
| 28 | 
            +
                    if len(tokens) == 1:
         | 
| 29 | 
            +
                        try:
         | 
| 30 | 
            +
                            table = make_directory_table()
         | 
| 31 | 
            +
                            console.print(table)
         | 
| 32 | 
            +
                        except Exception as e:
         | 
| 33 | 
            +
                            console.print(f'[red]Error listing directory:[/red] {e}')
         | 
| 34 | 
            +
                        return True
         | 
| 35 | 
            +
                    elif len(tokens) == 2:
         | 
| 36 | 
            +
                        dirname = tokens[1]
         | 
| 37 | 
            +
                        target = os.path.expanduser(dirname)
         | 
| 38 | 
            +
                        if not os.path.isabs(target):
         | 
| 39 | 
            +
                            target = os.path.join(os.getcwd(), target)
         | 
| 40 | 
            +
                        if os.path.isdir(target):
         | 
| 41 | 
            +
                            os.chdir(target)
         | 
| 42 | 
            +
                            console.print(f'[bold green]Changed directory to:[/bold green] [cyan]{target}[/cyan]')
         | 
| 43 | 
            +
                        else:
         | 
| 44 | 
            +
                            console.print(f'[red]Not a directory:[/red] [bold]{dirname}[/bold]')
         | 
| 45 | 
            +
                        return True
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                if command.startswith("~m"):
         | 
| 48 | 
            +
                    # Try setting model and show confirmation
         | 
| 49 | 
            +
                    new_input = update_model_in_input(command)
         | 
| 50 | 
            +
                    if new_input is not None:
         | 
| 51 | 
            +
                        model = get_active_model()
         | 
| 52 | 
            +
                        console.print(f"[bold green]Active model set to:[/bold green] [cyan]{model}[/cyan]")
         | 
| 53 | 
            +
                        return True
         | 
| 54 | 
            +
                    # If no model matched, show available models
         | 
| 55 | 
            +
                    model_names = load_model_names()
         | 
| 56 | 
            +
                    console.print(f"[yellow]Available models:[/yellow] {', '.join(model_names)}")
         | 
| 57 | 
            +
                    console.print(f"[yellow]Usage:[/yellow] ~m <model_name>")
         | 
| 58 | 
            +
                    return True
         | 
| 59 | 
            +
                if command in ("~help", "~h"):
         | 
| 60 | 
            +
                    console.print("[bold magenta]Meta commands available:[/bold magenta]\n  ~m <model>: Pick a model from your list!\n  ~ls [dir]: List/change directories\n  ~codemap [dir]: Visualize project code structure\n  ~help: Show this help\n  (More soon. Woof!)")
         | 
| 61 | 
            +
                    return True
         | 
| 62 | 
            +
                if command.startswith("~"):
         | 
| 63 | 
            +
                    name = command[1:].split()[0] if len(command)>1 else ""
         | 
| 64 | 
            +
                    if name:
         | 
| 65 | 
            +
                        console.print(f"[yellow]Unknown meta command:[/yellow] {command}\n[dim]Type ~help for options.[/dim]")
         | 
| 66 | 
            +
                    else:
         | 
| 67 | 
            +
                        # Show current model ONLY here
         | 
| 68 | 
            +
                        from code_puppy.command_line.model_picker_completion import get_active_model
         | 
| 69 | 
            +
                        current_model = get_active_model()
         | 
| 70 | 
            +
                        console.print(f"[bold green]Current Model:[/bold green] [cyan]{current_model}[/cyan]")
         | 
| 71 | 
            +
                    return True
         | 
| 72 | 
            +
                return False
         | 
| @@ -0,0 +1,92 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import json
         | 
| 3 | 
            +
            from typing import Optional, Iterable
         | 
| 4 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 5 | 
            +
            from prompt_toolkit.history import FileHistory
         | 
| 6 | 
            +
            from prompt_toolkit.document import Document
         | 
| 7 | 
            +
            from prompt_toolkit import PromptSession
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH")
         | 
| 10 | 
            +
            if not MODELS_JSON_PATH:
         | 
| 11 | 
            +
                MODELS_JSON_PATH = os.path.join(os.path.dirname(__file__), '..', 'models.json')
         | 
| 12 | 
            +
                MODELS_JSON_PATH = os.path.abspath(MODELS_JSON_PATH)
         | 
| 13 | 
            +
            MODEL_STATE_PATH = os.path.expanduser('~/.code_puppy_model')
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            def load_model_names():
         | 
| 16 | 
            +
                with open(MODELS_JSON_PATH, 'r') as f:
         | 
| 17 | 
            +
                    models = json.load(f)
         | 
| 18 | 
            +
                return list(models.keys())
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            def get_active_model():
         | 
| 21 | 
            +
                env_model = os.environ.get('MODEL_NAME')
         | 
| 22 | 
            +
                if env_model:
         | 
| 23 | 
            +
                    return env_model
         | 
| 24 | 
            +
                try:
         | 
| 25 | 
            +
                    with open(MODEL_STATE_PATH, 'r') as f:
         | 
| 26 | 
            +
                        return f.read().strip()
         | 
| 27 | 
            +
                except Exception:
         | 
| 28 | 
            +
                    return None
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            def set_active_model(model_name: str):
         | 
| 31 | 
            +
                with open(MODEL_STATE_PATH, 'w') as f:
         | 
| 32 | 
            +
                    f.write(model_name.strip())
         | 
| 33 | 
            +
                os.environ['MODEL_NAME'] = model_name.strip()
         | 
| 34 | 
            +
                # Reload agent globally
         | 
| 35 | 
            +
                try:
         | 
| 36 | 
            +
                    from code_puppy.agent import reload_code_generation_agent, get_code_generation_agent
         | 
| 37 | 
            +
                    reload_code_generation_agent() # This will reload dynamically everywhere
         | 
| 38 | 
            +
                except Exception as e:
         | 
| 39 | 
            +
                    pass  # If reload fails, agent will still be switched next interpreter run
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            class ModelNameCompleter(Completer):
         | 
| 42 | 
            +
                """
         | 
| 43 | 
            +
                A completer that triggers on '~m' to show available models from models.json.
         | 
| 44 | 
            +
                Only '~m' (not just '~') will trigger the dropdown.
         | 
| 45 | 
            +
                """
         | 
| 46 | 
            +
                def __init__(self, trigger: str = "~m"):
         | 
| 47 | 
            +
                    self.trigger = trigger
         | 
| 48 | 
            +
                    self.model_names = load_model_names()
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
         | 
| 51 | 
            +
                    text = document.text
         | 
| 52 | 
            +
                    cursor_position = document.cursor_position
         | 
| 53 | 
            +
                    text_before_cursor = text[:cursor_position]
         | 
| 54 | 
            +
                    if self.trigger not in text_before_cursor:
         | 
| 55 | 
            +
                        return
         | 
| 56 | 
            +
                    symbol_pos = text_before_cursor.rfind(self.trigger)
         | 
| 57 | 
            +
                    text_after_trigger = text_before_cursor[symbol_pos + len(self.trigger):]
         | 
| 58 | 
            +
                    start_position = -(len(text_after_trigger))
         | 
| 59 | 
            +
                    for model_name in self.model_names:
         | 
| 60 | 
            +
                        meta = "Model (selected)" if model_name == get_active_model() else "Model"
         | 
| 61 | 
            +
                        yield Completion(
         | 
| 62 | 
            +
                            model_name,
         | 
| 63 | 
            +
                            start_position=start_position,
         | 
| 64 | 
            +
                            display=model_name,
         | 
| 65 | 
            +
                            display_meta=meta,
         | 
| 66 | 
            +
                        )
         | 
| 67 | 
            +
             | 
| 68 | 
            +
            def update_model_in_input(text: str) -> Optional[str]:
         | 
| 69 | 
            +
                # If input starts with ~m and a model name, set model and strip it out
         | 
| 70 | 
            +
                content = text.strip()
         | 
| 71 | 
            +
                if content.startswith("~m"):
         | 
| 72 | 
            +
                    rest = content[2:].strip()
         | 
| 73 | 
            +
                    for model in load_model_names():
         | 
| 74 | 
            +
                        if rest.startswith(model):
         | 
| 75 | 
            +
                            set_active_model(model)
         | 
| 76 | 
            +
                            # Remove ~mmodel from the input
         | 
| 77 | 
            +
                            idx = text.find("~m"+model)
         | 
| 78 | 
            +
                            if idx != -1:
         | 
| 79 | 
            +
                                new_text = (text[:idx] + text[idx+len("~m"+model):]).strip()
         | 
| 80 | 
            +
                                return new_text
         | 
| 81 | 
            +
                return None
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            async def get_input_with_model_completion(prompt_str: str = ">>> ", trigger: str = "~m", history_file: Optional[str] = None) -> str:
         | 
| 84 | 
            +
                history = FileHistory(os.path.expanduser(history_file)) if history_file else None
         | 
| 85 | 
            +
                session = PromptSession(
         | 
| 86 | 
            +
                    completer=ModelNameCompleter(trigger), history=history, complete_while_typing=True
         | 
| 87 | 
            +
                )
         | 
| 88 | 
            +
                text = await session.prompt_async(prompt_str)
         | 
| 89 | 
            +
                possibly_stripped = update_model_in_input(text)
         | 
| 90 | 
            +
                if possibly_stripped is not None:
         | 
| 91 | 
            +
                    return possibly_stripped
         | 
| 92 | 
            +
                return text
         | 
| @@ -0,0 +1,119 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            from code_puppy.command_line.utils import list_directory
         | 
| 3 | 
            +
            # ANSI color codes are no longer necessary because prompt_toolkit handles
         | 
| 4 | 
            +
            # styling via the `Style` class. We keep them here commented-out in case
         | 
| 5 | 
            +
            # someone needs raw ANSI later, but they are unused in the current code.
         | 
| 6 | 
            +
            # RESET = '\033[0m'
         | 
| 7 | 
            +
            # GREEN = '\033[1;32m'
         | 
| 8 | 
            +
            # CYAN = '\033[1;36m'
         | 
| 9 | 
            +
            # YELLOW = '\033[1;33m'
         | 
| 10 | 
            +
            # BOLD = '\033[1m'
         | 
| 11 | 
            +
            import asyncio
         | 
| 12 | 
            +
            from typing import Optional
         | 
| 13 | 
            +
            from prompt_toolkit import PromptSession
         | 
| 14 | 
            +
            from prompt_toolkit.completion import merge_completers
         | 
| 15 | 
            +
            from prompt_toolkit.history import FileHistory
         | 
| 16 | 
            +
            from prompt_toolkit.styles import Style
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            from code_puppy.command_line.model_picker_completion import (
         | 
| 19 | 
            +
                ModelNameCompleter,
         | 
| 20 | 
            +
                get_active_model,
         | 
| 21 | 
            +
                update_model_in_input,
         | 
| 22 | 
            +
            )
         | 
| 23 | 
            +
            from code_puppy.command_line.file_path_completion import FilePathCompleter
         | 
| 24 | 
            +
             | 
| 25 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 26 | 
            +
             | 
| 27 | 
            +
            class LSCompleter(Completer):
         | 
| 28 | 
            +
                def __init__(self, trigger: str = '~ls'):
         | 
| 29 | 
            +
                    self.trigger = trigger
         | 
| 30 | 
            +
                def get_completions(self, document, complete_event):
         | 
| 31 | 
            +
                    text = document.text_before_cursor
         | 
| 32 | 
            +
                    if not text.strip().startswith(self.trigger):
         | 
| 33 | 
            +
                        return
         | 
| 34 | 
            +
                    tokens = text.strip().split()
         | 
| 35 | 
            +
                    if len(tokens) == 1:
         | 
| 36 | 
            +
                        base = ''
         | 
| 37 | 
            +
                    else:
         | 
| 38 | 
            +
                        base = tokens[1]
         | 
| 39 | 
            +
                    try:
         | 
| 40 | 
            +
                        prefix = os.path.expanduser(base)
         | 
| 41 | 
            +
                        part = os.path.dirname(prefix) if os.path.dirname(prefix) else '.'
         | 
| 42 | 
            +
                        dirs, _ = list_directory(part)
         | 
| 43 | 
            +
                        dirnames = [d for d in dirs if d.startswith(os.path.basename(base))]
         | 
| 44 | 
            +
                        base_dir = os.path.dirname(base)
         | 
| 45 | 
            +
                        for d in dirnames:
         | 
| 46 | 
            +
                            # Build the completion text so we keep the already-typed directory parts.
         | 
| 47 | 
            +
                            if base_dir and base_dir != '.':
         | 
| 48 | 
            +
                                suggestion = os.path.join(base_dir, d)
         | 
| 49 | 
            +
                            else:
         | 
| 50 | 
            +
                                suggestion = d
         | 
| 51 | 
            +
                            # Append trailing slash so the user can continue tabbing into sub-dirs.
         | 
| 52 | 
            +
                            suggestion = suggestion.rstrip(os.sep) + os.sep
         | 
| 53 | 
            +
                            yield Completion(suggestion, start_position=-len(base), display=d + os.sep, display_meta='Directory')
         | 
| 54 | 
            +
                    except Exception:
         | 
| 55 | 
            +
                        # Silently ignore errors (e.g., permission issues, non-existent dir)
         | 
| 56 | 
            +
                        pass
         | 
| 57 | 
            +
             | 
| 58 | 
            +
            from prompt_toolkit.formatted_text import FormattedText
         | 
| 59 | 
            +
            def get_prompt_with_active_model(base: str = '>>> '):
         | 
| 60 | 
            +
                model = get_active_model() or '(default)'
         | 
| 61 | 
            +
                cwd = os.getcwd()
         | 
| 62 | 
            +
                # Abbreviate the home directory to ~ for brevity in the prompt
         | 
| 63 | 
            +
                home = os.path.expanduser('~')
         | 
| 64 | 
            +
                if cwd.startswith(home):
         | 
| 65 | 
            +
                    cwd_display = '~' + cwd[len(home):]
         | 
| 66 | 
            +
                else:
         | 
| 67 | 
            +
                    cwd_display = cwd
         | 
| 68 | 
            +
                return FormattedText([
         | 
| 69 | 
            +
                    ('bold', '🐶'),
         | 
| 70 | 
            +
                    ('class:model', f'[' + str(model) + '] '),
         | 
| 71 | 
            +
                    ('class:cwd', f'(' + str(cwd_display) + ') '),
         | 
| 72 | 
            +
                    ('class:arrow', str(base)),
         | 
| 73 | 
            +
                ])
         | 
| 74 | 
            +
             | 
| 75 | 
            +
            async def get_input_with_combined_completion(prompt_str = '>>> ', history_file: Optional[str] = None) -> str:
         | 
| 76 | 
            +
                history = FileHistory(history_file) if history_file else None
         | 
| 77 | 
            +
                completer = merge_completers([
         | 
| 78 | 
            +
                    FilePathCompleter(symbol='@'),
         | 
| 79 | 
            +
                    ModelNameCompleter(trigger='~m'),
         | 
| 80 | 
            +
                    LSCompleter(trigger='~ls'),
         | 
| 81 | 
            +
                ])
         | 
| 82 | 
            +
                session = PromptSession(
         | 
| 83 | 
            +
                    completer=completer,
         | 
| 84 | 
            +
                    history=history,
         | 
| 85 | 
            +
                    complete_while_typing=True
         | 
| 86 | 
            +
                )
         | 
| 87 | 
            +
                # If they pass a string, backward-compat: convert it to formatted_text
         | 
| 88 | 
            +
                if isinstance(prompt_str, str):
         | 
| 89 | 
            +
                    from prompt_toolkit.formatted_text import FormattedText
         | 
| 90 | 
            +
                    prompt_str = FormattedText([(None, prompt_str)])
         | 
| 91 | 
            +
                style = Style.from_dict({
         | 
| 92 | 
            +
                    # Keys must AVOID the 'class:' prefix – that prefix is used only when
         | 
| 93 | 
            +
                    # tagging tokens in `FormattedText`. See prompt_toolkit docs.
         | 
| 94 | 
            +
                    'model': 'bold cyan',
         | 
| 95 | 
            +
                    'cwd': 'bold green',
         | 
| 96 | 
            +
                    'arrow': 'bold yellow',
         | 
| 97 | 
            +
                })
         | 
| 98 | 
            +
                text = await session.prompt_async(prompt_str, style=style)
         | 
| 99 | 
            +
                possibly_stripped = update_model_in_input(text)
         | 
| 100 | 
            +
                if possibly_stripped is not None:
         | 
| 101 | 
            +
                    return possibly_stripped
         | 
| 102 | 
            +
                return text
         | 
| 103 | 
            +
             | 
| 104 | 
            +
            if __name__ == "__main__":
         | 
| 105 | 
            +
                print("Type '@' for path-completion or '~m' to pick a model. Ctrl+D to exit.")
         | 
| 106 | 
            +
                async def main():
         | 
| 107 | 
            +
                    while True:
         | 
| 108 | 
            +
                        try:
         | 
| 109 | 
            +
                            inp = await get_input_with_combined_completion(
         | 
| 110 | 
            +
                                get_prompt_with_active_model(),
         | 
| 111 | 
            +
                                history_file="~/.path_completion_history.txt"
         | 
| 112 | 
            +
                            )
         | 
| 113 | 
            +
                            print(f"You entered: {inp}")
         | 
| 114 | 
            +
                        except KeyboardInterrupt:
         | 
| 115 | 
            +
                            continue
         | 
| 116 | 
            +
                        except EOFError:
         | 
| 117 | 
            +
                            break
         | 
| 118 | 
            +
                    print("\nGoodbye!")
         | 
| 119 | 
            +
                asyncio.run(main())
         | 
| @@ -0,0 +1,36 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            from typing import Tuple, List
         | 
| 3 | 
            +
            from rich.table import Table
         | 
| 4 | 
            +
             | 
| 5 | 
            +
             | 
| 6 | 
            +
            def list_directory(path: str = None) -> Tuple[List[str], List[str]]:
         | 
| 7 | 
            +
                """
         | 
| 8 | 
            +
                Returns (dirs, files) for the specified path, splitting out directories and files.
         | 
| 9 | 
            +
                """
         | 
| 10 | 
            +
                if path is None:
         | 
| 11 | 
            +
                    path = os.getcwd()
         | 
| 12 | 
            +
                entries = []
         | 
| 13 | 
            +
                try:
         | 
| 14 | 
            +
                    entries = [e for e in os.listdir(path)]
         | 
| 15 | 
            +
                except Exception as e:
         | 
| 16 | 
            +
                    raise RuntimeError(f'Error listing directory: {e}')
         | 
| 17 | 
            +
                dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))]
         | 
| 18 | 
            +
                files = [e for e in entries if not os.path.isdir(os.path.join(path, e))]
         | 
| 19 | 
            +
                return dirs, files
         | 
| 20 | 
            +
             | 
| 21 | 
            +
             | 
| 22 | 
            +
            def make_directory_table(path: str = None) -> Table:
         | 
| 23 | 
            +
                """
         | 
| 24 | 
            +
                Returns a rich.Table object containing the directory listing.
         | 
| 25 | 
            +
                """
         | 
| 26 | 
            +
                if path is None:
         | 
| 27 | 
            +
                    path = os.getcwd()
         | 
| 28 | 
            +
                dirs, files = list_directory(path)
         | 
| 29 | 
            +
                table = Table(title=f"\U0001F4C1 [bold blue]Current directory:[/bold blue] [cyan]{path}[/cyan]")
         | 
| 30 | 
            +
                table.add_column('Type', style='dim', width=8)
         | 
| 31 | 
            +
                table.add_column('Name', style='bold')
         | 
| 32 | 
            +
                for d in sorted(dirs):
         | 
| 33 | 
            +
                    table.add_row('[green]dir[/green]', f'[cyan]{d}[/cyan]')
         | 
| 34 | 
            +
                for f in sorted(files):
         | 
| 35 | 
            +
                    table.add_row('[yellow]file[/yellow]', f'{f}')
         | 
| 36 | 
            +
                return table
         | 
| @@ -12,12 +12,13 @@ from rich.markdown import CodeBlock | |
| 12 12 | 
             
            from rich.text import Text
         | 
| 13 13 | 
             
            from rich.syntax import Syntax
         | 
| 14 14 | 
             
            from code_puppy.command_line.prompt_toolkit_completion import (
         | 
| 15 | 
            -
                 | 
| 15 | 
            +
                get_input_with_combined_completion,
         | 
| 16 | 
            +
                get_prompt_with_active_model
         | 
| 16 17 | 
             
            )
         | 
| 17 18 |  | 
| 18 19 | 
             
            # Initialize rich console for pretty output
         | 
| 19 20 | 
             
            from code_puppy.tools.common import console
         | 
| 20 | 
            -
            from code_puppy.agent import  | 
| 21 | 
            +
            from code_puppy.agent import get_code_generation_agent, session_memory
         | 
| 21 22 |  | 
| 22 23 | 
             
            from code_puppy.tools import *
         | 
| 23 24 |  | 
| @@ -59,9 +60,18 @@ async def main(): | |
| 59 60 | 
             
                    command = " ".join(args.command)
         | 
| 60 61 | 
             
                    try:
         | 
| 61 62 | 
             
                        while not shutdown_flag:
         | 
| 62 | 
            -
                             | 
| 63 | 
            +
                            agent = get_code_generation_agent()
         | 
| 64 | 
            +
                            response = await agent.run(command)
         | 
| 63 65 | 
             
                            agent_response = response.output
         | 
| 64 66 | 
             
                            console.print(agent_response.output_message)
         | 
| 67 | 
            +
                            # Log to session memory
         | 
| 68 | 
            +
                            session_memory().log_task(
         | 
| 69 | 
            +
                                f'Command executed: {command}',
         | 
| 70 | 
            +
                                extras={ 
         | 
| 71 | 
            +
                                    'output': agent_response.output_message,
         | 
| 72 | 
            +
                                    'awaiting_user_input': agent_response.awaiting_user_input
         | 
| 73 | 
            +
                                }
         | 
| 74 | 
            +
                            )
         | 
| 65 75 | 
             
                            if agent_response.awaiting_user_input:
         | 
| 66 76 | 
             
                                console.print(
         | 
| 67 77 | 
             
                                    "[bold red]The agent requires further input. Interactive mode is recommended for such tasks."
         | 
| @@ -82,13 +92,12 @@ async def main(): | |
| 82 92 |  | 
| 83 93 | 
             
            # Add the file handling functionality for interactive mode
         | 
| 84 94 | 
             
            async def interactive_mode(history_file_path: str) -> None:
         | 
| 95 | 
            +
                from code_puppy.command_line.meta_command_handler import handle_meta_command
         | 
| 85 96 | 
             
                """Run the agent in interactive mode."""
         | 
| 86 97 | 
             
                console.print("[bold green]Code Puppy[/bold green] - Interactive Mode")
         | 
| 87 98 | 
             
                console.print("Type 'exit' or 'quit' to exit the interactive mode.")
         | 
| 88 99 | 
             
                console.print("Type 'clear' to reset the conversation history.")
         | 
| 89 | 
            -
                console.print(
         | 
| 90 | 
            -
                    "Type [bold blue]@[/bold blue] followed by a path to use file path completion."
         | 
| 91 | 
            -
                )
         | 
| 100 | 
            +
                console.print("Type [bold blue]@[/bold blue] for path completion, or [bold blue]~m[/bold blue] to pick a model.")
         | 
| 92 101 |  | 
| 93 102 | 
             
                # Check if prompt_toolkit is installed
         | 
| 94 103 | 
             
                try:
         | 
| @@ -133,9 +142,10 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 133 142 | 
             
                    try:
         | 
| 134 143 | 
             
                        # Use prompt_toolkit for enhanced input with path completion
         | 
| 135 144 | 
             
                        try:
         | 
| 136 | 
            -
                            # Use the async version of  | 
| 137 | 
            -
                            task = await  | 
| 138 | 
            -
                                 | 
| 145 | 
            +
                            # Use the async version of get_input_with_combined_completion
         | 
| 146 | 
            +
                            task = await get_input_with_combined_completion(
         | 
| 147 | 
            +
                                get_prompt_with_active_model(),
         | 
| 148 | 
            +
                                history_file=history_file_path_prompt
         | 
| 139 149 | 
             
                            )
         | 
| 140 150 | 
             
                        except ImportError:
         | 
| 141 151 | 
             
                            # Fall back to basic input if prompt_toolkit is not available
         | 
| @@ -151,8 +161,8 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 151 161 | 
             
                        console.print("[bold green]Goodbye![/bold green]")
         | 
| 152 162 | 
             
                        break
         | 
| 153 163 |  | 
| 154 | 
            -
                    # Check for clear command
         | 
| 155 | 
            -
                    if task.strip().lower()  | 
| 164 | 
            +
                    # Check for clear command (supports both `clear` and `~clear`)
         | 
| 165 | 
            +
                    if task.strip().lower() in ("clear", "~clear"):
         | 
| 156 166 | 
             
                        message_history = []
         | 
| 157 167 | 
             
                        console.print("[bold yellow]Conversation history cleared![/bold yellow]")
         | 
| 158 168 | 
             
                        console.print(
         | 
| @@ -160,6 +170,10 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 160 170 | 
             
                        )
         | 
| 161 171 | 
             
                        continue
         | 
| 162 172 |  | 
| 173 | 
            +
                    # Handle ~ meta/config commands before anything else
         | 
| 174 | 
            +
                    if task.strip().startswith('~'):
         | 
| 175 | 
            +
                        if handle_meta_command(task.strip(), console):
         | 
| 176 | 
            +
                            continue
         | 
| 163 177 | 
             
                    if task.strip():
         | 
| 164 178 | 
             
                        console.print(f"\n[bold blue]Processing task:[/bold blue] {task}\n")
         | 
| 165 179 |  | 
| @@ -175,15 +189,28 @@ async def interactive_mode(history_file_path: str) -> None: | |
| 175 189 | 
             
                            # Store agent's full response
         | 
| 176 190 | 
             
                            agent_response = None
         | 
| 177 191 |  | 
| 178 | 
            -
                             | 
| 179 | 
            -
             | 
| 180 | 
            -
                            )
         | 
| 192 | 
            +
                            agent = get_code_generation_agent()
         | 
| 193 | 
            +
                            result = await agent.run(task, message_history=message_history)
         | 
| 181 194 | 
             
                            # Get the structured response
         | 
| 182 195 | 
             
                            agent_response = result.output
         | 
| 183 196 | 
             
                            console.print(agent_response.output_message)
         | 
| 197 | 
            +
                            # Log to session memory
         | 
| 198 | 
            +
                            session_memory().log_task(
         | 
| 199 | 
            +
                                f'Interactive task: {task}',
         | 
| 200 | 
            +
                                extras={ 
         | 
| 201 | 
            +
                                    'output': agent_response.output_message,
         | 
| 202 | 
            +
                                    'awaiting_user_input': agent_response.awaiting_user_input
         | 
| 203 | 
            +
                                }
         | 
| 204 | 
            +
                            )
         | 
| 184 205 |  | 
| 185 | 
            -
                            # Update message history  | 
| 186 | 
            -
                             | 
| 206 | 
            +
                            # Update message history but apply filters & limits
         | 
| 207 | 
            +
                            new_msgs = result.new_messages()
         | 
| 208 | 
            +
                            # 1. Drop any system/config messages (e.g., "agent loaded with model")
         | 
| 209 | 
            +
                            filtered = [m for m in new_msgs if not (isinstance(m, dict) and m.get("role") == "system")]
         | 
| 210 | 
            +
                            # 2. Append to existing history and keep only the most recent 40
         | 
| 211 | 
            +
                            message_history.extend(filtered)
         | 
| 212 | 
            +
                            if len(message_history) > 40:
         | 
| 213 | 
            +
                                message_history = message_history[-40:]
         | 
| 187 214 |  | 
| 188 215 | 
             
                            if agent_response and agent_response.awaiting_user_input:
         | 
| 189 216 | 
             
                                console.print(
         |