patchpal 0.22.1__tar.gz → 0.22.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 (67) hide show
  1. {patchpal-0.22.1/patchpal.egg-info → patchpal-0.22.3}/PKG-INFO +2 -2
  2. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/__init__.py +1 -1
  3. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/agent/function_calling.py +19 -1
  4. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/agent/react.py +8 -0
  5. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/autopilot.py +36 -0
  6. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/interactive.py +40 -7
  7. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/sandbox.py +0 -3
  8. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/context.py +23 -1
  9. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/permissions.py +57 -6
  10. patchpal-0.22.3/patchpal/tools/audit.py +405 -0
  11. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/code_analysis.py +0 -1
  12. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/common.py +32 -3
  13. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/file_reading.py +57 -17
  14. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/file_writing.py +0 -8
  15. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/find_tool.py +0 -5
  16. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/grep_tool.py +0 -3
  17. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/repo_map.py +19 -11
  18. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/shell_tools.py +45 -12
  19. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/todo_tools.py +0 -9
  20. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/user_interaction.py +18 -5
  21. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/web_tools.py +31 -4
  22. {patchpal-0.22.1 → patchpal-0.22.3/patchpal.egg-info}/PKG-INFO +2 -2
  23. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal.egg-info/SOURCES.txt +1 -0
  24. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal.egg-info/requires.txt +1 -1
  25. {patchpal-0.22.1 → patchpal-0.22.3}/pyproject.toml +1 -1
  26. {patchpal-0.22.1 → patchpal-0.22.3}/LICENSE +0 -0
  27. {patchpal-0.22.1 → patchpal-0.22.3}/MANIFEST.in +0 -0
  28. {patchpal-0.22.1 → patchpal-0.22.3}/README.md +0 -0
  29. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/agent/__init__.py +0 -0
  30. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/__init__.py +0 -0
  31. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/mcp.py +0 -0
  32. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/cli/streaming.py +0 -0
  33. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/config.py +0 -0
  34. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/prompts/react_prompt.md +0 -0
  35. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/prompts/system_prompt.md +0 -0
  36. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/skills.py +0 -0
  37. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/__init__.py +0 -0
  38. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/definitions.py +0 -0
  39. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/image_handler.py +0 -0
  40. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/mcp.py +0 -0
  41. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal/tools/tool_schema.py +0 -0
  42. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal.egg-info/dependency_links.txt +0 -0
  43. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal.egg-info/entry_points.txt +0 -0
  44. {patchpal-0.22.1 → patchpal-0.22.3}/patchpal.egg-info/top_level.txt +0 -0
  45. {patchpal-0.22.1 → patchpal-0.22.3}/setup.cfg +0 -0
  46. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_agent.py +0 -0
  47. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_cli.py +0 -0
  48. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_config_dynamic.py +0 -0
  49. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_context.py +0 -0
  50. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_custom_tools.py +0 -0
  51. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_enabled_tools.py +0 -0
  52. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_find_tool.py +0 -0
  53. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_guardrails.py +0 -0
  54. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_image_blocking.py +0 -0
  55. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_maximum_security.py +0 -0
  56. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_mcp_config.py +0 -0
  57. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_memory.py +0 -0
  58. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_operational_safety.py +0 -0
  59. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_optional_tools.py +0 -0
  60. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_permissions.py +0 -0
  61. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_react.py +0 -0
  62. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_reasoning_content.py +0 -0
  63. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_repo_map.py +0 -0
  64. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_simplified_prompt.py +0 -0
  65. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_skills.py +0 -0
  66. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_streaming.py +0 -0
  67. {patchpal-0.22.1 → patchpal-0.22.3}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.22.1
3
+ Version: 0.22.3
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -30,7 +30,7 @@ Requires-Dist: boto3
30
30
  Requires-Dist: pymupdf>=1.23.0
31
31
  Requires-Dist: python-docx>=1.0.0
32
32
  Requires-Dist: python-pptx>=0.6.0
33
- Requires-Dist: tree-sitter-language-pack>=0.3.0
33
+ Requires-Dist: tree-sitter-language-pack<1.0.0,>=0.3.0
34
34
  Provides-Extra: dev
35
35
  Requires-Dist: pytest>=7.0.0; extra == "dev"
36
36
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.22.1"
3
+ __version__ = "0.22.3"
4
4
 
5
5
  from patchpal.agent import create_agent, create_react_agent
6
6
  from patchpal.cli.autopilot import autopilot_loop
@@ -479,6 +479,10 @@ class PatchPalAgent:
479
479
  self.cumulative_input_tokens = 0
480
480
  self.cumulative_output_tokens = 0
481
481
 
482
+ # Track last prompt tokens from most recent API response (includes cache operations)
483
+ # This is the ACTUAL token count sent to the API, used for accurate context management
484
+ self.last_prompt_tokens = None
485
+
482
486
  # Track cache-related tokens (for Anthropic/Bedrock models with prompt caching)
483
487
  self.cumulative_cache_creation_tokens = 0
484
488
  self.cumulative_cache_read_tokens = 0
@@ -508,6 +512,14 @@ class PatchPalAgent:
508
512
  # Load MEMORY.md if it exists and has non-template content
509
513
  self._load_project_memory()
510
514
 
515
+ # Log session start
516
+ try:
517
+ from patchpal.tools.audit import log_session_start
518
+
519
+ log_session_start(agent_type="function_calling", model=self.model_id)
520
+ except Exception:
521
+ pass # Don't fail if audit logging fails
522
+
511
523
  def _load_project_memory(self):
512
524
  """Load MEMORY.md file at session start if it has non-template content."""
513
525
  try:
@@ -945,7 +957,10 @@ It's currently empty (just the template). The file is automatically loaded at se
945
957
 
946
958
  # Check for compaction BEFORE starting work
947
959
  # This ensures we never compact mid-execution and lose tool results
948
- if self.enable_auto_compact and self.context_manager.needs_compaction(self.messages):
960
+ # Use last_prompt_tokens from previous API call for accurate check (includes cache operations)
961
+ if self.enable_auto_compact and self.context_manager.needs_compaction(
962
+ self.messages, actual_prompt_tokens=self.last_prompt_tokens
963
+ ):
949
964
  self._perform_auto_compaction()
950
965
 
951
966
  # Agent loop with interrupt handling
@@ -1092,7 +1107,10 @@ It's currently empty (just the template). The file is automatically loaded at se
1092
1107
  last_prompt_tokens = None # Track for reactive context management
1093
1108
  if hasattr(response, "usage") and response.usage:
1094
1109
  if hasattr(response.usage, "prompt_tokens"):
1110
+ # LiteLLM already includes cache operations in prompt_tokens for Anthropic/Bedrock
1111
+ # (see litellm/llms/anthropic/chat/transformation.py)
1095
1112
  last_prompt_tokens = response.usage.prompt_tokens
1113
+ self.last_prompt_tokens = last_prompt_tokens # Store for /status command
1096
1114
  self.cumulative_input_tokens += response.usage.prompt_tokens
1097
1115
  if hasattr(response.usage, "completion_tokens"):
1098
1116
  self.cumulative_output_tokens += response.usage.completion_tokens
@@ -164,6 +164,14 @@ class ReActAgent:
164
164
  # Load project memory
165
165
  self._load_project_memory()
166
166
 
167
+ # Log session start
168
+ try:
169
+ from patchpal.tools.audit import log_session_start
170
+
171
+ log_session_start(agent_type="react", model=self.model_id)
172
+ except Exception:
173
+ pass # Don't fail if audit logging fails
174
+
167
175
  def _load_project_memory(self):
168
176
  """Load project memory file if it exists."""
169
177
  from pathlib import Path
@@ -112,10 +112,27 @@ def autopilot_loop(
112
112
  print(f"🔄 Autopilot Iteration {iteration + 1}/{max_iterations}")
113
113
  print(f"{'=' * 80}\n")
114
114
 
115
+ # Log user prompt to audit log (first iteration only)
116
+ if iteration == 0:
117
+ try:
118
+ from patchpal.tools.audit import log_user_prompt
119
+
120
+ log_user_prompt(prompt)
121
+ except Exception:
122
+ pass # Don't fail if audit logging fails
123
+
115
124
  # Run agent with the SAME prompt every time
116
125
  # The agent's conversation history accumulates, so it can see all previous work
117
126
  response = agent.run(prompt, max_iterations=100)
118
127
 
128
+ # Log agent response to audit log
129
+ try:
130
+ from patchpal.tools.audit import log_agent_response
131
+
132
+ log_agent_response(response, success=True)
133
+ except Exception:
134
+ pass # Don't fail if audit logging fails
135
+
119
136
  print(f"\n{'=' * 80}")
120
137
  print("📝 Agent Response:")
121
138
  print(f"{'=' * 80}")
@@ -144,6 +161,16 @@ def autopilot_loop(
144
161
  )
145
162
  if agent.cumulative_cost > 0:
146
163
  print(f"Total cost: ${agent.cumulative_cost:.4f}")
164
+
165
+ # Log successful session end
166
+ try:
167
+ from patchpal.tools.audit import log_session_end
168
+ from patchpal.tools.common import get_operation_count
169
+
170
+ log_session_end(total_operations=get_operation_count(), success=True)
171
+ except Exception:
172
+ pass # Don't fail if audit logging fails
173
+
147
174
  return response
148
175
 
149
176
  # Stop hook: Agent tried to complete, but no completion promise
@@ -168,6 +195,15 @@ def autopilot_loop(
168
195
  if agent.cumulative_cost > 0:
169
196
  print(f"Total cost: ${agent.cumulative_cost:.4f}")
170
197
 
198
+ # Log session end
199
+ try:
200
+ from patchpal.tools.audit import log_session_end
201
+ from patchpal.tools.common import get_operation_count
202
+
203
+ log_session_end(total_operations=get_operation_count(), success=False)
204
+ except Exception:
205
+ pass # Don't fail if audit logging fails
206
+
171
207
  return None
172
208
 
173
209
 
@@ -564,6 +564,15 @@ Supported models: Any LiteLLM-supported model
564
564
 
565
565
  audit_logger.info(", ".join(log_parts))
566
566
 
567
+ # Log structured session end
568
+ try:
569
+ from patchpal.tools.audit import log_session_end
570
+ from patchpal.tools.common import get_operation_count
571
+
572
+ log_session_end(total_operations=get_operation_count(), success=True)
573
+ except Exception:
574
+ pass # Don't fail if audit logging fails
575
+
567
576
  print("\nGoodbye!")
568
577
  break
569
578
 
@@ -661,7 +670,9 @@ Supported models: Any LiteLLM-supported model
661
670
 
662
671
  # Handle /status command - show context window usage
663
672
  if user_input.lower() in ["status", "/status"]:
664
- stats = agent.context_manager.get_usage_stats(agent.messages)
673
+ stats = agent.context_manager.get_usage_stats(
674
+ agent.messages, actual_prompt_tokens=agent.last_prompt_tokens
675
+ )
665
676
 
666
677
  print("\n" + "=" * 70)
667
678
  print("\033[1;36mContext Window Status\033[0m")
@@ -1542,10 +1553,17 @@ Supported models: Any LiteLLM-supported model
1542
1553
  if skill_args:
1543
1554
  prompt += f"\n\nArguments: {skill_args}"
1544
1555
 
1545
- # Log user prompt to audit log (sanitize to prevent Windows Unicode errors)
1546
- audit_logger.info(
1547
- _sanitize_for_logging(f"USER_PROMPT: /{skill_name} {skill_args}")
1548
- )
1556
+ # Log skill invocation to audit log with hash-chaining
1557
+ try:
1558
+ from patchpal.tools.audit import log_user_prompt
1559
+
1560
+ log_user_prompt(f"/{skill_name} {skill_args}")
1561
+ except Exception:
1562
+ # Fallback to old-style logging if audit fails
1563
+ audit_logger.info(
1564
+ _sanitize_for_logging(f"USER_PROMPT: /{skill_name} {skill_args}")
1565
+ )
1566
+
1549
1567
  result = agent.run(prompt, max_iterations=max_iterations)
1550
1568
 
1551
1569
  print("\n" + "=" * 80)
@@ -1565,10 +1583,25 @@ Supported models: Any LiteLLM-supported model
1565
1583
  # Run the agent (Ctrl-C here will interrupt agent, not exit)
1566
1584
  try:
1567
1585
  print() # Add blank line before agent output
1568
- # Log user prompt to audit log (sanitize to prevent Windows Unicode errors)
1569
- audit_logger.info(_sanitize_for_logging(f"USER_PROMPT: {user_input}"))
1586
+ # Log user prompt to audit log with hash-chaining
1587
+ try:
1588
+ from patchpal.tools.audit import log_user_prompt
1589
+
1590
+ log_user_prompt(user_input)
1591
+ except Exception:
1592
+ # Fallback to old-style logging if audit fails
1593
+ audit_logger.info(_sanitize_for_logging(f"USER_PROMPT: {user_input}"))
1594
+
1570
1595
  result = agent.run(user_input, max_iterations=max_iterations)
1571
1596
 
1597
+ # Log agent response to audit log with hash-chaining
1598
+ try:
1599
+ from patchpal.tools.audit import log_agent_response
1600
+
1601
+ log_agent_response(result, success=True)
1602
+ except Exception:
1603
+ pass # Don't fail if audit logging fails
1604
+
1572
1605
  print("\n" + "=" * 80)
1573
1606
  print("\033[1;32mAgent:\033[0m")
1574
1607
  print("=" * 80)
@@ -686,9 +686,6 @@ DESCRIPTION:
686
686
  - Pre-built image with patchpal installed (fast startup)
687
687
  - Auto-mounts ~/.patchpal for custom tools, config, and memory
688
688
  - Custom tools work automatically (from ~/.patchpal/tools/ and <repo>/.patchpal/tools/)
689
- - Auto-sets OLLAMA_CONTEXT_LENGTH for Ollama models:
690
- * 8192 for regular models (agents)
691
- * 32768 for reasoning models (gpt-oss, deepseek-r1, qwq, qwen)
692
689
 
693
690
  Recommended for autopilot mode and high-risk operations.
694
691
 
@@ -355,15 +355,37 @@ Be comprehensive but concise. The goal is to continue work seamlessly without lo
355
355
  usage_ratio = total_tokens / self.context_limit
356
356
  return usage_ratio >= self.COMPACT_THRESHOLD
357
357
 
358
- def get_usage_stats(self, messages: List[Dict[str, Any]]) -> Dict[str, Any]:
358
+ def get_usage_stats(
359
+ self, messages: List[Dict[str, Any]], actual_prompt_tokens: int = None
360
+ ) -> Dict[str, Any]:
359
361
  """Get current context usage statistics.
360
362
 
361
363
  Args:
362
364
  messages: Current message history
365
+ actual_prompt_tokens: Optional actual prompt tokens from latest API response (includes cache operations)
363
366
 
364
367
  Returns:
365
368
  Dict with usage statistics
366
369
  """
370
+ # If we have actual prompt tokens from API (includes cache writes/reads), use those
371
+ if actual_prompt_tokens is not None:
372
+ total_tokens = actual_prompt_tokens + self.output_reserve
373
+ # For display purposes, estimate system vs message breakdown
374
+ system_tokens = self.estimator.estimate_tokens(self.system_prompt)
375
+ datetime_tokens = 30
376
+ message_tokens = actual_prompt_tokens - system_tokens - datetime_tokens
377
+
378
+ return {
379
+ "system_tokens": system_tokens + datetime_tokens,
380
+ "message_tokens": max(0, message_tokens), # Ensure non-negative
381
+ "output_reserve": self.output_reserve,
382
+ "total_tokens": total_tokens,
383
+ "context_limit": self.context_limit,
384
+ "usage_ratio": total_tokens / self.context_limit,
385
+ "usage_percent": int((total_tokens / self.context_limit) * 100),
386
+ }
387
+
388
+ # Fallback to estimation when actual tokens not available
367
389
  system_tokens = self.estimator.estimate_tokens(self.system_prompt)
368
390
  datetime_tokens = 30 # Approximate size of dynamic date/time message
369
391
  message_tokens = self.estimator.estimate_messages_tokens(messages)
@@ -67,6 +67,11 @@ class PermissionManager:
67
67
  These commands replace dedicated tools that were removed (replaced by find tool)
68
68
  to reduce redundancy. Since those tools didn't require permissions, their shell
69
69
  equivalents shouldn't either.
70
+
71
+ SECURITY NOTE: Environment variable commands (env, printenv, set, Get-Variable)
72
+ are NOT in this list because they can expose API keys and secrets loaded from
73
+ .env files. While we block reading .env files directly, we must also block
74
+ reading the environment variables that were loaded from them.
70
75
  """
71
76
  # Check if web tools are enabled
72
77
  web_tools_enabled = config.ENABLE_WEB
@@ -99,9 +104,6 @@ class PermissionManager:
99
104
  "whereis",
100
105
  # Current directory
101
106
  "pwd",
102
- # Environment
103
- "env",
104
- "printenv",
105
107
  # Network diagnostic
106
108
  "ifconfig",
107
109
  # Disk/system info
@@ -132,8 +134,6 @@ class PermissionManager:
132
134
  "assoc",
133
135
  "ftype",
134
136
  "doskey /history",
135
- # Environment
136
- "set",
137
137
  # Network diagnostic
138
138
  "tracert",
139
139
  "nslookup",
@@ -169,7 +169,6 @@ class PermissionManager:
169
169
  "get-host",
170
170
  "get-command",
171
171
  "get-alias",
172
- "get-variable",
173
172
  "get-member",
174
173
  "get-help",
175
174
  # Search/filter
@@ -420,6 +419,19 @@ class PermissionManager:
420
419
 
421
420
  # Check if already granted (with full_command for multi-word pattern matching)
422
421
  if self._check_existing_grant(tool_name, pattern, full_command):
422
+ # Log that permission was auto-granted from previous session grant
423
+ try:
424
+ from patchpal.tools.audit import log_action_approved
425
+
426
+ log_action_approved(
427
+ tool_name=tool_name,
428
+ description=description,
429
+ approval_type="auto_granted",
430
+ pattern=pattern,
431
+ context={"working_dir": context} if context else None,
432
+ )
433
+ except Exception:
434
+ pass # Don't fail if audit logging fails
423
435
  return True
424
436
 
425
437
  # Display the request - use stderr to avoid Rich console capture
@@ -481,14 +493,53 @@ class PermissionManager:
481
493
  choice = input("\n\033[1;36mChoice [1-3]:\033[0m ").strip()
482
494
 
483
495
  if choice == "1":
496
+ # Log approval
497
+ try:
498
+ from patchpal.tools.audit import log_action_approved
499
+
500
+ log_action_approved(
501
+ tool_name=tool_name,
502
+ description=description,
503
+ approval_type="user_approved",
504
+ pattern=pattern,
505
+ context={"working_dir": context} if context else None,
506
+ )
507
+ except Exception:
508
+ pass # Don't fail if audit logging fails
484
509
  return True
485
510
  elif choice == "2":
486
511
  # Grant session-only permission (like Claude Code)
487
512
  self._grant_permission(tool_name, persistent=False, pattern=pattern)
513
+ # Log approval with session grant
514
+ try:
515
+ from patchpal.tools.audit import log_action_approved
516
+
517
+ log_action_approved(
518
+ tool_name=tool_name,
519
+ description=description,
520
+ approval_type="session_granted",
521
+ pattern=pattern,
522
+ context={"working_dir": context} if context else None,
523
+ )
524
+ except Exception:
525
+ pass # Don't fail if audit logging fails
488
526
  return True
489
527
  elif choice == "3":
490
528
  sys.stderr.write("\n\033[1;31mOperation cancelled.\033[0m\n")
491
529
  sys.stderr.flush()
530
+ # Log rejection
531
+ try:
532
+ from patchpal.tools.audit import log_action_blocked
533
+
534
+ log_action_blocked(
535
+ tool_name=tool_name,
536
+ description=description,
537
+ reason="user_rejected",
538
+ pattern=pattern,
539
+ context={"working_dir": context} if context else None,
540
+ )
541
+ except Exception:
542
+ pass # Don't fail if audit logging fails
492
543
  return False
493
544
  else:
494
545
  sys.stderr.write("Invalid choice. Please enter 1, 2, or 3.\n")