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.
Files changed (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. 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
+ }