patchpal 0.3.1__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.
Files changed (27) hide show
  1. {patchpal-0.3.1/patchpal.egg-info → patchpal-0.3.2}/PKG-INFO +1 -1
  2. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/__init__.py +1 -1
  3. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/agent.py +1 -1
  4. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/cli.py +49 -1
  5. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/tools.py +225 -22
  6. {patchpal-0.3.1 → patchpal-0.3.2/patchpal.egg-info}/PKG-INFO +1 -1
  7. {patchpal-0.3.1 → patchpal-0.3.2}/LICENSE +0 -0
  8. {patchpal-0.3.1 → patchpal-0.3.2}/MANIFEST.in +0 -0
  9. {patchpal-0.3.1 → patchpal-0.3.2}/README.md +0 -0
  10. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/context.py +0 -0
  11. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/permissions.py +0 -0
  12. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/skills.py +0 -0
  13. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal/system_prompt.md +0 -0
  14. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal.egg-info/SOURCES.txt +0 -0
  15. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal.egg-info/dependency_links.txt +0 -0
  16. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal.egg-info/entry_points.txt +0 -0
  17. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal.egg-info/requires.txt +0 -0
  18. {patchpal-0.3.1 → patchpal-0.3.2}/patchpal.egg-info/top_level.txt +0 -0
  19. {patchpal-0.3.1 → patchpal-0.3.2}/pyproject.toml +0 -0
  20. {patchpal-0.3.1 → patchpal-0.3.2}/setup.cfg +0 -0
  21. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_agent.py +0 -0
  22. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_cli.py +0 -0
  23. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_context.py +0 -0
  24. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_guardrails.py +0 -0
  25. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_operational_safety.py +0 -0
  26. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_skills.py +0 -0
  27. {patchpal-0.3.1 → patchpal-0.3.2}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.3.1"
3
+ __version__ = "0.3.2"
4
4
 
5
5
  from patchpal.agent import create_agent
6
6
  from patchpal.tools import (
@@ -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("Use '/status' to check context window usage, '/compact' to manually compact.")
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
 
@@ -1738,26 +1926,7 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1738
1926
  f"💡 Tip: Use read_lines() to see the exact context, or use apply_patch() for multiple changes."
1739
1927
  )
1740
1928
 
1741
- # Check permission before proceeding
1742
- permission_manager = _get_permission_manager()
1743
-
1744
- # Format colored diff for permission prompt (use the matched string for accurate diff)
1745
- diff_display = _format_colored_diff(matched_string, new_string, file_path=path)
1746
-
1747
- # Add warning if writing outside repository
1748
- outside_repo_warning = ""
1749
- if not _is_inside_repo(p):
1750
- outside_repo_warning = "\n ⚠️ WARNING: Writing file outside repository\n"
1751
-
1752
- description = f" ● Update({path}){outside_repo_warning}\n{diff_display}"
1753
-
1754
- if not permission_manager.request_permission("edit_file", description, pattern=path):
1755
- return "Operation cancelled by user."
1756
-
1757
- # Backup if enabled
1758
- backup_path = _backup_file(p)
1759
-
1760
- # Perform replacement using the matched string
1929
+ # Perform indentation adjustment and trailing newline preservation BEFORE showing diff
1761
1930
  # Important: Adjust indentation and preserve trailing newlines to maintain file structure
1762
1931
  adjusted_new_string = new_string
1763
1932
 
@@ -1803,6 +1972,25 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1803
1972
  trailing_newlines = len(matched_string) - len(matched_string.rstrip("\n"))
1804
1973
  adjusted_new_string = adjusted_new_string + ("\n" * trailing_newlines)
1805
1974
 
1975
+ # Check permission before proceeding (use adjusted_new_string for accurate diff display)
1976
+ permission_manager = _get_permission_manager()
1977
+
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)
1980
+
1981
+ # Add warning if writing outside repository
1982
+ outside_repo_warning = ""
1983
+ if not _is_inside_repo(p):
1984
+ outside_repo_warning = "\n ⚠️ WARNING: Writing file outside repository\n"
1985
+
1986
+ description = f" ● Update({path}){outside_repo_warning}\n{diff_display}"
1987
+
1988
+ if not permission_manager.request_permission("edit_file", description, pattern=path):
1989
+ return "Operation cancelled by user."
1990
+
1991
+ # Backup if enabled
1992
+ backup_path = _backup_file(p)
1993
+
1806
1994
  new_content = content.replace(matched_string, adjusted_new_string)
1807
1995
 
1808
1996
  # Write the new content
@@ -2359,4 +2547,19 @@ def run_shell(cmd: str) -> str:
2359
2547
  stdout = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
2360
2548
  stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
2361
2549
 
2362
- return stdout + stderr
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
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