raggiecode 0.2.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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- skills/tool.py +121 -0
Tools/fuzzy_search.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .utils import is_ignored, BLUE, RESET
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _fuzzy_score(query: str, target: str):
|
|
7
|
+
"""Return a score for how well *target* matches *query* fuzzily.
|
|
8
|
+
|
|
9
|
+
Uses a subsequence-matching algorithm with bonuses for:
|
|
10
|
+
- Exact substring match (highest priority)
|
|
11
|
+
- Consecutive character matches
|
|
12
|
+
- Matches at word boundaries (start of string, after separator)
|
|
13
|
+
|
|
14
|
+
Returns None if query is not a subsequence of target.
|
|
15
|
+
"""
|
|
16
|
+
query = query.lower()
|
|
17
|
+
target = target.lower()
|
|
18
|
+
|
|
19
|
+
if not query:
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
# Exact substring — best possible match
|
|
23
|
+
if query in target:
|
|
24
|
+
return 100 + (len(target) - len(query))
|
|
25
|
+
|
|
26
|
+
qi = 0
|
|
27
|
+
score = 0
|
|
28
|
+
prev_match = -1
|
|
29
|
+
|
|
30
|
+
for ti, ch in enumerate(target):
|
|
31
|
+
if qi < len(query) and ch == query[qi]:
|
|
32
|
+
# Bonus for match at start of string or after a separator
|
|
33
|
+
if ti == 0 or target[ti - 1] in "/._- ":
|
|
34
|
+
score += 10
|
|
35
|
+
# Bonus for consecutive matches
|
|
36
|
+
if prev_match == ti - 1:
|
|
37
|
+
score += 5
|
|
38
|
+
score += 1
|
|
39
|
+
prev_match = ti
|
|
40
|
+
qi += 1
|
|
41
|
+
|
|
42
|
+
if qi < len(query):
|
|
43
|
+
return None # Not all query chars matched
|
|
44
|
+
|
|
45
|
+
# Penalise longer targets (prefer shorter file names)
|
|
46
|
+
score -= len(target) * 0.1
|
|
47
|
+
|
|
48
|
+
return score
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def handle(arguments, toolcall_id):
|
|
52
|
+
query = arguments.get("query", "")
|
|
53
|
+
directory = arguments.get("directory", os.getcwd())
|
|
54
|
+
max_results = arguments.get("max_results", 5)
|
|
55
|
+
|
|
56
|
+
print(f"{BLUE}Fuzzy file search '{query}' in '{directory}'{RESET}")
|
|
57
|
+
|
|
58
|
+
if not query:
|
|
59
|
+
return {
|
|
60
|
+
"role": "tool",
|
|
61
|
+
"tool_call_id": toolcall_id,
|
|
62
|
+
"content": "Error: query is required",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
root = Path(directory)
|
|
67
|
+
if not root.exists():
|
|
68
|
+
return {
|
|
69
|
+
"role": "tool",
|
|
70
|
+
"tool_call_id": toolcall_id,
|
|
71
|
+
"content": "Directory not found",
|
|
72
|
+
}
|
|
73
|
+
if not root.is_dir():
|
|
74
|
+
return {
|
|
75
|
+
"role": "tool",
|
|
76
|
+
"tool_call_id": toolcall_id,
|
|
77
|
+
"content": "Path is not a directory",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
results = []
|
|
81
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
82
|
+
# Skip hidden directories like .git, __pycache__, etc.
|
|
83
|
+
dirnames[:] = [
|
|
84
|
+
d for d in dirnames
|
|
85
|
+
if not d.startswith(".") and d != "__pycache__"
|
|
86
|
+
]
|
|
87
|
+
for filename in filenames:
|
|
88
|
+
full_path = os.path.join(dirpath, filename)
|
|
89
|
+
if is_ignored(full_path):
|
|
90
|
+
continue
|
|
91
|
+
score = _fuzzy_score(query, filename)
|
|
92
|
+
if score is not None:
|
|
93
|
+
rel_path = os.path.relpath(full_path, root)
|
|
94
|
+
results.append((score, rel_path))
|
|
95
|
+
|
|
96
|
+
results.sort(key=lambda x: (-x[0], x[1]))
|
|
97
|
+
results = results[:max_results]
|
|
98
|
+
|
|
99
|
+
if not results:
|
|
100
|
+
return {
|
|
101
|
+
"role": "tool",
|
|
102
|
+
"tool_call_id": toolcall_id,
|
|
103
|
+
"content": f"No files matching '{query}' found.",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines = [f"Found {len(results)} file(s) matching '{query}':"]
|
|
107
|
+
for score, path in results:
|
|
108
|
+
lines.append(f" {path}")
|
|
109
|
+
return {
|
|
110
|
+
"role": "tool",
|
|
111
|
+
"tool_call_id": toolcall_id,
|
|
112
|
+
"content": "\n".join(lines),
|
|
113
|
+
}
|
|
114
|
+
except Exception as e:
|
|
115
|
+
return {
|
|
116
|
+
"role": "tool",
|
|
117
|
+
"tool_call_id": toolcall_id,
|
|
118
|
+
"content": f"Error during fuzzy search: {str(e)}",
|
|
119
|
+
}
|
Tools/list_dir.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from .utils import BLUE, RESET
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _list_directory(path: Path, depth: int, current_depth: int, indent: str, items: list):
|
|
6
|
+
for item in sorted(path.iterdir()):
|
|
7
|
+
item_type = "DIR" if item.is_dir() else "FILE"
|
|
8
|
+
size = item.stat().st_size if item.is_file() else 0
|
|
9
|
+
items.append(f"{indent}{item_type}: {item.name} ({size} bytes)")
|
|
10
|
+
if item.is_dir() and current_depth < depth:
|
|
11
|
+
_list_directory(item, depth, current_depth + 1, indent + " ", items)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle(arguments, toolcall_id):
|
|
15
|
+
directory_path = arguments["directory_path"]
|
|
16
|
+
depth = arguments.get("depth", 1)
|
|
17
|
+
print(f"{BLUE}Listing directory {directory_path} (depth={depth}){RESET}")
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
path = Path(directory_path)
|
|
21
|
+
|
|
22
|
+
if not path.exists():
|
|
23
|
+
return {
|
|
24
|
+
"role": "tool",
|
|
25
|
+
"tool_call_id": toolcall_id,
|
|
26
|
+
"content": "Directory not found",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if not path.is_dir():
|
|
30
|
+
return {
|
|
31
|
+
"role": "tool",
|
|
32
|
+
"tool_call_id": toolcall_id,
|
|
33
|
+
"content": "Path is not a directory",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
items = []
|
|
37
|
+
_list_directory(path, depth, 1, "", items)
|
|
38
|
+
|
|
39
|
+
result = f"Directory contents ({len(items)} items):\n" + "\n".join(items)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
"role": "tool",
|
|
43
|
+
"tool_call_id": toolcall_id,
|
|
44
|
+
"content": result,
|
|
45
|
+
}
|
|
46
|
+
except Exception as e:
|
|
47
|
+
return {
|
|
48
|
+
"role": "tool",
|
|
49
|
+
"tool_call_id": toolcall_id,
|
|
50
|
+
"content": f"Error listing directory: {str(e)}",
|
|
51
|
+
}
|
Tools/read.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from .utils import is_ignored_by_gitignore, is_within_cwd, BLUE, RESET
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def handle(arguments, toolcall_id):
|
|
8
|
+
|
|
9
|
+
file_path = arguments["file_path"]
|
|
10
|
+
print(f"{BLUE}Reading {file_path}{RESET}")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
# Check if the file is outside the current working directory
|
|
14
|
+
if not is_within_cwd(file_path):
|
|
15
|
+
return {
|
|
16
|
+
"role": "tool",
|
|
17
|
+
"tool_call_id": toolcall_id,
|
|
18
|
+
"content": "Error: access denied - path is outside the current working directory",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Check if the file is gitignored
|
|
22
|
+
if is_ignored_by_gitignore(file_path):
|
|
23
|
+
return {
|
|
24
|
+
"role": "tool",
|
|
25
|
+
"tool_call_id": toolcall_id,
|
|
26
|
+
"content": "Error: file is gitignored",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
with open(file_path, "r") as f:
|
|
30
|
+
result = f.read()
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"role": "tool",
|
|
34
|
+
"tool_call_id": toolcall_id,
|
|
35
|
+
"content": f"{result}",
|
|
36
|
+
}
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return {
|
|
39
|
+
"role": "tool",
|
|
40
|
+
"tool_call_id": toolcall_id,
|
|
41
|
+
"content": "File not found",
|
|
42
|
+
}
|
|
43
|
+
except IsADirectoryError:
|
|
44
|
+
dir_content = [item.name for item in Path(file_path).iterdir()]
|
|
45
|
+
return {
|
|
46
|
+
"role": "tool",
|
|
47
|
+
"tool_call_id": toolcall_id,
|
|
48
|
+
"content": f"directory contents: {', '.join(dir_content)}",
|
|
49
|
+
}
|
Tools/read_image.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .utils import is_ignored_by_gitignore, is_within_cwd, BLUE, RESET
|
|
4
|
+
|
|
5
|
+
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg', '.tiff', '.ico', '.heic', '.heif'}
|
|
6
|
+
|
|
7
|
+
def handle(arguments, toolcall_id):
|
|
8
|
+
file_path = arguments["file_path"]
|
|
9
|
+
print(f"{BLUE}Reading image {file_path}{RESET}")
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
# Check if the file is outside the current working directory
|
|
13
|
+
if not is_within_cwd(file_path):
|
|
14
|
+
return {
|
|
15
|
+
"role": "tool",
|
|
16
|
+
"tool_call_id": toolcall_id,
|
|
17
|
+
"content": "Error: access denied - path is outside the current working directory",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
# Check if the file is gitignored
|
|
21
|
+
if is_ignored_by_gitignore(file_path):
|
|
22
|
+
return {
|
|
23
|
+
"role": "tool",
|
|
24
|
+
"tool_call_id": toolcall_id,
|
|
25
|
+
"content": "Error: file is gitignored",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
path = Path(file_path)
|
|
29
|
+
|
|
30
|
+
# Check if it's an image file
|
|
31
|
+
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
|
32
|
+
return {
|
|
33
|
+
"role": "tool",
|
|
34
|
+
"tool_call_id": toolcall_id,
|
|
35
|
+
"content": f"Error: {path.suffix} is not a supported image format. Supported formats: {', '.join(IMAGE_EXTENSIONS)}",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Read and encode image
|
|
39
|
+
with open(file_path, "rb") as f:
|
|
40
|
+
image_data = f.read()
|
|
41
|
+
|
|
42
|
+
base64_data = base64.b64encode(image_data).decode('utf-8')
|
|
43
|
+
mime_type = f"image/{path.suffix.lstrip('.').lower()}"
|
|
44
|
+
|
|
45
|
+
# Return in format compatible with vision APIs
|
|
46
|
+
result = {
|
|
47
|
+
"role": "tool",
|
|
48
|
+
"tool_call_id": toolcall_id,
|
|
49
|
+
"content": f"Image read successfully. Format: {mime_type}, Size: {len(image_data)} bytes",
|
|
50
|
+
"image_data": {
|
|
51
|
+
"mime_type": mime_type,
|
|
52
|
+
"base64_data": base64_data
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return {
|
|
60
|
+
"role": "tool",
|
|
61
|
+
"tool_call_id": toolcall_id,
|
|
62
|
+
"content": "File not found",
|
|
63
|
+
}
|
|
64
|
+
except IsADirectoryError:
|
|
65
|
+
return {
|
|
66
|
+
"role": "tool",
|
|
67
|
+
"tool_call_id": toolcall_id,
|
|
68
|
+
"content": "Path is a directory, not an image file",
|
|
69
|
+
}
|
|
70
|
+
except Exception as e:
|
|
71
|
+
return {
|
|
72
|
+
"role": "tool",
|
|
73
|
+
"tool_call_id": toolcall_id,
|
|
74
|
+
"content": f"Error reading image: {str(e)}",
|
|
75
|
+
}
|
Tools/remove.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .utils import is_ignored_by_gitignore, is_within_cwd, BLUE, RESET, auto_record_change, reindex_after_change
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def handle(arguments, toolcall_id, session_id=None, code_indexer=None):
|
|
9
|
+
file_path = arguments.get("file_path")
|
|
10
|
+
print(f"{BLUE}Remove {file_path}{RESET}")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
# Check if the file is outside the current working directory
|
|
14
|
+
if not is_within_cwd(file_path):
|
|
15
|
+
return {
|
|
16
|
+
"role": "tool",
|
|
17
|
+
"tool_call_id": toolcall_id,
|
|
18
|
+
"content": "Error: access denied - path is outside the current working directory",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
target = Path(file_path).resolve()
|
|
22
|
+
|
|
23
|
+
# Check if the path exists
|
|
24
|
+
if not target.exists():
|
|
25
|
+
return {
|
|
26
|
+
"role": "tool",
|
|
27
|
+
"tool_call_id": toolcall_id,
|
|
28
|
+
"content": f"Error: '{file_path}' does not exist.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Refuse to remove anything that is gitignored
|
|
32
|
+
if is_ignored_by_gitignore(str(target)):
|
|
33
|
+
return {
|
|
34
|
+
"role": "tool",
|
|
35
|
+
"tool_call_id": toolcall_id,
|
|
36
|
+
"content": "cannot remove sensitive information. DO NOT TRY TO REMOVE this using shell toolcalls either, instead instruct the user to make the changes themselves.",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if target.is_dir():
|
|
40
|
+
shutil.rmtree(target)
|
|
41
|
+
if session_id is not None:
|
|
42
|
+
from Agent.chat_history_db import record_session_file
|
|
43
|
+
record_session_file(session_id, file_path, "remove")
|
|
44
|
+
auto_record_change(session_id, file_path, "file_delete", f"Removed directory {file_path}")
|
|
45
|
+
reindex_after_change(code_indexer)
|
|
46
|
+
return {
|
|
47
|
+
"role": "tool",
|
|
48
|
+
"tool_call_id": toolcall_id,
|
|
49
|
+
"content": f"Successfully removed directory '{file_path}'",
|
|
50
|
+
}
|
|
51
|
+
else:
|
|
52
|
+
os.remove(target)
|
|
53
|
+
if session_id is not None:
|
|
54
|
+
from Agent.chat_history_db import record_session_file
|
|
55
|
+
record_session_file(session_id, file_path, "remove")
|
|
56
|
+
auto_record_change(session_id, file_path, "file_delete", f"Removed file {file_path}")
|
|
57
|
+
reindex_after_change(code_indexer)
|
|
58
|
+
return {
|
|
59
|
+
"role": "tool",
|
|
60
|
+
"tool_call_id": toolcall_id,
|
|
61
|
+
"content": f"Successfully removed file '{file_path}'",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
except PermissionError:
|
|
65
|
+
return {
|
|
66
|
+
"role": "tool",
|
|
67
|
+
"tool_call_id": toolcall_id,
|
|
68
|
+
"content": f"Error: Permission denied when trying to remove '{file_path}'",
|
|
69
|
+
}
|
|
70
|
+
except Exception as e:
|
|
71
|
+
return {
|
|
72
|
+
"role": "tool",
|
|
73
|
+
"tool_call_id": toolcall_id,
|
|
74
|
+
"content": f"Error removing '{file_path}': {str(e)}",
|
|
75
|
+
}
|
Tools/replace.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from .utils import is_ignored_by_gitignore, is_within_cwd, BLUE, RESET, auto_record_change, reindex_after_change
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _fuzzy_find_literal(content, old_string):
|
|
7
|
+
"""Find old_string in content with whitespace-tolerant fallback.
|
|
8
|
+
|
|
9
|
+
Returns (matches, warning) where matches is a list of (start, end, matched_text)
|
|
10
|
+
and warning is None for exact matches or a string for fuzzy matches.
|
|
11
|
+
"""
|
|
12
|
+
# 1. Exact match
|
|
13
|
+
matches = []
|
|
14
|
+
start_idx = 0
|
|
15
|
+
sub_len = len(old_string)
|
|
16
|
+
while True:
|
|
17
|
+
idx = content.find(old_string, start_idx)
|
|
18
|
+
if idx == -1:
|
|
19
|
+
break
|
|
20
|
+
matches.append((idx, idx + sub_len))
|
|
21
|
+
start_idx = idx + sub_len
|
|
22
|
+
if matches:
|
|
23
|
+
return [(s, e, content[s:e]) for s, e in matches], None
|
|
24
|
+
|
|
25
|
+
# 2. Stripped whole-string match (handles leading/trailing whitespace)
|
|
26
|
+
stripped = old_string.strip()
|
|
27
|
+
if stripped and stripped != old_string:
|
|
28
|
+
start_idx = 0
|
|
29
|
+
while True:
|
|
30
|
+
idx = content.find(stripped, start_idx)
|
|
31
|
+
if idx == -1:
|
|
32
|
+
break
|
|
33
|
+
matches.append((idx, idx + len(stripped)))
|
|
34
|
+
start_idx = idx + len(stripped)
|
|
35
|
+
if matches:
|
|
36
|
+
return (
|
|
37
|
+
[(s, e, content[s:e]) for s, e in matches],
|
|
38
|
+
"Matched after stripping leading/trailing whitespace from old_string.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 3. Per-line stripped match (tolerates per-line indentation differences)
|
|
42
|
+
old_lines_stripped = [line.strip() for line in old_string.split("\n")]
|
|
43
|
+
if not all(old_lines_stripped):
|
|
44
|
+
return [], None
|
|
45
|
+
|
|
46
|
+
content_lines = content.split("\n")
|
|
47
|
+
content_lines_stripped = [line.strip() for line in content_lines]
|
|
48
|
+
|
|
49
|
+
n_old = len(old_lines_stripped)
|
|
50
|
+
n_content = len(content_lines_stripped)
|
|
51
|
+
|
|
52
|
+
# Precompute character offset of each line start in original content
|
|
53
|
+
line_starts = [0]
|
|
54
|
+
for line in content_lines[:-1]:
|
|
55
|
+
line_starts.append(line_starts[-1] + len(line) + 1)
|
|
56
|
+
|
|
57
|
+
for i in range(n_content - n_old + 1):
|
|
58
|
+
if content_lines_stripped[i : i + n_old] == old_lines_stripped:
|
|
59
|
+
first_line = content_lines[i]
|
|
60
|
+
leading_ws = len(first_line) - len(first_line.lstrip())
|
|
61
|
+
start = line_starts[i] + leading_ws
|
|
62
|
+
|
|
63
|
+
last_line = content_lines[i + n_old - 1]
|
|
64
|
+
last_line_end = line_starts[i + n_old - 1] + len(last_line)
|
|
65
|
+
trailing_ws = len(last_line) - len(last_line.rstrip())
|
|
66
|
+
end = last_line_end - trailing_ws
|
|
67
|
+
|
|
68
|
+
matches.append((start, end))
|
|
69
|
+
|
|
70
|
+
if matches:
|
|
71
|
+
return (
|
|
72
|
+
[(s, e, content[s:e]) for s, e in matches],
|
|
73
|
+
"Matched with per-line whitespace normalization. Verify the result is correct.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return [], None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_closest_snippet(content, old_string, max_lines=10):
|
|
80
|
+
"""Find a region in content that resembles old_string for error reporting."""
|
|
81
|
+
first_line = old_string.strip().split("\n")[0].strip()
|
|
82
|
+
if not first_line or len(first_line) < 3:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
content_lines = content.split("\n")
|
|
86
|
+
for i, line in enumerate(content_lines):
|
|
87
|
+
if first_line in line.strip():
|
|
88
|
+
start = max(0, i - 2)
|
|
89
|
+
end = min(len(content_lines), i + max_lines)
|
|
90
|
+
snippet_lines = []
|
|
91
|
+
for j in range(start, end):
|
|
92
|
+
marker = " >" if j == i else " "
|
|
93
|
+
snippet_lines.append(f"{marker} {j+1}: {content_lines[j]}")
|
|
94
|
+
return (
|
|
95
|
+
f"First line of old_string resembles file content at line {i+1}.\n"
|
|
96
|
+
f"Actual file content:\n" + "\n".join(snippet_lines)
|
|
97
|
+
)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def handle(arguments, toolcall_id, session_id=None, code_indexer=None):
|
|
102
|
+
file_path = arguments.get("file_path")
|
|
103
|
+
old_string = arguments.get("old_string")
|
|
104
|
+
new_string = arguments.get("new_string")
|
|
105
|
+
replace_all = arguments.get("replace_all", False)
|
|
106
|
+
use_regex = arguments.get("use_regex", False)
|
|
107
|
+
|
|
108
|
+
print(f"{BLUE}Replace {file_path}{RESET}")
|
|
109
|
+
|
|
110
|
+
if not old_string:
|
|
111
|
+
return {
|
|
112
|
+
"role": "tool",
|
|
113
|
+
"tool_call_id": toolcall_id,
|
|
114
|
+
"content": "Error: 'old_string' cannot be empty.",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Check if the file is outside the current working directory
|
|
119
|
+
if not is_within_cwd(file_path):
|
|
120
|
+
return {
|
|
121
|
+
"role": "tool",
|
|
122
|
+
"tool_call_id": toolcall_id,
|
|
123
|
+
"content": "Error: access denied - path is outside the current working directory",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Check if file is in .gitignore
|
|
127
|
+
if is_ignored_by_gitignore(file_path):
|
|
128
|
+
return {
|
|
129
|
+
"role": "tool",
|
|
130
|
+
"tool_call_id": toolcall_id,
|
|
131
|
+
"content": (
|
|
132
|
+
f"Error: File '{file_path}' is in .gitignore. "
|
|
133
|
+
"Operations on gitignored files are not allowed."
|
|
134
|
+
),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if not os.path.exists(file_path):
|
|
138
|
+
return {
|
|
139
|
+
"role": "tool",
|
|
140
|
+
"tool_call_id": toolcall_id,
|
|
141
|
+
"content": f"Error: File '{file_path}' does not exist.",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
145
|
+
content = f.read()
|
|
146
|
+
|
|
147
|
+
# Gather matches as a unified list of tuples:
|
|
148
|
+
# (start_idx, end_idx, matched_substring, resolved_replacement)
|
|
149
|
+
match_data = []
|
|
150
|
+
fuzzy_warning = None
|
|
151
|
+
|
|
152
|
+
if use_regex:
|
|
153
|
+
try:
|
|
154
|
+
pattern = re.compile(old_string)
|
|
155
|
+
except re.error as e:
|
|
156
|
+
return {
|
|
157
|
+
"role": "tool",
|
|
158
|
+
"tool_call_id": toolcall_id,
|
|
159
|
+
"content": f"Error: Invalid regular expression: {e}",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Collect all matches and validate expansions before applying any
|
|
163
|
+
raw_matches = list(pattern.finditer(content))
|
|
164
|
+
expanded = []
|
|
165
|
+
for m in raw_matches:
|
|
166
|
+
try:
|
|
167
|
+
repl = m.expand(new_string)
|
|
168
|
+
except re.error as e:
|
|
169
|
+
return {
|
|
170
|
+
"role": "tool",
|
|
171
|
+
"tool_call_id": toolcall_id,
|
|
172
|
+
"content": f"Error expanding regex replacement group: {e}",
|
|
173
|
+
}
|
|
174
|
+
expanded.append(repl)
|
|
175
|
+
for m, repl in zip(raw_matches, expanded):
|
|
176
|
+
match_data.append((m.start(), m.end(), content[m.start():m.end()], repl))
|
|
177
|
+
else:
|
|
178
|
+
# Literal mode with whitespace-tolerant fallback
|
|
179
|
+
raw_matches, fuzzy_warning = _fuzzy_find_literal(content, old_string)
|
|
180
|
+
for start, end, matched_text in raw_matches:
|
|
181
|
+
match_data.append((start, end, matched_text, new_string))
|
|
182
|
+
|
|
183
|
+
count = len(match_data)
|
|
184
|
+
|
|
185
|
+
if count == 0:
|
|
186
|
+
err_msg = "Error: Pattern not found in file."
|
|
187
|
+
if not use_regex:
|
|
188
|
+
snippet = _find_closest_snippet(content, old_string)
|
|
189
|
+
if snippet:
|
|
190
|
+
err_msg += f"\n\n{snippet}"
|
|
191
|
+
else:
|
|
192
|
+
err_msg += (
|
|
193
|
+
" Ensure indentation and line breaks match the file perfectly."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"role": "tool",
|
|
198
|
+
"tool_call_id": toolcall_id,
|
|
199
|
+
"content": err_msg,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if count > 1 and not replace_all:
|
|
203
|
+
return {
|
|
204
|
+
"role": "tool",
|
|
205
|
+
"tool_call_id": toolcall_id,
|
|
206
|
+
"content": (
|
|
207
|
+
f"Error: Pattern is not unique in file (found {count} times). "
|
|
208
|
+
"Use replace_all=true to replace all occurrences, or provide "
|
|
209
|
+
"more context in old_string to narrow to a single match."
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# Apply replacements in reverse order so string index spans stay valid
|
|
214
|
+
new_content = content
|
|
215
|
+
for start, end, _, replacement in reversed(match_data):
|
|
216
|
+
new_content = new_content[:start] + replacement + new_content[end:]
|
|
217
|
+
|
|
218
|
+
# Build diff view: show removed (-) and added (+) lines with context
|
|
219
|
+
CONTEXT = 6
|
|
220
|
+
old_lines_all = content.split("\n")
|
|
221
|
+
new_lines_all = new_content.split("\n")
|
|
222
|
+
result_blocks = []
|
|
223
|
+
line_offset = 0
|
|
224
|
+
|
|
225
|
+
for start, end, matched_text, replacement in match_data:
|
|
226
|
+
start_line = content[:start].count("\n")
|
|
227
|
+
matched_line_count = matched_text.count("\n") + 1
|
|
228
|
+
replacement_line_count = replacement.count("\n") + 1
|
|
229
|
+
|
|
230
|
+
old_start = start_line
|
|
231
|
+
old_end = start_line + matched_line_count
|
|
232
|
+
|
|
233
|
+
new_start = start_line + line_offset
|
|
234
|
+
new_end = new_start + replacement_line_count
|
|
235
|
+
|
|
236
|
+
ctx_start = max(0, new_start - CONTEXT)
|
|
237
|
+
ctx_after = min(len(new_lines_all), new_end + CONTEXT)
|
|
238
|
+
|
|
239
|
+
block = [
|
|
240
|
+
f"@@ {file_path}:{new_start+1}-{new_end} @@",
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
for i in range(ctx_start, new_start):
|
|
244
|
+
block.append(f" {new_lines_all[i]}")
|
|
245
|
+
|
|
246
|
+
for i in range(old_start, old_end):
|
|
247
|
+
block.append(f" - {old_lines_all[i]}")
|
|
248
|
+
|
|
249
|
+
for i in range(new_start, new_end):
|
|
250
|
+
block.append(f" + {new_lines_all[i]}")
|
|
251
|
+
|
|
252
|
+
for i in range(new_end, ctx_after):
|
|
253
|
+
block.append(f" {new_lines_all[i]}")
|
|
254
|
+
|
|
255
|
+
result_blocks.append("\n".join(block))
|
|
256
|
+
line_offset += replacement_line_count - matched_line_count
|
|
257
|
+
|
|
258
|
+
# Atomic write: temp file + os.replace
|
|
259
|
+
tmp_path = file_path + ".raggie_tmp"
|
|
260
|
+
try:
|
|
261
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
262
|
+
f.write(new_content)
|
|
263
|
+
os.replace(tmp_path, file_path)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
try:
|
|
266
|
+
if os.path.exists(tmp_path):
|
|
267
|
+
os.remove(tmp_path)
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
return {
|
|
271
|
+
"role": "tool",
|
|
272
|
+
"tool_call_id": toolcall_id,
|
|
273
|
+
"content": f"Error: Failed to write file: {e}",
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
mode = "regex" if use_regex else "literal"
|
|
277
|
+
replaced = count if replace_all else 1
|
|
278
|
+
result_text = "\n\n".join(result_blocks)
|
|
279
|
+
|
|
280
|
+
if session_id is not None:
|
|
281
|
+
from Agent.chat_history_db import record_session_file
|
|
282
|
+
record_session_file(session_id, file_path, "replace")
|
|
283
|
+
auto_record_change(session_id, file_path, "file_edit", f"Edited {file_path}: replaced {replaced} occurrence(s)", result_text)
|
|
284
|
+
|
|
285
|
+
summary = (
|
|
286
|
+
f"Replaced {replaced} occurrence(s) in {file_path} ({mode} match):\n\n"
|
|
287
|
+
f"{result_text}"
|
|
288
|
+
)
|
|
289
|
+
if fuzzy_warning:
|
|
290
|
+
summary = f"Warning: {fuzzy_warning}\n\n" + summary
|
|
291
|
+
|
|
292
|
+
reindex_after_change(code_indexer)
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
"role": "tool",
|
|
296
|
+
"tool_call_id": toolcall_id,
|
|
297
|
+
"content": summary,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
return {
|
|
302
|
+
"role": "tool",
|
|
303
|
+
"tool_call_id": toolcall_id,
|
|
304
|
+
"content": f"Error executing replace: {str(e)}",
|
|
305
|
+
}
|