minion-code 0.1.0__py3-none-any.whl → 0.1.1__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.
- examples/cli_entrypoint.py +60 -0
- examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
- examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
- examples/components/messages_component.py +199 -0
- examples/file_freshness_example.py +22 -22
- examples/file_watching_example.py +32 -26
- examples/interruptible_tui.py +921 -3
- examples/repl_tui.py +129 -0
- examples/skills/example_usage.py +57 -0
- examples/start.py +173 -0
- minion_code/__init__.py +1 -1
- minion_code/acp_server/__init__.py +34 -0
- minion_code/acp_server/agent.py +539 -0
- minion_code/acp_server/hooks.py +354 -0
- minion_code/acp_server/main.py +194 -0
- minion_code/acp_server/permissions.py +142 -0
- minion_code/acp_server/test_client.py +104 -0
- minion_code/adapters/__init__.py +22 -0
- minion_code/adapters/output_adapter.py +207 -0
- minion_code/adapters/rich_adapter.py +169 -0
- minion_code/adapters/textual_adapter.py +254 -0
- minion_code/agents/__init__.py +2 -2
- minion_code/agents/code_agent.py +517 -104
- minion_code/agents/hooks.py +378 -0
- minion_code/cli.py +538 -429
- minion_code/cli_simple.py +665 -0
- minion_code/commands/__init__.py +136 -29
- minion_code/commands/clear_command.py +19 -46
- minion_code/commands/help_command.py +33 -49
- minion_code/commands/history_command.py +37 -55
- minion_code/commands/model_command.py +194 -0
- minion_code/commands/quit_command.py +9 -12
- minion_code/commands/resume_command.py +181 -0
- minion_code/commands/skill_command.py +89 -0
- minion_code/commands/status_command.py +48 -73
- minion_code/commands/tools_command.py +54 -52
- minion_code/commands/version_command.py +34 -69
- minion_code/components/ConfirmDialog.py +430 -0
- minion_code/components/Message.py +318 -97
- minion_code/components/MessageResponse.py +30 -29
- minion_code/components/Messages.py +351 -0
- minion_code/components/PromptInput.py +499 -245
- minion_code/components/__init__.py +24 -17
- minion_code/const.py +7 -0
- minion_code/screens/REPL.py +1453 -469
- minion_code/screens/__init__.py +1 -1
- minion_code/services/__init__.py +20 -20
- minion_code/services/event_system.py +19 -14
- minion_code/services/file_freshness_service.py +223 -170
- minion_code/skills/__init__.py +25 -0
- minion_code/skills/skill.py +128 -0
- minion_code/skills/skill_loader.py +198 -0
- minion_code/skills/skill_registry.py +177 -0
- minion_code/subagents/__init__.py +31 -0
- minion_code/subagents/builtin/__init__.py +30 -0
- minion_code/subagents/builtin/claude_code_guide.py +32 -0
- minion_code/subagents/builtin/explore.py +36 -0
- minion_code/subagents/builtin/general_purpose.py +19 -0
- minion_code/subagents/builtin/plan.py +61 -0
- minion_code/subagents/subagent.py +116 -0
- minion_code/subagents/subagent_loader.py +147 -0
- minion_code/subagents/subagent_registry.py +151 -0
- minion_code/tools/__init__.py +8 -2
- minion_code/tools/bash_tool.py +16 -3
- minion_code/tools/file_edit_tool.py +201 -104
- minion_code/tools/file_read_tool.py +183 -26
- minion_code/tools/file_write_tool.py +17 -3
- minion_code/tools/glob_tool.py +23 -2
- minion_code/tools/grep_tool.py +229 -21
- minion_code/tools/ls_tool.py +28 -3
- minion_code/tools/multi_edit_tool.py +89 -84
- minion_code/tools/python_interpreter_tool.py +9 -1
- minion_code/tools/skill_tool.py +210 -0
- minion_code/tools/task_tool.py +287 -0
- minion_code/tools/todo_read_tool.py +28 -24
- minion_code/tools/todo_write_tool.py +82 -65
- minion_code/{types.py → type_defs.py} +15 -2
- minion_code/utils/__init__.py +45 -17
- minion_code/utils/config.py +610 -0
- minion_code/utils/history.py +114 -0
- minion_code/utils/logs.py +53 -0
- minion_code/utils/mcp_loader.py +153 -55
- minion_code/utils/output_truncator.py +233 -0
- minion_code/utils/session_storage.py +369 -0
- minion_code/utils/todo_file_utils.py +26 -22
- minion_code/utils/todo_storage.py +43 -33
- minion_code/web/__init__.py +9 -0
- minion_code/web/adapters/__init__.py +5 -0
- minion_code/web/adapters/web_adapter.py +524 -0
- minion_code/web/api/__init__.py +7 -0
- minion_code/web/api/chat.py +277 -0
- minion_code/web/api/interactions.py +136 -0
- minion_code/web/api/sessions.py +135 -0
- minion_code/web/server.py +149 -0
- minion_code/web/services/__init__.py +5 -0
- minion_code/web/services/session_manager.py +420 -0
- minion_code-0.1.1.dist-info/METADATA +475 -0
- minion_code-0.1.1.dist-info/RECORD +111 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/WHEEL +1 -1
- minion_code-0.1.1.dist-info/entry_points.txt +6 -0
- tests/test_adapter.py +67 -0
- tests/test_adapter_simple.py +79 -0
- tests/test_file_read_tool.py +144 -0
- tests/test_readonly_tools.py +0 -2
- tests/test_skills.py +441 -0
- examples/advance_tui.py +0 -508
- examples/rich_example.py +0 -4
- examples/simple_file_watching.py +0 -57
- examples/simple_tui.py +0 -267
- examples/simple_usage.py +0 -69
- minion_code-0.1.0.dist-info/METADATA +0 -350
- minion_code-0.1.0.dist-info/RECORD +0 -59
- minion_code-0.1.0.dist-info/entry_points.txt +0 -4
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/licenses/LICENSE +0 -0
- {minion_code-0.1.0.dist-info → minion_code-0.1.1.dist-info}/top_level.txt +0 -0
|
@@ -4,13 +4,26 @@
|
|
|
4
4
|
File reading tool
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import base64
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import Optional
|
|
9
|
+
from typing import Optional, Union, Any
|
|
9
10
|
from minion.tools import BaseTool
|
|
11
|
+
from ..utils.output_truncator import (
|
|
12
|
+
check_file_size_before_read,
|
|
13
|
+
FileTooLargeError,
|
|
14
|
+
truncate_output,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
HAS_PIL = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_PIL = False
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
class FileReadTool(BaseTool):
|
|
13
|
-
"""File reading tool"""
|
|
26
|
+
"""File reading tool with image support"""
|
|
14
27
|
|
|
15
28
|
name = "file_read"
|
|
16
29
|
description = "Read file content, supports text files and image files"
|
|
@@ -19,23 +32,46 @@ class FileReadTool(BaseTool):
|
|
|
19
32
|
"file_path": {"type": "string", "description": "File path to read"},
|
|
20
33
|
"offset": {
|
|
21
34
|
"type": "integer",
|
|
22
|
-
"description": "Starting line number (optional)",
|
|
35
|
+
"description": "Starting line number (optional, for text files)",
|
|
23
36
|
"nullable": True,
|
|
24
37
|
},
|
|
25
38
|
"limit": {
|
|
26
39
|
"type": "integer",
|
|
27
|
-
"description": "Line count limit (optional)",
|
|
40
|
+
"description": "Line count limit (optional, for text files)",
|
|
28
41
|
"nullable": True,
|
|
29
42
|
},
|
|
30
43
|
}
|
|
31
|
-
output_type = "string
|
|
44
|
+
output_type = "any" # Can return string or PIL.Image
|
|
45
|
+
|
|
46
|
+
def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
|
|
47
|
+
super().__init__(*args, **kwargs)
|
|
48
|
+
self.workdir = Path(workdir) if workdir else None
|
|
49
|
+
# State tracking for last execution
|
|
50
|
+
self._last_file_path = None
|
|
51
|
+
self._last_offset = None
|
|
52
|
+
self._last_limit = None
|
|
53
|
+
self._last_total_lines = None
|
|
54
|
+
|
|
55
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
56
|
+
"""Resolve path using workdir if path is relative."""
|
|
57
|
+
path = Path(file_path)
|
|
58
|
+
if path.is_absolute():
|
|
59
|
+
return path
|
|
60
|
+
if self.workdir:
|
|
61
|
+
return self.workdir / path
|
|
62
|
+
return path # Relative to cwd (backward compatible)
|
|
32
63
|
|
|
33
64
|
def forward(
|
|
34
65
|
self, file_path: str, offset: Optional[int] = None, limit: Optional[int] = None
|
|
35
|
-
) -> str:
|
|
36
|
-
"""Read file content
|
|
66
|
+
) -> Union[str, Any]:
|
|
67
|
+
"""Read file content
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
- For text files: returns the text content as string
|
|
71
|
+
- For image files: returns PIL.Image object (or error string if PIL not available)
|
|
72
|
+
"""
|
|
37
73
|
try:
|
|
38
|
-
path =
|
|
74
|
+
path = self._resolve_path(file_path)
|
|
39
75
|
if not path.exists():
|
|
40
76
|
return f"Error: File does not exist - {file_path}"
|
|
41
77
|
|
|
@@ -43,31 +79,152 @@ class FileReadTool(BaseTool):
|
|
|
43
79
|
return f"Error: Path is not a file - {file_path}"
|
|
44
80
|
|
|
45
81
|
# Check if it's an image file
|
|
46
|
-
image_extensions = {
|
|
82
|
+
image_extensions = {
|
|
83
|
+
".png",
|
|
84
|
+
".jpg",
|
|
85
|
+
".jpeg",
|
|
86
|
+
".gif",
|
|
87
|
+
".bmp",
|
|
88
|
+
".webp",
|
|
89
|
+
".tiff",
|
|
90
|
+
".svg",
|
|
91
|
+
}
|
|
47
92
|
if path.suffix.lower() in image_extensions:
|
|
48
|
-
return
|
|
93
|
+
return self._read_image(path)
|
|
94
|
+
|
|
95
|
+
# 执行前检查文件大小(仅对非分页读取)
|
|
96
|
+
if offset is None and limit is None:
|
|
97
|
+
try:
|
|
98
|
+
check_file_size_before_read(file_path)
|
|
99
|
+
except FileTooLargeError as e:
|
|
100
|
+
return f"Error: {str(e)}"
|
|
49
101
|
|
|
50
102
|
# Read text file
|
|
51
|
-
|
|
52
|
-
|
|
103
|
+
return self._read_text(path, offset, limit)
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
return f"Error reading file: {str(e)}"
|
|
53
107
|
|
|
54
|
-
|
|
108
|
+
def _read_image(self, path: Path) -> Union[Any, str]:
|
|
109
|
+
"""Read image file and return PIL.Image object"""
|
|
110
|
+
if not HAS_PIL:
|
|
111
|
+
return (
|
|
112
|
+
f"Error: PIL (Pillow) is not installed. Cannot read image file: {path}"
|
|
113
|
+
)
|
|
55
114
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
115
|
+
try:
|
|
116
|
+
image = Image.open(path)
|
|
117
|
+
# Store state for format_for_observation
|
|
118
|
+
self._last_file_path = str(path)
|
|
119
|
+
self._last_offset = None
|
|
120
|
+
self._last_limit = None
|
|
121
|
+
self._last_total_lines = None
|
|
122
|
+
return image
|
|
123
|
+
except Exception as e:
|
|
124
|
+
return f"Error opening image file {path}: {str(e)}"
|
|
61
125
|
|
|
62
|
-
|
|
126
|
+
def _read_text(
|
|
127
|
+
self, path: Path, offset: Optional[int] = None, limit: Optional[int] = None
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Read text file and return content"""
|
|
130
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
131
|
+
lines = f.readlines()
|
|
63
132
|
|
|
64
|
-
|
|
65
|
-
result += f"Total lines: {total_lines}\n"
|
|
66
|
-
if offset is not None or limit is not None:
|
|
67
|
-
result += f"Displayed lines: {len(lines)}\n"
|
|
68
|
-
result += f"Content:\n{content}"
|
|
133
|
+
total_lines = len(lines)
|
|
69
134
|
|
|
70
|
-
|
|
135
|
+
# Store state for format_for_observation
|
|
136
|
+
self._last_file_path = str(path)
|
|
137
|
+
self._last_offset = offset
|
|
138
|
+
self._last_limit = limit
|
|
139
|
+
self._last_total_lines = total_lines
|
|
140
|
+
|
|
141
|
+
# Apply offset and limit
|
|
142
|
+
if offset is not None:
|
|
143
|
+
lines = lines[offset:]
|
|
144
|
+
if limit is not None:
|
|
145
|
+
lines = lines[:limit]
|
|
146
|
+
|
|
147
|
+
content = "".join(lines)
|
|
148
|
+
return content
|
|
149
|
+
|
|
150
|
+
def format_for_observation(self, output: Any) -> str:
|
|
151
|
+
"""Format tool output for LLM observation.
|
|
152
|
+
|
|
153
|
+
For images: Convert PIL.Image to base64 encoded format
|
|
154
|
+
For text: Add line numbers and metadata
|
|
155
|
+
"""
|
|
156
|
+
# Handle error strings
|
|
157
|
+
if isinstance(output, str) and output.startswith("Error:"):
|
|
158
|
+
return output
|
|
71
159
|
|
|
160
|
+
# Handle PIL Image
|
|
161
|
+
if HAS_PIL and isinstance(output, Image.Image):
|
|
162
|
+
return self._format_image_for_observation(output)
|
|
163
|
+
|
|
164
|
+
# Handle text content
|
|
165
|
+
if isinstance(output, str):
|
|
166
|
+
return self._format_text_for_observation(output)
|
|
167
|
+
|
|
168
|
+
# Fallback
|
|
169
|
+
return str(output) if output is not None else ""
|
|
170
|
+
|
|
171
|
+
def _format_image_for_observation(self, image: Any) -> str:
|
|
172
|
+
"""Format PIL Image as base64 for LLM observation"""
|
|
173
|
+
import io
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Convert image to RGB if necessary (for PNG with transparency, etc.)
|
|
177
|
+
if image.mode not in ("RGB", "L"):
|
|
178
|
+
image = image.convert("RGB")
|
|
179
|
+
|
|
180
|
+
# Save image to bytes buffer
|
|
181
|
+
buffer = io.BytesIO()
|
|
182
|
+
image.save(buffer, format="PNG")
|
|
183
|
+
buffer.seek(0)
|
|
184
|
+
|
|
185
|
+
# Encode as base64
|
|
186
|
+
img_base64 = base64.b64encode(buffer.read()).decode("utf-8")
|
|
187
|
+
|
|
188
|
+
# Format for LLM observation
|
|
189
|
+
result = f"Image file: {self._last_file_path}\n"
|
|
190
|
+
result += f"Size: {image.size[0]}x{image.size[1]} pixels\n"
|
|
191
|
+
result += f"Mode: {image.mode}\n"
|
|
192
|
+
result += f"Format: {image.format}\n"
|
|
193
|
+
result += f"\nBase64 encoded image:\n"
|
|
194
|
+
result += f"data:image/png;base64,{img_base64}"
|
|
195
|
+
|
|
196
|
+
return result
|
|
72
197
|
except Exception as e:
|
|
73
|
-
return f"Error
|
|
198
|
+
return f"Error formatting image for observation: {str(e)}"
|
|
199
|
+
|
|
200
|
+
def _format_text_for_observation(self, content: str) -> str:
|
|
201
|
+
"""Format text content with line numbers for LLM observation"""
|
|
202
|
+
if not content:
|
|
203
|
+
return f"File: {self._last_file_path}\n(empty file)"
|
|
204
|
+
|
|
205
|
+
lines = content.splitlines(keepends=True)
|
|
206
|
+
|
|
207
|
+
# Calculate starting line number
|
|
208
|
+
start_line = 1
|
|
209
|
+
if self._last_offset is not None:
|
|
210
|
+
start_line = self._last_offset + 1
|
|
211
|
+
|
|
212
|
+
# Add line numbers
|
|
213
|
+
numbered_lines = []
|
|
214
|
+
for i, line in enumerate(lines, start=start_line):
|
|
215
|
+
# Format: line_number→content
|
|
216
|
+
numbered_lines.append(f"{i:5d}→{line}")
|
|
217
|
+
|
|
218
|
+
result = f"File: {self._last_file_path}\n"
|
|
219
|
+
if self._last_total_lines is not None:
|
|
220
|
+
result += f"Total lines: {self._last_total_lines}\n"
|
|
221
|
+
if self._last_offset is not None or self._last_limit is not None:
|
|
222
|
+
result += f"Displayed lines: {len(lines)}"
|
|
223
|
+
if self._last_offset is not None:
|
|
224
|
+
result += f" (starting from line {start_line})"
|
|
225
|
+
result += "\n"
|
|
226
|
+
result += "\n"
|
|
227
|
+
result += "".join(numbered_lines)
|
|
228
|
+
|
|
229
|
+
# 应用输出截断
|
|
230
|
+
return truncate_output(result, tool_name=self.name)
|
|
@@ -5,6 +5,7 @@ File writing tool
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
8
9
|
from minion.tools import BaseTool
|
|
9
10
|
|
|
10
11
|
|
|
@@ -20,17 +21,30 @@ class FileWriteTool(BaseTool):
|
|
|
20
21
|
}
|
|
21
22
|
output_type = "string"
|
|
22
23
|
|
|
24
|
+
def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self.workdir = Path(workdir) if workdir else None
|
|
27
|
+
|
|
28
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
29
|
+
"""Resolve path using workdir if path is relative."""
|
|
30
|
+
path = Path(file_path)
|
|
31
|
+
if path.is_absolute():
|
|
32
|
+
return path
|
|
33
|
+
if self.workdir:
|
|
34
|
+
return self.workdir / path
|
|
35
|
+
return path # Relative to cwd (backward compatible)
|
|
36
|
+
|
|
23
37
|
def forward(self, file_path: str, content: str) -> str:
|
|
24
38
|
"""Write file content"""
|
|
25
39
|
try:
|
|
26
|
-
path =
|
|
40
|
+
path = self._resolve_path(file_path)
|
|
27
41
|
# Create directory if it doesn't exist
|
|
28
42
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
43
|
|
|
30
|
-
with open(
|
|
44
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
31
45
|
f.write(content)
|
|
32
46
|
|
|
33
|
-
return f"Successfully wrote to file: {
|
|
47
|
+
return f"Successfully wrote to file: {path} ({len(content)} characters)"
|
|
34
48
|
|
|
35
49
|
except Exception as e:
|
|
36
50
|
return f"Error writing file: {str(e)}"
|
minion_code/tools/glob_tool.py
CHANGED
|
@@ -6,7 +6,9 @@ File pattern matching tool
|
|
|
6
6
|
|
|
7
7
|
import glob
|
|
8
8
|
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
9
10
|
from minion.tools import BaseTool
|
|
11
|
+
from ..utils.output_truncator import truncate_output
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
class GlobTool(BaseTool):
|
|
@@ -21,10 +23,23 @@ class GlobTool(BaseTool):
|
|
|
21
23
|
}
|
|
22
24
|
output_type = "string"
|
|
23
25
|
|
|
26
|
+
def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self.workdir = Path(workdir) if workdir else None
|
|
29
|
+
|
|
30
|
+
def _resolve_path(self, path: str) -> Path:
|
|
31
|
+
"""Resolve path using workdir if path is relative."""
|
|
32
|
+
p = Path(path)
|
|
33
|
+
if p.is_absolute():
|
|
34
|
+
return p
|
|
35
|
+
if self.workdir:
|
|
36
|
+
return self.workdir / p
|
|
37
|
+
return p # Relative to cwd (backward compatible)
|
|
38
|
+
|
|
24
39
|
def forward(self, pattern: str, path: str = ".") -> str:
|
|
25
40
|
"""Match files using glob pattern"""
|
|
26
41
|
try:
|
|
27
|
-
search_path =
|
|
42
|
+
search_path = self._resolve_path(path)
|
|
28
43
|
if not search_path.exists():
|
|
29
44
|
return f"Error: Path does not exist - {path}"
|
|
30
45
|
|
|
@@ -52,7 +67,13 @@ class GlobTool(BaseTool):
|
|
|
52
67
|
result += f" Other: {match}\n"
|
|
53
68
|
|
|
54
69
|
result += f"\nTotal {len(matches)} matches found"
|
|
55
|
-
return result
|
|
70
|
+
return self.format_for_observation(result)
|
|
56
71
|
|
|
57
72
|
except Exception as e:
|
|
58
73
|
return f"Error during glob matching: {str(e)}"
|
|
74
|
+
|
|
75
|
+
def format_for_observation(self, output: Any) -> str:
|
|
76
|
+
"""格式化输出,自动截断过大内容"""
|
|
77
|
+
if isinstance(output, str):
|
|
78
|
+
return truncate_output(output, tool_name=self.name)
|
|
79
|
+
return str(output)
|
minion_code/tools/grep_tool.py
CHANGED
|
@@ -6,8 +6,9 @@ Text search tool
|
|
|
6
6
|
|
|
7
7
|
import re
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import List, Optional
|
|
9
|
+
from typing import List, Optional, Any
|
|
10
10
|
from minion.tools import BaseTool
|
|
11
|
+
from ..utils.output_truncator import truncate_output
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class GrepTool(BaseTool):
|
|
@@ -17,22 +18,84 @@ class GrepTool(BaseTool):
|
|
|
17
18
|
description = "Search for text patterns in files"
|
|
18
19
|
readonly = True # Read-only tool, does not modify system state
|
|
19
20
|
inputs = {
|
|
20
|
-
"pattern": {
|
|
21
|
+
"pattern": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Regular expression pattern to search for",
|
|
24
|
+
},
|
|
21
25
|
"path": {"type": "string", "description": "Search path (file or directory)"},
|
|
22
26
|
"include": {
|
|
23
27
|
"type": "string",
|
|
24
28
|
"description": "File pattern to include (optional)",
|
|
25
29
|
"nullable": True,
|
|
26
30
|
},
|
|
31
|
+
"output_mode": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Output mode: 'content' (show matching lines), 'files_with_matches' (show file paths), 'count' (show match counts)",
|
|
34
|
+
"nullable": True,
|
|
35
|
+
},
|
|
36
|
+
"head_limit": {
|
|
37
|
+
"type": "integer",
|
|
38
|
+
"description": "Limit output to first N entries",
|
|
39
|
+
"nullable": True,
|
|
40
|
+
},
|
|
41
|
+
"after_context": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"description": "Number of lines to show after each match (-A)",
|
|
44
|
+
"nullable": True,
|
|
45
|
+
},
|
|
46
|
+
"before_context": {
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"description": "Number of lines to show before each match (-B)",
|
|
49
|
+
"nullable": True,
|
|
50
|
+
},
|
|
51
|
+
"context": {
|
|
52
|
+
"type": "integer",
|
|
53
|
+
"description": "Number of lines to show before and after each match (-C)",
|
|
54
|
+
"nullable": True,
|
|
55
|
+
},
|
|
27
56
|
}
|
|
28
57
|
output_type = "string"
|
|
29
58
|
|
|
59
|
+
def __init__(self, workdir: Optional[str] = None, *args, **kwargs):
|
|
60
|
+
super().__init__(*args, **kwargs)
|
|
61
|
+
self.workdir = Path(workdir) if workdir else None
|
|
62
|
+
|
|
63
|
+
def _resolve_path(self, path: str) -> Path:
|
|
64
|
+
"""Resolve path using workdir if path is relative."""
|
|
65
|
+
p = Path(path)
|
|
66
|
+
if p.is_absolute():
|
|
67
|
+
return p
|
|
68
|
+
if self.workdir:
|
|
69
|
+
return self.workdir / p
|
|
70
|
+
return p # Relative to cwd (backward compatible)
|
|
71
|
+
|
|
30
72
|
def forward(
|
|
31
|
-
self,
|
|
73
|
+
self,
|
|
74
|
+
pattern: str,
|
|
75
|
+
path: str = ".",
|
|
76
|
+
include: Optional[str] = None,
|
|
77
|
+
output_mode: Optional[str] = None,
|
|
78
|
+
head_limit: Optional[int] = None,
|
|
79
|
+
after_context: Optional[int] = None,
|
|
80
|
+
before_context: Optional[int] = None,
|
|
81
|
+
context: Optional[int] = None,
|
|
32
82
|
) -> str:
|
|
33
83
|
"""Search for text pattern"""
|
|
34
84
|
try:
|
|
35
|
-
|
|
85
|
+
# Default to 'content' mode for backward compatibility
|
|
86
|
+
if output_mode is None:
|
|
87
|
+
output_mode = "content"
|
|
88
|
+
|
|
89
|
+
# Validate output_mode
|
|
90
|
+
if output_mode not in ["content", "files_with_matches", "count"]:
|
|
91
|
+
return f"Error: Invalid output_mode '{output_mode}'. Must be 'content', 'files_with_matches', or 'count'"
|
|
92
|
+
|
|
93
|
+
# Handle context parameters (-C sets both -A and -B)
|
|
94
|
+
if context is not None:
|
|
95
|
+
after_context = context
|
|
96
|
+
before_context = context
|
|
97
|
+
|
|
98
|
+
search_path = self._resolve_path(path)
|
|
36
99
|
if not search_path.exists():
|
|
37
100
|
return f"Error: Path does not exist - {path}"
|
|
38
101
|
|
|
@@ -40,45 +103,184 @@ class GrepTool(BaseTool):
|
|
|
40
103
|
|
|
41
104
|
if search_path.is_file():
|
|
42
105
|
# Search single file
|
|
43
|
-
matches.extend(
|
|
106
|
+
matches.extend(
|
|
107
|
+
self._search_file(
|
|
108
|
+
search_path, pattern, before_context, after_context
|
|
109
|
+
)
|
|
110
|
+
)
|
|
44
111
|
else:
|
|
45
112
|
# Search directory
|
|
46
113
|
if include:
|
|
47
114
|
# Filter using file pattern
|
|
48
115
|
for file_path in search_path.rglob(include):
|
|
49
116
|
if file_path.is_file():
|
|
50
|
-
matches.extend(
|
|
117
|
+
matches.extend(
|
|
118
|
+
self._search_file(
|
|
119
|
+
file_path, pattern, before_context, after_context
|
|
120
|
+
)
|
|
121
|
+
)
|
|
51
122
|
else:
|
|
52
123
|
# Search all text files
|
|
53
124
|
for file_path in search_path.rglob("*"):
|
|
54
125
|
if file_path.is_file() and self._is_text_file(file_path):
|
|
55
|
-
matches.extend(
|
|
126
|
+
matches.extend(
|
|
127
|
+
self._search_file(
|
|
128
|
+
file_path, pattern, before_context, after_context
|
|
129
|
+
)
|
|
130
|
+
)
|
|
56
131
|
|
|
57
132
|
if not matches:
|
|
58
133
|
return f"No content found matching pattern '{pattern}'"
|
|
59
134
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
135
|
+
# Format output based on mode
|
|
136
|
+
if output_mode == "files_with_matches":
|
|
137
|
+
result = self._format_files_with_matches(matches, pattern, head_limit)
|
|
138
|
+
elif output_mode == "count":
|
|
139
|
+
result = self._format_count(matches, pattern, head_limit)
|
|
140
|
+
else: # content mode
|
|
141
|
+
result = self._format_content(
|
|
142
|
+
matches, pattern, head_limit, before_context, after_context
|
|
143
|
+
)
|
|
68
144
|
|
|
69
|
-
|
|
70
|
-
return result
|
|
145
|
+
return self.format_for_observation(result)
|
|
71
146
|
|
|
72
147
|
except Exception as e:
|
|
73
148
|
return f"Error during search: {str(e)}"
|
|
74
149
|
|
|
75
|
-
def
|
|
76
|
-
|
|
150
|
+
def _format_content(
|
|
151
|
+
self,
|
|
152
|
+
matches: List[tuple],
|
|
153
|
+
pattern: str,
|
|
154
|
+
head_limit: Optional[int],
|
|
155
|
+
before_context: Optional[int] = None,
|
|
156
|
+
after_context: Optional[int] = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Format matches as content with line numbers and optional context"""
|
|
159
|
+
result = f"Search results for pattern '{pattern}':\n\n"
|
|
160
|
+
current_file = None
|
|
161
|
+
count = 0
|
|
162
|
+
has_context = before_context or after_context
|
|
163
|
+
|
|
164
|
+
for match in matches:
|
|
165
|
+
if head_limit and count >= head_limit:
|
|
166
|
+
result += f"\n(Output limited to {head_limit} matches)"
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
# Unpack match tuple based on whether it has context
|
|
170
|
+
if has_context and len(match) == 5:
|
|
171
|
+
file_path, line_num, line_content, before_lines, after_lines = match
|
|
172
|
+
else:
|
|
173
|
+
file_path, line_num, line_content = match[:3]
|
|
174
|
+
before_lines = []
|
|
175
|
+
after_lines = []
|
|
176
|
+
|
|
177
|
+
if file_path != current_file:
|
|
178
|
+
if current_file is not None and has_context:
|
|
179
|
+
result += "--\n" # Separator between files
|
|
180
|
+
result += f"File: {file_path}\n"
|
|
181
|
+
current_file = file_path
|
|
182
|
+
|
|
183
|
+
# Show before context lines
|
|
184
|
+
for ctx_line_num, ctx_line in before_lines:
|
|
185
|
+
result += f" {ctx_line_num}- {ctx_line.rstrip()}\n"
|
|
186
|
+
|
|
187
|
+
# Show the matching line (highlighted with :)
|
|
188
|
+
result += f" {line_num}: {line_content.rstrip()}\n"
|
|
189
|
+
|
|
190
|
+
# Show after context lines
|
|
191
|
+
for ctx_line_num, ctx_line in after_lines:
|
|
192
|
+
result += f" {ctx_line_num}- {ctx_line.rstrip()}\n"
|
|
193
|
+
|
|
194
|
+
# Add separator between matches if using context
|
|
195
|
+
if has_context:
|
|
196
|
+
result += "--\n"
|
|
197
|
+
|
|
198
|
+
count += 1
|
|
199
|
+
|
|
200
|
+
result += f"\nTotal {len(matches)} matches found"
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
def _format_files_with_matches(
|
|
204
|
+
self, matches: List[tuple], pattern: str, head_limit: Optional[int]
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Format matches as list of unique file paths"""
|
|
207
|
+
# Get unique file paths
|
|
208
|
+
unique_files = []
|
|
209
|
+
seen = set()
|
|
210
|
+
for file_path, _, _ in matches:
|
|
211
|
+
if file_path not in seen:
|
|
212
|
+
seen.add(file_path)
|
|
213
|
+
unique_files.append(file_path)
|
|
214
|
+
if head_limit and len(unique_files) >= head_limit:
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
result = f"Files matching pattern '{pattern}':\n\n"
|
|
218
|
+
for file_path in unique_files:
|
|
219
|
+
result += f"{file_path}\n"
|
|
220
|
+
|
|
221
|
+
if head_limit and len(seen) > head_limit:
|
|
222
|
+
result += f"\n(Output limited to {head_limit} files)"
|
|
223
|
+
result += f"\nTotal {len(seen)} files with matches"
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
def _format_count(
|
|
227
|
+
self, matches: List[tuple], pattern: str, head_limit: Optional[int]
|
|
228
|
+
) -> str:
|
|
229
|
+
"""Format matches as count per file"""
|
|
230
|
+
# Count matches per file
|
|
231
|
+
file_counts = {}
|
|
232
|
+
for file_path, _, _ in matches:
|
|
233
|
+
file_counts[file_path] = file_counts.get(file_path, 0) + 1
|
|
234
|
+
|
|
235
|
+
result = f"Match counts for pattern '{pattern}':\n\n"
|
|
236
|
+
count = 0
|
|
237
|
+
for file_path, match_count in file_counts.items():
|
|
238
|
+
if head_limit and count >= head_limit:
|
|
239
|
+
result += f"\n(Output limited to {head_limit} files)"
|
|
240
|
+
break
|
|
241
|
+
result += f"{file_path}: {match_count} matches\n"
|
|
242
|
+
count += 1
|
|
243
|
+
|
|
244
|
+
result += (
|
|
245
|
+
f"\nTotal {sum(file_counts.values())} matches in {len(file_counts)} files"
|
|
246
|
+
)
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
def _search_file(
|
|
250
|
+
self,
|
|
251
|
+
file_path: Path,
|
|
252
|
+
pattern: str,
|
|
253
|
+
before_context: Optional[int] = None,
|
|
254
|
+
after_context: Optional[int] = None,
|
|
255
|
+
) -> List[tuple]:
|
|
256
|
+
"""Search pattern in a single file with optional context lines"""
|
|
77
257
|
matches = []
|
|
78
258
|
try:
|
|
79
259
|
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
80
|
-
|
|
81
|
-
|
|
260
|
+
lines = f.readlines()
|
|
261
|
+
|
|
262
|
+
total_lines = len(lines)
|
|
263
|
+
for line_num, line in enumerate(lines, 1):
|
|
264
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
265
|
+
if before_context or after_context:
|
|
266
|
+
# Collect before context
|
|
267
|
+
before_lines = []
|
|
268
|
+
if before_context:
|
|
269
|
+
start = max(0, line_num - 1 - before_context)
|
|
270
|
+
for i in range(start, line_num - 1):
|
|
271
|
+
before_lines.append((i + 1, lines[i]))
|
|
272
|
+
|
|
273
|
+
# Collect after context
|
|
274
|
+
after_lines = []
|
|
275
|
+
if after_context:
|
|
276
|
+
end = min(total_lines, line_num + after_context)
|
|
277
|
+
for i in range(line_num, end):
|
|
278
|
+
after_lines.append((i + 1, lines[i]))
|
|
279
|
+
|
|
280
|
+
matches.append(
|
|
281
|
+
(str(file_path), line_num, line, before_lines, after_lines)
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
82
284
|
matches.append((str(file_path), line_num, line))
|
|
83
285
|
except Exception:
|
|
84
286
|
# Ignore files that cannot be read
|
|
@@ -103,3 +305,9 @@ class GrepTool(BaseTool):
|
|
|
103
305
|
".conf",
|
|
104
306
|
}
|
|
105
307
|
return file_path.suffix.lower() in text_extensions
|
|
308
|
+
|
|
309
|
+
def format_for_observation(self, output: Any) -> str:
|
|
310
|
+
"""格式化输出,自动截断过大内容"""
|
|
311
|
+
if isinstance(output, str):
|
|
312
|
+
return truncate_output(output, tool_name=self.name)
|
|
313
|
+
return str(output)
|