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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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")
|