patchpal 0.3.0__tar.gz → 0.3.2__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.
- {patchpal-0.3.0/patchpal.egg-info → patchpal-0.3.2}/PKG-INFO +1 -1
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/__init__.py +1 -1
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/agent.py +1 -1
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/cli.py +49 -1
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/tools.py +272 -14
- {patchpal-0.3.0 → patchpal-0.3.2/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_tools.py +123 -5
- {patchpal-0.3.0 → patchpal-0.3.2}/LICENSE +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/MANIFEST.in +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/README.md +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/context.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/permissions.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/skills.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal/system_prompt.md +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/SOURCES.txt +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/pyproject.toml +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/setup.cfg +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_agent.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_cli.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_context.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_guardrails.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_operational_safety.py +0 -0
- {patchpal-0.3.0 → patchpal-0.3.2}/tests/test_skills.py +0 -0
|
@@ -541,7 +541,7 @@ TOOLS = [
|
|
|
541
541
|
"type": "function",
|
|
542
542
|
"function": {
|
|
543
543
|
"name": "run_shell",
|
|
544
|
-
"description": "Run a safe shell command in the repository. Privilege escalation (sudo, su) blocked by default unless PATCHPAL_ALLOW_SUDO=true.",
|
|
544
|
+
"description": "Run a safe shell command in the repository. Commands execute from repository root automatically (no need for 'cd'). Privilege escalation (sudo, su) blocked by default unless PATCHPAL_ALLOW_SUDO=true.",
|
|
545
545
|
"parameters": {
|
|
546
546
|
"type": "object",
|
|
547
547
|
"properties": {
|
|
@@ -248,7 +248,9 @@ Supported models: Any LiteLLM-supported model
|
|
|
248
248
|
print(f"\033[1;36m🔧 Using custom system prompt: {custom_prompt_path}\033[0m")
|
|
249
249
|
|
|
250
250
|
print("\nType 'exit' to quit.")
|
|
251
|
-
print(
|
|
251
|
+
print(
|
|
252
|
+
"Use '/status' to check context window usage, '/compact' to manually compact, '/clear' to start fresh."
|
|
253
|
+
)
|
|
252
254
|
print("Use 'list skills' to see available skills or /skillname to invoke skills.")
|
|
253
255
|
print("Press Ctrl-C during agent execution to interrupt the agent.\n")
|
|
254
256
|
|
|
@@ -363,6 +365,52 @@ Supported models: Any LiteLLM-supported model
|
|
|
363
365
|
print("=" * 70 + "\n")
|
|
364
366
|
continue
|
|
365
367
|
|
|
368
|
+
# Handle /clear command - clear conversation history
|
|
369
|
+
if user_input.lower() in ["clear", "/clear"]:
|
|
370
|
+
print("\n" + "=" * 70)
|
|
371
|
+
print("\033[1;36mClear Context\033[0m")
|
|
372
|
+
print("=" * 70)
|
|
373
|
+
|
|
374
|
+
if not agent.messages:
|
|
375
|
+
print("\033[1;33m Context is already empty.\033[0m")
|
|
376
|
+
print("=" * 70 + "\n")
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
# Show current status
|
|
380
|
+
stats = agent.context_manager.get_usage_stats(agent.messages)
|
|
381
|
+
print(
|
|
382
|
+
f" Current: {len(agent.messages)} messages, {stats['total_tokens']:,} tokens"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Confirm before clearing
|
|
386
|
+
try:
|
|
387
|
+
confirm = pt_prompt(
|
|
388
|
+
FormattedText(
|
|
389
|
+
[
|
|
390
|
+
("ansiyellow", " Clear all context and start fresh? (y/n): "),
|
|
391
|
+
("", ""),
|
|
392
|
+
]
|
|
393
|
+
)
|
|
394
|
+
).strip()
|
|
395
|
+
if confirm.lower() not in ["y", "yes"]:
|
|
396
|
+
print(" Cancelled.")
|
|
397
|
+
print("=" * 70 + "\n")
|
|
398
|
+
continue
|
|
399
|
+
except KeyboardInterrupt:
|
|
400
|
+
print("\n Cancelled.")
|
|
401
|
+
print("=" * 70 + "\n")
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
# Clear conversation history
|
|
405
|
+
agent.messages = []
|
|
406
|
+
agent._last_compaction_message_count = 0
|
|
407
|
+
|
|
408
|
+
print("\n\033[1;32m✓ Context cleared successfully!\033[0m")
|
|
409
|
+
print(" Starting fresh with empty conversation history.")
|
|
410
|
+
print(" All previous context has been removed - ready for a new task.")
|
|
411
|
+
print("=" * 70 + "\n")
|
|
412
|
+
continue
|
|
413
|
+
|
|
366
414
|
# Handle /compact command - manually trigger compaction
|
|
367
415
|
if user_input.lower() in ["compact", "/compact"]:
|
|
368
416
|
print("\n" + "=" * 70)
|
|
@@ -100,6 +100,10 @@ WEB_USER_AGENT = f"PatchPal/{__version__} (AI Code Assistant)"
|
|
|
100
100
|
# Shell command configuration
|
|
101
101
|
SHELL_TIMEOUT = int(os.getenv("PATCHPAL_SHELL_TIMEOUT", 30)) # 30 seconds default
|
|
102
102
|
|
|
103
|
+
# Output filtering configuration - reduce token usage from verbose commands
|
|
104
|
+
ENABLE_OUTPUT_FILTERING = os.getenv("PATCHPAL_FILTER_OUTPUTS", "true").lower() == "true"
|
|
105
|
+
MAX_OUTPUT_LINES = int(os.getenv("PATCHPAL_MAX_OUTPUT_LINES", 500)) # Max lines of output
|
|
106
|
+
|
|
103
107
|
# Global flag for requiring permission on ALL operations (including reads)
|
|
104
108
|
# Set via CLI flag --require-permission-for-all
|
|
105
109
|
_REQUIRE_PERMISSION_FOR_ALL = False
|
|
@@ -195,10 +199,194 @@ class OperationLimiter:
|
|
|
195
199
|
audit_logger.info(f"Operation {self.operations}/{self.max_operations}: {operation}")
|
|
196
200
|
|
|
197
201
|
def reset(self):
|
|
198
|
-
"""Reset operation counter."""
|
|
202
|
+
"""Reset the operation counter (used in tests)."""
|
|
199
203
|
self.operations = 0
|
|
200
204
|
|
|
201
205
|
|
|
206
|
+
class OutputFilter:
|
|
207
|
+
"""Filter verbose command outputs to reduce token usage.
|
|
208
|
+
|
|
209
|
+
This class implements Claude Code's strategy of filtering verbose outputs
|
|
210
|
+
to show only relevant information (e.g., test failures, error messages).
|
|
211
|
+
Can save 75% or more on output tokens for verbose commands.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def should_filter(cmd: str) -> bool:
|
|
216
|
+
"""Check if a command should have its output filtered.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
cmd: The shell command
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
True if filtering should be applied
|
|
223
|
+
"""
|
|
224
|
+
if not ENABLE_OUTPUT_FILTERING:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
# Test runners - show only failures
|
|
228
|
+
test_patterns = [
|
|
229
|
+
"pytest",
|
|
230
|
+
"npm test",
|
|
231
|
+
"npm run test",
|
|
232
|
+
"yarn test",
|
|
233
|
+
"go test",
|
|
234
|
+
"cargo test",
|
|
235
|
+
"mvn test",
|
|
236
|
+
"gradle test",
|
|
237
|
+
"ruby -I test",
|
|
238
|
+
"rspec",
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
# Version control - limit log output
|
|
242
|
+
vcs_patterns = [
|
|
243
|
+
"git log",
|
|
244
|
+
"git reflog",
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
# Package managers - show only important info
|
|
248
|
+
pkg_patterns = [
|
|
249
|
+
"npm install",
|
|
250
|
+
"pip install",
|
|
251
|
+
"cargo build",
|
|
252
|
+
"go build",
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
all_patterns = test_patterns + vcs_patterns + pkg_patterns
|
|
256
|
+
return any(pattern in cmd for pattern in all_patterns)
|
|
257
|
+
|
|
258
|
+
@staticmethod
|
|
259
|
+
def filter_output(cmd: str, output: str) -> str:
|
|
260
|
+
"""Filter command output to reduce token usage.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
cmd: The shell command
|
|
264
|
+
output: The raw command output
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Filtered output with only relevant information
|
|
268
|
+
"""
|
|
269
|
+
if not output or not ENABLE_OUTPUT_FILTERING:
|
|
270
|
+
return output
|
|
271
|
+
|
|
272
|
+
lines = output.split("\n")
|
|
273
|
+
original_lines = len(lines)
|
|
274
|
+
|
|
275
|
+
# Test output - show only failures and summary
|
|
276
|
+
if any(
|
|
277
|
+
pattern in cmd
|
|
278
|
+
for pattern in ["pytest", "npm test", "yarn test", "go test", "cargo test", "rspec"]
|
|
279
|
+
):
|
|
280
|
+
filtered_lines = []
|
|
281
|
+
in_failure = False
|
|
282
|
+
failure_context = []
|
|
283
|
+
|
|
284
|
+
for line in lines:
|
|
285
|
+
# Capture failure indicators
|
|
286
|
+
if any(
|
|
287
|
+
keyword in line.upper()
|
|
288
|
+
for keyword in ["FAIL", "ERROR", "FAILED", "✗", "✖", "FAILURE"]
|
|
289
|
+
):
|
|
290
|
+
in_failure = True
|
|
291
|
+
failure_context = [line]
|
|
292
|
+
elif in_failure:
|
|
293
|
+
# Capture context after failure (up to 10 lines or until next test/blank line)
|
|
294
|
+
failure_context.append(line)
|
|
295
|
+
# End failure context on: blank line, next test case, or 10 lines
|
|
296
|
+
if (
|
|
297
|
+
not line.strip()
|
|
298
|
+
or "::" in line
|
|
299
|
+
or line.startswith("=")
|
|
300
|
+
or len(failure_context) >= 10
|
|
301
|
+
):
|
|
302
|
+
filtered_lines.extend(failure_context)
|
|
303
|
+
in_failure = False
|
|
304
|
+
failure_context = []
|
|
305
|
+
# Always capture summary lines
|
|
306
|
+
elif any(
|
|
307
|
+
keyword in line.lower()
|
|
308
|
+
for keyword in ["passed", "failed", "error", "summary", "total"]
|
|
309
|
+
):
|
|
310
|
+
filtered_lines.append(line)
|
|
311
|
+
|
|
312
|
+
# Add remaining failure context
|
|
313
|
+
if failure_context:
|
|
314
|
+
filtered_lines.extend(failure_context)
|
|
315
|
+
|
|
316
|
+
# If we filtered significantly, add header
|
|
317
|
+
if filtered_lines and len(filtered_lines) < original_lines * 0.5:
|
|
318
|
+
header = f"[Filtered test output - showing failures only ({len(filtered_lines)}/{original_lines} lines)]"
|
|
319
|
+
return header + "\n" + "\n".join(filtered_lines)
|
|
320
|
+
else:
|
|
321
|
+
# Not much to filter, return original but truncated if too long
|
|
322
|
+
return OutputFilter._truncate_output(output, lines, original_lines)
|
|
323
|
+
|
|
324
|
+
# Git log - limit to reasonable number of commits
|
|
325
|
+
elif "git log" in cmd or "git reflog" in cmd:
|
|
326
|
+
# Take first 50 lines (typically ~5-10 commits with details)
|
|
327
|
+
if len(lines) > 50:
|
|
328
|
+
truncated = "\n".join(lines[:50])
|
|
329
|
+
footer = f"\n[Output truncated: showing first 50/{original_lines} lines. Use --max-count to limit commits]"
|
|
330
|
+
return truncated + footer
|
|
331
|
+
return output
|
|
332
|
+
|
|
333
|
+
# Build/install output - show only errors and final status
|
|
334
|
+
elif any(
|
|
335
|
+
pattern in cmd for pattern in ["npm install", "pip install", "cargo build", "go build"]
|
|
336
|
+
):
|
|
337
|
+
filtered_lines = []
|
|
338
|
+
|
|
339
|
+
for line in lines:
|
|
340
|
+
# Keep error/warning lines
|
|
341
|
+
if any(
|
|
342
|
+
keyword in line.upper()
|
|
343
|
+
for keyword in ["ERROR", "WARN", "FAIL", "SUCCESSFULLY", "COMPLETE"]
|
|
344
|
+
):
|
|
345
|
+
filtered_lines.append(line)
|
|
346
|
+
# Keep final summary lines
|
|
347
|
+
elif any(
|
|
348
|
+
keyword in line.lower()
|
|
349
|
+
for keyword in ["installed", "built", "compiled", "finished"]
|
|
350
|
+
):
|
|
351
|
+
filtered_lines.append(line)
|
|
352
|
+
|
|
353
|
+
if filtered_lines and len(filtered_lines) < original_lines * 0.3:
|
|
354
|
+
header = f"[Filtered build output - showing errors and summary only ({len(filtered_lines)}/{original_lines} lines)]"
|
|
355
|
+
return header + "\n" + "\n".join(filtered_lines)
|
|
356
|
+
else:
|
|
357
|
+
return OutputFilter._truncate_output(output, lines, original_lines)
|
|
358
|
+
|
|
359
|
+
# Default: truncate if too long
|
|
360
|
+
return OutputFilter._truncate_output(output, lines, original_lines)
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _truncate_output(output: str, lines: list, original_lines: int) -> str:
|
|
364
|
+
"""Truncate output if it exceeds maximum lines.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
output: Original output string
|
|
368
|
+
lines: Split lines
|
|
369
|
+
original_lines: Count of original lines
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Truncated output if necessary
|
|
373
|
+
"""
|
|
374
|
+
if original_lines > MAX_OUTPUT_LINES:
|
|
375
|
+
# Show first and last portions
|
|
376
|
+
keep_start = MAX_OUTPUT_LINES // 2
|
|
377
|
+
keep_end = MAX_OUTPUT_LINES // 2
|
|
378
|
+
|
|
379
|
+
truncated_lines = (
|
|
380
|
+
lines[:keep_start]
|
|
381
|
+
+ ["", f"... [truncated {original_lines - MAX_OUTPUT_LINES} lines] ...", ""]
|
|
382
|
+
+ lines[-keep_end:]
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return "\n".join(truncated_lines)
|
|
386
|
+
|
|
387
|
+
return output
|
|
388
|
+
|
|
389
|
+
|
|
202
390
|
# Global operation limiter
|
|
203
391
|
_operation_limiter = OperationLimiter()
|
|
204
392
|
|
|
@@ -1457,7 +1645,7 @@ def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
|
|
|
1457
1645
|
content_lines = content.split("\n")
|
|
1458
1646
|
search_lines = old_string.split("\n")
|
|
1459
1647
|
|
|
1460
|
-
# Remove trailing empty line if present
|
|
1648
|
+
# Remove trailing empty line if present in search
|
|
1461
1649
|
if search_lines and search_lines[-1] == "":
|
|
1462
1650
|
search_lines.pop()
|
|
1463
1651
|
|
|
@@ -1472,7 +1660,19 @@ def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
|
|
|
1472
1660
|
if matches:
|
|
1473
1661
|
# Found a match - return the original lines (with indentation) joined
|
|
1474
1662
|
matched_lines = content_lines[i : i + len(search_lines)]
|
|
1475
|
-
|
|
1663
|
+
result = "\n".join(matched_lines)
|
|
1664
|
+
|
|
1665
|
+
# Preserve trailing newlines if present in the matched section
|
|
1666
|
+
# After the matched lines, check if there's more content (indicating trailing newline)
|
|
1667
|
+
end_index = i + len(search_lines)
|
|
1668
|
+
if end_index < len(content_lines):
|
|
1669
|
+
# There's more content after match, so add the newline that separates them
|
|
1670
|
+
result += "\n"
|
|
1671
|
+
elif content.endswith("\n"):
|
|
1672
|
+
# At end of file and file ends with newline, preserve it
|
|
1673
|
+
result += "\n"
|
|
1674
|
+
|
|
1675
|
+
return result
|
|
1476
1676
|
|
|
1477
1677
|
return None
|
|
1478
1678
|
|
|
@@ -1726,11 +1926,57 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1726
1926
|
f"💡 Tip: Use read_lines() to see the exact context, or use apply_patch() for multiple changes."
|
|
1727
1927
|
)
|
|
1728
1928
|
|
|
1729
|
-
#
|
|
1929
|
+
# Perform indentation adjustment and trailing newline preservation BEFORE showing diff
|
|
1930
|
+
# Important: Adjust indentation and preserve trailing newlines to maintain file structure
|
|
1931
|
+
adjusted_new_string = new_string
|
|
1932
|
+
|
|
1933
|
+
# Step 1: Adjust indentation if needed
|
|
1934
|
+
# Get the indentation of the first line in matched_string vs new_string
|
|
1935
|
+
matched_lines = matched_string.split("\n")
|
|
1936
|
+
new_lines = new_string.split("\n")
|
|
1937
|
+
|
|
1938
|
+
if matched_lines and new_lines and matched_lines[0] and new_lines[0]:
|
|
1939
|
+
# Get leading whitespace of first line in matched string
|
|
1940
|
+
matched_indent = len(matched_lines[0]) - len(matched_lines[0].lstrip())
|
|
1941
|
+
new_indent = len(new_lines[0]) - len(new_lines[0].lstrip())
|
|
1942
|
+
|
|
1943
|
+
if matched_indent != new_indent:
|
|
1944
|
+
# Need to adjust indentation
|
|
1945
|
+
indent_diff = matched_indent - new_indent
|
|
1946
|
+
|
|
1947
|
+
# Apply the indentation adjustment to all non-empty lines in new_string
|
|
1948
|
+
adjusted_lines = []
|
|
1949
|
+
for line in new_lines:
|
|
1950
|
+
if line.strip(): # Non-empty line
|
|
1951
|
+
if indent_diff > 0:
|
|
1952
|
+
# Need to add spaces
|
|
1953
|
+
adjusted_lines.append((" " * indent_diff) + line)
|
|
1954
|
+
else:
|
|
1955
|
+
# Need to remove spaces (if possible)
|
|
1956
|
+
spaces_to_remove = abs(indent_diff)
|
|
1957
|
+
if line[:spaces_to_remove].strip() == "": # All spaces
|
|
1958
|
+
adjusted_lines.append(line[spaces_to_remove:])
|
|
1959
|
+
else:
|
|
1960
|
+
# Can't remove that many spaces, keep as-is
|
|
1961
|
+
adjusted_lines.append(line)
|
|
1962
|
+
else:
|
|
1963
|
+
# Empty line, keep as-is
|
|
1964
|
+
adjusted_lines.append(line)
|
|
1965
|
+
|
|
1966
|
+
adjusted_new_string = "\n".join(adjusted_lines)
|
|
1967
|
+
|
|
1968
|
+
# Step 2: Preserve trailing newlines from matched_string
|
|
1969
|
+
if matched_string.endswith("\n") and not adjusted_new_string.endswith("\n"):
|
|
1970
|
+
# Matched block had trailing newline(s), preserve them
|
|
1971
|
+
# Count consecutive trailing newlines in matched_string
|
|
1972
|
+
trailing_newlines = len(matched_string) - len(matched_string.rstrip("\n"))
|
|
1973
|
+
adjusted_new_string = adjusted_new_string + ("\n" * trailing_newlines)
|
|
1974
|
+
|
|
1975
|
+
# Check permission before proceeding (use adjusted_new_string for accurate diff display)
|
|
1730
1976
|
permission_manager = _get_permission_manager()
|
|
1731
1977
|
|
|
1732
|
-
# Format colored diff for permission prompt (use
|
|
1733
|
-
diff_display = _format_colored_diff(matched_string,
|
|
1978
|
+
# Format colored diff for permission prompt (use adjusted_new_string so user sees what will actually be written)
|
|
1979
|
+
diff_display = _format_colored_diff(matched_string, adjusted_new_string, file_path=path)
|
|
1734
1980
|
|
|
1735
1981
|
# Add warning if writing outside repository
|
|
1736
1982
|
outside_repo_warning = ""
|
|
@@ -1745,21 +1991,18 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1745
1991
|
# Backup if enabled
|
|
1746
1992
|
backup_path = _backup_file(p)
|
|
1747
1993
|
|
|
1748
|
-
|
|
1749
|
-
# Note: newString is used as-is (OpenCode behavior)
|
|
1750
|
-
# The LLM should provide newString with proper indentation matching the original
|
|
1751
|
-
new_content = content.replace(matched_string, new_string)
|
|
1994
|
+
new_content = content.replace(matched_string, adjusted_new_string)
|
|
1752
1995
|
|
|
1753
1996
|
# Write the new content
|
|
1754
1997
|
p.write_text(new_content)
|
|
1755
1998
|
|
|
1756
|
-
# Generate diff for the specific change (use
|
|
1999
|
+
# Generate diff for the specific change (use adjusted_new_string for accurate diff)
|
|
1757
2000
|
old_lines = matched_string.split("\n")
|
|
1758
|
-
new_lines =
|
|
2001
|
+
new_lines = adjusted_new_string.split("\n")
|
|
1759
2002
|
diff = difflib.unified_diff(old_lines, new_lines, fromfile="old", tofile="new", lineterm="")
|
|
1760
2003
|
diff_str = "\n".join(diff)
|
|
1761
2004
|
|
|
1762
|
-
audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(
|
|
2005
|
+
audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(adjusted_new_string)} chars)")
|
|
1763
2006
|
|
|
1764
2007
|
backup_msg = f"\n[Backup saved: {backup_path}]" if backup_path else ""
|
|
1765
2008
|
return f"Successfully edited {path}{backup_msg}\n\nChange:\n{diff_str}"
|
|
@@ -2304,4 +2547,19 @@ def run_shell(cmd: str) -> str:
|
|
|
2304
2547
|
stdout = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
|
|
2305
2548
|
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
|
|
2306
2549
|
|
|
2307
|
-
|
|
2550
|
+
output = stdout + stderr
|
|
2551
|
+
|
|
2552
|
+
# Apply output filtering to reduce token usage
|
|
2553
|
+
if OutputFilter.should_filter(cmd):
|
|
2554
|
+
filtered_output = OutputFilter.filter_output(cmd, output)
|
|
2555
|
+
# Log if we filtered significantly
|
|
2556
|
+
original_lines = len(output.split("\n"))
|
|
2557
|
+
filtered_lines = len(filtered_output.split("\n"))
|
|
2558
|
+
if filtered_lines < original_lines * 0.5:
|
|
2559
|
+
audit_logger.info(
|
|
2560
|
+
f"SHELL_FILTER: Reduced output from {original_lines} to {filtered_lines} lines "
|
|
2561
|
+
f"(~{int((1 - filtered_lines / original_lines) * 100)}% reduction)"
|
|
2562
|
+
)
|
|
2563
|
+
return filtered_output
|
|
2564
|
+
|
|
2565
|
+
return output
|
|
@@ -1652,9 +1652,9 @@ def test_edit_file_matching_strategies_helper_functions(temp_repo):
|
|
|
1652
1652
|
assert _try_simple_match(content, 'print("world")') == 'print("world")'
|
|
1653
1653
|
assert _try_simple_match(content, "nonexistent") is None
|
|
1654
1654
|
|
|
1655
|
-
# Test line trimmed match (should find with correct indentation)
|
|
1655
|
+
# Test line trimmed match (should find with correct indentation AND trailing newline)
|
|
1656
1656
|
match = _try_line_trimmed_match(content, 'print("world")')
|
|
1657
|
-
assert match == ' print("world")'
|
|
1657
|
+
assert match == ' print("world")\n' # Now preserves trailing newline
|
|
1658
1658
|
|
|
1659
1659
|
# Test whitespace normalized match
|
|
1660
1660
|
content2 = "x = 42"
|
|
@@ -1679,8 +1679,8 @@ def test_edit_file_multiline_trimmed_match_helper(temp_repo):
|
|
|
1679
1679
|
return value"""
|
|
1680
1680
|
|
|
1681
1681
|
match = _try_line_trimmed_match(content, search)
|
|
1682
|
-
# Should return with proper indentation (8 spaces)
|
|
1683
|
-
assert match == " if True:\n do_something()\n return value"
|
|
1682
|
+
# Should return with proper indentation (8 spaces) AND trailing newline
|
|
1683
|
+
assert match == " if True:\n do_something()\n return value\n"
|
|
1684
1684
|
|
|
1685
1685
|
|
|
1686
1686
|
def test_edit_file_finds_match_with_strategy_order(temp_repo):
|
|
@@ -1696,5 +1696,123 @@ def test_edit_file_finds_match_with_strategy_order(temp_repo):
|
|
|
1696
1696
|
|
|
1697
1697
|
# Without indentation, should match the full line not substring in comment
|
|
1698
1698
|
match = _find_match_with_strategies(content, "return result")
|
|
1699
|
-
assert match == " return result"
|
|
1699
|
+
assert match == " return result\n" # Now includes trailing newline
|
|
1700
|
+
# Should prefer full line match for code patterns
|
|
1701
|
+
content = """def calculate():
|
|
1702
|
+
result = process() # result is important
|
|
1703
|
+
return result
|
|
1704
|
+
"""
|
|
1705
|
+
|
|
1706
|
+
# Without indentation, should match the full line not substring in comment
|
|
1707
|
+
match = _find_match_with_strategies(content, "return result")
|
|
1708
|
+
assert match == " return result\n" # Now includes trailing newline
|
|
1700
1709
|
# Should NOT match just "result" in the comment or variable name
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def test_edit_file_preserves_trailing_newline(temp_repo):
|
|
1713
|
+
"""Test that flexible matching preserves trailing newlines in matched blocks."""
|
|
1714
|
+
from patchpal.tools import edit_file
|
|
1715
|
+
|
|
1716
|
+
# Test case 1: Match in middle of file (should preserve ONE trailing newline, not blank lines)
|
|
1717
|
+
content_middle = """def function1():
|
|
1718
|
+
print("hello")
|
|
1719
|
+
return 1
|
|
1720
|
+
|
|
1721
|
+
def function2():
|
|
1722
|
+
print("world")
|
|
1723
|
+
return 2
|
|
1724
|
+
"""
|
|
1725
|
+
(temp_repo / "newline_test1.py").write_text(content_middle)
|
|
1726
|
+
|
|
1727
|
+
# Edit a block in the middle - the matched block should include ONE trailing newline
|
|
1728
|
+
# but NOT the blank line that follows (blank line is not part of the function)
|
|
1729
|
+
old_string = """def function1():
|
|
1730
|
+
print("hello")
|
|
1731
|
+
return 1"""
|
|
1732
|
+
|
|
1733
|
+
new_string = """def function1():
|
|
1734
|
+
print("modified")
|
|
1735
|
+
return 1"""
|
|
1736
|
+
|
|
1737
|
+
edit_file("newline_test1.py", old_string, new_string)
|
|
1738
|
+
|
|
1739
|
+
# Verify: the matched section gets replaced, preserving structure
|
|
1740
|
+
# The blank line should remain because it's between the two functions
|
|
1741
|
+
new_content = (temp_repo / "newline_test1.py").read_text()
|
|
1742
|
+
# After editing, there should still be a blank line between functions
|
|
1743
|
+
assert "\n\ndef function2():" in new_content
|
|
1744
|
+
assert 'print("modified")' in new_content
|
|
1745
|
+
|
|
1746
|
+
# Test case 2: Match at end of file WITH trailing newline
|
|
1747
|
+
content_end_with = """def function():
|
|
1748
|
+
print("test")
|
|
1749
|
+
return True
|
|
1750
|
+
"""
|
|
1751
|
+
(temp_repo / "newline_test2.py").write_text(content_end_with)
|
|
1752
|
+
|
|
1753
|
+
old_string = 'print("test")\n return True'
|
|
1754
|
+
new_string = 'print("modified")\n return True'
|
|
1755
|
+
|
|
1756
|
+
edit_file("newline_test2.py", old_string, new_string)
|
|
1757
|
+
|
|
1758
|
+
new_content = (temp_repo / "newline_test2.py").read_text()
|
|
1759
|
+
# File should still end with newline
|
|
1760
|
+
assert new_content.endswith("\n")
|
|
1761
|
+
assert 'print("modified")' in new_content
|
|
1762
|
+
|
|
1763
|
+
# Test case 3: Match at end of file WITHOUT trailing newline
|
|
1764
|
+
content_end_without = """def function():
|
|
1765
|
+
print("test")
|
|
1766
|
+
return False""" # No trailing newline
|
|
1767
|
+
|
|
1768
|
+
(temp_repo / "newline_test3.py").write_text(content_end_without)
|
|
1769
|
+
|
|
1770
|
+
old_string = 'print("test")\n return False'
|
|
1771
|
+
new_string = 'print("changed")\n return False'
|
|
1772
|
+
|
|
1773
|
+
edit_file("newline_test3.py", old_string, new_string)
|
|
1774
|
+
|
|
1775
|
+
new_content = (temp_repo / "newline_test3.py").read_text()
|
|
1776
|
+
# File should NOT have trailing newline (preserving original)
|
|
1777
|
+
assert not new_content.endswith("\n")
|
|
1778
|
+
assert 'print("changed")' in new_content
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
def test_edit_file_auto_adjusts_indentation(temp_repo):
|
|
1782
|
+
"""Test that edit_file automatically adjusts indentation of new_string to match matched_string."""
|
|
1783
|
+
from patchpal.tools import edit_file
|
|
1784
|
+
|
|
1785
|
+
# Create a file with specific indentation (28 spaces for elif)
|
|
1786
|
+
content = """ elif tool_name == "todo_add":
|
|
1787
|
+
print(
|
|
1788
|
+
f"Adding TODO",
|
|
1789
|
+
flush=True,
|
|
1790
|
+
)
|
|
1791
|
+
"""
|
|
1792
|
+
(temp_repo / "indent_adjust_test.py").write_text(content)
|
|
1793
|
+
|
|
1794
|
+
# Provide new_string with WRONG indentation (30 spaces)
|
|
1795
|
+
old_string = """elif tool_name == "todo_add":
|
|
1796
|
+
print(
|
|
1797
|
+
f"Adding TODO",
|
|
1798
|
+
flush=True,
|
|
1799
|
+
)"""
|
|
1800
|
+
|
|
1801
|
+
new_string = """ elif tool_name == "todo_add":
|
|
1802
|
+
print(
|
|
1803
|
+
f"Modified TODO",
|
|
1804
|
+
flush=True,
|
|
1805
|
+
)"""
|
|
1806
|
+
|
|
1807
|
+
edit_file("indent_adjust_test.py", old_string, new_string)
|
|
1808
|
+
|
|
1809
|
+
# Verify the indentation was AUTO-ADJUSTED to match original (28 spaces)
|
|
1810
|
+
new_content = (temp_repo / "indent_adjust_test.py").read_text()
|
|
1811
|
+
|
|
1812
|
+
# Should have 28 spaces before elif (not 30)
|
|
1813
|
+
assert " elif tool_name" in new_content
|
|
1814
|
+
# Should have 32 spaces before print (not 34)
|
|
1815
|
+
assert " print(" in new_content
|
|
1816
|
+
# Content should be updated
|
|
1817
|
+
assert "Modified TODO" in new_content
|
|
1818
|
+
assert "Adding TODO" not in new_content
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|