code-puppy 0.0.96__py3-none-any.whl → 0.0.118__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.
Files changed (81) hide show
  1. code_puppy/__init__.py +2 -5
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agent.py +125 -40
  4. code_puppy/agent_prompts.py +30 -24
  5. code_puppy/callbacks.py +152 -0
  6. code_puppy/command_line/command_handler.py +359 -0
  7. code_puppy/command_line/load_context_completion.py +59 -0
  8. code_puppy/command_line/model_picker_completion.py +14 -21
  9. code_puppy/command_line/motd.py +44 -28
  10. code_puppy/command_line/prompt_toolkit_completion.py +42 -23
  11. code_puppy/config.py +266 -26
  12. code_puppy/http_utils.py +122 -0
  13. code_puppy/main.py +570 -383
  14. code_puppy/message_history_processor.py +195 -104
  15. code_puppy/messaging/__init__.py +46 -0
  16. code_puppy/messaging/message_queue.py +288 -0
  17. code_puppy/messaging/queue_console.py +293 -0
  18. code_puppy/messaging/renderers.py +305 -0
  19. code_puppy/messaging/spinner/__init__.py +55 -0
  20. code_puppy/messaging/spinner/console_spinner.py +200 -0
  21. code_puppy/messaging/spinner/spinner_base.py +66 -0
  22. code_puppy/messaging/spinner/textual_spinner.py +97 -0
  23. code_puppy/model_factory.py +73 -105
  24. code_puppy/plugins/__init__.py +32 -0
  25. code_puppy/reopenable_async_client.py +225 -0
  26. code_puppy/state_management.py +60 -21
  27. code_puppy/summarization_agent.py +56 -35
  28. code_puppy/token_utils.py +7 -9
  29. code_puppy/tools/__init__.py +1 -4
  30. code_puppy/tools/command_runner.py +187 -32
  31. code_puppy/tools/common.py +44 -35
  32. code_puppy/tools/file_modifications.py +335 -118
  33. code_puppy/tools/file_operations.py +368 -95
  34. code_puppy/tools/token_check.py +27 -11
  35. code_puppy/tools/tools_content.py +53 -0
  36. code_puppy/tui/__init__.py +10 -0
  37. code_puppy/tui/app.py +1050 -0
  38. code_puppy/tui/components/__init__.py +21 -0
  39. code_puppy/tui/components/chat_view.py +512 -0
  40. code_puppy/tui/components/command_history_modal.py +218 -0
  41. code_puppy/tui/components/copy_button.py +139 -0
  42. code_puppy/tui/components/custom_widgets.py +58 -0
  43. code_puppy/tui/components/input_area.py +167 -0
  44. code_puppy/tui/components/sidebar.py +309 -0
  45. code_puppy/tui/components/status_bar.py +182 -0
  46. code_puppy/tui/messages.py +27 -0
  47. code_puppy/tui/models/__init__.py +8 -0
  48. code_puppy/tui/models/chat_message.py +25 -0
  49. code_puppy/tui/models/command_history.py +89 -0
  50. code_puppy/tui/models/enums.py +24 -0
  51. code_puppy/tui/screens/__init__.py +13 -0
  52. code_puppy/tui/screens/help.py +130 -0
  53. code_puppy/tui/screens/settings.py +256 -0
  54. code_puppy/tui/screens/tools.py +74 -0
  55. code_puppy/tui/tests/__init__.py +1 -0
  56. code_puppy/tui/tests/test_chat_message.py +28 -0
  57. code_puppy/tui/tests/test_chat_view.py +88 -0
  58. code_puppy/tui/tests/test_command_history.py +89 -0
  59. code_puppy/tui/tests/test_copy_button.py +191 -0
  60. code_puppy/tui/tests/test_custom_widgets.py +27 -0
  61. code_puppy/tui/tests/test_disclaimer.py +27 -0
  62. code_puppy/tui/tests/test_enums.py +15 -0
  63. code_puppy/tui/tests/test_file_browser.py +60 -0
  64. code_puppy/tui/tests/test_help.py +38 -0
  65. code_puppy/tui/tests/test_history_file_reader.py +107 -0
  66. code_puppy/tui/tests/test_input_area.py +33 -0
  67. code_puppy/tui/tests/test_settings.py +44 -0
  68. code_puppy/tui/tests/test_sidebar.py +33 -0
  69. code_puppy/tui/tests/test_sidebar_history.py +153 -0
  70. code_puppy/tui/tests/test_sidebar_history_navigation.py +132 -0
  71. code_puppy/tui/tests/test_status_bar.py +54 -0
  72. code_puppy/tui/tests/test_timestamped_history.py +52 -0
  73. code_puppy/tui/tests/test_tools.py +82 -0
  74. code_puppy/version_checker.py +26 -3
  75. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/METADATA +9 -2
  76. code_puppy-0.0.118.dist-info/RECORD +86 -0
  77. code_puppy-0.0.96.dist-info/RECORD +0 -32
  78. {code_puppy-0.0.96.data → code_puppy-0.0.118.data}/data/code_puppy/models.json +0 -0
  79. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/WHEEL +0 -0
  80. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.dist-info}/entry_points.txt +0 -0
  81. {code_puppy-0.0.96.dist-info → code_puppy-0.0.118.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.tools.common import should_ignore_path
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
- console.print("\n[bold white on blue] DIRECTORY LISTING [/bold white on blue]")
38
- console.print(
39
- f"\U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim](recursive={recursive})[/dim]"
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
- console.print("[dim]" + "-" * 60 + "[/dim]")
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
- console.print(
44
- f"[bold red]Error:[/bold red] Directory '{directory}' does not exist"
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
- console.print(f"[bold red]Error:[/bold red] '{directory}' is not a directory")
52
- console.print("[dim]" + "-" * 60 + "[/dim]\n")
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
- dir_path = os.path.join(directory, rel_path)
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
- console.print(
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
- console.print(f"{prefix}\U0001f4c1 [bold blue]{name}/[/bold blue]")
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
- console.print(
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
- console.print("[yellow]Directory is empty[/yellow]")
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
- console.print("\n[bold cyan]Summary:[/bold cyan]")
177
- console.print(
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
- console.print("[dim]" + "-" * 60 + "[/dim]\n")
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
- console.print(console_msg)
313
+ emit_info(console_msg, message_group=group_id)
203
314
 
204
- console.print("[dim]" + "-" * 60 + "[/dim]")
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
- num_tokens = estimate_tokens(content)
338
+ # Simple approximation: ~4 characters per token
339
+ num_tokens = len(content) // 4
228
340
  if num_tokens > 10000:
229
- raise ValueError(
230
- "The file is massive, greater than 10,000 tokens which is dangerous to read entirely. Please read this file in chunks."
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
- console.print(
257
- f"\n[bold white on blue] GREP [/bold white on blue] \U0001f4c2 [bold cyan]{directory}[/bold cyan] [dim]for '{search_string}'[/dim]"
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
- console.print("[dim]" + "-" * 60 + "[/dim]")
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
- # console.print(
286
- # f"[green]Match:[/green] {file_path}:{line_number} - {line_content.strip()}"
287
- # ) # Optional: for verbose match logging
288
- if len(matches) >= 200:
289
- console.print(
290
- "[yellow]Limit of 200 matches reached. Stopping search.[/yellow]"
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
- console.print(
295
- f"[yellow]File not found (possibly a broken symlink): {file_path}[/yellow]"
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
- console.print(
300
- f"[yellow]Cannot decode file (likely binary): {file_path}[/yellow]"
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
- console.print(f"[red]Error processing file {file_path}: {e}[/red]")
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
- console.print(
309
- f"[yellow]No matches found for '{search_string}' in {directory}[/yellow]"
420
+ emit_warning(
421
+ f"No matches found for '{search_string}' in {directory}",
422
+ message_group=group_id,
310
423
  )
311
424
  else:
312
- console.print(
313
- f"[green]Found {len(matches)} match(es) for '{search_string}' in {directory}[/green]"
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
- def list_files(
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 read_file(
333
- context: RunContext,
334
- file_path: str = "",
335
- start_line: int | None = None,
336
- num_lines: int | None = None,
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(list_files)
349
- agent.tool(read_file)
350
- agent.tool(grep)
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)
@@ -1,16 +1,32 @@
1
- from code_puppy.tools.common import get_model_context_length
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
- current_history = state_management.get_message_history()
9
- message_hist_tokens = sum(
10
- estimate_tokens_for_message(msg) for msg in current_history
11
- )
14
+ def token_guard(num_tokens: int):
15
+ try:
16
+ from code_puppy import state_management
12
17
 
13
- if message_hist_tokens + num_tokens > (get_model_context_length() * 0.9):
14
- raise ValueError(
15
- "Tokens produced by this tool call would exceed model capacity"
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
+ )