code-puppy 0.0.97__py3-none-any.whl → 0.0.119__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 +2 -5
- code_puppy/__main__.py +10 -0
- code_puppy/agent.py +125 -40
- code_puppy/agent_prompts.py +30 -24
- code_puppy/callbacks.py +152 -0
- code_puppy/command_line/command_handler.py +359 -0
- code_puppy/command_line/load_context_completion.py +59 -0
- code_puppy/command_line/model_picker_completion.py +14 -21
- code_puppy/command_line/motd.py +44 -28
- code_puppy/command_line/prompt_toolkit_completion.py +42 -23
- code_puppy/config.py +266 -26
- code_puppy/http_utils.py +122 -0
- code_puppy/main.py +570 -383
- code_puppy/message_history_processor.py +195 -104
- code_puppy/messaging/__init__.py +46 -0
- code_puppy/messaging/message_queue.py +288 -0
- code_puppy/messaging/queue_console.py +293 -0
- code_puppy/messaging/renderers.py +305 -0
- code_puppy/messaging/spinner/__init__.py +55 -0
- code_puppy/messaging/spinner/console_spinner.py +200 -0
- code_puppy/messaging/spinner/spinner_base.py +66 -0
- code_puppy/messaging/spinner/textual_spinner.py +97 -0
- code_puppy/model_factory.py +73 -105
- code_puppy/plugins/__init__.py +32 -0
- code_puppy/reopenable_async_client.py +225 -0
- code_puppy/state_management.py +60 -21
- code_puppy/summarization_agent.py +56 -35
- code_puppy/token_utils.py +7 -9
- code_puppy/tools/__init__.py +1 -4
- code_puppy/tools/command_runner.py +187 -32
- code_puppy/tools/common.py +44 -35
- code_puppy/tools/file_modifications.py +335 -118
- code_puppy/tools/file_operations.py +368 -95
- code_puppy/tools/token_check.py +27 -11
- code_puppy/tools/tools_content.py +53 -0
- code_puppy/tui/__init__.py +10 -0
- code_puppy/tui/app.py +1050 -0
- code_puppy/tui/components/__init__.py +21 -0
- code_puppy/tui/components/chat_view.py +512 -0
- code_puppy/tui/components/command_history_modal.py +218 -0
- code_puppy/tui/components/copy_button.py +139 -0
- code_puppy/tui/components/custom_widgets.py +58 -0
- code_puppy/tui/components/input_area.py +167 -0
- code_puppy/tui/components/sidebar.py +309 -0
- code_puppy/tui/components/status_bar.py +182 -0
- code_puppy/tui/messages.py +27 -0
- code_puppy/tui/models/__init__.py +8 -0
- code_puppy/tui/models/chat_message.py +25 -0
- code_puppy/tui/models/command_history.py +89 -0
- code_puppy/tui/models/enums.py +24 -0
- code_puppy/tui/screens/__init__.py +13 -0
- code_puppy/tui/screens/help.py +130 -0
- code_puppy/tui/screens/settings.py +255 -0
- code_puppy/tui/screens/tools.py +74 -0
- code_puppy/tui/tests/__init__.py +1 -0
- code_puppy/tui/tests/test_chat_message.py +28 -0
- code_puppy/tui/tests/test_chat_view.py +88 -0
- code_puppy/tui/tests/test_command_history.py +89 -0
- code_puppy/tui/tests/test_copy_button.py +191 -0
- code_puppy/tui/tests/test_custom_widgets.py +27 -0
- code_puppy/tui/tests/test_disclaimer.py +27 -0
- code_puppy/tui/tests/test_enums.py +15 -0
- code_puppy/tui/tests/test_file_browser.py +60 -0
- code_puppy/tui/tests/test_help.py +38 -0
- code_puppy/tui/tests/test_history_file_reader.py +107 -0
- code_puppy/tui/tests/test_input_area.py +33 -0
- code_puppy/tui/tests/test_settings.py +44 -0
- code_puppy/tui/tests/test_sidebar.py +33 -0
- code_puppy/tui/tests/test_sidebar_history.py +153 -0
- code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
- code_puppy/tui/tests/test_status_bar.py +54 -0
- code_puppy/tui/tests/test_timestamped_history.py +52 -0
- code_puppy/tui/tests/test_tools.py +82 -0
- code_puppy/version_checker.py +26 -3
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/METADATA +9 -2
- code_puppy-0.0.119.dist-info/RECORD +86 -0
- code_puppy-0.0.97.dist-info/RECORD +0 -32
- {code_puppy-0.0.97.data → code_puppy-0.0.119.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.97.dist-info → code_puppy-0.0.119.dist-info}/licenses/LICENSE +0 -0
|
@@ -6,21 +6,41 @@ from typing import List
|
|
|
6
6
|
from pydantic import BaseModel, conint
|
|
7
7
|
from pydantic_ai import RunContext
|
|
8
8
|
|
|
9
|
-
from code_puppy.tools.common import console
|
|
10
|
-
from code_puppy.token_utils import estimate_tokens
|
|
11
|
-
from code_puppy.tools.token_check import token_guard
|
|
12
|
-
|
|
13
9
|
# ---------------------------------------------------------------------------
|
|
14
10
|
# Module-level helper functions (exposed for unit tests _and_ used as tools)
|
|
15
11
|
# ---------------------------------------------------------------------------
|
|
16
|
-
from code_puppy.
|
|
12
|
+
from code_puppy.messaging import (
|
|
13
|
+
emit_divider,
|
|
14
|
+
emit_error,
|
|
15
|
+
emit_info,
|
|
16
|
+
emit_success,
|
|
17
|
+
emit_system_message,
|
|
18
|
+
emit_warning,
|
|
19
|
+
)
|
|
20
|
+
from code_puppy.tools.common import generate_group_id, should_ignore_path
|
|
21
|
+
|
|
22
|
+
# Add token checking functionality
|
|
23
|
+
try:
|
|
24
|
+
from code_puppy.token_utils import get_tokenizer
|
|
25
|
+
from code_puppy.tools.token_check import token_guard
|
|
26
|
+
except ImportError:
|
|
27
|
+
# Fallback for when token checking modules aren't available
|
|
28
|
+
def get_tokenizer():
|
|
29
|
+
# Simple token estimation - no longer using tiktoken
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
def token_guard(num_tokens):
|
|
33
|
+
if num_tokens > 10000:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Token count {num_tokens} exceeds safety limit of 10,000 tokens"
|
|
36
|
+
)
|
|
17
37
|
|
|
18
38
|
|
|
39
|
+
# Pydantic models for tool return types
|
|
19
40
|
class ListedFile(BaseModel):
|
|
20
41
|
path: str | None
|
|
21
42
|
type: str | None
|
|
22
43
|
size: int = 0
|
|
23
|
-
full_path: str | None
|
|
24
44
|
depth: int | None
|
|
25
45
|
|
|
26
46
|
|
|
@@ -29,30 +49,121 @@ class ListFileOutput(BaseModel):
|
|
|
29
49
|
error: str | None = None
|
|
30
50
|
|
|
31
51
|
|
|
52
|
+
class ReadFileOutput(BaseModel):
|
|
53
|
+
content: str | None
|
|
54
|
+
num_tokens: conint(lt=10000)
|
|
55
|
+
error: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MatchInfo(BaseModel):
|
|
59
|
+
file_path: str | None
|
|
60
|
+
line_number: int | None
|
|
61
|
+
line_content: str | None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GrepOutput(BaseModel):
|
|
65
|
+
matches: List[MatchInfo]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_likely_home_directory(directory):
|
|
69
|
+
"""Detect if directory is likely a user's home directory or common home subdirectory"""
|
|
70
|
+
abs_dir = os.path.abspath(directory)
|
|
71
|
+
home_dir = os.path.expanduser("~")
|
|
72
|
+
|
|
73
|
+
# Exact home directory match
|
|
74
|
+
if abs_dir == home_dir:
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
# Check for common home directory subdirectories
|
|
78
|
+
common_home_subdirs = {
|
|
79
|
+
"Documents",
|
|
80
|
+
"Desktop",
|
|
81
|
+
"Downloads",
|
|
82
|
+
"Pictures",
|
|
83
|
+
"Music",
|
|
84
|
+
"Videos",
|
|
85
|
+
"Movies",
|
|
86
|
+
"Public",
|
|
87
|
+
"Library",
|
|
88
|
+
"Applications", # Cover macOS/Linux
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
os.path.basename(abs_dir) in common_home_subdirs
|
|
92
|
+
and os.path.dirname(abs_dir) == home_dir
|
|
93
|
+
):
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_project_directory(directory):
|
|
100
|
+
"""Quick heuristic to detect if this looks like a project directory"""
|
|
101
|
+
project_indicators = {
|
|
102
|
+
"package.json",
|
|
103
|
+
"pyproject.toml",
|
|
104
|
+
"Cargo.toml",
|
|
105
|
+
"pom.xml",
|
|
106
|
+
"build.gradle",
|
|
107
|
+
"CMakeLists.txt",
|
|
108
|
+
".git",
|
|
109
|
+
"requirements.txt",
|
|
110
|
+
"composer.json",
|
|
111
|
+
"Gemfile",
|
|
112
|
+
"go.mod",
|
|
113
|
+
"Makefile",
|
|
114
|
+
"setup.py",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
contents = os.listdir(directory)
|
|
119
|
+
return any(indicator in contents for indicator in project_indicators)
|
|
120
|
+
except (OSError, PermissionError):
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
32
124
|
def _list_files(
|
|
33
125
|
context: RunContext, directory: str = ".", recursive: bool = True
|
|
34
126
|
) -> ListFileOutput:
|
|
35
127
|
results = []
|
|
36
128
|
directory = os.path.abspath(directory)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
129
|
+
|
|
130
|
+
# Generate group_id for this tool execution
|
|
131
|
+
group_id = generate_group_id("list_files", directory)
|
|
132
|
+
|
|
133
|
+
emit_info(
|
|
134
|
+
"\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]",
|
|
135
|
+
message_group=group_id,
|
|
40
136
|
)
|
|
41
|
-
|
|
137
|
+
emit_info(
|
|
138
|
+
f"\U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim](recursive={recursive})[/dim]\n",
|
|
139
|
+
message_group=group_id,
|
|
140
|
+
)
|
|
141
|
+
emit_divider(message_group=group_id)
|
|
42
142
|
if not os.path.exists(directory):
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
46
|
-
console.print("[dim]" + "-" * 60 + "[/dim]\n")
|
|
143
|
+
emit_error(f"Directory '{directory}' does not exist", message_group=group_id)
|
|
144
|
+
emit_divider(message_group=group_id)
|
|
47
145
|
return ListFileOutput(
|
|
48
146
|
files=[ListedFile(path=None, type=None, full_path=None, depth=None)]
|
|
49
147
|
)
|
|
50
148
|
if not os.path.isdir(directory):
|
|
51
|
-
|
|
52
|
-
|
|
149
|
+
emit_error(f"'{directory}' is not a directory", message_group=group_id)
|
|
150
|
+
emit_divider(message_group=group_id)
|
|
53
151
|
return ListFileOutput(
|
|
54
152
|
files=[ListedFile(path=None, type=None, full_path=None, depth=None)]
|
|
55
153
|
)
|
|
154
|
+
|
|
155
|
+
# Smart home directory detection - auto-limit recursion for performance
|
|
156
|
+
if is_likely_home_directory(directory) and recursive:
|
|
157
|
+
if not is_project_directory(directory):
|
|
158
|
+
emit_warning(
|
|
159
|
+
"🏠 Detected home directory - limiting to non-recursive listing for performance",
|
|
160
|
+
message_group=group_id,
|
|
161
|
+
)
|
|
162
|
+
emit_info(
|
|
163
|
+
f"💡 To force recursive listing in home directory, use list_files('{directory}', recursive=True) explicitly",
|
|
164
|
+
message_group=group_id,
|
|
165
|
+
)
|
|
166
|
+
recursive = False
|
|
56
167
|
folder_structure = {}
|
|
57
168
|
file_list = []
|
|
58
169
|
for root, dirs, files in os.walk(directory):
|
|
@@ -62,14 +173,13 @@ def _list_files(
|
|
|
62
173
|
if rel_path == ".":
|
|
63
174
|
rel_path = ""
|
|
64
175
|
if rel_path:
|
|
65
|
-
|
|
176
|
+
os.path.join(directory, rel_path)
|
|
66
177
|
results.append(
|
|
67
178
|
ListedFile(
|
|
68
179
|
**{
|
|
69
180
|
"path": rel_path,
|
|
70
181
|
"type": "directory",
|
|
71
182
|
"size": 0,
|
|
72
|
-
"full_path": dir_path,
|
|
73
183
|
"depth": depth,
|
|
74
184
|
}
|
|
75
185
|
)
|
|
@@ -77,7 +187,6 @@ def _list_files(
|
|
|
77
187
|
folder_structure[rel_path] = {
|
|
78
188
|
"path": rel_path,
|
|
79
189
|
"depth": depth,
|
|
80
|
-
"full_path": dir_path,
|
|
81
190
|
}
|
|
82
191
|
for file in files:
|
|
83
192
|
file_path = os.path.join(root, file)
|
|
@@ -90,7 +199,6 @@ def _list_files(
|
|
|
90
199
|
"path": rel_file_path,
|
|
91
200
|
"type": "file",
|
|
92
201
|
"size": size,
|
|
93
|
-
"full_path": file_path,
|
|
94
202
|
"depth": depth,
|
|
95
203
|
}
|
|
96
204
|
results.append(ListedFile(**file_info))
|
|
@@ -141,8 +249,9 @@ def _list_files(
|
|
|
141
249
|
|
|
142
250
|
if results:
|
|
143
251
|
files = sorted([f for f in results if f.type == "file"], key=lambda x: x.path)
|
|
144
|
-
|
|
145
|
-
f"\U0001f4c1 [bold blue]{os.path.basename(directory) or directory}[/bold blue]"
|
|
252
|
+
emit_info(
|
|
253
|
+
f"\U0001f4c1 [bold blue]{os.path.basename(directory) or directory}[/bold blue]",
|
|
254
|
+
message_group=group_id,
|
|
146
255
|
)
|
|
147
256
|
all_items = sorted(results, key=lambda x: x.path)
|
|
148
257
|
parent_dirs_with_content = set()
|
|
@@ -161,32 +270,31 @@ def _list_files(
|
|
|
161
270
|
prefix += " "
|
|
162
271
|
name = os.path.basename(item.path) or item.path
|
|
163
272
|
if item.type == "directory":
|
|
164
|
-
|
|
273
|
+
emit_info(
|
|
274
|
+
f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]",
|
|
275
|
+
message_group=group_id,
|
|
276
|
+
)
|
|
165
277
|
else:
|
|
166
278
|
icon = get_file_icon(item.path)
|
|
167
279
|
size_str = format_size(item.size)
|
|
168
|
-
|
|
169
|
-
f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]"
|
|
280
|
+
emit_info(
|
|
281
|
+
f"{prefix}{icon} [green]{name}[/green] [dim]({size_str})[/dim]",
|
|
282
|
+
message_group=group_id,
|
|
170
283
|
)
|
|
171
284
|
else:
|
|
172
|
-
|
|
285
|
+
emit_warning("Directory is empty", message_group=group_id)
|
|
173
286
|
dir_count = sum(1 for item in results if item.type == "directory")
|
|
174
287
|
file_count = sum(1 for item in results if item.type == "file")
|
|
175
288
|
total_size = sum(item.size for item in results if item.type == "file")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]"
|
|
289
|
+
emit_info("\n[bold cyan]Summary:[/bold cyan]", message_group=group_id)
|
|
290
|
+
emit_info(
|
|
291
|
+
f"\U0001f4c1 [blue]{dir_count} directories[/blue], \U0001f4c4 [green]{file_count} files[/green] [dim]({format_size(total_size)} total)[/dim]",
|
|
292
|
+
message_group=group_id,
|
|
179
293
|
)
|
|
180
|
-
|
|
294
|
+
emit_divider(message_group=group_id)
|
|
181
295
|
return ListFileOutput(files=results)
|
|
182
296
|
|
|
183
297
|
|
|
184
|
-
class ReadFileOutput(BaseModel):
|
|
185
|
-
content: str | None
|
|
186
|
-
num_tokens: conint(lt=10000)
|
|
187
|
-
error: str | None = None
|
|
188
|
-
|
|
189
|
-
|
|
190
298
|
def _read_file(
|
|
191
299
|
context: RunContext,
|
|
192
300
|
file_path: str,
|
|
@@ -195,13 +303,16 @@ def _read_file(
|
|
|
195
303
|
) -> ReadFileOutput:
|
|
196
304
|
file_path = os.path.abspath(file_path)
|
|
197
305
|
|
|
306
|
+
# Generate group_id for this tool execution
|
|
307
|
+
group_id = generate_group_id("read_file", file_path)
|
|
308
|
+
|
|
198
309
|
# Build console message with optional parameters
|
|
199
310
|
console_msg = f"\n[bold white on blue] READ FILE [/bold white on blue] \U0001f4c2 [bold cyan]{file_path}[/bold cyan]"
|
|
200
311
|
if start_line is not None and num_lines is not None:
|
|
201
312
|
console_msg += f" [dim](lines {start_line}-{start_line + num_lines - 1})[/dim]"
|
|
202
|
-
|
|
313
|
+
emit_info(console_msg, message_group=group_id)
|
|
203
314
|
|
|
204
|
-
|
|
315
|
+
emit_divider(message_group=group_id)
|
|
205
316
|
if not os.path.exists(file_path):
|
|
206
317
|
error_msg = f"File {file_path} does not exist"
|
|
207
318
|
return ReadFileOutput(content=error_msg, num_tokens=0, error=error_msg)
|
|
@@ -224,12 +335,14 @@ def _read_file(
|
|
|
224
335
|
# Read the entire file
|
|
225
336
|
content = f.read()
|
|
226
337
|
|
|
227
|
-
|
|
338
|
+
# Simple approximation: ~4 characters per token
|
|
339
|
+
num_tokens = len(content) // 4
|
|
228
340
|
if num_tokens > 10000:
|
|
229
|
-
|
|
230
|
-
|
|
341
|
+
return ReadFileOutput(
|
|
342
|
+
content=None,
|
|
343
|
+
error="The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks.",
|
|
344
|
+
num_tokens=0,
|
|
231
345
|
)
|
|
232
|
-
token_guard(num_tokens)
|
|
233
346
|
return ReadFileOutput(content=content, num_tokens=num_tokens)
|
|
234
347
|
except (FileNotFoundError, PermissionError):
|
|
235
348
|
# For backward compatibility with tests, return "FILE NOT FOUND" for these specific errors
|
|
@@ -240,23 +353,18 @@ def _read_file(
|
|
|
240
353
|
return ReadFileOutput(content=message, num_tokens=0, error=message)
|
|
241
354
|
|
|
242
355
|
|
|
243
|
-
class MatchInfo(BaseModel):
|
|
244
|
-
file_path: str | None
|
|
245
|
-
line_number: int | None
|
|
246
|
-
line_content: str | None
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
class GrepOutput(BaseModel):
|
|
250
|
-
matches: List[MatchInfo]
|
|
251
|
-
|
|
252
|
-
|
|
253
356
|
def _grep(context: RunContext, search_string: str, directory: str = ".") -> GrepOutput:
|
|
254
357
|
matches: List[MatchInfo] = []
|
|
255
358
|
directory = os.path.abspath(directory)
|
|
256
|
-
|
|
257
|
-
|
|
359
|
+
|
|
360
|
+
# Generate group_id for this tool execution
|
|
361
|
+
group_id = generate_group_id("grep", f"{directory}_{search_string}")
|
|
362
|
+
|
|
363
|
+
emit_info(
|
|
364
|
+
f"\n[bold white on blue] GREP [/bold white on blue] \U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim]for '{search_string}'[/dim]",
|
|
365
|
+
message_group=group_id,
|
|
258
366
|
)
|
|
259
|
-
|
|
367
|
+
emit_divider(message_group=group_id)
|
|
260
368
|
|
|
261
369
|
for root, dirs, files in os.walk(directory, topdown=True):
|
|
262
370
|
# Filter out ignored directories
|
|
@@ -266,11 +374,9 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
266
374
|
file_path = os.path.join(root, f_name)
|
|
267
375
|
|
|
268
376
|
if should_ignore_path(file_path):
|
|
269
|
-
# console.print(f"[dim]Ignoring: {file_path}[/dim]") # Optional: for debugging ignored files
|
|
270
377
|
continue
|
|
271
378
|
|
|
272
379
|
try:
|
|
273
|
-
# console.print(f"\U0001f4c2 [bold cyan]Searching: {file_path}[/bold cyan]") # Optional: for verbose searching log
|
|
274
380
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
275
381
|
for line_number, line_content in enumerate(fh, 1):
|
|
276
382
|
if search_string in line_content:
|
|
@@ -282,69 +388,236 @@ def _grep(context: RunContext, search_string: str, directory: str = ".") -> Grep
|
|
|
282
388
|
}
|
|
283
389
|
)
|
|
284
390
|
matches.append(match_info)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
391
|
+
emit_system_message(
|
|
392
|
+
f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}",
|
|
393
|
+
message_group=group_id,
|
|
394
|
+
)
|
|
395
|
+
if len(matches) >= 50:
|
|
396
|
+
emit_warning(
|
|
397
|
+
"Limit of 50 matches reached. Stopping search.",
|
|
398
|
+
message_group=group_id,
|
|
291
399
|
)
|
|
292
400
|
return GrepOutput(matches=matches)
|
|
293
401
|
except FileNotFoundError:
|
|
294
|
-
|
|
295
|
-
f"
|
|
402
|
+
emit_warning(
|
|
403
|
+
f"File not found (possibly a broken symlink): {file_path}",
|
|
404
|
+
message_group=group_id,
|
|
296
405
|
)
|
|
297
406
|
continue
|
|
298
407
|
except UnicodeDecodeError:
|
|
299
|
-
|
|
300
|
-
f"
|
|
408
|
+
emit_warning(
|
|
409
|
+
f"Cannot decode file (likely binary): {file_path}",
|
|
410
|
+
message_group=group_id,
|
|
301
411
|
)
|
|
302
412
|
continue
|
|
303
413
|
except Exception as e:
|
|
304
|
-
|
|
414
|
+
emit_error(
|
|
415
|
+
f"Error processing file {file_path}: {e}", message_group=group_id
|
|
416
|
+
)
|
|
305
417
|
continue
|
|
306
418
|
|
|
307
419
|
if not matches:
|
|
308
|
-
|
|
309
|
-
f"
|
|
420
|
+
emit_warning(
|
|
421
|
+
f"No matches found for '{search_string}' in {directory}",
|
|
422
|
+
message_group=group_id,
|
|
310
423
|
)
|
|
311
424
|
else:
|
|
312
|
-
|
|
313
|
-
f"
|
|
425
|
+
emit_success(
|
|
426
|
+
f"Found {len(matches)} match(es) for '{search_string}' in {directory}",
|
|
427
|
+
message_group=group_id,
|
|
314
428
|
)
|
|
315
429
|
|
|
316
430
|
return GrepOutput(matches=matches)
|
|
317
431
|
|
|
318
432
|
|
|
319
|
-
|
|
320
|
-
context: RunContext, directory: str = ".", recursive: bool = True
|
|
321
|
-
) -> ListFileOutput:
|
|
322
|
-
list_files_output = _list_files(context, directory, recursive)
|
|
323
|
-
num_tokens = estimate_tokens(list_files_output.model_dump_json())
|
|
324
|
-
if num_tokens > 10000:
|
|
325
|
-
return ListFileOutput(
|
|
326
|
-
files=[],
|
|
327
|
-
error="Too many files - tokens exceeded. Try listing non-recursively",
|
|
328
|
-
)
|
|
329
|
-
return list_files_output
|
|
433
|
+
# Exported top-level functions for direct import by tests and other code
|
|
330
434
|
|
|
331
435
|
|
|
332
|
-
def
|
|
333
|
-
context
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
) -> ReadFileOutput:
|
|
436
|
+
def list_files(context, directory=".", recursive=True):
|
|
437
|
+
return _list_files(context, directory, recursive)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def read_file(context, file_path, start_line=None, num_lines=None):
|
|
338
441
|
return _read_file(context, file_path, start_line, num_lines)
|
|
339
442
|
|
|
340
443
|
|
|
341
|
-
def grep(
|
|
342
|
-
context: RunContext, search_string: str = "", directory: str = "."
|
|
343
|
-
) -> GrepOutput:
|
|
444
|
+
def grep(context, search_string, directory="."):
|
|
344
445
|
return _grep(context, search_string, directory)
|
|
345
446
|
|
|
346
447
|
|
|
347
448
|
def register_file_operations_tools(agent):
|
|
348
|
-
agent.tool
|
|
349
|
-
|
|
350
|
-
|
|
449
|
+
@agent.tool
|
|
450
|
+
def list_files(
|
|
451
|
+
context: RunContext, directory: str = ".", recursive: bool = True
|
|
452
|
+
) -> ListFileOutput:
|
|
453
|
+
"""List files and directories with intelligent filtering and safety features.
|
|
454
|
+
|
|
455
|
+
This tool provides comprehensive directory listing with smart home directory
|
|
456
|
+
detection, project-aware recursion, and token-safe output. It automatically
|
|
457
|
+
ignores common build artifacts, cache directories, and other noise while
|
|
458
|
+
providing rich file metadata and visual formatting.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
462
|
+
directory (str, optional): Path to the directory to list. Can be relative
|
|
463
|
+
or absolute. Defaults to "." (current directory).
|
|
464
|
+
recursive (bool, optional): Whether to recursively list subdirectories.
|
|
465
|
+
Automatically disabled for home directories unless they contain
|
|
466
|
+
project indicators. Defaults to True.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
ListFileOutput: A structured response containing:
|
|
470
|
+
- files (List[ListedFile]): List of files and directories found, where
|
|
471
|
+
each ListedFile contains:
|
|
472
|
+
- path (str | None): Relative path from the listing directory
|
|
473
|
+
- type (str | None): "file" or "directory"
|
|
474
|
+
- size (int): File size in bytes (0 for directories)
|
|
475
|
+
- full_path (str | None): Absolute path to the item
|
|
476
|
+
- depth (int | None): Nesting depth from the root directory
|
|
477
|
+
- error (str | None): Error message if listing failed
|
|
478
|
+
|
|
479
|
+
Note:
|
|
480
|
+
- Automatically ignores common patterns (.git, node_modules, __pycache__, etc.)
|
|
481
|
+
- Limits output to 10,000 tokens for safety (suggests non-recursive if exceeded)
|
|
482
|
+
- Smart home directory detection prevents performance issues
|
|
483
|
+
- Files are displayed with appropriate icons and size formatting
|
|
484
|
+
- Project directories are detected via common configuration files
|
|
485
|
+
|
|
486
|
+
Examples:
|
|
487
|
+
>>> result = list_files(ctx, "./src", recursive=True)
|
|
488
|
+
>>> if not result.error:
|
|
489
|
+
... for file in result.files:
|
|
490
|
+
... if file.type == "file" and file.path.endswith(".py"):
|
|
491
|
+
... print(f"Python file: {file.path} ({file.size} bytes)")
|
|
492
|
+
|
|
493
|
+
Best Practice:
|
|
494
|
+
- Use recursive=False for initial exploration of unknown directories
|
|
495
|
+
- When encountering "too many files" errors, try non-recursive listing
|
|
496
|
+
- Check the error field before processing the files list
|
|
497
|
+
"""
|
|
498
|
+
list_files_result = _list_files(context, directory, recursive)
|
|
499
|
+
num_tokens = (
|
|
500
|
+
len(list_files_result.model_dump_json()) / 4
|
|
501
|
+
) # Rough estimate of tokens
|
|
502
|
+
if num_tokens > 10000:
|
|
503
|
+
return ListFileOutput(
|
|
504
|
+
files=[],
|
|
505
|
+
error="Too many files - tokens exceeded. Try listing non-recursively",
|
|
506
|
+
)
|
|
507
|
+
return list_files_result
|
|
508
|
+
|
|
509
|
+
@agent.tool
|
|
510
|
+
def read_file(
|
|
511
|
+
context: RunContext,
|
|
512
|
+
file_path: str = "",
|
|
513
|
+
start_line: int | None = None,
|
|
514
|
+
num_lines: int | None = None,
|
|
515
|
+
) -> ReadFileOutput:
|
|
516
|
+
"""Read file contents with optional line-range selection and token safety.
|
|
517
|
+
|
|
518
|
+
This tool provides safe file reading with automatic token counting and
|
|
519
|
+
optional line-range selection for handling large files efficiently.
|
|
520
|
+
It protects against reading excessively large files that could overwhelm
|
|
521
|
+
the agent's context window.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
525
|
+
file_path (str): Path to the file to read. Can be relative or absolute.
|
|
526
|
+
Cannot be empty.
|
|
527
|
+
start_line (int | None, optional): Starting line number for partial reads
|
|
528
|
+
(1-based indexing). If specified, num_lines must also be provided.
|
|
529
|
+
Defaults to None (read entire file).
|
|
530
|
+
num_lines (int | None, optional): Number of lines to read starting from
|
|
531
|
+
start_line. Must be specified if start_line is provided.
|
|
532
|
+
Defaults to None (read to end of file).
|
|
533
|
+
|
|
534
|
+
Returns:
|
|
535
|
+
ReadFileOutput: A structured response containing:
|
|
536
|
+
- content (str | None): The file contents or error message
|
|
537
|
+
- num_tokens (int): Estimated token count (constrained to < 10,000)
|
|
538
|
+
- error (str | None): Error message if reading failed
|
|
539
|
+
|
|
540
|
+
Note:
|
|
541
|
+
- Files larger than 10,000 estimated tokens cannot be read entirely
|
|
542
|
+
- Token estimation uses ~4 characters per token approximation
|
|
543
|
+
- Line numbers are 1-based (first line is line 1)
|
|
544
|
+
- Supports UTF-8 encoding with fallback error handling
|
|
545
|
+
- Non-existent files return "FILE NOT FOUND" for backward compatibility
|
|
546
|
+
|
|
547
|
+
Examples:
|
|
548
|
+
>>> # Read entire file
|
|
549
|
+
>>> result = read_file(ctx, "config.py")
|
|
550
|
+
>>> if not result.error:
|
|
551
|
+
... print(f"File has {result.num_tokens} tokens")
|
|
552
|
+
... print(result.content)
|
|
553
|
+
|
|
554
|
+
>>> # Read specific line range
|
|
555
|
+
>>> result = read_file(ctx, "large_file.py", start_line=100, num_lines=50)
|
|
556
|
+
>>> # Reads lines 100-149
|
|
557
|
+
|
|
558
|
+
Raises:
|
|
559
|
+
ValueError: If file exceeds 10,000 token safety limit (caught and returned as error)
|
|
560
|
+
|
|
561
|
+
Best Practice:
|
|
562
|
+
- For large files, use line-range reading to avoid token limits
|
|
563
|
+
- Always check the error field before processing content
|
|
564
|
+
- Use grep tool first to locate relevant sections in large files
|
|
565
|
+
- Prefer reading configuration files entirely, code files in chunks
|
|
566
|
+
"""
|
|
567
|
+
return _read_file(context, file_path, start_line, num_lines)
|
|
568
|
+
|
|
569
|
+
@agent.tool
|
|
570
|
+
def grep(
|
|
571
|
+
context: RunContext, search_string: str = "", directory: str = "."
|
|
572
|
+
) -> GrepOutput:
|
|
573
|
+
"""Recursively search for text patterns across files with intelligent filtering.
|
|
574
|
+
|
|
575
|
+
This tool provides powerful text searching across directory trees with
|
|
576
|
+
automatic filtering of irrelevant files, binary detection, and match limiting
|
|
577
|
+
for performance. It's essential for code exploration and finding specific
|
|
578
|
+
patterns or references.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
context (RunContext): The PydanticAI runtime context for the agent.
|
|
582
|
+
search_string (str): The text pattern to search for. Performs exact
|
|
583
|
+
string matching (not regex). Cannot be empty.
|
|
584
|
+
directory (str, optional): Root directory to start the recursive search.
|
|
585
|
+
Can be relative or absolute. Defaults to "." (current directory).
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
GrepOutput: A structured response containing:
|
|
589
|
+
- matches (List[MatchInfo]): List of matches found, where each
|
|
590
|
+
MatchInfo contains:
|
|
591
|
+
- file_path (str | None): Absolute path to the file containing the match
|
|
592
|
+
- line_number (int | None): Line number where match was found (1-based)
|
|
593
|
+
- line_content (str | None): Full line content containing the match
|
|
594
|
+
|
|
595
|
+
Note:
|
|
596
|
+
- Automatically ignores common patterns (.git, node_modules, __pycache__, etc.)
|
|
597
|
+
- Skips binary files and handles Unicode decode errors gracefully
|
|
598
|
+
- Limited to 200 matches maximum for performance and relevance
|
|
599
|
+
- UTF-8 encoding with error tolerance for text files
|
|
600
|
+
- Results are not sorted - appear in filesystem traversal order
|
|
601
|
+
|
|
602
|
+
Examples:
|
|
603
|
+
>>> # Search for function definitions
|
|
604
|
+
>>> result = grep(ctx, "def calculate_", "./src")
|
|
605
|
+
>>> for match in result.matches:
|
|
606
|
+
... print(f"{match.file_path}:{match.line_number}: {match.line_content.strip()}")
|
|
607
|
+
|
|
608
|
+
>>> # Find configuration references
|
|
609
|
+
>>> result = grep(ctx, "DATABASE_URL", ".")
|
|
610
|
+
>>> print(f"Found {len(result.matches)} references to DATABASE_URL")
|
|
611
|
+
|
|
612
|
+
Warning:
|
|
613
|
+
- Large codebases may hit the 200 match limit
|
|
614
|
+
- Search is case-sensitive and literal (no regex patterns)
|
|
615
|
+
- Binary files are automatically skipped with warnings
|
|
616
|
+
|
|
617
|
+
Best Practice:
|
|
618
|
+
- Use specific search terms to avoid too many matches
|
|
619
|
+
- Start with narrow directory scope for faster results
|
|
620
|
+
- Combine with read_file to examine matches in detail
|
|
621
|
+
- For case-insensitive search, try multiple variants manually
|
|
622
|
+
"""
|
|
623
|
+
return _grep(context, search_string, directory)
|
code_puppy/tools/token_check.py
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
from code_puppy.token_utils import estimate_tokens_for_message
|
|
1
|
+
try:
|
|
2
|
+
from code_puppy.token_utils import estimate_tokens_for_message
|
|
3
|
+
from code_puppy.tools.common import get_model_context_length
|
|
4
|
+
except ImportError:
|
|
5
|
+
# Fallback if these modules aren't available in the internal version
|
|
6
|
+
def get_model_context_length():
|
|
7
|
+
return 128000 # Default context length
|
|
3
8
|
|
|
9
|
+
def estimate_tokens_for_message(msg):
|
|
10
|
+
# Simple fallback estimation
|
|
11
|
+
return len(str(msg)) // 4 # Rough estimate: 4 chars per token
|
|
4
12
|
|
|
5
|
-
def token_guard(num_tokens: int):
|
|
6
|
-
from code_puppy import state_management
|
|
7
13
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
14
|
+
def token_guard(num_tokens: int):
|
|
15
|
+
try:
|
|
16
|
+
from code_puppy import state_management
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
current_history = state_management.get_message_history()
|
|
19
|
+
message_hist_tokens = sum(
|
|
20
|
+
estimate_tokens_for_message(msg) for msg in current_history
|
|
16
21
|
)
|
|
22
|
+
|
|
23
|
+
if message_hist_tokens + num_tokens > (get_model_context_length() * 0.9):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"Tokens produced by this tool call would exceed model capacity"
|
|
26
|
+
)
|
|
27
|
+
except ImportError:
|
|
28
|
+
# Fallback: simple check against a reasonable limit
|
|
29
|
+
if num_tokens > 10000:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Token count {num_tokens} exceeds safety limit of 10,000 tokens"
|
|
32
|
+
)
|