kolega-code 0.1.0__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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,323 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import List, Tuple, Literal
4
+ import shlex
5
+
6
+ from .base_tool import BaseTool
7
+
8
+ FileType = Literal["f", "d"]
9
+ FileRow = Tuple[str, FileType, int, int] # (path, type, size_bytes, mtime_epoch)
10
+
11
+
12
+ class GlobTool(BaseTool):
13
+ BINARY_EXTENSIONS = {
14
+ ".pyc",
15
+ ".so",
16
+ ".dll",
17
+ ".exe",
18
+ ".bin",
19
+ ".jar",
20
+ ".war",
21
+ ".jpg",
22
+ ".jpeg",
23
+ ".png",
24
+ ".gif",
25
+ ".bmp",
26
+ ".ico",
27
+ ".svg",
28
+ ".pdf",
29
+ ".zip",
30
+ ".tar",
31
+ ".gz",
32
+ ".tgz",
33
+ ".rar",
34
+ ".7z",
35
+ ".mp3",
36
+ ".mp4",
37
+ ".avi",
38
+ ".mov",
39
+ ".mkv",
40
+ ".wav",
41
+ ".o",
42
+ ".obj",
43
+ ".class",
44
+ ".binary",
45
+ ".wasm",
46
+ ".node",
47
+ }
48
+
49
+ EXCLUDE_DIRS = {
50
+ ".git",
51
+ ".svn",
52
+ ".hg",
53
+ ".idea",
54
+ ".vscode",
55
+ "__pycache__",
56
+ "node_modules",
57
+ "venv",
58
+ "env",
59
+ ".env",
60
+ "dist",
61
+ "build",
62
+ "target",
63
+ "bin",
64
+ "obj",
65
+ ".next",
66
+ ".nuxt",
67
+ "coverage",
68
+ }
69
+
70
+ MAX_RESULTS = 128
71
+ MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
72
+
73
+ async def find_files_by_pattern(
74
+ self, pattern: str, include_directories: bool = True, show_details: bool = True
75
+ ) -> str:
76
+ """
77
+ Find files matching a glob pattern in the project directory.
78
+
79
+ Args:
80
+ pattern: Glob pattern to match files (e.g., "*.py", "src/**/*.js")
81
+ include_directories: Whether to include directories in results (default: False)
82
+ show_details: Whether to show file details like size and modification time (default: True)
83
+
84
+ Returns:
85
+ Markdown formatted list of files matching the pattern, limited to MAX_RESULTS
86
+
87
+ Raises:
88
+ Exception: If any error occurs during the search operation
89
+ """
90
+ try:
91
+ await self.log_info(f"Searching for files matching pattern: '{pattern}'", sender=self.caller.agent_name)
92
+
93
+ normalized_pattern = self._normalize_pattern(pattern)
94
+
95
+ if hasattr(self.filesystem, "sandbox"):
96
+ rows, total_items, reached_limit = await self._search_files_sandbox(
97
+ normalized_pattern, include_directories, self.MAX_RESULTS
98
+ )
99
+ else:
100
+ rows, total_items, reached_limit = await self._search_files_local(
101
+ normalized_pattern, include_directories, self.MAX_RESULTS
102
+ )
103
+
104
+ if total_items == 0:
105
+ return f"No files found matching pattern: '{normalized_pattern}'"
106
+
107
+ return self._format_results(rows, total_items, reached_limit, show_details, normalized_pattern)
108
+
109
+ except Exception as e:
110
+ error_msg = f"Error finding files: {str(e)}"
111
+ await self.log_error(error_msg, sender=self.caller.agent_name)
112
+ return f"Error: {error_msg}"
113
+
114
+ def _normalize_pattern(self, pattern: str) -> str:
115
+ p = (pattern or "").strip()
116
+ if p.startswith("/"):
117
+ p = p[1:]
118
+ # Bare filename → recursive filename search
119
+ if all(ch not in p for ch in ("*", "?", "[")) and "/" not in p:
120
+ p = f"**/{p}"
121
+ return p
122
+
123
+ async def _search_files_local(
124
+ self, pattern: str, include_directories: bool, limit: int
125
+ ) -> Tuple[List[FileRow], int, bool]:
126
+ matched_paths = self.filesystem.glob(pattern)
127
+
128
+ filtered: List[FileRow] = []
129
+ total_items = 0
130
+
131
+ for rel_path in sorted(matched_paths):
132
+ is_file = self.filesystem.is_file(rel_path)
133
+ is_dir = self.filesystem.is_directory(rel_path)
134
+
135
+ if not include_directories and not is_file:
136
+ continue
137
+
138
+ # Exclude by directory names
139
+ parts = Path(rel_path).parts
140
+ if any(part in self.EXCLUDE_DIRS for part in parts):
141
+ continue
142
+
143
+ if is_file:
144
+ # Exclude by extension
145
+ if Path(rel_path).suffix.lower() in self.BINARY_EXTENSIONS:
146
+ continue
147
+
148
+ # Exclude by size and collect size/mtime
149
+ try:
150
+ stat_info = self.filesystem.stat(rel_path)
151
+ size = int(stat_info.get("size", 0))
152
+ if size > self.MAX_FILE_SIZE_BYTES:
153
+ continue
154
+ mtime = int(stat_info.get("modified_time", 0))
155
+ except Exception:
156
+ # If stat fails, skip file
157
+ continue
158
+
159
+ row: FileRow = (rel_path, "f", size, mtime)
160
+ elif is_dir and include_directories:
161
+ try:
162
+ stat_info = self.filesystem.stat(rel_path)
163
+ mtime = int(stat_info.get("modified_time", 0))
164
+ except Exception:
165
+ mtime = 0
166
+ row = (rel_path, "d", 0, mtime)
167
+ else:
168
+ continue
169
+
170
+ total_items += 1
171
+ if len(filtered) < limit:
172
+ filtered.append(row)
173
+
174
+ reached_limit = total_items > limit
175
+ return filtered, total_items, reached_limit
176
+
177
+ async def _search_files_sandbox(
178
+ self, pattern: str, include_directories: bool, limit: int
179
+ ) -> Tuple[List[FileRow], int, bool]:
180
+ root = shlex.quote(getattr(self.filesystem, "root_path", "."))
181
+ include_flag = "1" if include_directories else "0"
182
+
183
+ # Build prune expression for find
184
+ exclude_list = " -o ".join([f"-name {shlex.quote(d)}" for d in sorted(self.EXCLUDE_DIRS)])
185
+ prune = f"\\( {exclude_list} \\) -type d -prune -o"
186
+
187
+ script = f"""bash -O globstar -c '
188
+ set -euo pipefail
189
+ cd {root}
190
+
191
+ pattern={shlex.quote(pattern)}
192
+ max_results={limit}
193
+ include_dirs={include_flag}
194
+
195
+ run_find() {{
196
+ case "$pattern" in
197
+ **"**/"**)
198
+ name_pat="${{pattern##*/}}"
199
+ find . {prune} -name "$name_pat" -print
200
+ ;;
201
+ *"/"*)
202
+ dir_part="${{pattern%/*}}"
203
+ name_pat="${{pattern##*/}}"
204
+ base_dir="${{dir_part##*/}}"
205
+ case " {' '.join(sorted(self.EXCLUDE_DIRS))} " in *" $base_dir "*) exit 0;; esac
206
+ find "$dir_part" -maxdepth 1 -name "$name_pat" -print
207
+ ;;
208
+ *)
209
+ find . {prune} -name "$pattern" -print
210
+ ;;
211
+ esac
212
+ }}
213
+
214
+ matches=$(run_find | sed "s#^\\./##" | sort)
215
+ total_items=$(printf "%s\\n" "$matches" | sed "/^$/d" | wc -l | tr -d " ")
216
+ limited=$(printf "%s\\n" "$matches" | sed "/^$/d" | head -n "$max_results")
217
+
218
+ # Emit TSV: path \t type(f|d) \t size(bytes) \t mtime(epoch)
219
+ while IFS= read -r p; do
220
+ [[ -z "$p" ]] && continue
221
+ if [[ -f "$p" ]]; then
222
+ sz=$(stat -c %s "$p" 2>/dev/null || echo 0)
223
+ mt=$(stat -c %Y "$p" 2>/dev/null || echo 0)
224
+ printf "%s\tf\t%s\t%s\n" "$p" "$sz" "$mt"
225
+ elif [[ -d "$p" ]] && [[ "$include_dirs" == "1" ]]; then
226
+ mt=$(stat -c %Y "$p" 2>/dev/null || echo 0)
227
+ printf "%s\td\t0\t%s\n" "$p" "$mt"
228
+ fi
229
+ done <<< "$limited"
230
+
231
+ echo "__TOTAL__ $total_items"
232
+ '"""
233
+
234
+ result = await self.filesystem.sandbox.commands.run(script)
235
+ if result.exit_code != 0:
236
+ return [], 0, False
237
+
238
+ lines = [ln for ln in (result.stdout or "").splitlines() if ln.strip()]
239
+ rows: List[FileRow] = []
240
+ total_items = 0
241
+
242
+ for ln in lines:
243
+ if ln.startswith("__TOTAL__ "):
244
+ try:
245
+ total_items = int(ln.split()[-1])
246
+ except Exception:
247
+ total_items = len(rows)
248
+ continue
249
+ parts = ln.split("\t")
250
+ if len(parts) != 4:
251
+ continue
252
+ path_str, type_str, size_str, mtime_str = parts
253
+ # Additional filtering matching local rules
254
+ if type_str == "f":
255
+ if Path(path_str).suffix.lower() in self.BINARY_EXTENSIONS:
256
+ continue
257
+ try:
258
+ size_val = int(size_str or "0")
259
+ if size_val > self.MAX_FILE_SIZE_BYTES:
260
+ continue
261
+ except Exception:
262
+ continue
263
+ rows.append((path_str, "f" if type_str == "f" else "d", int(size_str or "0"), int(float(mtime_str or "0"))))
264
+
265
+ reached_limit = total_items > limit
266
+ return rows, total_items, reached_limit
267
+
268
+ def _format_results(
269
+ self, rows: List[FileRow], total_items: int, reached_limit: bool, show_details: bool, pattern: str
270
+ ) -> str:
271
+ results: List[str] = [f"# Files Matching '{pattern}'"]
272
+ if reached_limit:
273
+ results.append(f"\nFound {total_items} matching items (showing first {self.MAX_RESULTS})\n")
274
+ results.append(f"⚠️ **Note:** Displaying only the first {self.MAX_RESULTS} of {total_items} results.\n")
275
+ else:
276
+ results.append(f"\nFound {total_items} matching items\n")
277
+
278
+ by_directory: dict[str, List[FileRow]] = {}
279
+ for path_str, ftype, size_bytes, mtime_epoch in rows:
280
+ parent = self.filesystem.get_parent(path_str) or ""
281
+ by_directory.setdefault(parent, []).append((path_str, ftype, size_bytes, mtime_epoch))
282
+
283
+ for directory in sorted(by_directory.keys()):
284
+ # Match original behavior: only empty string maps to Root Directory; '.' prints as './'
285
+ if directory:
286
+ results.append(f"## {directory}/")
287
+ else:
288
+ results.append("## Root Directory")
289
+
290
+ for path_str, ftype, size_bytes, mtime_epoch in sorted(by_directory[directory]):
291
+ filename = self.filesystem.get_name(path_str)
292
+ if ftype == "d":
293
+ item_type = "📁 Directory"
294
+ size_text = "unknown items"
295
+ else:
296
+ item_type = "📄 File"
297
+ try:
298
+ if size_bytes < 1024:
299
+ size_text = f"{size_bytes} bytes"
300
+ elif size_bytes < 1024 * 1024:
301
+ size_text = f"{size_bytes/1024:.1f} KB"
302
+ else:
303
+ size_text = f"{size_bytes/(1024*1024):.1f} MB"
304
+ except Exception:
305
+ size_text = "unknown size"
306
+
307
+ line = f"- **{filename}** ({item_type})"
308
+ if show_details:
309
+ try:
310
+ mod_time = datetime.fromtimestamp(mtime_epoch)
311
+ mod_time_str = mod_time.strftime("%Y-%m-%d %H:%M:%S")
312
+ line += f"\n - Size: {size_text}"
313
+ line += f"\n - Modified: {mod_time_str}"
314
+ if ftype == "f":
315
+ ext = Path(filename).suffix
316
+ if ext:
317
+ line += f"\n - Type: {ext} file"
318
+ except Exception:
319
+ line += f"\n - Size: {size_text}"
320
+ results.append(line)
321
+ results.append("")
322
+
323
+ return "\n".join(results)
@@ -0,0 +1,300 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+
4
+ from .base_tool import BaseTool
5
+
6
+
7
+ class ListDirectoryTool(BaseTool):
8
+ async def list_directory(self, relative_path: str = "") -> str:
9
+ """
10
+ List files and directories at the specified path.
11
+
12
+ Args:
13
+ relative_path: Path to list, relative to the project root
14
+
15
+ Returns:
16
+ Markdown formatted list of files and directories with details
17
+
18
+ Raises:
19
+ NotADirectoryError: If the path is not a directory
20
+ """
21
+ if not self.filesystem.exists(relative_path):
22
+ raise FileNotFoundError(f"Directory not found: {relative_path}")
23
+
24
+ if not self.filesystem.is_directory(relative_path):
25
+ raise NotADirectoryError(f"Not a directory: {relative_path}")
26
+
27
+ # Use sandbox-specific implementation if available
28
+ if hasattr(self.filesystem, "sandbox"):
29
+ return await self._list_directory_sandbox(relative_path)
30
+ else:
31
+ # Use the original implementation for local filesystem
32
+ return await self._list_directory_local(relative_path)
33
+
34
+ async def _list_directory_sandbox(self, relative_path: str) -> str:
35
+ """
36
+ Sandbox-specific implementation using a single command for efficiency.
37
+ """
38
+ # Resolve the full path
39
+ full_path = self.filesystem._resolve_path(relative_path) if relative_path else self.filesystem.root_path
40
+
41
+ # Use ls with detailed format to get all info in one command
42
+ # -la: list all files with details
43
+ # --time-style=long-iso: consistent datetime format
44
+ # --group-directories-first: directories first
45
+ ls_cmd = f"cd {full_path} && ls -la --time-style=long-iso --group-directories-first 2>/dev/null"
46
+
47
+ # Also get directory item counts in one go
48
+ # Use a more robust command that handles cases with no directories
49
+ count_cmd = f'cd {full_path} && find . -maxdepth 1 -type d ! -name . -exec sh -c \'echo "$(basename "{{}}"):$(ls -1 "{{}}" 2>/dev/null | wc -l)"\' \\; 2>/dev/null || true'
50
+
51
+ # Run both commands - always await since sandbox commands are always async
52
+ ls_result = await self.filesystem.sandbox.commands.run(ls_cmd)
53
+ count_result = await self.filesystem.sandbox.commands.run(count_cmd)
54
+
55
+ if ls_result.exit_code != 0:
56
+ raise OSError(f"Failed to list directory: {ls_result.stderr}")
57
+
58
+ # Parse directory counts
59
+ dir_counts = {}
60
+ if count_result.exit_code == 0 and count_result.stdout.strip():
61
+ for line in count_result.stdout.strip().split("\n"):
62
+ if ":" in line:
63
+ dir_name, count = line.rsplit(":", 1)
64
+ dir_counts[dir_name.rstrip("/")] = count.strip()
65
+
66
+ # Parse ls output
67
+ lines_data = []
68
+ for line in ls_result.stdout.strip().split("\n")[1:]: # Skip "total" line
69
+ parts = line.split(None, 8) # Split into max 9 parts
70
+ if len(parts) < 8: # With long-iso format, we need at least 8 parts
71
+ continue
72
+
73
+ permissions = parts[0]
74
+ # parts[1] = number of links (skip)
75
+ # parts[2] = owner (skip)
76
+ # parts[3] = group (skip)
77
+ size = parts[4]
78
+ date = parts[5] # Date in YYYY-MM-DD format
79
+ time = parts[6] # Time in HH:MM format
80
+ name = parts[7] if len(parts) == 8 else " ".join(parts[7:]) # Handle filenames with spaces
81
+
82
+ # Skip . and ..
83
+ if name in [".", ".."]:
84
+ continue
85
+
86
+ # Skip .git directory
87
+ if name == ".git":
88
+ continue
89
+
90
+ # Determine if it's a directory
91
+ is_dir = permissions.startswith("d")
92
+
93
+ # Clean up name (remove trailing / for directories)
94
+ clean_name = name.rstrip("/")
95
+
96
+ # Build relative path
97
+ if relative_path:
98
+ item_path = f"{relative_path}/{clean_name}"
99
+ else:
100
+ item_path = clean_name
101
+
102
+ lines_data.append(
103
+ {
104
+ "name": clean_name,
105
+ "path": item_path,
106
+ "is_dir": is_dir,
107
+ "size": int(size) if not is_dir else dir_counts.get(clean_name, "0"),
108
+ "date": f"{date} {time}",
109
+ "permissions": permissions,
110
+ }
111
+ )
112
+
113
+ # Sort: directories first, then alphabetically
114
+ lines_data.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
115
+
116
+ # Build the markdown output
117
+ return self._format_directory_listing(relative_path, lines_data)
118
+
119
+ async def _list_directory_local(self, relative_path: str) -> str:
120
+ """
121
+ Original implementation for local filesystem.
122
+ """
123
+ # Get all items in the directory
124
+ items = self.filesystem.list_directory(relative_path)
125
+
126
+ # Sort items: directories first, then files, alphabetically within each group
127
+ items.sort(key=lambda x: (not self.filesystem.is_directory(x), self.filesystem.get_name(x).lower()))
128
+
129
+ # Build data for formatting
130
+ lines_data = []
131
+ for item in items:
132
+ # Skip .git directory
133
+ if self.filesystem.get_name(item) == ".git":
134
+ continue
135
+
136
+ try:
137
+ is_dir = self.filesystem.is_directory(item)
138
+
139
+ if is_dir:
140
+ # For directories, count items
141
+ try:
142
+ dir_items = self.filesystem.list_directory(item)
143
+ size = len(dir_items)
144
+ except:
145
+ size = 0
146
+ else:
147
+ # For files, get size
148
+ try:
149
+ size = self.filesystem.get_size(item)
150
+ except:
151
+ size = 0
152
+
153
+ # Get modification time
154
+ try:
155
+ mod_time = self.filesystem.get_modification_time(item).strftime("%Y-%m-%d %H:%M")
156
+ except:
157
+ mod_time = "Unknown"
158
+
159
+ lines_data.append(
160
+ {
161
+ "name": self.filesystem.get_name(item),
162
+ "path": item,
163
+ "is_dir": is_dir,
164
+ "size": size,
165
+ "date": mod_time,
166
+ "permissions": None, # Not available in local
167
+ }
168
+ )
169
+ except Exception as e:
170
+ # Log error but continue
171
+ await self.log_error(f"Error processing item {item}: {e}", sender=self.caller.agent_name)
172
+ continue
173
+
174
+ return self._format_directory_listing(relative_path, lines_data)
175
+
176
+ def _format_directory_listing(self, relative_path: str, items_data: list) -> str:
177
+ """
178
+ Format the directory listing data into markdown.
179
+ """
180
+ # Prepare the header
181
+ if relative_path:
182
+ title = f"# Directory: {relative_path}"
183
+ parent_dir = str(Path(relative_path).parent)
184
+ if parent_dir and parent_dir != ".":
185
+ navigation = f"📁 Parent Directory: {parent_dir}"
186
+ else:
187
+ navigation = f"📁 Root Directory"
188
+ else:
189
+ title = "# Root Directory"
190
+ navigation = ""
191
+
192
+ lines = [title, ""]
193
+ if navigation:
194
+ lines.append(navigation)
195
+ lines.append("")
196
+
197
+ lines.append("| Type | Name | Size | Modified | Description |")
198
+ lines.append("|------|------|------|----------|-------------|")
199
+
200
+ # Process each item
201
+ total_size = 0
202
+ dir_count = 0
203
+ file_count = 0
204
+
205
+ for item_data in items_data:
206
+ name = item_data["name"]
207
+ path = item_data["path"]
208
+ is_dir = item_data["is_dir"]
209
+ size = item_data["size"]
210
+ date = item_data["date"]
211
+
212
+ if is_dir:
213
+ icon = "📁"
214
+ size_str = f"{size} items"
215
+ description = "Directory"
216
+ dir_count += 1
217
+ else:
218
+ icon = "📄"
219
+ size_str = self._format_size(size)
220
+ description = self._get_file_description(name)
221
+ file_count += 1
222
+ total_size += size
223
+
224
+ # Clean up and escape the name
225
+ escaped_name = name.replace("|", "\\|")
226
+
227
+ # Format the line
228
+ lines.append(f"| {icon} | {escaped_name} | {size_str} | {date} | {description} |")
229
+
230
+ # Add summary
231
+ lines.append("")
232
+ lines.append(f"**Summary:** {dir_count} directories, {file_count} files, {self._format_size(total_size)} total")
233
+
234
+ return "\n".join(lines)
235
+
236
+ def _format_size(self, size_bytes: int) -> str:
237
+ """Format file size in human-readable format."""
238
+ if size_bytes < 1024:
239
+ return f"{size_bytes} B"
240
+ elif size_bytes < 1024 * 1024:
241
+ return f"{size_bytes/1024:.1f} KB"
242
+ elif size_bytes < 1024 * 1024 * 1024:
243
+ return f"{size_bytes/(1024*1024):.1f} MB"
244
+ else:
245
+ return f"{size_bytes/(1024*1024*1024):.1f} GB"
246
+
247
+ def _get_file_description(self, filename: str) -> str:
248
+ """Get a description based on file extension."""
249
+ extension_map = {
250
+ ".py": "Python Source",
251
+ ".js": "JavaScript Source",
252
+ ".jsx": "React JSX Source",
253
+ ".ts": "TypeScript Source",
254
+ ".tsx": "React TSX Source",
255
+ ".html": "HTML Document",
256
+ ".css": "CSS Stylesheet",
257
+ ".json": "JSON Data",
258
+ ".md": "Markdown Document",
259
+ ".txt": "Text File",
260
+ ".csv": "CSV Data",
261
+ ".yml": "YAML Configuration",
262
+ ".yaml": "YAML Configuration",
263
+ ".xml": "XML Document",
264
+ ".sql": "SQL Script",
265
+ ".sh": "Shell Script",
266
+ ".bat": "Batch Script",
267
+ ".ps1": "PowerShell Script",
268
+ ".jpg": "JPEG Image",
269
+ ".jpeg": "JPEG Image",
270
+ ".png": "PNG Image",
271
+ ".gif": "GIF Image",
272
+ ".svg": "SVG Image",
273
+ ".pdf": "PDF Document",
274
+ ".zip": "ZIP Archive",
275
+ ".tar": "TAR Archive",
276
+ ".gz": "GZIP Archive",
277
+ ".env": "Environment Variables",
278
+ ".dockerfile": "Docker Definition",
279
+ }
280
+
281
+ # Handle special files by exact filename match
282
+ lower_name = filename.lower()
283
+ if lower_name == "dockerfile":
284
+ return "Docker Definition"
285
+ elif lower_name == ".gitignore":
286
+ return "Git Ignore Rules"
287
+ elif lower_name == "readme.md":
288
+ return "Project Documentation"
289
+ elif lower_name == "license":
290
+ return "License Information"
291
+ elif lower_name == "requirements.txt":
292
+ return "Python Dependencies"
293
+ elif lower_name == "package.json":
294
+ return "Node.js Package"
295
+ elif lower_name in ["makefile", "makefile.in"]:
296
+ return "Make Build Rules"
297
+
298
+ # Get extension
299
+ ext = Path(filename).suffix.lower()
300
+ return extension_map.get(ext, f"{ext[1:].upper() if ext else 'Unknown'} File")