minion-code 0.1.0__py3-none-any.whl → 0.1.2__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 (115) hide show
  1. examples/cli_entrypoint.py +60 -0
  2. examples/{agent_with_todos.py → components/agent_with_todos.py} +58 -47
  3. examples/{message_response_children_demo.py → components/message_response_children_demo.py} +61 -55
  4. examples/components/messages_component.py +199 -0
  5. examples/file_freshness_example.py +22 -22
  6. examples/file_watching_example.py +32 -26
  7. examples/interruptible_tui.py +921 -3
  8. examples/repl_tui.py +129 -0
  9. examples/skills/example_usage.py +57 -0
  10. examples/start.py +173 -0
  11. minion_code/__init__.py +1 -1
  12. minion_code/acp_server/__init__.py +34 -0
  13. minion_code/acp_server/agent.py +539 -0
  14. minion_code/acp_server/hooks.py +354 -0
  15. minion_code/acp_server/main.py +194 -0
  16. minion_code/acp_server/permissions.py +142 -0
  17. minion_code/acp_server/test_client.py +104 -0
  18. minion_code/adapters/__init__.py +22 -0
  19. minion_code/adapters/output_adapter.py +207 -0
  20. minion_code/adapters/rich_adapter.py +169 -0
  21. minion_code/adapters/textual_adapter.py +254 -0
  22. minion_code/agents/__init__.py +2 -2
  23. minion_code/agents/code_agent.py +517 -104
  24. minion_code/agents/hooks.py +378 -0
  25. minion_code/cli.py +538 -429
  26. minion_code/cli_simple.py +665 -0
  27. minion_code/commands/__init__.py +136 -29
  28. minion_code/commands/clear_command.py +19 -46
  29. minion_code/commands/help_command.py +33 -49
  30. minion_code/commands/history_command.py +37 -55
  31. minion_code/commands/model_command.py +194 -0
  32. minion_code/commands/quit_command.py +9 -12
  33. minion_code/commands/resume_command.py +181 -0
  34. minion_code/commands/skill_command.py +89 -0
  35. minion_code/commands/status_command.py +48 -73
  36. minion_code/commands/tools_command.py +54 -52
  37. minion_code/commands/version_command.py +34 -69
  38. minion_code/components/ConfirmDialog.py +430 -0
  39. minion_code/components/Message.py +318 -97
  40. minion_code/components/MessageResponse.py +30 -29
  41. minion_code/components/Messages.py +351 -0
  42. minion_code/components/PromptInput.py +499 -245
  43. minion_code/components/__init__.py +24 -17
  44. minion_code/const.py +7 -0
  45. minion_code/screens/REPL.py +1453 -469
  46. minion_code/screens/__init__.py +1 -1
  47. minion_code/services/__init__.py +20 -20
  48. minion_code/services/event_system.py +19 -14
  49. minion_code/services/file_freshness_service.py +223 -170
  50. minion_code/skills/__init__.py +25 -0
  51. minion_code/skills/skill.py +128 -0
  52. minion_code/skills/skill_loader.py +198 -0
  53. minion_code/skills/skill_registry.py +177 -0
  54. minion_code/subagents/__init__.py +31 -0
  55. minion_code/subagents/builtin/__init__.py +30 -0
  56. minion_code/subagents/builtin/claude_code_guide.py +32 -0
  57. minion_code/subagents/builtin/explore.py +36 -0
  58. minion_code/subagents/builtin/general_purpose.py +19 -0
  59. minion_code/subagents/builtin/plan.py +61 -0
  60. minion_code/subagents/subagent.py +116 -0
  61. minion_code/subagents/subagent_loader.py +147 -0
  62. minion_code/subagents/subagent_registry.py +151 -0
  63. minion_code/tools/__init__.py +8 -2
  64. minion_code/tools/bash_tool.py +16 -3
  65. minion_code/tools/file_edit_tool.py +201 -104
  66. minion_code/tools/file_read_tool.py +183 -26
  67. minion_code/tools/file_write_tool.py +17 -3
  68. minion_code/tools/glob_tool.py +23 -2
  69. minion_code/tools/grep_tool.py +229 -21
  70. minion_code/tools/ls_tool.py +28 -3
  71. minion_code/tools/multi_edit_tool.py +89 -84
  72. minion_code/tools/python_interpreter_tool.py +9 -1
  73. minion_code/tools/skill_tool.py +210 -0
  74. minion_code/tools/task_tool.py +287 -0
  75. minion_code/tools/todo_read_tool.py +28 -24
  76. minion_code/tools/todo_write_tool.py +82 -65
  77. minion_code/{types.py → type_defs.py} +15 -2
  78. minion_code/utils/__init__.py +45 -17
  79. minion_code/utils/config.py +610 -0
  80. minion_code/utils/history.py +114 -0
  81. minion_code/utils/logs.py +53 -0
  82. minion_code/utils/mcp_loader.py +153 -55
  83. minion_code/utils/output_truncator.py +233 -0
  84. minion_code/utils/session_storage.py +369 -0
  85. minion_code/utils/todo_file_utils.py +26 -22
  86. minion_code/utils/todo_storage.py +43 -33
  87. minion_code/web/__init__.py +9 -0
  88. minion_code/web/adapters/__init__.py +5 -0
  89. minion_code/web/adapters/web_adapter.py +524 -0
  90. minion_code/web/api/__init__.py +7 -0
  91. minion_code/web/api/chat.py +277 -0
  92. minion_code/web/api/interactions.py +136 -0
  93. minion_code/web/api/sessions.py +135 -0
  94. minion_code/web/server.py +149 -0
  95. minion_code/web/services/__init__.py +5 -0
  96. minion_code/web/services/session_manager.py +420 -0
  97. minion_code-0.1.2.dist-info/METADATA +476 -0
  98. minion_code-0.1.2.dist-info/RECORD +111 -0
  99. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/WHEEL +1 -1
  100. minion_code-0.1.2.dist-info/entry_points.txt +6 -0
  101. tests/test_adapter.py +67 -0
  102. tests/test_adapter_simple.py +79 -0
  103. tests/test_file_read_tool.py +144 -0
  104. tests/test_readonly_tools.py +0 -2
  105. tests/test_skills.py +441 -0
  106. examples/advance_tui.py +0 -508
  107. examples/rich_example.py +0 -4
  108. examples/simple_file_watching.py +0 -57
  109. examples/simple_tui.py +0 -267
  110. examples/simple_usage.py +0 -69
  111. minion_code-0.1.0.dist-info/METADATA +0 -350
  112. minion_code-0.1.0.dist-info/RECORD +0 -59
  113. minion_code-0.1.0.dist-info/entry_points.txt +0 -4
  114. {minion_code-0.1.0.dist-info → minion_code-0.1.2.dist-info}/licenses/LICENSE +0 -0
  115. {minion_code-0.1.0.dist-info → minion_code-0.1.2.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 = Path(file_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 = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
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 f"Image file: {file_path} (size: {path.stat().st_size} bytes)"
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
- with open(file_path, "r", encoding="utf-8", errors="replace") as f:
52
- lines = f.readlines()
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
- total_lines = len(lines)
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
- # Apply offset and limit
57
- if offset is not None:
58
- lines = lines[offset:]
59
- if limit is not None:
60
- lines = lines[:limit]
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
- content = "".join(lines)
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
- result = f"File: {file_path}\n"
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
- return result
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 reading file: {str(e)}"
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 = Path(file_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(file_path, "w", encoding="utf-8") as f:
44
+ with open(path, "w", encoding="utf-8") as f:
31
45
  f.write(content)
32
46
 
33
- return f"Successfully wrote to file: {file_path} ({len(content)} characters)"
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)}"
@@ -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 = Path(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)
@@ -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": {"type": "string", "description": "Regular expression pattern to search for"},
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, pattern: str, path: str = ".", include: Optional[str] = None
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
- search_path = Path(path)
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(self._search_file(search_path, pattern))
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(self._search_file(file_path, pattern))
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(self._search_file(file_path, pattern))
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
- # Group results by file
61
- result = f"Search results for pattern '{pattern}':\n\n"
62
- current_file = None
63
- for file_path, line_num, line_content in matches:
64
- if file_path != current_file:
65
- result += f"File: {file_path}\n"
66
- current_file = file_path
67
- result += f" Line {line_num}: {line_content.strip()}\n"
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
- result += f"\nTotal {len(matches)} matches found"
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 _search_file(self, file_path: Path, pattern: str) -> List[tuple]:
76
- """Search pattern in a single file"""
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
- for line_num, line in enumerate(f, 1):
81
- if re.search(pattern, line, re.IGNORECASE):
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)