alpiecode 8.0.1__tar.gz → 8.0.3__tar.gz

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 (44) hide show
  1. {alpiecode-8.0.1 → alpiecode-8.0.3}/PKG-INFO +1 -1
  2. {alpiecode-8.0.1 → alpiecode-8.0.3}/pyproject.toml +1 -1
  3. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/SOURCES.txt +1 -0
  5. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/context.py +27 -0
  7. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/executor.py +94 -53
  8. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/extension/alpiecode.vsix +0 -0
  9. alpiecode-8.0.3/src/codeagent/guardrails.py +135 -0
  10. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/orchestrator.py +23 -3
  11. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/prompt.py +4 -0
  12. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/tools.py +74 -28
  13. {alpiecode-8.0.1 → alpiecode-8.0.3}/README.md +0 -0
  14. {alpiecode-8.0.1 → alpiecode-8.0.3}/setup.cfg +0 -0
  15. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode/__init__.py +0 -0
  16. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  17. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/entry_points.txt +0 -0
  18. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/requires.txt +0 -0
  19. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/top_level.txt +0 -0
  20. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/agent.py +0 -0
  21. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/__init__.py +0 -0
  22. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/base.py +0 -0
  23. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/local_backend.py +0 -0
  24. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/openai_backend.py +0 -0
  25. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/cache.py +0 -0
  26. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/cli.py +0 -0
  27. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/client.py +0 -0
  28. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/compaction.py +0 -0
  29. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/config.py +0 -0
  30. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/discovery.py +0 -0
  31. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/doctor.py +0 -0
  32. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/git_ops.py +0 -0
  33. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/github.py +0 -0
  34. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/guardian.py +0 -0
  35. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/ipython_ext.py +0 -0
  36. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/local_model.py +0 -0
  37. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/media.py +0 -0
  38. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/memory.py +0 -0
  39. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/progress.py +0 -0
  40. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/rephraser.py +0 -0
  41. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/server.py +0 -0
  42. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/session.py +0 -0
  43. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/updater.py +0 -0
  44. {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.1
3
+ Version: 8.0.3
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "8.0.1"
7
+ version = "8.0.3"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.1
3
+ Version: 8.0.3
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -21,6 +21,7 @@ src/codeagent/executor.py
21
21
  src/codeagent/git_ops.py
22
22
  src/codeagent/github.py
23
23
  src/codeagent/guardian.py
24
+ src/codeagent/guardrails.py
24
25
  src/codeagent/ipython_ext.py
25
26
  src/codeagent/local_model.py
26
27
  src/codeagent/media.py
@@ -16,7 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "7.0.7"
19
+ __version__ = "8.0.2"
20
20
 
21
21
 
22
22
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
@@ -39,10 +39,37 @@ class ContextManager:
39
39
  self.max_tokens = max_tokens
40
40
  self._messages: List[dict] = []
41
41
 
42
+ def rolling_compact_old_tools(self, keep_last_turns: int = 3) -> None:
43
+ """Truncate large historical tool outputs from turns older than keep_last_turns."""
44
+ assistant_indices = [
45
+ i for i, m in enumerate(self._messages)
46
+ if isinstance(m, dict) and m.get("role") == "assistant"
47
+ ]
48
+ if len(assistant_indices) <= keep_last_turns:
49
+ return
50
+
51
+ cutoff_idx = assistant_indices[-keep_last_turns]
52
+ for i in range(cutoff_idx):
53
+ msg = self._messages[i]
54
+ if isinstance(msg, dict) and msg.get("role") == "tool":
55
+ content = str(msg.get("content", ""))
56
+ # Never truncate update_plan output
57
+ if "[Plan updated]" in content or len(content) <= 300:
58
+ continue
59
+ lines = content.splitlines()
60
+ if len(lines) > 8:
61
+ preview_start = "\n".join(lines[:3])
62
+ preview_end = "\n".join(lines[-2:])
63
+ msg["content"] = f"[Output: {len(lines)} lines truncated for brevity]\n{preview_start}\n...\n{preview_end}"
64
+ else:
65
+ msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
66
+
42
67
  @property
43
68
  def messages(self) -> List[dict]:
69
+ self.rolling_compact_old_tools(keep_last_turns=3)
44
70
  return self._messages
45
71
 
72
+
46
73
  @messages.setter
47
74
  def messages(self, msgs: List[dict]) -> None:
48
75
  self._messages = msgs
@@ -171,62 +171,103 @@ class ToolExecutor:
171
171
  tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
172
172
  )
173
173
 
174
- # Stage 2: Sequential mutating tools (write_file, edit_file, bash)
175
- for tc in mut_calls:
176
- if on_tool_start:
177
- on_tool_start(tc.name, tc.arguments)
178
- t0 = time.monotonic()
179
- safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
180
- fn = self.dispatch.get(tc.name)
181
- if fn:
182
- try:
183
- res_str = str(fn(safe_args))
184
- except Exception as e:
185
- res_str = f"error: {e}"
186
- else:
187
- res_str = f"error: Unknown tool '{tc.name}'"
188
- elapsed = (time.monotonic() - t0) * 1000
189
-
190
- # Tool loop detection guard
191
- call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
192
- self.tool_call_history.append(call_sig)
193
- repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
194
-
195
- if repeat_count >= 3:
196
- res_str += (
197
- f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
198
- f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
199
- "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
200
- )
174
+ # Stage 2: Mutating tools (parallel write_file if distinct paths, otherwise sequential)
175
+ all_writes = all(tc.name == "write_file" for tc in mut_calls)
176
+ paths = [
177
+ tc.arguments.get("path") if isinstance(tc.arguments, dict) else None
178
+ for tc in mut_calls
179
+ ]
180
+ can_parallel_write = (
181
+ all_writes
182
+ and len(mut_calls) > 1
183
+ and len(paths) == len(set(paths))
184
+ and None not in paths
185
+ )
186
+
187
+ if can_parallel_write:
188
+ with ThreadPoolExecutor(max_workers=min(4, len(mut_calls))) as pool:
189
+ future_to_tc = {}
190
+ for tc in mut_calls:
191
+ if on_tool_start:
192
+ on_tool_start(tc.name, tc.arguments)
193
+ t0 = time.monotonic()
194
+ safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
195
+ fn = self.dispatch.get(tc.name)
196
+ if fn:
197
+ future = pool.submit(fn, safe_args)
198
+ else:
199
+ future = pool.submit(lambda: f"error: Unknown tool '{tc.name}'")
200
+ future_to_tc[future] = (tc, t0)
201
201
 
202
- # Compilation failure recovery hint
203
- if tc.name == "bash":
204
- cmd = tc.arguments.get("command", "") if isinstance(tc.arguments, dict) else ""
205
- is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
206
- if is_compile and "exit_code" in str(res_str):
202
+ for future in as_completed(future_to_tc):
203
+ tc, t0 = future_to_tc[future]
204
+ elapsed = (time.monotonic() - t0) * 1000
207
205
  try:
208
- result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
209
- if result_data.get("exit_code", 0) != 0:
210
- compile_key = cmd.strip()
211
- self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
212
- if self.compile_fail_counts[compile_key] >= 3:
213
- res_str += (
214
- "\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
215
- f"#{self.compile_fail_counts[compile_key]}). "
216
- "STOP making blind edits. Re-read the ENTIRE source file with "
217
- "read_file to understand its full structure, then fix ALL errors "
218
- "comprehensively in one edit."
219
- )
220
- else:
221
- self.compile_fail_counts.pop(cmd.strip(), None)
222
- except (json.JSONDecodeError, ValueError):
223
- pass
206
+ res_str = str(future.result())
207
+ except Exception as e:
208
+ res_str = f"error: {e}"
209
+ if on_tool_end:
210
+ on_tool_end(tc.name, res_str)
211
+ results_map[tc.id] = ToolResult(
212
+ tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
213
+ )
214
+ else:
215
+ # Stage 2: Sequential mutating tools (write_file, edit_file, bash)
216
+ for tc in mut_calls:
217
+ if on_tool_start:
218
+ on_tool_start(tc.name, tc.arguments)
219
+ t0 = time.monotonic()
220
+ safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
221
+ fn = self.dispatch.get(tc.name)
222
+ if fn:
223
+ try:
224
+ res_str = str(fn(safe_args))
225
+ except Exception as e:
226
+ res_str = f"error: {e}"
227
+ else:
228
+ res_str = f"error: Unknown tool '{tc.name}'"
229
+ elapsed = (time.monotonic() - t0) * 1000
230
+
231
+ # Tool loop detection guard
232
+ call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
233
+ self.tool_call_history.append(call_sig)
234
+ repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
235
+
236
+ if repeat_count >= 3:
237
+ res_str += (
238
+ f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
239
+ f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
240
+ "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
241
+ )
224
242
 
225
- if on_tool_end:
226
- on_tool_end(tc.name, res_str)
227
- results_map[tc.id] = ToolResult(
228
- tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
229
- )
243
+ # Compilation failure recovery hint
244
+ if tc.name == "bash":
245
+ cmd = tc.arguments.get("command", "") if isinstance(tc.arguments, dict) else ""
246
+ is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
247
+ if is_compile and "exit_code" in str(res_str):
248
+ try:
249
+ result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
250
+ if result_data.get("exit_code", 0) != 0:
251
+ compile_key = cmd.strip()
252
+ self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
253
+ if self.compile_fail_counts[compile_key] >= 3:
254
+ res_str += (
255
+ "\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
256
+ f"#{self.compile_fail_counts[compile_key]}). "
257
+ "STOP making blind edits. Re-read the ENTIRE source file with "
258
+ "read_file to understand its full structure, then fix ALL errors "
259
+ "comprehensively in one edit."
260
+ )
261
+ else:
262
+ self.compile_fail_counts.pop(cmd.strip(), None)
263
+ except (json.JSONDecodeError, ValueError):
264
+ pass
265
+
266
+ if on_tool_end:
267
+ on_tool_end(tc.name, res_str)
268
+ results_map[tc.id] = ToolResult(
269
+ tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
270
+ )
230
271
 
231
272
  return [results_map[tc.id] for tc in tool_calls if tc.id in results_map]
232
273
 
@@ -0,0 +1,135 @@
1
+ """
2
+ Pre-Commit AST & Syntax Guardrail for AlpieCode.
3
+
4
+ Validates code syntax before writing to disk, ensuring that corrupt code,
5
+ unclosed brackets, indentation errors, or syntax mistakes are caught and
6
+ can be self-healed in-place without breaking the user workspace.
7
+ """
8
+
9
+ import ast
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ try:
18
+ import tomllib
19
+ except ImportError:
20
+ try:
21
+ import tomli as tomllib
22
+ except ImportError:
23
+ tomllib = None
24
+
25
+
26
+ def validate_code_syntax(file_path: str, content: str) -> Tuple[bool, Optional[str]]:
27
+ """
28
+ Validate the syntax of code content for a given file path.
29
+ Returns (is_valid, error_message_if_any).
30
+ """
31
+ if not content or not content.strip():
32
+ return True, None
33
+
34
+ ext = Path(file_path).suffix.lower()
35
+
36
+ # 1. Python AST Validation
37
+ if ext == ".py":
38
+ try:
39
+ ast.parse(content, filename=file_path)
40
+ return True, None
41
+ except SyntaxError as e:
42
+ line = e.lineno or 0
43
+ col = e.offset or 0
44
+ text_line = e.text.strip() if e.text else ""
45
+ err_msg = (
46
+ f"SyntaxError in '{file_path}' at line {line}, col {col}: {e.msg}.\n"
47
+ f"Offending line: {text_line}\n"
48
+ "Please fix this syntax error."
49
+ )
50
+ return False, err_msg
51
+ except Exception as e:
52
+ return False, f"Syntax parsing error in '{file_path}': {e}"
53
+
54
+ # 2. JSON Validation
55
+ if ext == ".json":
56
+ try:
57
+ json.loads(content)
58
+ return True, None
59
+ except Exception as e:
60
+ return False, f"Invalid JSON in '{file_path}': {e}"
61
+
62
+ # 3. TOML Validation
63
+ if ext == ".toml" and tomllib:
64
+ try:
65
+ tomllib.loads(content)
66
+ return True, None
67
+ except Exception as e:
68
+ return False, f"Invalid TOML in '{file_path}': {e}"
69
+
70
+ # 4. JavaScript / TypeScript Validation
71
+ if ext in (".js", ".mjs", ".cjs"):
72
+ node_bin = shutil.which("node")
73
+ if node_bin:
74
+ try:
75
+ res = subprocess.run(
76
+ [node_bin, "--check", "-"],
77
+ input=content,
78
+ text=True,
79
+ capture_output=True,
80
+ timeout=3
81
+ )
82
+ if res.returncode != 0:
83
+ err = res.stderr.strip() or "Syntax error in JavaScript code"
84
+ return False, f"JavaScript SyntaxError in '{file_path}': {err}"
85
+ except Exception:
86
+ pass
87
+
88
+ # Fallback lexical check for bracket balance
89
+ is_balanced, balance_err = check_bracket_balance(content)
90
+ if not is_balanced:
91
+ return False, f"Syntax issue in '{file_path}': {balance_err}"
92
+ return True, None
93
+
94
+ return True, None
95
+
96
+
97
+ def check_bracket_balance(code: str) -> Tuple[bool, Optional[str]]:
98
+ """Fast lexical check for unclosed brackets or strings in code."""
99
+ stack = []
100
+ pairs = {")": "(", "}": "{", "]": "["}
101
+ in_string = False
102
+ quote_char = ""
103
+ escaped = False
104
+
105
+ lines = code.split("\n")
106
+ for line_idx, line in enumerate(lines, 1):
107
+ for char_idx, ch in enumerate(line, 1):
108
+ if in_string:
109
+ if escaped:
110
+ escaped = False
111
+ elif ch == "\\":
112
+ escaped = True
113
+ elif ch == quote_char:
114
+ in_string = False
115
+ continue
116
+
117
+ if ch in ("'", '"', '`'):
118
+ in_string = True
119
+ quote_char = ch
120
+ continue
121
+
122
+ if ch in ("(", "{", "["):
123
+ stack.append((ch, line_idx, char_idx))
124
+ elif ch in (")", "}", "]"):
125
+ if not stack:
126
+ return False, f"Unexpected closing bracket '{ch}' at line {line_idx}:{char_idx}"
127
+ top, t_line, t_col = stack.pop()
128
+ if pairs[ch] != top:
129
+ return False, f"Mismatched bracket: opened '{top}' at line {t_line}:{t_col} but closed with '{ch}' at line {line_idx}:{char_idx}"
130
+
131
+ if stack:
132
+ top, t_line, t_col = stack[-1]
133
+ return False, f"Unclosed bracket '{top}' opened at line {t_line}:{t_col}"
134
+
135
+ return True, None
@@ -150,7 +150,7 @@ class AgentOrchestrator:
150
150
  session.is_offline = is_offline
151
151
 
152
152
  # ── Configure tools & system prompt based on complexity ──
153
- active_tools = self.prompt_builder.get_tools(is_offline=is_offline, complexity=complexity)
153
+ active_tools = self.prompt_builder.get_tools(is_offline=is_offline, complexity=complexity, task_context=task_context)
154
154
  system_prompt = self.prompt_builder.build_system_prompt(
155
155
  session.workdir, is_offline=is_offline, complexity=complexity,
156
156
  task_context=task_context,
@@ -329,7 +329,17 @@ class AgentOrchestrator:
329
329
  })
330
330
  session.context.add_tool_result(res.tool_call_id, res.content)
331
331
 
332
- yield AgentEvent("message", {"content": resp.content})
332
+ if resp.content:
333
+ # Clean duplicate DONE: if repeated
334
+ if resp.content.upper().count("DONE:") > 1:
335
+ parts = re.split(r"(?i)\bDONE:\s*", resp.content)
336
+ if len(parts) >= 3 and parts[1].strip() == parts[2].strip():
337
+ resp.content = parts[0] + "DONE: " + parts[1].strip()
338
+
339
+ # Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
340
+ if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
341
+ yield AgentEvent("message", {"content": resp.content})
342
+
333
343
  extract_and_save_memories(session.workdir, session.context.messages)
334
344
 
335
345
  # Generate and write real walkthrough.md file to the project workspace
@@ -423,7 +433,17 @@ class AgentOrchestrator:
423
433
  except Exception:
424
434
  pass
425
435
 
426
- yield AgentEvent("message", {"content": resp.content})
436
+ if resp.content:
437
+ # Clean duplicate DONE: if repeated
438
+ if resp.content.upper().count("DONE:") > 1:
439
+ parts = re.split(r"(?i)\bDONE:\s*", resp.content)
440
+ if len(parts) >= 3 and parts[1].strip() == parts[2].strip():
441
+ resp.content = parts[0] + "DONE: " + parts[1].strip()
442
+
443
+ # Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
444
+ if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
445
+ yield AgentEvent("message", {"content": resp.content})
446
+
427
447
  extract_and_save_memories(session.workdir, session.context.messages)
428
448
  yield AgentEvent("done", {"summary": resp.content})
429
449
  return
@@ -551,9 +551,13 @@ class PromptBuilder:
551
551
  self,
552
552
  is_offline: bool = False,
553
553
  complexity: str = "low",
554
+ task_context=None,
554
555
  ) -> List[dict]:
555
556
  if complexity == "qa":
556
557
  return [] # No tools for Q&A
558
+ if task_context is not None:
559
+ if getattr(task_context, "tool_set", None) == "none" or getattr(task_context, "intent", None) in ("qa", "explain"):
560
+ return []
557
561
  if is_offline:
558
562
  return OFFLINE_TOOLS
559
563
  if complexity in ("medium", "high"):
@@ -1,3 +1,4 @@
1
+ from .guardrails import validate_code_syntax
1
2
  _RECENTLY_WRITTEN_FILES = set()
2
3
 
3
4
  """
@@ -519,6 +520,11 @@ def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int =
519
520
 
520
521
 
521
522
  def _write_file(workdir: Path, path: str, content: str) -> str:
523
+ # Pre-commit AST syntax check to prevent writing corrupt code
524
+ is_valid, err = validate_code_syntax(path, content)
525
+ if not is_valid:
526
+ return f"error: Pre-commit syntax check failed for '{path}':\n{err}\nPlease fix the syntax error before writing."
527
+
522
528
  p = workdir / path
523
529
  p.parent.mkdir(parents=True, exist_ok=True)
524
530
  p.write_text(content, encoding="utf-8")
@@ -532,23 +538,53 @@ def _edit_file(workdir: Path, path: str, old_str: str, new_str: str) -> str:
532
538
  return f"error: file not found: {path}"
533
539
  text = p.read_text(encoding="utf-8", errors="replace")
534
540
  count = text.count(old_str)
535
- if count == 0:
536
- import difflib
537
- lines = text.splitlines()
538
- old_lines = [ol.strip() for ol in old_str.splitlines() if ol.strip()]
539
- target_line = old_lines[0] if old_lines else old_str.strip()
540
- close_matches = difflib.get_close_matches(target_line, [l.strip() for l in lines], n=3, cutoff=0.3)
541
- nearby = [(i + 1, l) for i, l in enumerate(lines) if l.strip() in close_matches]
542
- hint = ""
543
- if nearby:
544
- hint = "\nClosest matching lines in file:\n" + "\n".join(f" Line {n}: {l.strip()[:80]}" for n, l in nearby[:3])
545
- return (
546
- f"error: old_str not found in file '{path}'. It must match existing text EXACTLY (including whitespace/indentation).{hint}\n"
547
- f"→ Action: Run read_file on '{path}' around these lines to see the exact whitespace, then retry edit_file."
548
- )
549
- if count > 1:
541
+
542
+ if count == 1:
543
+ new_text = text.replace(old_str, new_str, 1)
544
+ elif count > 1:
550
545
  return f"error: old_str matched {count} times in '{path}'. Must match exactly 1 occurrence. Include more surrounding lines in old_str to make it unique."
551
- p.write_text(text.replace(old_str, new_str, 1), encoding="utf-8")
546
+ else:
547
+ # Whitespace-tolerant fuzzy block matching
548
+ import difflib
549
+ lines = text.splitlines(keepends=True)
550
+ old_lines = old_str.splitlines(keepends=True)
551
+ n_old = len(old_lines)
552
+ best_ratio = 0.0
553
+ best_idx = -1
554
+
555
+ norm_old = "".join(l.strip() for l in old_lines)
556
+
557
+ for i in range(len(lines) - n_old + 1):
558
+ window = lines[i:i + n_old]
559
+ norm_win = "".join(l.strip() for l in window)
560
+ ratio = difflib.SequenceMatcher(None, norm_win, norm_old).ratio()
561
+ if ratio > best_ratio:
562
+ best_ratio = ratio
563
+ best_idx = i
564
+
565
+ if best_ratio >= 0.85 and best_idx >= 0:
566
+ # Fuzzy match found with high confidence
567
+ new_lines_slice = new_str.splitlines(keepends=True)
568
+ new_text = "".join(lines[:best_idx] + new_lines_slice + lines[best_idx + n_old:])
569
+ else:
570
+ old_stripped = [ol.strip() for ol in old_lines if ol.strip()]
571
+ target_line = old_stripped[0] if old_stripped else old_str.strip()
572
+ close_matches = difflib.get_close_matches(target_line, [l.strip() for l in lines], n=3, cutoff=0.3)
573
+ nearby = [(i + 1, l) for i, l in enumerate(lines) if l.strip() in close_matches]
574
+ hint = ""
575
+ if nearby:
576
+ hint = "\nClosest matching lines in file:\n" + "\n".join(f" Line {n}: {l.strip()[:80]}" for n, l in nearby[:3])
577
+ return (
578
+ f"error: old_str not found in file '{path}'.{hint}\n"
579
+ f"→ Action: Run read_file on '{path}' around these lines, then retry edit_file."
580
+ )
581
+
582
+ # Post-edit syntax validation
583
+ is_valid, err = validate_code_syntax(path, new_text)
584
+ if not is_valid:
585
+ return f"error: Edit would result in a syntax error in '{path}':\n{err}\nPlease revise the replacement."
586
+
587
+ p.write_text(new_text, encoding="utf-8")
552
588
  return "edit applied"
553
589
 
554
590
 
@@ -590,19 +626,29 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
590
626
 
591
627
  def _file_search(workdir: Path, pattern: str, path: str = ".", include: str = None,
592
628
  case_insensitive: bool = False) -> str:
593
- """Search for a pattern across files using grep."""
629
+ """Search for a pattern across files using ripgrep (rg) or grep."""
594
630
  target = workdir / path
595
-
596
- # Build grep command
597
- cmd = ["grep", "-rn", "--color=never"]
598
- if case_insensitive:
599
- cmd.append("-i")
600
- if include:
601
- cmd.extend(["--include", include])
602
- # Exclude common non-code directories
603
- for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
604
- cmd.extend(["--exclude-dir", excl])
605
- cmd.extend([pattern, str(target)])
631
+ import shutil
632
+
633
+ rg_bin = shutil.which("rg")
634
+ if rg_bin:
635
+ cmd = [rg_bin, "--no-heading", "--line-number", "--color=never"]
636
+ if case_insensitive:
637
+ cmd.append("-i")
638
+ if include:
639
+ cmd.extend(["-g", include])
640
+ cmd.extend(["--hidden", "--glob", "!.git/*", "--glob", "!node_modules/*", "--glob", "!.venv/*"])
641
+ cmd.extend(["-e", pattern, str(target)])
642
+ else:
643
+ # Fallback to grep
644
+ cmd = ["grep", "-rn", "--color=never"]
645
+ if case_insensitive:
646
+ cmd.append("-i")
647
+ if include:
648
+ cmd.extend(["--include", include])
649
+ for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
650
+ cmd.extend(["--exclude-dir", excl])
651
+ cmd.extend([pattern, str(target)])
606
652
 
607
653
  try:
608
654
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=workdir)
File without changes
File without changes