code-puppy 0.0.59__py3-none-any.whl → 0.0.61__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/agent.py CHANGED
@@ -18,12 +18,23 @@ from code_puppy.tools.common import console
18
18
 
19
19
  MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH", None)
20
20
 
21
- # Load puppy rules if provided
21
+ # Puppy rules loader
22
22
  PUPPY_RULES_PATH = Path(".puppy_rules")
23
23
  PUPPY_RULES = None
24
- if PUPPY_RULES_PATH.exists():
25
- with open(PUPPY_RULES_PATH, "r") as f:
26
- PUPPY_RULES = f.read()
24
+
25
+
26
+ def load_puppy_rules(path=None):
27
+ global PUPPY_RULES
28
+ rules_path = Path(path) if path else PUPPY_RULES_PATH
29
+ if rules_path.exists():
30
+ with open(rules_path, "r") as f:
31
+ PUPPY_RULES = f.read()
32
+ else:
33
+ PUPPY_RULES = None
34
+
35
+
36
+ # Load at import
37
+ load_puppy_rules()
27
38
 
28
39
 
29
40
  class AgentResponse(pydantic.BaseModel):
@@ -30,6 +30,7 @@ File Operations:
30
30
  - edit_file(path, diff): Use this single tool to create new files, overwrite entire files, perform targeted replacements, or delete snippets depending on the JSON/raw payload provided.
31
31
  - delete_file(file_path): Use this to remove files when needed
32
32
  - grep(search_string, directory="."): Use this to recursively search for a string across files starting from the specified directory, capping results at 200 matches.
33
+ - code_map(directory="."): Use this to generate a code map for the specified directory.
33
34
 
34
35
  Tool Usage Instructions:
35
36
 
@@ -49,7 +50,7 @@ Example (create):
49
50
  edit_file("src/example.py", "print('hello')\n")
50
51
  ```
51
52
 
52
- Example (replacement):
53
+ Example (replacement): -- YOU SHOULD PREFER THIS AS THE PRIMARY WAY TO EDIT FILES.
53
54
  ```json
54
55
  edit_file(
55
56
  "src/example.py",
@@ -6,6 +6,7 @@ from code_puppy.command_line.model_picker_completion import (
6
6
  load_model_names,
7
7
  update_model_in_input,
8
8
  )
9
+ from code_puppy.config import get_config_keys
9
10
  from code_puppy.command_line.utils import make_directory_table
10
11
 
11
12
  META_COMMANDS_HELP = """
@@ -20,9 +21,15 @@ META_COMMANDS_HELP = """
20
21
 
21
22
 
22
23
  def handle_meta_command(command: str, console: Console) -> bool:
24
+ """
25
+ Handle meta/config commands prefixed with '~'.
26
+ Returns True if the command was handled (even if just an error/help), False if not.
27
+ """
28
+ command = command.strip()
29
+
23
30
  # ~codemap (code structure visualization)
24
31
  if command.startswith("~codemap"):
25
- from code_puppy.tools.code_map import make_code_map
32
+ from code_puppy.tools.ts_code_map import make_code_map
26
33
 
27
34
  tokens = command.split()
28
35
  if len(tokens) > 1:
@@ -30,16 +37,11 @@ def handle_meta_command(command: str, console: Console) -> bool:
30
37
  else:
31
38
  target_dir = os.getcwd()
32
39
  try:
33
- tree = make_code_map(target_dir, show_doc=True)
34
- console.print(tree)
40
+ make_code_map(target_dir, ignore_tests=True)
35
41
  except Exception as e:
36
42
  console.print(f"[red]Error generating code map:[/red] {e}")
37
43
  return True
38
- """
39
- Handle meta/config commands prefixed with '~'.
40
- Returns True if the command was handled (even if just an error/help), False if not.
41
- """
42
- command = command.strip()
44
+
43
45
  if command.startswith("~cd"):
44
46
  tokens = command.split()
45
47
  if len(tokens) == 1:
@@ -83,7 +85,7 @@ def handle_meta_command(command: str, console: Console) -> bool:
83
85
 
84
86
  if command.startswith("~set"):
85
87
  # Syntax: ~set KEY=VALUE or ~set KEY VALUE
86
- from code_puppy.config import get_config_keys, set_config_value
88
+ from code_puppy.config import set_config_value
87
89
 
88
90
  tokens = command.split(None, 2)
89
91
  argstr = command[len("~set") :].strip()
@@ -100,8 +102,9 @@ def handle_meta_command(command: str, console: Console) -> bool:
100
102
  key = tokens[1]
101
103
  value = ""
102
104
  else:
103
- console.print("[yellow]Usage:[/yellow] ~set KEY=VALUE or ~set KEY VALUE")
104
- console.print("Config keys: " + ", ".join(get_config_keys()))
105
+ console.print(
106
+ f"[yellow]Usage:[/yellow] ~set KEY=VALUE or ~set KEY VALUE\nConfig keys: {', '.join(get_config_keys())}"
107
+ )
105
108
  return True
106
109
  if key:
107
110
  set_config_value(key, value)
@@ -116,9 +119,11 @@ def handle_meta_command(command: str, console: Console) -> bool:
116
119
  # Try setting model and show confirmation
117
120
  new_input = update_model_in_input(command)
118
121
  if new_input is not None:
122
+ from code_puppy.command_line.model_picker_completion import get_active_model
119
123
  from code_puppy.agent import get_code_generation_agent
120
124
 
121
125
  model = get_active_model()
126
+ # Make sure this is called for the test
122
127
  get_code_generation_agent(force_reload=True)
123
128
  console.print(
124
129
  f"[bold green]Active model set and loaded:[/bold green] [cyan]{model}[/cyan]"
@@ -126,8 +131,8 @@ def handle_meta_command(command: str, console: Console) -> bool:
126
131
  return True
127
132
  # If no model matched, show available models
128
133
  model_names = load_model_names()
134
+ console.print("[yellow]Usage:[/yellow] ~m <model-name>")
129
135
  console.print(f"[yellow]Available models:[/yellow] {', '.join(model_names)}")
130
- console.print("[yellow]Usage:[/yellow] ~m <model_name>")
131
136
  return True
132
137
  if command in ("~help", "~h"):
133
138
  console.print(META_COMMANDS_HELP)
@@ -33,53 +33,60 @@ class SetCompleter(Completer):
33
33
  self.trigger = trigger
34
34
 
35
35
  def get_completions(self, document, complete_event):
36
- text = document.text_before_cursor
37
- if not text.strip().startswith(self.trigger):
36
+ text_before_cursor = document.text_before_cursor
37
+ stripped_text_for_trigger_check = text_before_cursor.lstrip()
38
+
39
+ if not stripped_text_for_trigger_check.startswith(self.trigger):
38
40
  return
39
- # If the only thing typed is exactly '~set', suggest space
40
- if text.strip() == self.trigger:
41
+
42
+ # Determine the part of the text that is relevant for this completer
43
+ # This handles cases like " ~set foo" where the trigger isn't at the start of the string
44
+ actual_trigger_pos = text_before_cursor.find(self.trigger)
45
+ effective_input = text_before_cursor[
46
+ actual_trigger_pos:
47
+ ] # e.g., "~set keypart" or "~set " or "~set"
48
+
49
+ tokens = effective_input.split()
50
+
51
+ # Case 1: Input is exactly the trigger (e.g., "~set") and nothing more (not even a trailing space on effective_input).
52
+ # Suggest adding a space.
53
+ if (
54
+ len(tokens) == 1
55
+ and tokens[0] == self.trigger
56
+ and not effective_input.endswith(" ")
57
+ ):
41
58
  yield Completion(
42
- self.trigger + " ",
43
- start_position=-len(self.trigger),
44
- display=f"{self.trigger} ",
45
- display_meta="set config",
59
+ text=self.trigger + " ", # Text to insert
60
+ start_position=-len(tokens[0]), # Replace the trigger itself
61
+ display=self.trigger + " ", # Visual display
62
+ display_meta="set config key",
46
63
  )
47
- tokens = text.strip().split()
48
- # completion for the first arg after ~set
49
- if len(tokens) == 1:
50
- # user just typed ~set <-- suggest config keys
51
- base = ""
52
- else:
53
- base = tokens[1]
64
+ return
65
+
66
+ # Case 2: Input is trigger + space (e.g., "~set ") or trigger + partial key (e.g., "~set partial")
67
+ base_to_complete = ""
68
+ if len(tokens) > 1: # e.g., ["~set", "partialkey"]
69
+ base_to_complete = tokens[1]
70
+ # If len(tokens) == 1, it implies effective_input was like "~set ", so base_to_complete remains ""
71
+ # This means we list all keys.
72
+
54
73
  # --- SPECIAL HANDLING FOR 'model' KEY ---
55
- if base == "model":
74
+ if base_to_complete == "model":
56
75
  # Don't return any completions -- let ModelNameCompleter handle it
57
76
  return
58
77
  for key in get_config_keys():
59
78
  if key == "model":
60
79
  continue # exclude 'model' from regular ~set completions
61
- if key.startswith(base):
80
+ if key.startswith(base_to_complete):
62
81
  prev_value = get_value(key)
63
- # Ensure there's a space after '~set' if it's the only thing typed
64
- if text.strip() == self.trigger or text.strip() == self.trigger + "":
65
- prefix = self.trigger + " " # Always enforce a space
66
- insert_text = (
67
- f"{prefix}{key} = {prev_value}"
68
- if prev_value is not None
69
- else f"{prefix}{key} = "
70
- )
71
- sp = -len(text)
72
- else:
73
- insert_text = (
74
- f"{key} = {prev_value}"
75
- if prev_value is not None
76
- else f"{key} = "
77
- )
78
- sp = -len(base)
79
- # Make it obvious the value part is from before
82
+ value_part = f" = {prev_value}" if prev_value is not None else " = "
83
+ completion_text = f"{key}{value_part}"
84
+
80
85
  yield Completion(
81
- insert_text,
82
- start_position=sp,
86
+ completion_text,
87
+ start_position=-len(
88
+ base_to_complete
89
+ ), # Correctly replace only the typed part of the key
83
90
  display_meta=f"puppy.cfg key (was: {prev_value})"
84
91
  if prev_value is not None
85
92
  else "puppy.cfg key",
@@ -1,14 +1,9 @@
1
- import asyncio
2
1
  import json
3
2
  import os
4
- import threading
5
- import time
6
- from collections import deque
7
3
  from typing import Any, Dict
8
4
 
9
5
  import httpx
10
6
  from anthropic import AsyncAnthropic
11
- from httpx import Response
12
7
  from openai import AsyncAzureOpenAI # For Azure OpenAI client
13
8
  from pydantic_ai.models.anthropic import AnthropicModel
14
9
  from pydantic_ai.models.gemini import GeminiModel
@@ -27,98 +22,6 @@ from pydantic_ai.providers.openai import OpenAIProvider
27
22
  # Example: "X-Api-Key": "$OPENAI_API_KEY" will use the value from os.environ.get("OPENAI_API_KEY")
28
23
 
29
24
 
30
- def make_client(
31
- max_requests_per_minute: int = 10, max_retries: int = 3, retry_base_delay: int = 10
32
- ) -> httpx.AsyncClient:
33
- # Create a rate limiter using a token bucket approach
34
- class RateLimiter:
35
- def __init__(self, max_requests_per_minute):
36
- self.max_requests_per_minute = max_requests_per_minute
37
- self.interval = (
38
- 60.0 / max_requests_per_minute
39
- ) # Time between requests in seconds
40
- self.request_times = deque(maxlen=max_requests_per_minute)
41
- self.lock = threading.Lock()
42
-
43
- async def acquire(self):
44
- """Wait until a request can be made according to the rate limit."""
45
- while True:
46
- with self.lock:
47
- now = time.time()
48
-
49
- # Remove timestamps older than 1 minute
50
- while self.request_times and now - self.request_times[0] > 60:
51
- self.request_times.popleft()
52
-
53
- # If we haven't reached the limit, add the timestamp and proceed
54
- if len(self.request_times) < self.max_requests_per_minute:
55
- self.request_times.append(now)
56
- return
57
-
58
- # Otherwise, calculate the wait time until we can make another request
59
- oldest = self.request_times[0]
60
- wait_time = max(0, oldest + 60 - now)
61
-
62
- if wait_time > 0:
63
- print(
64
- f"Rate limit would be exceeded. Waiting {wait_time:.2f} seconds before sending request."
65
- )
66
- await asyncio.sleep(wait_time)
67
- else:
68
- # Try again immediately
69
- continue
70
-
71
- # Create the rate limiter instance
72
- rate_limiter = RateLimiter(max_requests_per_minute)
73
-
74
- def should_retry(response: Response) -> bool:
75
- return response.status_code == 429 or (500 <= response.status_code < 600)
76
-
77
- async def request_hook(request):
78
- # Wait until we can make a request according to our rate limit
79
- await rate_limiter.acquire()
80
- return request
81
-
82
- async def response_hook(response: Response) -> Response:
83
- retries = getattr(response.request, "_retries", 0)
84
-
85
- if should_retry(response) and retries < max_retries:
86
- setattr(response.request, "_retries", retries + 1)
87
-
88
- delay = retry_base_delay * (2**retries)
89
-
90
- if response.status_code == 429:
91
- print(
92
- f"Rate limit exceeded. Retrying in {delay:.2f} seconds (attempt {retries + 1}/{max_retries})"
93
- )
94
- else:
95
- print(
96
- f"Server error {response.status_code}. Retrying in {delay:.2f} seconds (attempt {retries + 1}/{max_retries})"
97
- )
98
-
99
- await asyncio.sleep(delay)
100
-
101
- new_request = response.request.copy()
102
- async with httpx.AsyncClient() as client:
103
- # Apply rate limiting to the retry request as well
104
- await rate_limiter.acquire()
105
- new_response = await client.request(
106
- new_request.method,
107
- str(new_request.url),
108
- headers=new_request.headers,
109
- content=new_request.content,
110
- params=dict(new_request.url.params),
111
- )
112
- return new_response
113
- return response
114
-
115
- # Setup both request and response hooks
116
- event_hooks = {"request": [request_hook], "response": [response_hook]}
117
-
118
- client = httpx.AsyncClient(event_hooks=event_hooks)
119
- return client
120
-
121
-
122
25
  def get_custom_config(model_config):
123
26
  custom_config = model_config.get("custom_endpoint", {})
124
27
  if not custom_config:
@@ -167,17 +70,6 @@ class ModelFactory:
167
70
 
168
71
  model_type = model_config.get("type")
169
72
 
170
- # Common configuration for rate limiting and retries
171
- max_requests_per_minute = model_config.get("max_requests_per_minute", 100)
172
- max_retries = model_config.get("max_retries", 3)
173
- retry_base_delay = model_config.get("retry_base_delay", 1.0)
174
-
175
- client = make_client(
176
- max_requests_per_minute=max_requests_per_minute,
177
- max_retries=max_retries,
178
- retry_base_delay=retry_base_delay,
179
- )
180
-
181
73
  if model_type == "gemini":
182
74
  provider = GoogleGLAProvider(api_key=os.environ.get("GEMINI_API_KEY", ""))
183
75
 
code_puppy/models.json CHANGED
@@ -1,38 +1,23 @@
1
1
  {
2
2
  "gemini-2.5-flash-preview-05-20": {
3
3
  "type": "gemini",
4
- "name": "gemini-2.5-flash-preview-05-20",
5
- "max_requests_per_minute": 10,
6
- "max_retries": 3,
7
- "retry_base_delay": 10
4
+ "name": "gemini-2.5-flash-preview-05-20"
8
5
  },
9
6
  "gpt-4.1": {
10
7
  "type": "openai",
11
- "name": "gpt-4.1",
12
- "max_requests_per_minute": 100,
13
- "max_retries": 3,
14
- "retry_base_delay": 10
8
+ "name": "gpt-4.1"
15
9
  },
16
10
  "gpt-4.1-mini": {
17
11
  "type": "openai",
18
- "name": "gpt-4.1-mini",
19
- "max_requests_per_minute": 100,
20
- "max_retries": 3,
21
- "retry_base_delay": 10
12
+ "name": "gpt-4.1-mini"
22
13
  },
23
14
  "gpt-4.1-nano": {
24
15
  "type": "openai",
25
- "name": "gpt-4.1-nano",
26
- "max_requests_per_minute": 100,
27
- "max_retries": 3,
28
- "retry_base_delay": 10
16
+ "name": "gpt-4.1-nano"
29
17
  },
30
18
  "gpt-4.1-custom": {
31
19
  "type": "custom_openai",
32
20
  "name": "gpt-4.1-custom",
33
- "max_requests_per_minute": 100,
34
- "max_retries": 3,
35
- "retry_base_delay": 10,
36
21
  "custom_endpoint": {
37
22
  "url": "https://my.cute.endpoint:8080",
38
23
  "headers": {
@@ -44,9 +29,6 @@
44
29
  "ollama-llama3.3": {
45
30
  "type": "custom_openai",
46
31
  "name": "llama3.3",
47
- "max_requests_per_minute": 100,
48
- "max_retries": 3,
49
- "retry_base_delay": 5,
50
32
  "custom_endpoint": {
51
33
  "url": "http://localhost:11434/v1"
52
34
  }
@@ -54,9 +36,6 @@
54
36
  "meta-llama/Llama-3.3-70B-Instruct-Turbo": {
55
37
  "type": "custom_openai",
56
38
  "name": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
57
- "max_requests_per_minute": 100,
58
- "max_retries": 3,
59
- "retry_base_delay": 5,
60
39
  "custom_endpoint": {
61
40
  "url": "https://api.together.xyz/v1",
62
41
  "api_key": "$TOGETHER_API_KEY"
@@ -65,9 +44,6 @@
65
44
  "grok-3-mini-fast": {
66
45
  "type": "custom_openai",
67
46
  "name": "grok-3-mini-fast",
68
- "max_requests_per_minute": 100,
69
- "max_retries": 3,
70
- "retry_base_delay": 5,
71
47
  "custom_endpoint": {
72
48
  "url": "https://api.x.ai/v1",
73
49
  "api_key": "$XAI_API_KEY"
@@ -76,9 +52,6 @@
76
52
  "azure-gpt-4.1": {
77
53
  "type": "azure_openai",
78
54
  "name": "gpt-4.1",
79
- "max_requests_per_minute": 100,
80
- "max_retries": 3,
81
- "retry_base_delay": 5,
82
55
  "api_version": "2024-12-01-preview",
83
56
  "api_key": "$AZURE_OPENAI_API_KEY",
84
57
  "azure_endpoint": "$AZURE_OPENAI_ENDPOINT"
@@ -136,12 +136,17 @@ def run_shell_command(
136
136
  except Exception as e:
137
137
  console.print_exception(show_locals=True)
138
138
  console.print("[dim]" + "-" * 60 + "[/dim]\n")
139
+ # Ensure stdout and stderr are always defined
140
+ if "stdout" not in locals():
141
+ stdout = None
142
+ if "stderr" not in locals():
143
+ stderr = None
139
144
  return {
140
145
  "success": False,
141
146
  "command": command,
142
147
  "error": f"Error executing command: {str(e)}",
143
- "stdout": stdout[-1000:],
144
- "stderr": stderr[-1000:],
148
+ "stdout": stdout[-1000:] if stdout else None,
149
+ "stderr": stderr[-1000:] if stderr else None,
145
150
  "exit_code": -1,
146
151
  "timeout": False,
147
152
  }
@@ -1,4 +1,5 @@
1
1
  import os
2
+ import fnmatch
2
3
 
3
4
  from typing import Optional, Tuple
4
5
 
@@ -8,6 +9,56 @@ from rich.console import Console
8
9
  NO_COLOR = bool(int(os.environ.get("CODE_PUPPY_NO_COLOR", "0")))
9
10
  console = Console(no_color=NO_COLOR)
10
11
 
12
+
13
+ # -------------------
14
+ # Shared ignore patterns/helpers
15
+ # -------------------
16
+ IGNORE_PATTERNS = [
17
+ "**/node_modules/**",
18
+ "**/node_modules/**/*.js",
19
+ "node_modules/**",
20
+ "node_modules",
21
+ "**/.git/**",
22
+ "**/.git",
23
+ ".git/**",
24
+ ".git",
25
+ "**/__pycache__/**",
26
+ "**/__pycache__",
27
+ "__pycache__/**",
28
+ "__pycache__",
29
+ "**/.DS_Store",
30
+ ".DS_Store",
31
+ "**/.env",
32
+ ".env",
33
+ "**/.venv/**",
34
+ "**/.venv",
35
+ "**/venv/**",
36
+ "**/venv",
37
+ "**/.idea/**",
38
+ "**/.idea",
39
+ "**/.vscode/**",
40
+ "**/.vscode",
41
+ "**/dist/**",
42
+ "**/dist",
43
+ "**/build/**",
44
+ "**/build",
45
+ "**/*.pyc",
46
+ "**/*.pyo",
47
+ "**/*.pyd",
48
+ "**/*.so",
49
+ "**/*.dll",
50
+ "**/.*",
51
+ ]
52
+
53
+
54
+ def should_ignore_path(path: str) -> bool:
55
+ """Return True if *path* matches any pattern in IGNORE_PATTERNS."""
56
+ for pattern in IGNORE_PATTERNS:
57
+ if fnmatch.fnmatch(path, pattern):
58
+ return True
59
+ return False
60
+
61
+
11
62
  JW_THRESHOLD = 0.95
12
63
 
13
64