patchpal 0.2.1__tar.gz → 0.3.0__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.2.1/patchpal.egg-info → patchpal-0.3.0}/PKG-INFO +11 -9
  2. {patchpal-0.2.1 → patchpal-0.3.0}/README.md +10 -8
  3. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/__init__.py +1 -1
  4. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/agent.py +88 -8
  5. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/cli.py +4 -1
  6. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/context.py +5 -4
  7. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/tools.py +149 -21
  8. {patchpal-0.2.1 → patchpal-0.3.0/patchpal.egg-info}/PKG-INFO +11 -9
  9. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_agent.py +100 -0
  10. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_tools.py +253 -0
  11. {patchpal-0.2.1 → patchpal-0.3.0}/LICENSE +0 -0
  12. {patchpal-0.2.1 → patchpal-0.3.0}/MANIFEST.in +0 -0
  13. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/permissions.py +0 -0
  14. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/skills.py +0 -0
  15. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/system_prompt.md +0 -0
  16. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/SOURCES.txt +0 -0
  17. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/dependency_links.txt +0 -0
  18. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/entry_points.txt +0 -0
  19. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/requires.txt +0 -0
  20. {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/top_level.txt +0 -0
  21. {patchpal-0.2.1 → patchpal-0.3.0}/pyproject.toml +0 -0
  22. {patchpal-0.2.1 → patchpal-0.3.0}/setup.cfg +0 -0
  23. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_cli.py +0 -0
  24. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_context.py +0 -0
  25. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_guardrails.py +0 -0
  26. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_operational_safety.py +0 -0
  27. {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -717,7 +717,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
717
717
  ```bash
718
718
  # Auto-Compaction
719
719
  export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
720
- export PATCHPAL_COMPACT_THRESHOLD=0.85 # Trigger compaction at % full (default: 0.85 = 85%)
720
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
721
721
 
722
722
  # Context Limits
723
723
  export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
@@ -892,7 +892,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
892
892
  **Features:**
893
893
  - **Automatic token tracking**: Monitors context usage in real-time
894
894
  - **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
895
- - **Auto-compaction**: Summarizes conversation history when approaching 85% capacity
895
+ - **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
896
896
  - **Manual control**: Check status with `/status`, disable with environment variable
897
897
 
898
898
  **Commands:**
@@ -931,7 +931,7 @@ You can test the context management system with small values to trigger compacti
931
931
  ```bash
932
932
  # Set up small context window for testing
933
933
  export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
934
- export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (instead of 85%)
934
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
935
935
  # Note: System prompt + output reserve = ~6.4k tokens baseline
936
936
  # So 75% of 10k = 7.5k, leaving ~1k for conversation
937
937
  export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
@@ -951,9 +951,9 @@ You: /status
951
951
  # Continue - should see pruning messages
952
952
  You: search for "context" in all files
953
953
  # You should see:
954
- # ⚠️ Context window at 85% capacity. Compacting...
954
+ # ⚠️ Context window at 75% capacity. Compacting...
955
955
  # Pruned old tool outputs (saved ~400 tokens)
956
- # ✓ Compaction complete. Saved 850 tokens (85% → 68%)
956
+ # ✓ Compaction complete. Saved 850 tokens (75% → 58%)
957
957
  ```
958
958
 
959
959
  **How It Works:**
@@ -984,7 +984,7 @@ Context Window Status
984
984
  Usage: 80%
985
985
  [████████████████████████████████████████░░░░░░░░░]
986
986
 
987
- Auto-compaction: Enabled (triggers at 85%)
987
+ Auto-compaction: Enabled (triggers at 75%)
988
988
  ======================================================================
989
989
  ```
990
990
 
@@ -998,7 +998,9 @@ The system ensures you can work for extended periods without hitting context lim
998
998
 
999
999
  **Error: "Context Window Error - Input is too long"**
1000
1000
  - PatchPal includes automatic context management (compaction) to prevent this error.
1001
- - Use `/status` to check your context window usage.
1001
+ - **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
1002
+ - Use `/status` to check your context window usage and see how close you are to the limit.
1002
1003
  - If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
1003
- - Context is automatically managed at 85% capacity through pruning and compaction.
1004
+ - Context is automatically managed at 75% capacity through pruning and compaction.
1005
+ - **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
1004
1006
  - See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
@@ -680,7 +680,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
680
680
  ```bash
681
681
  # Auto-Compaction
682
682
  export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
683
- export PATCHPAL_COMPACT_THRESHOLD=0.85 # Trigger compaction at % full (default: 0.85 = 85%)
683
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
684
684
 
685
685
  # Context Limits
686
686
  export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
@@ -855,7 +855,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
855
855
  **Features:**
856
856
  - **Automatic token tracking**: Monitors context usage in real-time
857
857
  - **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
858
- - **Auto-compaction**: Summarizes conversation history when approaching 85% capacity
858
+ - **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
859
859
  - **Manual control**: Check status with `/status`, disable with environment variable
860
860
 
861
861
  **Commands:**
@@ -894,7 +894,7 @@ You can test the context management system with small values to trigger compacti
894
894
  ```bash
895
895
  # Set up small context window for testing
896
896
  export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
897
- export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (instead of 85%)
897
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
898
898
  # Note: System prompt + output reserve = ~6.4k tokens baseline
899
899
  # So 75% of 10k = 7.5k, leaving ~1k for conversation
900
900
  export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
@@ -914,9 +914,9 @@ You: /status
914
914
  # Continue - should see pruning messages
915
915
  You: search for "context" in all files
916
916
  # You should see:
917
- # ⚠️ Context window at 85% capacity. Compacting...
917
+ # ⚠️ Context window at 75% capacity. Compacting...
918
918
  # Pruned old tool outputs (saved ~400 tokens)
919
- # ✓ Compaction complete. Saved 850 tokens (85% → 68%)
919
+ # ✓ Compaction complete. Saved 850 tokens (75% → 58%)
920
920
  ```
921
921
 
922
922
  **How It Works:**
@@ -947,7 +947,7 @@ Context Window Status
947
947
  Usage: 80%
948
948
  [████████████████████████████████████████░░░░░░░░░]
949
949
 
950
- Auto-compaction: Enabled (triggers at 85%)
950
+ Auto-compaction: Enabled (triggers at 75%)
951
951
  ======================================================================
952
952
  ```
953
953
 
@@ -961,7 +961,9 @@ The system ensures you can work for extended periods without hitting context lim
961
961
 
962
962
  **Error: "Context Window Error - Input is too long"**
963
963
  - PatchPal includes automatic context management (compaction) to prevent this error.
964
- - Use `/status` to check your context window usage.
964
+ - **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
965
+ - Use `/status` to check your context window usage and see how close you are to the limit.
965
966
  - If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
966
- - Context is automatically managed at 85% capacity through pruning and compaction.
967
+ - Context is automatically managed at 75% capacity through pruning and compaction.
968
+ - **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
967
969
  - See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from patchpal.agent import create_agent
6
6
  from patchpal.tools import (
@@ -702,6 +702,73 @@ def _load_system_prompt() -> str:
702
702
  SYSTEM_PROMPT = _load_system_prompt()
703
703
 
704
704
 
705
+ def _supports_prompt_caching(model_id: str) -> bool:
706
+ """Check if the model supports prompt caching.
707
+
708
+ Args:
709
+ model_id: LiteLLM model identifier
710
+
711
+ Returns:
712
+ True if the model supports prompt caching
713
+ """
714
+ # Anthropic models support caching (direct API or via Bedrock)
715
+ if "anthropic" in model_id.lower() or "claude" in model_id.lower():
716
+ return True
717
+ # Bedrock with Anthropic models
718
+ if model_id.startswith("bedrock/") and "anthropic" in model_id.lower():
719
+ return True
720
+ return False
721
+
722
+
723
+ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List[Dict[str, Any]]:
724
+ """Apply prompt caching markers to messages following OpenCode's strategy.
725
+
726
+ Caches:
727
+ - System messages (first 1-2 messages with role="system")
728
+ - Last 2 conversation messages (recent context)
729
+
730
+ This provides 90% cost reduction on cached content after the first request.
731
+
732
+ Args:
733
+ messages: List of message dictionaries
734
+ model_id: LiteLLM model identifier
735
+
736
+ Returns:
737
+ Modified messages with cache markers
738
+ """
739
+ if not _supports_prompt_caching(model_id):
740
+ return messages
741
+
742
+ # Determine cache marker format based on provider
743
+ if model_id.startswith("bedrock/"):
744
+ # Bedrock uses cachePoint
745
+ cache_marker = {"cachePoint": {"type": "ephemeral"}}
746
+ else:
747
+ # Direct Anthropic API uses cacheControl
748
+ cache_marker = {"cacheControl": {"type": "ephemeral"}}
749
+
750
+ # Find system messages (usually at the start)
751
+ system_messages = [i for i, msg in enumerate(messages) if msg.get("role") == "system"]
752
+
753
+ # Find last 2 non-system messages (recent context)
754
+ non_system_messages = [i for i, msg in enumerate(messages) if msg.get("role") != "system"]
755
+ last_two_indices = (
756
+ non_system_messages[-2:] if len(non_system_messages) >= 2 else non_system_messages
757
+ )
758
+
759
+ # Apply caching to system messages (first 2)
760
+ for idx in system_messages[:2]:
761
+ if "cache_control" not in messages[idx] and "cachePoint" not in messages[idx]:
762
+ messages[idx] = {**messages[idx], **cache_marker}
763
+
764
+ # Apply caching to last 2 messages
765
+ for idx in last_two_indices:
766
+ if "cache_control" not in messages[idx] and "cachePoint" not in messages[idx]:
767
+ messages[idx] = {**messages[idx], **cache_marker}
768
+
769
+ return messages
770
+
771
+
705
772
  class PatchPalAgent:
706
773
  """Simple agent that uses LiteLLM for tool calling."""
707
774
 
@@ -765,7 +832,7 @@ class PatchPalAgent:
765
832
  def _perform_auto_compaction(self):
766
833
  """Perform automatic context window compaction.
767
834
 
768
- This method is called when the context window reaches 85% capacity.
835
+ This method is called when the context window reaches 75% capacity.
769
836
  It attempts pruning first, then full compaction if needed.
770
837
  """
771
838
  # Don't compact if we have very few messages - compaction summary
@@ -824,13 +891,20 @@ class PatchPalAgent:
824
891
 
825
892
  try:
826
893
  # Create compaction using the LLM
827
- summary_msg, summary_text = self.context_manager.create_compaction(
828
- self.messages,
829
- lambda msgs: litellm.completion(
894
+ def compaction_completion(msgs):
895
+ # Prepare messages with system prompt
896
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}] + msgs
897
+ # Apply prompt caching for supported models
898
+ messages = _apply_prompt_caching(messages, self.model_id)
899
+ return litellm.completion(
830
900
  model=self.model_id,
831
- messages=[{"role": "system", "content": SYSTEM_PROMPT}] + msgs,
901
+ messages=messages,
832
902
  **self.litellm_kwargs,
833
- ),
903
+ )
904
+
905
+ summary_msg, summary_text = self.context_manager.create_compaction(
906
+ self.messages,
907
+ compaction_completion,
834
908
  )
835
909
 
836
910
  # Replace message history with compacted version
@@ -906,11 +980,17 @@ class PatchPalAgent:
906
980
  # Show thinking message
907
981
  print("\033[2m🤔 Thinking...\033[0m", flush=True)
908
982
 
983
+ # Prepare messages with system prompt
984
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}] + self.messages
985
+
986
+ # Apply prompt caching for supported models (Anthropic/Claude)
987
+ messages = _apply_prompt_caching(messages, self.model_id)
988
+
909
989
  # Use LiteLLM for all providers
910
990
  try:
911
991
  response = litellm.completion(
912
992
  model=self.model_id,
913
- messages=[{"role": "system", "content": SYSTEM_PROMPT}] + self.messages,
993
+ messages=messages,
914
994
  tools=TOOLS,
915
995
  tool_choice="auto",
916
996
  **self.litellm_kwargs,
@@ -1048,7 +1128,7 @@ class PatchPalAgent:
1048
1128
  print("\033[2m📋 Listing TODO tasks...\033[0m", flush=True)
1049
1129
  elif tool_name == "todo_complete":
1050
1130
  print(
1051
- f"\033[2m✓ Completing task #{tool_args.get('task_id', '')}\033[0m",
1131
+ f"\033[2m✓ Completed task #{tool_args.get('task_id', '')}\033[0m",
1052
1132
  flush=True,
1053
1133
  )
1054
1134
  elif tool_name == "todo_update":
@@ -351,7 +351,10 @@ Supported models: Any LiteLLM-supported model
351
351
 
352
352
  # Show auto-compaction status
353
353
  if agent.enable_auto_compact:
354
- print("\n Auto-compaction: \033[32mEnabled\033[0m (triggers at 85%)")
354
+ threshold_pct = int(agent.context_manager.COMPACT_THRESHOLD * 100)
355
+ print(
356
+ f"\n Auto-compaction: \033[32mEnabled\033[0m (triggers at {threshold_pct}%)"
357
+ )
355
358
  else:
356
359
  print(
357
360
  "\n Auto-compaction: \033[33mDisabled\033[0m (set PATCHPAL_DISABLE_AUTOCOMPACT=false to enable)"
@@ -57,8 +57,9 @@ class TokenEstimator:
57
57
  except Exception:
58
58
  pass
59
59
 
60
- # Fallback: ~4 chars per token average
61
- return len(str(text)) // 4
60
+ # Fallback: ~3 chars per token (conservative for code-heavy content)
61
+ # This is more accurate than 4 chars/token for technical content
62
+ return len(str(text)) // 3
62
63
 
63
64
  def estimate_message_tokens(self, message: Dict[str, Any]) -> int:
64
65
  """Estimate tokens in a single message.
@@ -119,8 +120,8 @@ class ContextManager:
119
120
  os.getenv("PATCHPAL_PRUNE_MINIMUM", "20000")
120
121
  ) # Minimum tokens to prune to make it worthwhile
121
122
  COMPACT_THRESHOLD = float(
122
- os.getenv("PATCHPAL_COMPACT_THRESHOLD", "0.85")
123
- ) # Compact at 85% capacity
123
+ os.getenv("PATCHPAL_COMPACT_THRESHOLD", "0.75")
124
+ ) # Compact at 75% capacity (lower due to estimation inaccuracy)
124
125
 
125
126
  # Model context limits (tokens)
126
127
  # From OpenCode's models.dev data - see https://models.dev/api.json
@@ -1437,6 +1437,122 @@ def ask_user(question: str, options: Optional[list] = None) -> str:
1437
1437
  return answer
1438
1438
 
1439
1439
 
1440
+ # ============================================================================
1441
+ # Edit File - Multi-Strategy String Matching
1442
+ # ============================================================================
1443
+ # Based on approaches from gemini-cli and OpenCode: try multiple matching
1444
+ # strategies to handle # whitespace/indentation issues without requiring
1445
+ # exact character-by-character matching
1446
+
1447
+
1448
+ def _try_simple_match(content: str, old_string: str) -> Optional[str]:
1449
+ """Try exact string match."""
1450
+ if old_string in content:
1451
+ return old_string
1452
+ return None
1453
+
1454
+
1455
+ def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
1456
+ """Try matching lines where content is the same when trimmed."""
1457
+ content_lines = content.split("\n")
1458
+ search_lines = old_string.split("\n")
1459
+
1460
+ # Remove trailing empty line if present
1461
+ if search_lines and search_lines[-1] == "":
1462
+ search_lines.pop()
1463
+
1464
+ # Scan through content looking for matching block
1465
+ for i in range(len(content_lines) - len(search_lines) + 1):
1466
+ matches = True
1467
+ for j, search_line in enumerate(search_lines):
1468
+ if content_lines[i + j].strip() != search_line.strip():
1469
+ matches = False
1470
+ break
1471
+
1472
+ if matches:
1473
+ # Found a match - return the original lines (with indentation) joined
1474
+ matched_lines = content_lines[i : i + len(search_lines)]
1475
+ return "\n".join(matched_lines)
1476
+
1477
+ return None
1478
+
1479
+
1480
+ def _try_whitespace_normalized_match(content: str, old_string: str) -> Optional[str]:
1481
+ """Try matching with normalized whitespace (all whitespace becomes single space)."""
1482
+
1483
+ def normalize(text: str) -> str:
1484
+ return " ".join(text.split())
1485
+
1486
+ normalized_search = normalize(old_string)
1487
+
1488
+ # Try single line matches
1489
+ for line in content.split("\n"):
1490
+ if normalize(line) == normalized_search:
1491
+ return line
1492
+
1493
+ # Try multi-line matches
1494
+ search_lines = old_string.split("\n")
1495
+ if len(search_lines) > 1:
1496
+ content_lines = content.split("\n")
1497
+ for i in range(len(content_lines) - len(search_lines) + 1):
1498
+ block_lines = content_lines[i : i + len(search_lines)]
1499
+ if normalize("\n".join(block_lines)) == normalized_search:
1500
+ return "\n".join(block_lines)
1501
+
1502
+ return None
1503
+
1504
+
1505
+ def _find_match_with_strategies(content: str, old_string: str) -> Optional[str]:
1506
+ """
1507
+ Try multiple matching strategies in order.
1508
+ Returns the matched string from content (preserving original formatting).
1509
+ """
1510
+ # Strategy 1: Exact match (but only if it's not a substring that would match better with trimming)
1511
+ # Skip exact match if old_string doesn't have leading/trailing whitespace
1512
+ # and we're searching for what looks like a complete statement
1513
+ use_exact = old_string in content
1514
+
1515
+ # If the old_string has no leading whitespace but contains a newline or looks like code,
1516
+ # skip exact match and try trimmed matching first
1517
+ if use_exact and not old_string.startswith((" ", "\t", "\n")):
1518
+ # Check if this looks like we're searching for a line of code
1519
+ # (contains common code patterns but no leading indentation)
1520
+ code_patterns = [
1521
+ "(",
1522
+ ")",
1523
+ "=",
1524
+ "def ",
1525
+ "class ",
1526
+ "if ",
1527
+ "for ",
1528
+ "while ",
1529
+ "return ",
1530
+ "print(",
1531
+ ]
1532
+ if any(pattern in old_string for pattern in code_patterns):
1533
+ # Try trimmed match first for code-like patterns
1534
+ match = _try_line_trimmed_match(content, old_string)
1535
+ if match:
1536
+ return match
1537
+
1538
+ # Now try exact match
1539
+ match = _try_simple_match(content, old_string)
1540
+ if match:
1541
+ return match
1542
+
1543
+ # Strategy 2: Line-trimmed match (handles indentation differences)
1544
+ match = _try_line_trimmed_match(content, old_string)
1545
+ if match:
1546
+ return match
1547
+
1548
+ # Strategy 3: Whitespace-normalized match (handles spacing differences)
1549
+ match = _try_whitespace_normalized_match(content, old_string)
1550
+ if match:
1551
+ return match
1552
+
1553
+ return None
1554
+
1555
+
1440
1556
  # ============================================================================
1441
1557
 
1442
1558
 
@@ -1537,18 +1653,31 @@ def apply_patch(path: str, new_content: str) -> str:
1537
1653
 
1538
1654
  def edit_file(path: str, old_string: str, new_string: str) -> str:
1539
1655
  """
1540
- Edit a file by replacing an exact string match.
1656
+ Edit a file by replacing a string match with flexible whitespace handling.
1657
+
1658
+ Uses multiple matching strategies to find old_string:
1659
+ 1. Exact match
1660
+ 2. Trimmed line match (ignores indentation differences in search)
1661
+ 3. Normalized whitespace match (ignores spacing differences in search)
1662
+
1663
+ Important: The flexible matching only applies to FINDING old_string.
1664
+ The new_string is used exactly as provided, so it should include proper
1665
+ indentation/formatting to match the surrounding code.
1541
1666
 
1542
1667
  Args:
1543
1668
  path: Relative path to the file from the repository root
1544
- old_string: The exact string to find and replace
1545
- new_string: The string to replace it with
1669
+ old_string: The string to find (whitespace can be approximate)
1670
+ new_string: The replacement string (use exact whitespace/indentation you want)
1546
1671
 
1547
1672
  Returns:
1548
1673
  Confirmation message with the changes made
1549
1674
 
1550
1675
  Raises:
1551
1676
  ValueError: If file not found, old_string not found, or multiple matches
1677
+
1678
+ Example:
1679
+ # Find with flexible matching, but provide new_string with proper indent
1680
+ edit_file("test.py", "print('hello')", " print('world')") # 4 spaces
1552
1681
  """
1553
1682
  _operation_limiter.check_limit(f"edit_file({path[:30]}...)")
1554
1683
 
@@ -1566,28 +1695,25 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1566
1695
  except Exception as e:
1567
1696
  raise ValueError(f"Failed to read file: {e}")
1568
1697
 
1569
- # Check for old_string
1570
- if old_string not in content:
1571
- # Show first 5 lines of file to help user
1572
- lines = content.split("\n")[:5]
1573
- preview = "\n".join(f" {i + 1}: {line[:80]}" for i, line in enumerate(lines))
1698
+ # Try to find a match using multiple strategies
1699
+ matched_string = _find_match_with_strategies(content, old_string)
1574
1700
 
1701
+ if not matched_string:
1702
+ # No match found with any strategy
1575
1703
  raise ValueError(
1576
1704
  f"String not found in {path}.\n\n"
1577
1705
  f"Searched for:\n{old_string[:200]}\n\n"
1578
- f"File starts with:\n{preview}\n\n"
1579
- f"💡 Tip: Use read_lines() to get exact text including whitespace, "
1580
- f"or use apply_patch() for larger changes."
1706
+ f"💡 Tip: Use read_lines() to see exact content, or use apply_patch() for larger changes."
1581
1707
  )
1582
1708
 
1583
- # Count occurrences
1584
- count = content.count(old_string)
1709
+ # Count occurrences of the matched string
1710
+ count = content.count(matched_string)
1585
1711
  if count > 1:
1586
1712
  # Show WHERE the matches are
1587
1713
  positions = []
1588
1714
  start = 0
1589
1715
  while True:
1590
- pos = content.find(old_string, start)
1716
+ pos = content.find(matched_string, start)
1591
1717
  if pos == -1:
1592
1718
  break
1593
1719
  line_num = content[:pos].count("\n") + 1
@@ -1603,8 +1729,8 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1603
1729
  # Check permission before proceeding
1604
1730
  permission_manager = _get_permission_manager()
1605
1731
 
1606
- # Format colored diff for permission prompt
1607
- diff_display = _format_colored_diff(old_string, new_string, file_path=path)
1732
+ # Format colored diff for permission prompt (use the matched string for accurate diff)
1733
+ diff_display = _format_colored_diff(matched_string, new_string, file_path=path)
1608
1734
 
1609
1735
  # Add warning if writing outside repository
1610
1736
  outside_repo_warning = ""
@@ -1619,19 +1745,21 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
1619
1745
  # Backup if enabled
1620
1746
  backup_path = _backup_file(p)
1621
1747
 
1622
- # Perform replacement
1623
- new_content = content.replace(old_string, new_string)
1748
+ # Perform replacement using the matched string
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)
1624
1752
 
1625
1753
  # Write the new content
1626
1754
  p.write_text(new_content)
1627
1755
 
1628
- # Generate diff for the specific change
1629
- old_lines = old_string.split("\n")
1756
+ # Generate diff for the specific change (use matched_string for accurate diff)
1757
+ old_lines = matched_string.split("\n")
1630
1758
  new_lines = new_string.split("\n")
1631
1759
  diff = difflib.unified_diff(old_lines, new_lines, fromfile="old", tofile="new", lineterm="")
1632
1760
  diff_str = "\n".join(diff)
1633
1761
 
1634
- audit_logger.info(f"EDIT: {path} ({len(old_string)} -> {len(new_string)} chars)")
1762
+ audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(new_string)} chars)")
1635
1763
 
1636
1764
  backup_msg = f"\n[Backup saved: {backup_path}]" if backup_path else ""
1637
1765
  return f"Successfully edited {path}{backup_msg}\n\nChange:\n{diff_str}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: A lean Claude Code clone in pure Python
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -717,7 +717,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
717
717
  ```bash
718
718
  # Auto-Compaction
719
719
  export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
720
- export PATCHPAL_COMPACT_THRESHOLD=0.85 # Trigger compaction at % full (default: 0.85 = 85%)
720
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
721
721
 
722
722
  # Context Limits
723
723
  export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
@@ -892,7 +892,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
892
892
  **Features:**
893
893
  - **Automatic token tracking**: Monitors context usage in real-time
894
894
  - **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
895
- - **Auto-compaction**: Summarizes conversation history when approaching 85% capacity
895
+ - **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
896
896
  - **Manual control**: Check status with `/status`, disable with environment variable
897
897
 
898
898
  **Commands:**
@@ -931,7 +931,7 @@ You can test the context management system with small values to trigger compacti
931
931
  ```bash
932
932
  # Set up small context window for testing
933
933
  export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
934
- export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (instead of 85%)
934
+ export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
935
935
  # Note: System prompt + output reserve = ~6.4k tokens baseline
936
936
  # So 75% of 10k = 7.5k, leaving ~1k for conversation
937
937
  export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
@@ -951,9 +951,9 @@ You: /status
951
951
  # Continue - should see pruning messages
952
952
  You: search for "context" in all files
953
953
  # You should see:
954
- # ⚠️ Context window at 85% capacity. Compacting...
954
+ # ⚠️ Context window at 75% capacity. Compacting...
955
955
  # Pruned old tool outputs (saved ~400 tokens)
956
- # ✓ Compaction complete. Saved 850 tokens (85% → 68%)
956
+ # ✓ Compaction complete. Saved 850 tokens (75% → 58%)
957
957
  ```
958
958
 
959
959
  **How It Works:**
@@ -984,7 +984,7 @@ Context Window Status
984
984
  Usage: 80%
985
985
  [████████████████████████████████████████░░░░░░░░░]
986
986
 
987
- Auto-compaction: Enabled (triggers at 85%)
987
+ Auto-compaction: Enabled (triggers at 75%)
988
988
  ======================================================================
989
989
  ```
990
990
 
@@ -998,7 +998,9 @@ The system ensures you can work for extended periods without hitting context lim
998
998
 
999
999
  **Error: "Context Window Error - Input is too long"**
1000
1000
  - PatchPal includes automatic context management (compaction) to prevent this error.
1001
- - Use `/status` to check your context window usage.
1001
+ - **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
1002
+ - Use `/status` to check your context window usage and see how close you are to the limit.
1002
1003
  - If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
1003
- - Context is automatically managed at 85% capacity through pruning and compaction.
1004
+ - Context is automatically managed at 75% capacity through pruning and compaction.
1005
+ - **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
1004
1006
  - See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
@@ -408,3 +408,103 @@ def test_agent_doesnt_trigger_on_file_containing_cancellation_text(monkeypatch):
408
408
  finally:
409
409
  # Restore original function
410
410
  TOOL_FUNCTIONS["read_file"] = original_read_file
411
+
412
+
413
+ def test_prompt_caching_detection():
414
+ """Test that prompt caching is correctly detected for supported models."""
415
+ from patchpal.agent import _supports_prompt_caching
416
+
417
+ # Anthropic models should support caching
418
+ assert _supports_prompt_caching("anthropic/claude-sonnet-4-5")
419
+ assert _supports_prompt_caching("anthropic/claude-opus-4")
420
+
421
+ # Bedrock Anthropic models should support caching
422
+ assert _supports_prompt_caching("bedrock/anthropic.claude-sonnet-4-5-v1:0")
423
+ assert _supports_prompt_caching("bedrock/anthropic.claude-v2")
424
+
425
+ # Non-Anthropic models should not support caching
426
+ assert not _supports_prompt_caching("openai/gpt-4o")
427
+ assert not _supports_prompt_caching("ollama_chat/llama3.1")
428
+
429
+
430
+ def test_prompt_caching_application_anthropic():
431
+ """Test that prompt caching markers are correctly applied for Anthropic models."""
432
+ from patchpal.agent import _apply_prompt_caching
433
+
434
+ messages = [
435
+ {"role": "system", "content": "You are a helpful assistant."},
436
+ {"role": "user", "content": "Hello"},
437
+ {"role": "assistant", "content": "Hi there!"},
438
+ {"role": "user", "content": "How are you?"},
439
+ ]
440
+
441
+ # Test with direct Anthropic API
442
+ cached_messages = _apply_prompt_caching(messages.copy(), "anthropic/claude-sonnet-4-5")
443
+
444
+ # System message should have cacheControl
445
+ assert "cacheControl" in cached_messages[0]
446
+ assert cached_messages[0]["cacheControl"] == {"type": "ephemeral"}
447
+
448
+ # Last 2 messages should have cacheControl
449
+ assert "cacheControl" in cached_messages[-1] # Last user message
450
+ assert "cacheControl" in cached_messages[-2] # Last assistant message
451
+
452
+
453
+ def test_prompt_caching_application_bedrock():
454
+ """Test that prompt caching markers use correct format for Bedrock."""
455
+ from patchpal.agent import _apply_prompt_caching
456
+
457
+ messages = [
458
+ {"role": "system", "content": "You are a helpful assistant."},
459
+ {"role": "user", "content": "Hello"},
460
+ {"role": "assistant", "content": "Hi there!"},
461
+ {"role": "user", "content": "How are you?"},
462
+ ]
463
+
464
+ # Test with Bedrock
465
+ cached_messages = _apply_prompt_caching(
466
+ messages.copy(), "bedrock/anthropic.claude-sonnet-4-5-v1:0"
467
+ )
468
+
469
+ # System message should have cachePoint (Bedrock format)
470
+ assert "cachePoint" in cached_messages[0]
471
+ assert cached_messages[0]["cachePoint"] == {"type": "ephemeral"}
472
+
473
+ # Last 2 messages should have cachePoint
474
+ assert "cachePoint" in cached_messages[-1]
475
+ assert "cachePoint" in cached_messages[-2]
476
+
477
+
478
+ def test_prompt_caching_no_modification_for_unsupported():
479
+ """Test that prompt caching doesn't modify messages for unsupported models."""
480
+ from patchpal.agent import _apply_prompt_caching
481
+
482
+ messages = [
483
+ {"role": "system", "content": "You are a helpful assistant."},
484
+ {"role": "user", "content": "Hello"},
485
+ ]
486
+
487
+ # Test with non-Anthropic model
488
+ cached_messages = _apply_prompt_caching(messages.copy(), "openai/gpt-4o")
489
+
490
+ # Messages should be unchanged
491
+ assert "cacheControl" not in cached_messages[0]
492
+ assert "cachePoint" not in cached_messages[0]
493
+ assert cached_messages == messages
494
+
495
+
496
+ def test_prompt_caching_idempotent():
497
+ """Test that applying caching multiple times doesn't add duplicate markers."""
498
+ from patchpal.agent import _apply_prompt_caching
499
+
500
+ messages = [
501
+ {"role": "system", "content": "You are a helpful assistant."},
502
+ {"role": "user", "content": "Hello"},
503
+ ]
504
+
505
+ # Apply caching twice
506
+ cached_once = _apply_prompt_caching(messages.copy(), "anthropic/claude-sonnet-4-5")
507
+ cached_twice = _apply_prompt_caching(cached_once.copy(), "anthropic/claude-sonnet-4-5")
508
+
509
+ # Should be the same after second application
510
+ assert cached_once == cached_twice
@@ -1445,3 +1445,256 @@ def test_ask_user_empty_options_list(monkeypatch):
1445
1445
  with patch("rich.prompt.Prompt.ask", return_value="Free form answer"):
1446
1446
  result = ask_user("What do you think?", options=[])
1447
1447
  assert result == "Free form answer"
1448
+
1449
+
1450
+ # ============================================================================
1451
+ # Flexible edit_file Matching Strategy Tests
1452
+ # ============================================================================
1453
+
1454
+
1455
+ def test_edit_file_with_wrong_indentation(temp_repo):
1456
+ """Test edit_file with flexible matching but proper indentation in new_string."""
1457
+ from patchpal.tools import edit_file
1458
+
1459
+ # Create a Python file with proper indentation
1460
+ content = """def hello():
1461
+ if True:
1462
+ print("world")
1463
+ return 42
1464
+ """
1465
+ (temp_repo / "indent_test.py").write_text(content)
1466
+
1467
+ # Search without indentation (flexible matching finds it),
1468
+ # but provide new_string WITH proper indentation (OpenCode behavior)
1469
+ result = edit_file("indent_test.py", 'print("world")', ' print("universe")')
1470
+
1471
+ assert "Successfully edited" in result
1472
+
1473
+ # Verify the edit preserved indentation (because we provided it in new_string)
1474
+ new_content = (temp_repo / "indent_test.py").read_text()
1475
+ assert ' print("universe")' in new_content # 8 spaces preserved
1476
+ assert ' print("world")' not in new_content
1477
+ # Other lines should be unchanged
1478
+ assert "def hello():" in new_content
1479
+ assert " if True:" in new_content
1480
+
1481
+
1482
+ def test_edit_file_multiline_wrong_indentation(temp_repo):
1483
+ """Test edit_file with multi-line blocks - new_string must have proper indentation."""
1484
+ from patchpal.tools import edit_file
1485
+
1486
+ content = """class MyClass:
1487
+ def process(self):
1488
+ if self.valid:
1489
+ result = self.compute()
1490
+ return result
1491
+ return None
1492
+ """
1493
+ (temp_repo / "multiline_test.py").write_text(content)
1494
+
1495
+ # Search without proper indentation (flexible matching),
1496
+ # but provide replacement WITH proper indentation
1497
+ old_string = """if self.valid:
1498
+ result = self.compute()
1499
+ return result"""
1500
+
1501
+ new_string = """ if self.valid:
1502
+ result = self.compute_new()
1503
+ return result"""
1504
+
1505
+ result = edit_file("multiline_test.py", old_string, new_string)
1506
+
1507
+ assert "Successfully edited" in result
1508
+
1509
+ # Verify correct indentation (because we provided it in new_string)
1510
+ new_content = (temp_repo / "multiline_test.py").read_text()
1511
+ assert " if self.valid:" in new_content
1512
+ assert " result = self.compute_new()" in new_content
1513
+ assert " return result" in new_content
1514
+
1515
+
1516
+ def test_edit_file_whitespace_normalization(temp_repo):
1517
+ """Test edit_file handles extra whitespace in search string."""
1518
+ from patchpal.tools import edit_file
1519
+
1520
+ content = """x = 42
1521
+ y = 100
1522
+ z = x + y
1523
+ """
1524
+ (temp_repo / "whitespace_test.py").write_text(content)
1525
+
1526
+ # User provides with extra spaces (e.g., copied from terminal with weird formatting)
1527
+ old_string = "x = 42"
1528
+
1529
+ result = edit_file("whitespace_test.py", old_string, "x = 99")
1530
+
1531
+ assert "Successfully edited" in result
1532
+
1533
+ # Verify the edit worked
1534
+ new_content = (temp_repo / "whitespace_test.py").read_text()
1535
+ assert "x = 99" in new_content
1536
+ assert "x = 42" not in new_content
1537
+
1538
+
1539
+ def test_edit_file_real_world_agent_scenario(temp_repo):
1540
+ """Test the actual scenario that failed before: editing agent.py with indentation issues."""
1541
+ from patchpal.tools import edit_file
1542
+
1543
+ # Simulate content from agent.py around tool execution
1544
+ content = """ )
1545
+
1546
+ # Silently filter out invalid args (models sometimes hallucinate parameters)
1547
+
1548
+ tool_result = tool_func(**filtered_args)
1549
+ except Exception as e:
1550
+ tool_result = f"Error executing {tool_name}: {e}"
1551
+ print(f"\\033[1;31mX {tool_display}: {e}\\033[0m")
1552
+
1553
+ # Add tool result to messages"""
1554
+
1555
+ (temp_repo / "agent_snippet.py").write_text(content)
1556
+
1557
+ # What the LLM might provide (without correct indentation)
1558
+ old_string = """# Silently filter out invalid args (models sometimes hallucinate parameters)
1559
+
1560
+ tool_result = tool_func(**filtered_args)"""
1561
+
1562
+ new_string = """# Silently filter out invalid args (models sometimes hallucinate parameters)
1563
+
1564
+ tool_result = tool_func(**filtered_args)
1565
+
1566
+ # Display result for certain tools where the result contains important info
1567
+ if tool_name == "todo_add" and not isinstance(tool_result, Exception):
1568
+ # Extract and display the task number from the result
1569
+ print(f"\\033[2m{tool_result.split(':')[0]}\\033[0m", flush=True)"""
1570
+
1571
+ result = edit_file("agent_snippet.py", old_string, new_string)
1572
+
1573
+ assert "Successfully edited" in result
1574
+
1575
+ # Verify the edit preserved original indentation
1576
+ new_content = (temp_repo / "agent_snippet.py").read_text()
1577
+ assert " # Display result for certain tools" in new_content
1578
+ assert "tool_result.split" in new_content
1579
+
1580
+
1581
+ def test_edit_file_code_without_indentation_prefers_line_match(temp_repo):
1582
+ """Test that code patterns without indentation prefer line-level matching over substring."""
1583
+ from patchpal.tools import edit_file
1584
+
1585
+ content = """def calculate():
1586
+ result = compute()
1587
+ return result
1588
+ """
1589
+ (temp_repo / "code_test.py").write_text(content)
1590
+
1591
+ # Search without indentation (flexible matching finds full line),
1592
+ # provide replacement WITH proper indentation
1593
+ old_string = "return result"
1594
+ new_string = " return final_result" # With proper indentation
1595
+
1596
+ edit_file("code_test.py", old_string, new_string)
1597
+
1598
+ new_content = (temp_repo / "code_test.py").read_text()
1599
+ assert " return final_result" in new_content
1600
+ assert "result = compute()" in new_content # Should not have changed this line
1601
+
1602
+
1603
+ def test_edit_file_preserves_exact_match_when_possible(temp_repo):
1604
+ """Test that exact matches are still preferred when indentation is correct."""
1605
+ from patchpal.tools import edit_file
1606
+
1607
+ content = """def hello():
1608
+ print("world")
1609
+ """
1610
+ (temp_repo / "exact_test.py").write_text(content)
1611
+
1612
+ # With correct indentation, should use exact match
1613
+ old_string = ' print("world")'
1614
+
1615
+ result = edit_file("exact_test.py", old_string, ' print("universe")')
1616
+
1617
+ assert "Successfully edited" in result
1618
+
1619
+ new_content = (temp_repo / "exact_test.py").read_text()
1620
+ assert ' print("universe")' in new_content
1621
+
1622
+
1623
+ def test_edit_file_flexible_matching_error_message(temp_repo):
1624
+ """Test error message when string not found."""
1625
+ from patchpal.tools import edit_file
1626
+
1627
+ (temp_repo / "test_error.py").write_text("def hello():\n pass\n")
1628
+
1629
+ # Try to find something that doesn't exist
1630
+ with pytest.raises(ValueError) as exc_info:
1631
+ edit_file("test_error.py", "goodbye()", "farewell()")
1632
+
1633
+ error_msg = str(exc_info.value)
1634
+ assert "String not found" in error_msg
1635
+ assert "read_lines()" in error_msg # Should suggest using read_lines()
1636
+
1637
+
1638
+ def test_edit_file_matching_strategies_helper_functions(temp_repo):
1639
+ """Test the underlying matching strategy helper functions directly."""
1640
+ from patchpal.tools import (
1641
+ _try_line_trimmed_match,
1642
+ _try_simple_match,
1643
+ _try_whitespace_normalized_match,
1644
+ )
1645
+
1646
+ content = """def hello():
1647
+ print("world")
1648
+ return 42
1649
+ """
1650
+
1651
+ # Test simple match
1652
+ assert _try_simple_match(content, 'print("world")') == 'print("world")'
1653
+ assert _try_simple_match(content, "nonexistent") is None
1654
+
1655
+ # Test line trimmed match (should find with correct indentation)
1656
+ match = _try_line_trimmed_match(content, 'print("world")')
1657
+ assert match == ' print("world")'
1658
+
1659
+ # Test whitespace normalized match
1660
+ content2 = "x = 42"
1661
+ match = _try_whitespace_normalized_match(content2, "x = 42")
1662
+ assert match == "x = 42"
1663
+
1664
+
1665
+ def test_edit_file_multiline_trimmed_match_helper(temp_repo):
1666
+ """Test line-trimmed matching with multi-line blocks."""
1667
+ from patchpal.tools import _try_line_trimmed_match
1668
+
1669
+ content = """class Test:
1670
+ def method(self):
1671
+ if True:
1672
+ do_something()
1673
+ return value
1674
+ """
1675
+
1676
+ # Search without proper indentation
1677
+ search = """if True:
1678
+ do_something()
1679
+ return value"""
1680
+
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"
1684
+
1685
+
1686
+ def test_edit_file_finds_match_with_strategy_order(temp_repo):
1687
+ """Test that strategies are tried in correct order."""
1688
+ from patchpal.tools import _find_match_with_strategies
1689
+
1690
+ # Scenario: content has both a substring and a full line
1691
+ # Should prefer full line match for code patterns
1692
+ content = """def calculate():
1693
+ result = process() # result is important
1694
+ return result
1695
+ """
1696
+
1697
+ # Without indentation, should match the full line not substring in comment
1698
+ match = _find_match_with_strategies(content, "return result")
1699
+ assert match == " return result"
1700
+ # Should NOT match just "result" in the comment or variable name
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes