abstractcore 2.4.2__py3-none-any.whl → 2.4.4__py3-none-any.whl

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 (34) hide show
  1. abstractcore/apps/app_config_utils.py +19 -0
  2. abstractcore/apps/summarizer.py +85 -56
  3. abstractcore/architectures/detection.py +15 -4
  4. abstractcore/assets/architecture_formats.json +1 -1
  5. abstractcore/assets/model_capabilities.json +420 -11
  6. abstractcore/core/interface.py +2 -0
  7. abstractcore/core/session.py +4 -0
  8. abstractcore/embeddings/manager.py +54 -16
  9. abstractcore/media/__init__.py +116 -148
  10. abstractcore/media/auto_handler.py +363 -0
  11. abstractcore/media/base.py +456 -0
  12. abstractcore/media/capabilities.py +335 -0
  13. abstractcore/media/types.py +300 -0
  14. abstractcore/media/vision_fallback.py +260 -0
  15. abstractcore/providers/anthropic_provider.py +18 -1
  16. abstractcore/providers/base.py +187 -0
  17. abstractcore/providers/huggingface_provider.py +111 -12
  18. abstractcore/providers/lmstudio_provider.py +88 -5
  19. abstractcore/providers/mlx_provider.py +33 -1
  20. abstractcore/providers/ollama_provider.py +37 -3
  21. abstractcore/providers/openai_provider.py +18 -1
  22. abstractcore/server/app.py +1390 -104
  23. abstractcore/tools/common_tools.py +12 -8
  24. abstractcore/utils/__init__.py +9 -5
  25. abstractcore/utils/cli.py +199 -17
  26. abstractcore/utils/message_preprocessor.py +182 -0
  27. abstractcore/utils/structured_logging.py +117 -16
  28. abstractcore/utils/version.py +1 -1
  29. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/METADATA +214 -20
  30. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/RECORD +34 -27
  31. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/entry_points.txt +1 -0
  32. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/WHEEL +0 -0
  33. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/licenses/LICENSE +0 -0
  34. {abstractcore-2.4.2.dist-info → abstractcore-2.4.4.dist-info}/top_level.txt +0 -0
@@ -121,7 +121,8 @@ def list_files(directory_path: str = ".", pattern: str = "*", recursive: bool =
121
121
  except ValueError:
122
122
  head_limit = 50 # fallback to default
123
123
 
124
- directory = Path(directory_path)
124
+ # Expand home directory shortcuts like ~
125
+ directory = Path(directory_path).expanduser()
125
126
 
126
127
  if not directory.exists():
127
128
  return f"Error: Directory '{directory_path}' does not exist"
@@ -302,7 +303,8 @@ def search_files(
302
303
  except ValueError:
303
304
  head_limit = 20 # fallback to default
304
305
 
305
- search_path = Path(path)
306
+ # Expand home directory shortcuts like ~
307
+ search_path = Path(path).expanduser()
306
308
 
307
309
  # Compile regex pattern
308
310
  flags = 0 if case_sensitive else re.IGNORECASE
@@ -651,7 +653,8 @@ def read_file(file_path: str, should_read_entire_file: bool = True, start_line_o
651
653
  File contents or error message
652
654
  """
653
655
  try:
654
- path = Path(file_path)
656
+ # Expand home directory shortcuts like ~
657
+ path = Path(file_path).expanduser()
655
658
 
656
659
  if not path.exists():
657
660
  return f"Error: File '{file_path}' does not exist"
@@ -765,8 +768,8 @@ def write_file(file_path: str, content: str = "", mode: str = "w", create_dirs:
765
768
  OSError: If there are filesystem issues
766
769
  """
767
770
  try:
768
- # Convert to Path object for better handling
769
- path = Path(file_path)
771
+ # Convert to Path object for better handling and expand home directory shortcuts like ~
772
+ path = Path(file_path).expanduser()
770
773
 
771
774
  # Create parent directories if requested and they don't exist
772
775
  if create_dirs and path.parent != path:
@@ -1073,8 +1076,8 @@ def edit_file(
1073
1076
  edit_file("test.py", "class OldClass", "class NewClass", preview_only=True)
1074
1077
  """
1075
1078
  try:
1076
- # Validate file exists
1077
- path = Path(file_path)
1079
+ # Validate file exists and expand home directory shortcuts like ~
1080
+ path = Path(file_path).expanduser()
1078
1081
  if not path.exists():
1079
1082
  return f"❌ File not found: {file_path}"
1080
1083
 
@@ -1344,7 +1347,8 @@ def execute_command(
1344
1347
 
1345
1348
  # Working directory validation
1346
1349
  if working_directory:
1347
- working_dir = Path(working_directory).resolve()
1350
+ # Expand home directory shortcuts like ~ before resolving
1351
+ working_dir = Path(working_directory).expanduser().resolve()
1348
1352
  if not working_dir.exists():
1349
1353
  return f"❌ Error: Working directory does not exist: {working_directory}"
1350
1354
  if not working_dir.is_dir():
@@ -5,13 +5,14 @@ Utility functions for AbstractCore.
5
5
  from .structured_logging import configure_logging, get_logger, capture_session
6
6
  from .version import __version__
7
7
  from .token_utils import (
8
- TokenUtils,
9
- count_tokens,
10
- estimate_tokens,
8
+ TokenUtils,
9
+ count_tokens,
10
+ estimate_tokens,
11
11
  count_tokens_precise,
12
12
  TokenCountMethod,
13
13
  ContentType
14
14
  )
15
+ from .message_preprocessor import MessagePreprocessor, parse_files, has_files
15
16
 
16
17
  __all__ = [
17
18
  'configure_logging',
@@ -20,8 +21,11 @@ __all__ = [
20
21
  '__version__',
21
22
  'TokenUtils',
22
23
  'count_tokens',
23
- 'estimate_tokens',
24
+ 'estimate_tokens',
24
25
  'count_tokens_precise',
25
26
  'TokenCountMethod',
26
- 'ContentType'
27
+ 'ContentType',
28
+ 'MessagePreprocessor',
29
+ 'parse_files',
30
+ 'has_files'
27
31
  ]
abstractcore/utils/cli.py CHANGED
@@ -25,6 +25,18 @@ import sys
25
25
  import time
26
26
  from typing import Optional
27
27
 
28
+ # Enable command history and arrow key navigation
29
+ try:
30
+ import readline
31
+ # Configure readline for better history behavior
32
+ readline.set_startup_hook(lambda: readline.insert_text(''))
33
+ readline.parse_and_bind("tab: complete")
34
+ # Set a reasonable history length
35
+ readline.set_history_length(1000)
36
+ except ImportError:
37
+ # readline not available (typically on Windows)
38
+ readline = None
39
+
28
40
  from .. import create_llm, BasicSession
29
41
  from ..tools.common_tools import list_files, read_file, write_file, execute_command, search_files
30
42
  from ..processing import BasicExtractor, BasicJudge
@@ -57,11 +69,14 @@ class SimpleCLI:
57
69
 
58
70
  self.max_tokens = max_tokens
59
71
 
72
+ # Initialize command history with persistent storage
73
+ self._setup_command_history()
74
+
60
75
  # Initialize provider and session with tools
61
76
  self.provider = create_llm(provider, model=model, max_tokens=max_tokens, **kwargs)
62
77
  self.session = BasicSession(
63
78
  self.provider,
64
- system_prompt="You are a helpful AI assistant.",
79
+ system_prompt="You are a helpful AI assistant with vision capabilities. When users provide images or media files, analyze and describe them directly. You also have access to file operation tools.",
65
80
  tools=[list_files, read_file, write_file, execute_command, search_files]
66
81
  )
67
82
 
@@ -81,6 +96,47 @@ class SimpleCLI:
81
96
  print("💡 Ask questions naturally or use tools: 'What files are here?'")
82
97
  print("=" * 70)
83
98
 
99
+ def _setup_command_history(self):
100
+ """Setup command history with persistent storage."""
101
+ if readline is None:
102
+ return # No readline support available
103
+
104
+ # Store history in user's home directory
105
+ import os
106
+ import pathlib
107
+
108
+ # Create .abstractcore directory if it doesn't exist
109
+ history_dir = pathlib.Path.home() / '.abstractcore'
110
+ history_dir.mkdir(exist_ok=True)
111
+
112
+ # Define history file path
113
+ self.history_file = history_dir / 'cli_history.txt'
114
+
115
+ try:
116
+ # Load existing history if file exists
117
+ if self.history_file.exists():
118
+ readline.read_history_file(str(self.history_file))
119
+ if self.debug_mode:
120
+ history_size = readline.get_current_history_length()
121
+ print(f"🔍 Loaded {history_size} command(s) from history")
122
+ except (FileNotFoundError, PermissionError) as e:
123
+ if self.debug_mode:
124
+ print(f"⚠️ Could not load command history: {e}")
125
+
126
+ def _save_command_history(self):
127
+ """Save current command history to disk."""
128
+ if readline is None or not hasattr(self, 'history_file'):
129
+ return
130
+
131
+ try:
132
+ # Ensure the directory exists
133
+ self.history_file.parent.mkdir(exist_ok=True)
134
+ # Save history to file
135
+ readline.write_history_file(str(self.history_file))
136
+ except (PermissionError, OSError) as e:
137
+ if self.debug_mode:
138
+ print(f"⚠️ Could not save command history: {e}")
139
+
84
140
  def handle_command(self, user_input: str) -> bool:
85
141
  """Handle commands. Returns True if command processed, False otherwise."""
86
142
  if not user_input.startswith('/'):
@@ -89,6 +145,7 @@ class SimpleCLI:
89
145
  cmd = user_input[1:].strip()
90
146
 
91
147
  if cmd in ['quit', 'exit', 'q']:
148
+ self._save_command_history()
92
149
  print("👋 Goodbye!")
93
150
  sys.exit(0)
94
151
 
@@ -101,7 +158,8 @@ class SimpleCLI:
101
158
  print("─" * 50)
102
159
  print(" /help Show this comprehensive help")
103
160
  print(" /quit Exit the CLI")
104
- print(" /clear Clear conversation history")
161
+ print(" /clear Clear the screen (like unix terminal)")
162
+ print(" /reset Reset conversation history")
105
163
  print(" /status Show system status and capabilities")
106
164
 
107
165
  print("\n💬 CONVERSATION MANAGEMENT")
@@ -153,11 +211,22 @@ class SimpleCLI:
153
211
  print(" • write_file Create or modify files")
154
212
  print(" • execute_command Run shell commands")
155
213
 
214
+ print("\n📎 FILE ATTACHMENTS")
215
+ print("─" * 50)
216
+ print(" Use @filename syntax to attach files to your message:")
217
+ print(" • Images: 'Analyze this screenshot @screenshot.png'")
218
+ print(" • Documents: 'Summarize @report.pdf and @data.csv'")
219
+ print(" • Multiple files: 'Compare @image1.jpg @image2.jpg @notes.txt'")
220
+ print(" • Vision analysis: Works with vision models (GPT-4o, Claude, qwen2.5vl)")
221
+ print(" • Auto-fallback: Text-only models use vision captioning for images")
222
+ print(" • Supported formats: Images (jpg, png, gif), PDFs, Office docs, text files")
223
+
156
224
  print("\n💡 TIPS & EXAMPLES")
157
225
  print("─" * 50)
158
226
  print(" • Ask questions naturally: 'What files are in this directory?'")
159
227
  print(" • Search inside files: 'Find all TODO comments in Python files'")
160
228
  print(" • Request file operations: 'Read the README.md file'")
229
+ print(" • Attach files: 'What's in this image? @photo.jpg'")
161
230
  print(" • Save important conversations: '/save project_discussion --summary'")
162
231
  print(" • Switch models for different tasks: '/model ollama:qwen3-coder:30b'")
163
232
  print(" • Use /status to check token usage and model capabilities")
@@ -167,8 +236,13 @@ class SimpleCLI:
167
236
  print("=" * 70 + "\n")
168
237
 
169
238
  elif cmd == 'clear':
239
+ # Clear the screen like in unix terminal
240
+ import os
241
+ os.system('cls' if os.name == 'nt' else 'clear')
242
+
243
+ elif cmd == 'reset':
170
244
  self.session.clear_history(keep_system=True)
171
- print("🧹 History cleared")
245
+ print("🧹 Chat history reset")
172
246
 
173
247
  elif cmd == 'stream':
174
248
  self.stream_mode = not self.stream_mode
@@ -208,7 +282,7 @@ class SimpleCLI:
208
282
  max_tokens=self.max_tokens, **self.kwargs)
209
283
  self.session = BasicSession(
210
284
  self.provider,
211
- system_prompt="You are a helpful AI assistant.",
285
+ system_prompt="You are a helpful AI assistant with vision capabilities. When users provide images or media files, analyze and describe them directly. You also have access to file operation tools.",
212
286
  tools=[list_files, read_file, write_file, execute_command, search_files]
213
287
  )
214
288
  print("✅ Model switched")
@@ -898,18 +972,62 @@ class SimpleCLI:
898
972
 
899
973
  print("=" * 60)
900
974
 
975
+ def _parse_file_attachments(self, user_input: str):
976
+ """Parse @filename references using AbstractCore's message preprocessor."""
977
+ from ..utils.message_preprocessor import MessagePreprocessor
978
+ import os
979
+
980
+ # Use AbstractCore's centralized file parsing logic
981
+ clean_input, media_files = MessagePreprocessor.parse_file_attachments(
982
+ user_input,
983
+ validate_existence=True,
984
+ verbose=self.debug_mode
985
+ )
986
+
987
+ # Show user-friendly status messages for CLI (only in interactive mode)
988
+ if media_files and not self.single_prompt_mode:
989
+ print(f"📎 Attaching {len(media_files)} file(s): {', '.join(media_files)}")
990
+
991
+ # Check for vision capabilities if images are attached
992
+ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'}
993
+ image_files = [f for f in media_files if os.path.splitext(f.lower())[1] in image_extensions]
994
+
995
+ if image_files:
996
+ try:
997
+ from ..media.capabilities import is_vision_model
998
+ if is_vision_model(self.model_name):
999
+ print(f"👁️ Vision model detected - will analyze {len(image_files)} image(s)")
1000
+ else:
1001
+ print(f"📷 Text model - will use vision fallback for {len(image_files)} image(s)")
1002
+ except:
1003
+ print(f"📷 Processing {len(image_files)} image(s)")
1004
+
1005
+ return clean_input, media_files
1006
+
901
1007
  def generate_response(self, user_input: str):
902
- """Generate and display response with tool execution."""
1008
+ """Generate and display response with tool execution and file attachment support."""
903
1009
  import re
904
1010
  start_time = time.time()
905
1011
 
906
1012
  try:
1013
+ # Parse @filename attachments
1014
+ clean_input, media_files = self._parse_file_attachments(user_input)
1015
+
1016
+ # If no text remains after removing file references, provide default prompt
1017
+ if not clean_input and media_files:
1018
+ clean_input = "Please analyze the attached file(s)."
1019
+
907
1020
  if self.debug_mode:
908
1021
  print(f"🔍 Sending to {self.provider_name}:{self.model_name}")
909
-
910
- # Don't pass tool_call_tags to avoid format confusion
911
- # Let the model use its native format, we'll parse it universally
912
- response = self.session.generate(user_input, stream=self.stream_mode)
1022
+ if media_files:
1023
+ print(f"🔍 Media files: {media_files}")
1024
+
1025
+ # Generate response with media support
1026
+ response = self.session.generate(
1027
+ clean_input,
1028
+ stream=self.stream_mode,
1029
+ media=media_files if media_files else None
1030
+ )
913
1031
 
914
1032
  if self.stream_mode:
915
1033
  if not self.single_prompt_mode:
@@ -1180,10 +1298,12 @@ class SimpleCLI:
1180
1298
  print("\n\n👋 Use /quit to exit.")
1181
1299
  continue
1182
1300
  except EOFError:
1301
+ self._save_command_history()
1183
1302
  print("\n👋 Goodbye!")
1184
1303
  break
1185
1304
 
1186
1305
  except Exception as e:
1306
+ self._save_command_history()
1187
1307
  print(f"❌ Fatal error: {e}")
1188
1308
 
1189
1309
  def run_single_prompt(self, prompt: str):
@@ -1205,7 +1325,7 @@ Examples:
1205
1325
  python -m abstractcore.utils.cli --provider ollama --model qwen3-coder:30b
1206
1326
  python -m abstractcore.utils.cli --provider openai --model gpt-4o-mini --stream
1207
1327
  python -m abstractcore.utils.cli --provider anthropic --model claude-3-5-haiku-20241022
1208
- python -m abstractcore.utils.cli --provider ollama --model qwen3-coder:30b --prompt "What is Python?"
1328
+ python -m abstractcore.utils.cli --prompt "What is Python?" # Uses configured defaults
1209
1329
 
1210
1330
  Key Commands:
1211
1331
  /help Show comprehensive command guide
@@ -1221,17 +1341,26 @@ Key Commands:
1221
1341
 
1222
1342
  Tools: list_files, search_files, read_file, write_file, execute_command
1223
1343
 
1344
+ File Attachments:
1345
+ Use @filename syntax to attach files: "Analyze @image.jpg and @doc.pdf"
1346
+ Supports images, PDFs, Office docs, text files with automatic processing
1347
+ Vision models analyze images directly; text models use vision fallback
1348
+
1349
+ Configuration:
1350
+ Set defaults with: abstractcore --set-app-default cli <provider> <model>
1351
+ Check status with: abstractcore --status
1352
+
1224
1353
  Note: This is a basic demonstrator with limited capabilities. For production
1225
1354
  use cases requiring advanced reasoning, ReAct patterns, or complex tool chains,
1226
1355
  build custom solutions using the AbstractCore framework directly.
1227
1356
  """
1228
1357
  )
1229
1358
 
1230
- # Required arguments
1231
- parser.add_argument('--provider', required=True,
1359
+ # Optional arguments (no longer required - will use configured defaults)
1360
+ parser.add_argument('--provider',
1232
1361
  choices=['openai', 'anthropic', 'ollama', 'huggingface', 'mlx', 'lmstudio'],
1233
- help='LLM provider to use')
1234
- parser.add_argument('--model', required=True, help='Model name to use')
1362
+ help='LLM provider to use (optional - uses configured default)')
1363
+ parser.add_argument('--model', help='Model name to use (optional - uses configured default)')
1235
1364
 
1236
1365
  # Optional arguments
1237
1366
  parser.add_argument('--stream', action='store_true', help='Enable streaming mode')
@@ -1246,6 +1375,59 @@ build custom solutions using the AbstractCore framework directly.
1246
1375
 
1247
1376
  args = parser.parse_args()
1248
1377
 
1378
+ # Load configuration manager for defaults
1379
+ try:
1380
+ from ..config import get_config_manager
1381
+ config_manager = get_config_manager()
1382
+ except Exception as e:
1383
+ config_manager = None
1384
+ if not args.provider or not args.model:
1385
+ print(f"❌ Error loading configuration: {e}")
1386
+ print("💡 Please specify --provider and --model explicitly")
1387
+ sys.exit(1)
1388
+
1389
+ # Get provider and model from configuration if not specified
1390
+ if not args.provider or not args.model:
1391
+ if config_manager:
1392
+ default_provider, default_model = config_manager.get_app_default('cli')
1393
+
1394
+ # Use configured defaults if available
1395
+ provider = args.provider or default_provider
1396
+ model = args.model or default_model
1397
+
1398
+ if not provider or not model:
1399
+ print("❌ Error: No provider/model specified and no defaults configured")
1400
+ print()
1401
+ print("💡 Solutions:")
1402
+ print(" 1. Specify explicitly: --provider ollama --model gemma3:1b-it-qat")
1403
+ print(" 2. Configure defaults: abstractcore --set-app-default cli ollama gemma3:1b-it-qat")
1404
+ print(" 3. Check current config: abstractcore --status")
1405
+ sys.exit(1)
1406
+
1407
+ # Show what we're using if defaults were applied
1408
+ if not args.provider or not args.model:
1409
+ if not args.prompt: # Only show in interactive mode
1410
+ print(f"🔧 Using configured defaults: {provider}/{model}")
1411
+ print(" (Configure with: abstractcore --set-app-default cli <provider> <model>)")
1412
+ print()
1413
+ else:
1414
+ print("❌ Error: No provider/model specified and configuration unavailable")
1415
+ sys.exit(1)
1416
+ else:
1417
+ # Use explicit arguments
1418
+ provider = args.provider
1419
+ model = args.model
1420
+
1421
+ # Get streaming default from configuration (only if --stream not explicitly provided)
1422
+ if not args.stream and config_manager:
1423
+ try:
1424
+ default_streaming = config_manager.get_streaming_default('cli')
1425
+ stream_mode = default_streaming
1426
+ except Exception:
1427
+ stream_mode = False # Safe fallback
1428
+ else:
1429
+ stream_mode = args.stream
1430
+
1249
1431
  # Build kwargs
1250
1432
  kwargs = {'temperature': args.temperature}
1251
1433
  if args.base_url:
@@ -1255,9 +1437,9 @@ build custom solutions using the AbstractCore framework directly.
1255
1437
 
1256
1438
  # Create CLI (suppress banner for single-prompt mode)
1257
1439
  cli = SimpleCLI(
1258
- provider=args.provider,
1259
- model=args.model,
1260
- stream=args.stream,
1440
+ provider=provider,
1441
+ model=model,
1442
+ stream=stream_mode,
1261
1443
  max_tokens=args.max_tokens,
1262
1444
  debug=args.debug,
1263
1445
  show_banner=not args.prompt, # Hide banner in single-prompt mode
@@ -0,0 +1,182 @@
1
+ """
2
+ Message preprocessing utilities for AbstractCore.
3
+
4
+ This module provides utilities for parsing and preprocessing user messages,
5
+ particularly for extracting file references using @filename syntax.
6
+ Used across all AbstractCore applications for consistent behavior.
7
+ """
8
+
9
+ import re
10
+ import os
11
+ from typing import Tuple, List, Optional
12
+
13
+
14
+ class MessagePreprocessor:
15
+ """
16
+ Message preprocessing utilities for extracting file references and cleaning input.
17
+
18
+ Supports @filename syntax for attaching files to messages across all AbstractCore apps.
19
+ """
20
+
21
+ # Pattern to match @filename (supports various file extensions)
22
+ FILE_PATTERN = r'@([^\s@]+\.[\w]+)'
23
+
24
+ @staticmethod
25
+ def parse_file_attachments(user_input: str,
26
+ validate_existence: bool = True,
27
+ verbose: bool = False) -> Tuple[str, List[str]]:
28
+ """
29
+ Parse @filename references from user input and return cleaned text + file list.
30
+
31
+ Args:
32
+ user_input: The user's message that may contain @filename references
33
+ validate_existence: Whether to check if files actually exist (default: True)
34
+ verbose: Whether to log file processing details (default: False)
35
+
36
+ Returns:
37
+ Tuple of (clean_input_text, list_of_valid_file_paths)
38
+
39
+ Examples:
40
+ >>> clean_text, files = MessagePreprocessor.parse_file_attachments(
41
+ ... "Analyze this image @screenshot.png and @data.csv"
42
+ ... )
43
+ >>> print(clean_text)
44
+ "Analyze this image and"
45
+ >>> print(files)
46
+ ["screenshot.png", "data.csv"]
47
+ """
48
+ # Find all @filename references
49
+ matches = re.findall(MessagePreprocessor.FILE_PATTERN, user_input)
50
+
51
+ if not matches:
52
+ return user_input, []
53
+
54
+ valid_files = []
55
+ invalid_files = []
56
+
57
+ for filename in matches:
58
+ if not validate_existence or os.path.exists(filename):
59
+ valid_files.append(filename)
60
+ if verbose:
61
+ size_kb = os.path.getsize(filename) / 1024 if validate_existence else 0
62
+ print(f"📎 Found file: {filename} ({size_kb:.1f}KB)")
63
+ else:
64
+ invalid_files.append(filename)
65
+
66
+ # Show warnings for missing files if verbose mode
67
+ if verbose and invalid_files:
68
+ print(f"⚠️ Files not found: {', '.join(invalid_files)}")
69
+
70
+ # Remove @filename references from the input text
71
+ clean_input = re.sub(MessagePreprocessor.FILE_PATTERN, '', user_input)
72
+
73
+ # Clean up extra whitespace
74
+ clean_input = re.sub(r'\s+', ' ', clean_input).strip()
75
+
76
+ return clean_input, valid_files
77
+
78
+ @staticmethod
79
+ def has_file_attachments(user_input: str) -> bool:
80
+ """
81
+ Check if the user input contains any @filename references.
82
+
83
+ Args:
84
+ user_input: The message to check
85
+
86
+ Returns:
87
+ True if @filename patterns are found, False otherwise
88
+ """
89
+ return bool(re.search(MessagePreprocessor.FILE_PATTERN, user_input))
90
+
91
+ @staticmethod
92
+ def get_file_count(user_input: str) -> int:
93
+ """
94
+ Count the number of @filename references in the input.
95
+
96
+ Args:
97
+ user_input: The message to analyze
98
+
99
+ Returns:
100
+ Number of @filename patterns found
101
+ """
102
+ return len(re.findall(MessagePreprocessor.FILE_PATTERN, user_input))
103
+
104
+ @staticmethod
105
+ def extract_file_paths(user_input: str) -> List[str]:
106
+ """
107
+ Extract just the file paths from @filename references without validation.
108
+
109
+ Args:
110
+ user_input: The message containing @filename references
111
+
112
+ Returns:
113
+ List of file paths (may include non-existent files)
114
+ """
115
+ return re.findall(MessagePreprocessor.FILE_PATTERN, user_input)
116
+
117
+ @staticmethod
118
+ def process_message_with_media(user_input: str,
119
+ default_prompt: Optional[str] = None,
120
+ validate_files: bool = True,
121
+ verbose: bool = False) -> Tuple[str, List[str]]:
122
+ """
123
+ Process a message with @filename attachments, providing a default prompt if needed.
124
+
125
+ This is the main entry point for applications that want full message preprocessing.
126
+
127
+ Args:
128
+ user_input: The user's message
129
+ default_prompt: Default text to use if only files are specified (e.g., "Analyze the attached files")
130
+ validate_files: Whether to validate file existence
131
+ verbose: Whether to show processing details
132
+
133
+ Returns:
134
+ Tuple of (processed_prompt, media_file_list)
135
+ """
136
+ clean_input, media_files = MessagePreprocessor.parse_file_attachments(
137
+ user_input,
138
+ validate_existence=validate_files,
139
+ verbose=verbose
140
+ )
141
+
142
+ # If no text remains after removing file references, use default prompt
143
+ if not clean_input and media_files and default_prompt:
144
+ clean_input = default_prompt
145
+
146
+ return clean_input, media_files
147
+
148
+
149
+ # Convenience functions for common use cases
150
+ def parse_files(user_input: str, verbose: bool = False) -> Tuple[str, List[str]]:
151
+ """
152
+ Convenience function for basic file parsing.
153
+
154
+ Args:
155
+ user_input: Message with @filename references
156
+ verbose: Show processing details
157
+
158
+ Returns:
159
+ Tuple of (clean_text, file_list)
160
+ """
161
+ return MessagePreprocessor.parse_file_attachments(user_input, verbose=verbose)
162
+
163
+
164
+ def has_files(user_input: str) -> bool:
165
+ """
166
+ Convenience function to check if message has file attachments.
167
+
168
+ Args:
169
+ user_input: Message to check
170
+
171
+ Returns:
172
+ True if @filename patterns found
173
+ """
174
+ return MessagePreprocessor.has_file_attachments(user_input)
175
+
176
+
177
+ # Export main classes and functions
178
+ __all__ = [
179
+ 'MessagePreprocessor',
180
+ 'parse_files',
181
+ 'has_files'
182
+ ]