pi-mono-coding 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.
- pi_coding/__init__.py +215 -0
- pi_coding/config.py +59 -0
- pi_coding/core/__init__.py +5 -0
- pi_coding/core/defaults.py +4 -0
- pi_coding/tools/__init__.py +70 -0
- pi_coding/tools/bash.py +213 -0
- pi_coding/tools/edit.py +176 -0
- pi_coding/tools/find.py +182 -0
- pi_coding/tools/grep.py +280 -0
- pi_coding/tools/ls.py +126 -0
- pi_coding/tools/read.py +210 -0
- pi_coding/tools/write.py +99 -0
- pi_coding/utils/__init__.py +91 -0
- pi_coding/utils/edit_diff.py +374 -0
- pi_coding/utils/git.py +201 -0
- pi_coding/utils/path_utils.py +129 -0
- pi_coding/utils/shell.py +143 -0
- pi_coding/utils/truncate.py +274 -0
- pi_mono_coding-0.1.0.dist-info/METADATA +485 -0
- pi_mono_coding-0.1.0.dist-info/RECORD +21 -0
- pi_mono_coding-0.1.0.dist-info/WHEEL +4 -0
pi_coding/tools/edit.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Edit tool - edits files by replacing exact text matches with fuzzy matching support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from pi_agent.types import AgentToolUpdateCallback
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pi_agent import AgentTool, AgentToolResult
|
|
11
|
+
from pi_ai.types import TextContent
|
|
12
|
+
|
|
13
|
+
from pi_coding.utils import (
|
|
14
|
+
detect_line_ending,
|
|
15
|
+
fuzzy_find_text,
|
|
16
|
+
generate_diff_string,
|
|
17
|
+
normalize_for_fuzzy_match,
|
|
18
|
+
normalize_to_lf,
|
|
19
|
+
resolve_to_cwd,
|
|
20
|
+
restore_line_endings,
|
|
21
|
+
strip_bom,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_EDIT_TOOL_PARAMETERS = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"path": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Path to the file to edit (relative or absolute)",
|
|
30
|
+
},
|
|
31
|
+
"old_text": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Exact text to find and replace (must match exactly)",
|
|
34
|
+
},
|
|
35
|
+
"new_text": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "New text to replace the old text with",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
"required": ["path", "old_text", "new_text"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _execute_edit(
|
|
45
|
+
tool_call_id: str,
|
|
46
|
+
params: dict[str, Any],
|
|
47
|
+
cwd: str,
|
|
48
|
+
cancel_event: asyncio.Event | None,
|
|
49
|
+
on_update: AgentToolUpdateCallback | None,
|
|
50
|
+
) -> AgentToolResult:
|
|
51
|
+
path: str = params.get("path", "")
|
|
52
|
+
old_text: str = params.get("old_text", "")
|
|
53
|
+
new_text: str = params.get("new_text", "")
|
|
54
|
+
|
|
55
|
+
if not all([path, old_text, new_text]):
|
|
56
|
+
return AgentToolResult(
|
|
57
|
+
content=[
|
|
58
|
+
TextContent(
|
|
59
|
+
type="text", text="Error: 'path', 'old_text', and 'new_text' parameters are required"
|
|
60
|
+
)
|
|
61
|
+
],
|
|
62
|
+
details={"error": "missing_parameters"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
absolute_path = resolve_to_cwd(path, cwd)
|
|
66
|
+
|
|
67
|
+
if cancel_event and cancel_event.is_set():
|
|
68
|
+
return AgentToolResult(
|
|
69
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
70
|
+
details={"error": "aborted"},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if not os.path.exists(absolute_path):
|
|
75
|
+
return AgentToolResult(
|
|
76
|
+
content=[TextContent(type="text", text=f"Error: File not found: {path}")],
|
|
77
|
+
details={"error": "file_not_found", "path": absolute_path},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
with open(absolute_path, encoding="utf-8") as f:
|
|
81
|
+
raw_content = f.read()
|
|
82
|
+
|
|
83
|
+
if cancel_event and cancel_event.is_set():
|
|
84
|
+
return AgentToolResult(
|
|
85
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
86
|
+
details={"error": "aborted"},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
bom, content = strip_bom(raw_content)
|
|
90
|
+
original_ending = detect_line_ending(content)
|
|
91
|
+
normalized_content = normalize_to_lf(content)
|
|
92
|
+
normalized_old_text = normalize_to_lf(old_text)
|
|
93
|
+
normalized_new_text = normalize_to_lf(new_text)
|
|
94
|
+
|
|
95
|
+
match_result = fuzzy_find_text(normalized_content, normalized_old_text)
|
|
96
|
+
|
|
97
|
+
if not match_result.found:
|
|
98
|
+
return AgentToolResult(
|
|
99
|
+
content=[
|
|
100
|
+
TextContent(
|
|
101
|
+
type="text",
|
|
102
|
+
text=f"Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines.",
|
|
103
|
+
)
|
|
104
|
+
],
|
|
105
|
+
details={"error": "text_not_found", "path": absolute_path},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
|
109
|
+
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
|
110
|
+
occurrences = fuzzy_content.count(fuzzy_old_text)
|
|
111
|
+
|
|
112
|
+
if occurrences > 1:
|
|
113
|
+
return AgentToolResult(
|
|
114
|
+
content=[
|
|
115
|
+
TextContent(
|
|
116
|
+
type="text",
|
|
117
|
+
text=f"Found {occurrences} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique.",
|
|
118
|
+
)
|
|
119
|
+
],
|
|
120
|
+
details={"error": "multiple_occurrences", "occurrences": occurrences, "path": absolute_path},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if cancel_event and cancel_event.is_set():
|
|
124
|
+
return AgentToolResult(
|
|
125
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
126
|
+
details={"error": "aborted"},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
base_content = match_result.content_for_replacement
|
|
130
|
+
new_content = (
|
|
131
|
+
base_content[: match_result.index]
|
|
132
|
+
+ normalized_new_text
|
|
133
|
+
+ base_content[match_result.index + match_result.match_length :]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if base_content == new_content:
|
|
137
|
+
return AgentToolResult(
|
|
138
|
+
content=[
|
|
139
|
+
TextContent(
|
|
140
|
+
type="text",
|
|
141
|
+
text=f"No changes made to {path}. The replacement produced identical content.",
|
|
142
|
+
)
|
|
143
|
+
],
|
|
144
|
+
details={"error": "no_changes", "path": absolute_path},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
final_content = bom + restore_line_endings(new_content, original_ending)
|
|
148
|
+
with open(absolute_path, "w", encoding="utf-8") as f:
|
|
149
|
+
f.write(final_content)
|
|
150
|
+
|
|
151
|
+
diff_result = generate_diff_string(base_content, new_content)
|
|
152
|
+
return AgentToolResult(
|
|
153
|
+
content=[TextContent(type="text", text=f"Successfully replaced text in {path}.")],
|
|
154
|
+
details={"diff": diff_result["diff"], "first_changed_line": diff_result["first_changed_line"]},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return AgentToolResult(
|
|
159
|
+
content=[TextContent(type="text", text=f"Error editing file: {e}")],
|
|
160
|
+
details={"error": str(e), "path": absolute_path},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def create_edit_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
|
|
165
|
+
return AgentTool(
|
|
166
|
+
name="edit",
|
|
167
|
+
label="Edit",
|
|
168
|
+
description="Edit a file by replacing exact text. The old_text must match exactly (including whitespace). Use this for precise, surgical edits.",
|
|
169
|
+
parameters=_EDIT_TOOL_PARAMETERS,
|
|
170
|
+
execute=lambda tool_call_id, params, cancel_event, on_update: _execute_edit(
|
|
171
|
+
tool_call_id, params, cwd, cancel_event, on_update
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
edit_tool = create_edit_tool(os.getcwd())
|
pi_coding/tools/find.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Find tool - searches for files by glob pattern using fd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from pi_agent.types import AgentToolUpdateCallback
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pi_agent import AgentTool, AgentToolResult
|
|
12
|
+
from pi_ai.types import TextContent
|
|
13
|
+
|
|
14
|
+
from pi_coding.utils import DEFAULT_MAX_BYTES, format_size, resolve_to_cwd, truncate_head
|
|
15
|
+
|
|
16
|
+
DEFAULT_LIMIT = 1000
|
|
17
|
+
|
|
18
|
+
_FIND_TOOL_PARAMETERS = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"pattern": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'",
|
|
24
|
+
},
|
|
25
|
+
"path": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Directory to search in (default: current directory)",
|
|
28
|
+
},
|
|
29
|
+
"limit": {
|
|
30
|
+
"type": "number",
|
|
31
|
+
"description": "Maximum number of results (default: 1000)",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
"required": ["pattern"],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _execute_find(
|
|
39
|
+
tool_call_id: str,
|
|
40
|
+
params: dict[str, Any],
|
|
41
|
+
cwd: str,
|
|
42
|
+
cancel_event: asyncio.Event | None,
|
|
43
|
+
on_update: AgentToolUpdateCallback | None,
|
|
44
|
+
) -> AgentToolResult:
|
|
45
|
+
pattern = params.get("pattern")
|
|
46
|
+
search_dir = params.get("path", ".")
|
|
47
|
+
limit = params.get("limit", DEFAULT_LIMIT)
|
|
48
|
+
|
|
49
|
+
if not pattern:
|
|
50
|
+
return AgentToolResult(
|
|
51
|
+
content=[TextContent(type="text", text="Error: 'pattern' parameter is required")],
|
|
52
|
+
details={"error": "missing_parameter"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
fd_path = shutil.which("fd")
|
|
56
|
+
if not fd_path:
|
|
57
|
+
return AgentToolResult(
|
|
58
|
+
content=[TextContent(type="text", text="Error: fd is not available")],
|
|
59
|
+
details={"error": "fd_not_found"},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
search_path = resolve_to_cwd(search_dir, cwd)
|
|
63
|
+
|
|
64
|
+
if not os.path.exists(search_path):
|
|
65
|
+
return AgentToolResult(
|
|
66
|
+
content=[TextContent(type="text", text=f"Error: Path not found: {search_path}")],
|
|
67
|
+
details={"error": "path_not_found", "path": search_path},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if cancel_event and cancel_event.is_set():
|
|
71
|
+
return AgentToolResult(
|
|
72
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
73
|
+
details={"error": "aborted"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
args = [
|
|
77
|
+
fd_path,
|
|
78
|
+
"--glob",
|
|
79
|
+
"--color=never",
|
|
80
|
+
"--hidden",
|
|
81
|
+
"--max-results",
|
|
82
|
+
str(limit),
|
|
83
|
+
pattern,
|
|
84
|
+
search_path,
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
process = await asyncio.create_subprocess_exec(
|
|
89
|
+
*args,
|
|
90
|
+
stdout=asyncio.subprocess.PIPE,
|
|
91
|
+
stderr=asyncio.subprocess.PIPE,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
stdout, stderr = await process.communicate()
|
|
95
|
+
|
|
96
|
+
if cancel_event and cancel_event.is_set():
|
|
97
|
+
return AgentToolResult(
|
|
98
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
99
|
+
details={"error": "aborted"},
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
output = stdout.decode("utf-8", errors="replace").strip()
|
|
103
|
+
|
|
104
|
+
if process.returncode is not None and process.returncode != 0 and not output:
|
|
105
|
+
error_msg = stderr.decode("utf-8", errors="replace").strip() or f"fd exited with code {process.returncode}"
|
|
106
|
+
return AgentToolResult(
|
|
107
|
+
content=[TextContent(type="text", text=f"Error: {error_msg}")],
|
|
108
|
+
details={"error": "fd_error", "exit_code": process.returncode},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if not output:
|
|
112
|
+
return AgentToolResult(
|
|
113
|
+
content=[TextContent(type="text", text="No files found matching pattern")],
|
|
114
|
+
details={"pattern": pattern, "results": 0},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
lines = output.split("\n")
|
|
118
|
+
relativized: list[str] = []
|
|
119
|
+
|
|
120
|
+
for raw_line in lines:
|
|
121
|
+
line = raw_line.rstrip("\r").strip()
|
|
122
|
+
if not line:
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
had_trailing_slash = line.endswith("/") or line.endswith("\\")
|
|
126
|
+
if line.startswith(search_path):
|
|
127
|
+
relative_path = line[len(search_path) + 1 :]
|
|
128
|
+
else:
|
|
129
|
+
try:
|
|
130
|
+
relative_path = os.path.relpath(line, search_path)
|
|
131
|
+
except ValueError:
|
|
132
|
+
relative_path = line
|
|
133
|
+
|
|
134
|
+
if had_trailing_slash and not relative_path.endswith("/"):
|
|
135
|
+
relative_path += "/"
|
|
136
|
+
|
|
137
|
+
relativized.append(relative_path)
|
|
138
|
+
|
|
139
|
+
result_limit_reached = len(relativized) >= limit
|
|
140
|
+
raw_output = "\n".join(relativized)
|
|
141
|
+
truncation = truncate_head(raw_output)
|
|
142
|
+
|
|
143
|
+
result_output = truncation.content
|
|
144
|
+
details: dict[str, Any] = {}
|
|
145
|
+
notices: list[str] = []
|
|
146
|
+
|
|
147
|
+
if result_limit_reached:
|
|
148
|
+
notices.append(f"{limit} results limit reached. Use limit={limit * 2} for more, or refine pattern")
|
|
149
|
+
details["result_limit_reached"] = limit
|
|
150
|
+
|
|
151
|
+
if truncation.truncated:
|
|
152
|
+
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
|
153
|
+
details["truncation"] = truncation.__dict__
|
|
154
|
+
|
|
155
|
+
if notices:
|
|
156
|
+
result_output += f"\n\n[{'. '.join(notices)}]"
|
|
157
|
+
|
|
158
|
+
return AgentToolResult(
|
|
159
|
+
content=[TextContent(type="text", text=result_output)],
|
|
160
|
+
details=details if details else None,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
return AgentToolResult(
|
|
165
|
+
content=[TextContent(type="text", text=f"Error running find: {e}")],
|
|
166
|
+
details={"error": str(e)},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def create_find_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
|
|
171
|
+
return AgentTool(
|
|
172
|
+
name="find",
|
|
173
|
+
label="Find",
|
|
174
|
+
description=f"Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} results or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first).",
|
|
175
|
+
parameters=_FIND_TOOL_PARAMETERS,
|
|
176
|
+
execute=lambda tool_call_id, params, cancel_event, on_update: _execute_find(
|
|
177
|
+
tool_call_id, params, cwd, cancel_event, on_update
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
find_tool = create_find_tool(os.getcwd())
|
pi_coding/tools/grep.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Grep tool - searches file contents using ripgrep with context lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pi_agent.types import AgentToolUpdateCallback
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pi_agent import AgentTool, AgentToolResult
|
|
13
|
+
from pi_ai.types import TextContent
|
|
14
|
+
|
|
15
|
+
from pi_coding.utils import (
|
|
16
|
+
DEFAULT_MAX_BYTES,
|
|
17
|
+
GREP_MAX_LINE_LENGTH,
|
|
18
|
+
format_size,
|
|
19
|
+
resolve_to_cwd,
|
|
20
|
+
truncate_head,
|
|
21
|
+
truncate_line,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DEFAULT_LIMIT = 100
|
|
25
|
+
|
|
26
|
+
_GREP_TOOL_PARAMETERS = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"pattern": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Search pattern (regex or literal string)",
|
|
32
|
+
},
|
|
33
|
+
"path": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Directory or file to search (default: current directory)",
|
|
36
|
+
},
|
|
37
|
+
"glob": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
|
|
40
|
+
},
|
|
41
|
+
"ignore_case": {
|
|
42
|
+
"type": "boolean",
|
|
43
|
+
"description": "Case-insensitive search (default: false)",
|
|
44
|
+
},
|
|
45
|
+
"literal": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "Treat pattern as literal string instead of regex (default: false)",
|
|
48
|
+
},
|
|
49
|
+
"context": {
|
|
50
|
+
"type": "number",
|
|
51
|
+
"description": "Number of lines to show before and after each match (default: 0)",
|
|
52
|
+
},
|
|
53
|
+
"limit": {
|
|
54
|
+
"type": "number",
|
|
55
|
+
"description": "Maximum number of matches to return (default: 100)",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"required": ["pattern"],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _execute_grep(
|
|
63
|
+
tool_call_id: str,
|
|
64
|
+
params: dict[str, Any],
|
|
65
|
+
cwd: str,
|
|
66
|
+
cancel_event: asyncio.Event | None,
|
|
67
|
+
on_update: AgentToolUpdateCallback | None,
|
|
68
|
+
) -> AgentToolResult:
|
|
69
|
+
pattern = params.get("pattern")
|
|
70
|
+
search_dir = params.get("path", ".")
|
|
71
|
+
glob_pattern = params.get("glob")
|
|
72
|
+
ignore_case = params.get("ignore_case", False)
|
|
73
|
+
literal = params.get("literal", False)
|
|
74
|
+
context = params.get("context", 0)
|
|
75
|
+
limit = params.get("limit", DEFAULT_LIMIT)
|
|
76
|
+
|
|
77
|
+
if not pattern:
|
|
78
|
+
return AgentToolResult(
|
|
79
|
+
content=[TextContent(type="text", text="Error: 'pattern' parameter is required")],
|
|
80
|
+
details={"error": "missing_parameter"},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
rg_path = shutil.which("rg")
|
|
84
|
+
if not rg_path:
|
|
85
|
+
return AgentToolResult(
|
|
86
|
+
content=[TextContent(type="text", text="Error: ripgrep (rg) is not available")],
|
|
87
|
+
details={"error": "rg_not_found"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
search_path = resolve_to_cwd(search_dir, cwd)
|
|
91
|
+
|
|
92
|
+
if not os.path.exists(search_path):
|
|
93
|
+
return AgentToolResult(
|
|
94
|
+
content=[TextContent(type="text", text=f"Error: Path not found: {search_path}")],
|
|
95
|
+
details={"error": "path_not_found", "path": search_path},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
is_directory = os.path.isdir(search_path)
|
|
99
|
+
context_value = max(0, context) if context else 0
|
|
100
|
+
effective_limit = max(1, limit)
|
|
101
|
+
|
|
102
|
+
def format_path(file_path: str) -> str:
|
|
103
|
+
if is_directory:
|
|
104
|
+
try:
|
|
105
|
+
relative = os.path.relpath(file_path, search_path)
|
|
106
|
+
if not relative.startswith(".."):
|
|
107
|
+
return relative.replace("\\", "/")
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
return os.path.basename(file_path)
|
|
111
|
+
|
|
112
|
+
args = [rg_path, "--json", "--line-number", "--color=never", "--hidden"]
|
|
113
|
+
|
|
114
|
+
if ignore_case:
|
|
115
|
+
args.append("--ignore-case")
|
|
116
|
+
|
|
117
|
+
if literal:
|
|
118
|
+
args.append("--fixed-strings")
|
|
119
|
+
|
|
120
|
+
if glob_pattern:
|
|
121
|
+
args.extend(["--glob", glob_pattern])
|
|
122
|
+
|
|
123
|
+
args.extend([pattern, search_path])
|
|
124
|
+
|
|
125
|
+
if cancel_event and cancel_event.is_set():
|
|
126
|
+
return AgentToolResult(
|
|
127
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
128
|
+
details={"error": "aborted"},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
process = await asyncio.create_subprocess_exec(
|
|
133
|
+
*args,
|
|
134
|
+
stdout=asyncio.subprocess.PIPE,
|
|
135
|
+
stderr=asyncio.subprocess.PIPE,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
matches: list[tuple[str, int]] = []
|
|
139
|
+
output_lines: list[str] = []
|
|
140
|
+
match_count = 0
|
|
141
|
+
match_limit_reached = False
|
|
142
|
+
lines_truncated = False
|
|
143
|
+
|
|
144
|
+
file_cache: dict[str, list[str]] = {}
|
|
145
|
+
|
|
146
|
+
def get_file_lines(file_path: str) -> list[str]:
|
|
147
|
+
if file_path not in file_cache:
|
|
148
|
+
try:
|
|
149
|
+
with open(file_path, encoding="utf-8", errors="replace") as f:
|
|
150
|
+
content = f.read().replace("\r\n", "\n").replace("\r", "\n")
|
|
151
|
+
file_cache[file_path] = content.split("\n")
|
|
152
|
+
except Exception:
|
|
153
|
+
file_cache[file_path] = []
|
|
154
|
+
return file_cache[file_path]
|
|
155
|
+
|
|
156
|
+
def format_block(file_path: str, line_number: int) -> list[str]:
|
|
157
|
+
relative_path = format_path(file_path)
|
|
158
|
+
lines = get_file_lines(file_path)
|
|
159
|
+
if not lines:
|
|
160
|
+
return [f"{relative_path}:{line_number}: (unable to read file)"]
|
|
161
|
+
|
|
162
|
+
block: list[str] = []
|
|
163
|
+
start = max(1, line_number - context_value) if context_value > 0 else line_number
|
|
164
|
+
end = min(len(lines), line_number + context_value) if context_value > 0 else line_number
|
|
165
|
+
|
|
166
|
+
for current in range(start, end + 1):
|
|
167
|
+
line_text = lines[current - 1] if current <= len(lines) else ""
|
|
168
|
+
nonlocal lines_truncated
|
|
169
|
+
truncated_text, was_truncated = truncate_line(line_text, GREP_MAX_LINE_LENGTH)
|
|
170
|
+
if was_truncated:
|
|
171
|
+
lines_truncated = True
|
|
172
|
+
|
|
173
|
+
if current == line_number:
|
|
174
|
+
block.append(f"{relative_path}:{current}: {truncated_text}")
|
|
175
|
+
else:
|
|
176
|
+
block.append(f"{relative_path}-{current}- {truncated_text}")
|
|
177
|
+
|
|
178
|
+
return block
|
|
179
|
+
|
|
180
|
+
if process.stdout is None:
|
|
181
|
+
return AgentToolResult(
|
|
182
|
+
content=[TextContent(type="text", text="Error: Failed to read ripgrep output")],
|
|
183
|
+
details={"error": "no_stdout"},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
while True:
|
|
187
|
+
try:
|
|
188
|
+
line = await process.stdout.readline()
|
|
189
|
+
if not line:
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
line_text = line.decode("utf-8", errors="replace").strip()
|
|
193
|
+
if not line_text or match_count >= effective_limit:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
event = json.loads(line_text)
|
|
198
|
+
except json.JSONDecodeError:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
if event.get("type") == "match":
|
|
202
|
+
match_count += 1
|
|
203
|
+
file_path = event.get("data", {}).get("path", {}).get("text", "")
|
|
204
|
+
line_number = event.get("data", {}).get("line_number")
|
|
205
|
+
|
|
206
|
+
if file_path and isinstance(line_number, int):
|
|
207
|
+
matches.append((file_path, line_number))
|
|
208
|
+
|
|
209
|
+
if match_count >= effective_limit:
|
|
210
|
+
match_limit_reached = True
|
|
211
|
+
process.kill()
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
break
|
|
216
|
+
|
|
217
|
+
await process.wait()
|
|
218
|
+
|
|
219
|
+
if cancel_event and cancel_event.is_set():
|
|
220
|
+
return AgentToolResult(
|
|
221
|
+
content=[TextContent(type="text", text="Operation aborted")],
|
|
222
|
+
details={"error": "aborted"},
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if match_count == 0:
|
|
226
|
+
return AgentToolResult(
|
|
227
|
+
content=[TextContent(type="text", text="No matches found")],
|
|
228
|
+
details={"pattern": pattern, "matches": 0},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for file_path, line_number in matches:
|
|
232
|
+
output_lines.extend(format_block(file_path, line_number))
|
|
233
|
+
|
|
234
|
+
raw_output = "\n".join(output_lines)
|
|
235
|
+
truncation = truncate_head(raw_output)
|
|
236
|
+
|
|
237
|
+
output = truncation.content
|
|
238
|
+
details: dict[str, Any] = {}
|
|
239
|
+
notices: list[str] = []
|
|
240
|
+
|
|
241
|
+
if match_limit_reached:
|
|
242
|
+
notices.append(f"{effective_limit} matches limit reached. Use limit={effective_limit * 2} for more, or refine pattern")
|
|
243
|
+
details["match_limit_reached"] = effective_limit
|
|
244
|
+
|
|
245
|
+
if truncation.truncated:
|
|
246
|
+
notices.append(f"{format_size(DEFAULT_MAX_BYTES)} limit reached")
|
|
247
|
+
details["truncation"] = truncation.__dict__
|
|
248
|
+
|
|
249
|
+
if lines_truncated:
|
|
250
|
+
notices.append(f"Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines")
|
|
251
|
+
details["lines_truncated"] = True
|
|
252
|
+
|
|
253
|
+
if notices:
|
|
254
|
+
output += f"\n\n[{'. '.join(notices)}]"
|
|
255
|
+
|
|
256
|
+
return AgentToolResult(
|
|
257
|
+
content=[TextContent(type="text", text=output)],
|
|
258
|
+
details=details if details else None,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
return AgentToolResult(
|
|
263
|
+
content=[TextContent(type="text", text=f"Error running grep: {e}")],
|
|
264
|
+
details={"error": str(e)},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def create_grep_tool(cwd: str, options: dict[str, Any] | None = None) -> AgentTool:
|
|
269
|
+
return AgentTool(
|
|
270
|
+
name="grep",
|
|
271
|
+
label="Grep",
|
|
272
|
+
description=f"Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} matches or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). Long lines are truncated to {GREP_MAX_LINE_LENGTH} chars.",
|
|
273
|
+
parameters=_GREP_TOOL_PARAMETERS,
|
|
274
|
+
execute=lambda tool_call_id, params, cancel_event, on_update: _execute_grep(
|
|
275
|
+
tool_call_id, params, cwd, cancel_event, on_update
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
grep_tool = create_grep_tool(os.getcwd())
|