flowly-code 1.0.0__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 (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,280 @@
1
+ """File system tools: read, write, edit."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from flowly_code.agent.tools.base import Tool
7
+
8
+ # Allowed paths outside workspace (home-relative config/data dirs)
9
+ _ALLOWED_PREFIXES = (
10
+ Path.home() / ".flowly",
11
+ )
12
+
13
+
14
+ def _is_path_allowed(
15
+ resolved_path: Path,
16
+ workspace: Path | None,
17
+ access_mode: str = "full",
18
+ allowed_paths: list[Path] | None = None,
19
+ ) -> bool:
20
+ """Check if a resolved path is allowed based on access mode.
21
+
22
+ Modes:
23
+ full — all paths allowed
24
+ projects — only Dispatch project dirs + ~/.flowly
25
+ workspace — only ~/.flowly/workspace + ~/.flowly
26
+ """
27
+ if access_mode == "full":
28
+ return True
29
+
30
+ # ~/.flowly is always allowed
31
+ for prefix in _ALLOWED_PREFIXES:
32
+ try:
33
+ resolved_path.relative_to(prefix.resolve())
34
+ return True
35
+ except ValueError:
36
+ continue
37
+
38
+ # Project directories (when mode=projects)
39
+ if access_mode == "projects" and allowed_paths:
40
+ for p in allowed_paths:
41
+ try:
42
+ resolved_path.relative_to(p.resolve())
43
+ return True
44
+ except ValueError:
45
+ continue
46
+
47
+ # Workspace directory
48
+ if workspace:
49
+ try:
50
+ resolved_path.relative_to(workspace.resolve())
51
+ return True
52
+ except ValueError:
53
+ pass
54
+
55
+ return False
56
+
57
+
58
+ class ReadFileTool(Tool):
59
+ """Tool to read file contents."""
60
+
61
+ def __init__(self, workspace: Path | None = None,
62
+ access_mode: str = "full", allowed_paths: list[Path] | None = None):
63
+ self.workspace = workspace
64
+ self.access_mode = access_mode
65
+ self.allowed_paths = allowed_paths
66
+
67
+ @property
68
+ def name(self) -> str:
69
+ return "read_file"
70
+
71
+ @property
72
+ def description(self) -> str:
73
+ return "Read the contents of a file at the given path."
74
+
75
+ @property
76
+ def parameters(self) -> dict[str, Any]:
77
+ return {
78
+ "type": "object",
79
+ "properties": {
80
+ "path": {
81
+ "type": "string",
82
+ "description": "The file path to read"
83
+ }
84
+ },
85
+ "required": ["path"]
86
+ }
87
+
88
+ async def execute(self, path: str, **kwargs: Any) -> str:
89
+ try:
90
+ file_path = Path(path).expanduser().resolve()
91
+
92
+ if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
93
+ return f"Error: Access denied — path outside allowed directories: {path}"
94
+
95
+ if not file_path.exists():
96
+ return f"Error: File not found: {path}"
97
+ if not file_path.is_file():
98
+ return f"Error: Not a file: {path}"
99
+
100
+ content = file_path.read_text(encoding="utf-8")
101
+ return content
102
+ except PermissionError:
103
+ return f"Error: Permission denied: {path}"
104
+ except Exception as e:
105
+ return f"Error reading file: {str(e)}"
106
+
107
+
108
+ class WriteFileTool(Tool):
109
+ """Tool to write content to a file."""
110
+
111
+ def __init__(self, workspace: Path | None = None,
112
+ access_mode: str = "full", allowed_paths: list[Path] | None = None):
113
+ self.workspace = workspace
114
+ self.access_mode = access_mode
115
+ self.allowed_paths = allowed_paths
116
+
117
+ @property
118
+ def name(self) -> str:
119
+ return "write_file"
120
+
121
+ @property
122
+ def description(self) -> str:
123
+ return "Write content to a file at the given path. Creates parent directories if needed."
124
+
125
+ @property
126
+ def parameters(self) -> dict[str, Any]:
127
+ return {
128
+ "type": "object",
129
+ "properties": {
130
+ "path": {
131
+ "type": "string",
132
+ "description": "The file path to write to"
133
+ },
134
+ "content": {
135
+ "type": "string",
136
+ "description": "The content to write"
137
+ }
138
+ },
139
+ "required": ["path", "content"]
140
+ }
141
+
142
+ async def execute(self, path: str, content: str, **kwargs: Any) -> str:
143
+ try:
144
+ file_path = Path(path).expanduser().resolve()
145
+
146
+ if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
147
+ return f"Error: Access denied — path outside allowed directories: {path}"
148
+
149
+ file_path.parent.mkdir(parents=True, exist_ok=True)
150
+ file_path.write_text(content, encoding="utf-8")
151
+ return f"Successfully wrote {len(content)} bytes to {path}"
152
+ except PermissionError:
153
+ return f"Error: Permission denied: {path}"
154
+ except Exception as e:
155
+ return f"Error writing file: {str(e)}"
156
+
157
+
158
+ class EditFileTool(Tool):
159
+ """Tool to edit a file by replacing text."""
160
+
161
+ def __init__(self, workspace: Path | None = None,
162
+ access_mode: str = "full", allowed_paths: list[Path] | None = None):
163
+ self.workspace = workspace
164
+ self.access_mode = access_mode
165
+ self.allowed_paths = allowed_paths
166
+
167
+ @property
168
+ def name(self) -> str:
169
+ return "edit_file"
170
+
171
+ @property
172
+ def description(self) -> str:
173
+ return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
174
+
175
+ @property
176
+ def parameters(self) -> dict[str, Any]:
177
+ return {
178
+ "type": "object",
179
+ "properties": {
180
+ "path": {
181
+ "type": "string",
182
+ "description": "The file path to edit"
183
+ },
184
+ "old_text": {
185
+ "type": "string",
186
+ "description": "The exact text to find and replace"
187
+ },
188
+ "new_text": {
189
+ "type": "string",
190
+ "description": "The text to replace with"
191
+ }
192
+ },
193
+ "required": ["path", "old_text", "new_text"]
194
+ }
195
+
196
+ async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
197
+ try:
198
+ file_path = Path(path).expanduser().resolve()
199
+
200
+ if not _is_path_allowed(file_path, self.workspace, self.access_mode, self.allowed_paths):
201
+ return f"Error: Access denied — path outside allowed directories: {path}"
202
+
203
+ if not file_path.exists():
204
+ return f"Error: File not found: {path}"
205
+
206
+ content = file_path.read_text(encoding="utf-8")
207
+
208
+ if old_text not in content:
209
+ return f"Error: old_text not found in file. Make sure it matches exactly."
210
+
211
+ # Count occurrences
212
+ count = content.count(old_text)
213
+ if count > 1:
214
+ return f"Warning: old_text appears {count} times. Please provide more context to make it unique."
215
+
216
+ new_content = content.replace(old_text, new_text, 1)
217
+ file_path.write_text(new_content, encoding="utf-8")
218
+
219
+ return f"Successfully edited {path}"
220
+ except PermissionError:
221
+ return f"Error: Permission denied: {path}"
222
+ except Exception as e:
223
+ return f"Error editing file: {str(e)}"
224
+
225
+
226
+ class ListDirTool(Tool):
227
+ """Tool to list directory contents."""
228
+
229
+ def __init__(self, workspace: Path | None = None,
230
+ access_mode: str = "full", allowed_paths: list[Path] | None = None):
231
+ self.workspace = workspace
232
+ self.access_mode = access_mode
233
+ self.allowed_paths = allowed_paths
234
+
235
+ @property
236
+ def name(self) -> str:
237
+ return "list_dir"
238
+
239
+ @property
240
+ def description(self) -> str:
241
+ return "List the contents of a directory."
242
+
243
+ @property
244
+ def parameters(self) -> dict[str, Any]:
245
+ return {
246
+ "type": "object",
247
+ "properties": {
248
+ "path": {
249
+ "type": "string",
250
+ "description": "The directory path to list"
251
+ }
252
+ },
253
+ "required": ["path"]
254
+ }
255
+
256
+ async def execute(self, path: str, **kwargs: Any) -> str:
257
+ try:
258
+ dir_path = Path(path).expanduser().resolve()
259
+
260
+ if not _is_path_allowed(dir_path, self.workspace, self.access_mode, self.allowed_paths):
261
+ return f"Error: Access denied — path outside allowed directories: {path}"
262
+
263
+ if not dir_path.exists():
264
+ return f"Error: Directory not found: {path}"
265
+ if not dir_path.is_dir():
266
+ return f"Error: Not a directory: {path}"
267
+
268
+ items = []
269
+ for item in sorted(dir_path.iterdir()):
270
+ prefix = "d " if item.is_dir() else "f "
271
+ items.append(f"{prefix}{item.name}")
272
+
273
+ if not items:
274
+ return f"Directory {path} is empty"
275
+
276
+ return "\n".join(items)
277
+ except PermissionError:
278
+ return f"Error: Permission denied: {path}"
279
+ except Exception as e:
280
+ return f"Error listing directory: {str(e)}"
@@ -0,0 +1,85 @@
1
+ """MCP client: connects to MCP servers and wraps their tools as native flowly tools."""
2
+
3
+ from contextlib import AsyncExitStack
4
+ from typing import Any
5
+
6
+ from loguru import logger
7
+
8
+ from flowly_code.agent.tools.base import Tool
9
+ from flowly_code.agent.tools.registry import ToolRegistry
10
+
11
+
12
+ class MCPToolWrapper(Tool):
13
+ """Wraps a single MCP server tool as a flowly Tool."""
14
+
15
+ def __init__(self, session, server_name: str, tool_def):
16
+ self._session = session
17
+ self._original_name = tool_def.name
18
+ self._name = f"mcp_{server_name}_{tool_def.name}"
19
+ self._description = tool_def.description or tool_def.name
20
+ self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return self._name
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ return self._description
29
+
30
+ @property
31
+ def parameters(self) -> dict[str, Any]:
32
+ return self._parameters
33
+
34
+ async def execute(self, **kwargs: Any) -> str:
35
+ from mcp import types
36
+
37
+ result = await self._session.call_tool(self._original_name, arguments=kwargs)
38
+ parts = []
39
+ for block in result.content:
40
+ if isinstance(block, types.TextContent):
41
+ parts.append(block.text)
42
+ else:
43
+ parts.append(str(block))
44
+ return "\n".join(parts) or "(no output)"
45
+
46
+
47
+ async def connect_mcp_servers(
48
+ mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
49
+ ) -> None:
50
+ """Connect to configured MCP servers and register their tools."""
51
+ from mcp import ClientSession, StdioServerParameters
52
+ from mcp.client.stdio import stdio_client
53
+
54
+ for name, cfg in mcp_servers.items():
55
+ try:
56
+ if cfg.command:
57
+ env = dict(cfg.env) if cfg.env else None
58
+ params = StdioServerParameters(
59
+ command=cfg.command, args=list(cfg.args), env=env
60
+ )
61
+ read, write = await stack.enter_async_context(stdio_client(params))
62
+ elif cfg.url:
63
+ from mcp.client.streamable_http import streamable_http_client
64
+
65
+ read, write, _ = await stack.enter_async_context(
66
+ streamable_http_client(cfg.url)
67
+ )
68
+ else:
69
+ logger.warning(f"MCP server '{name}': no command or url configured, skipping")
70
+ continue
71
+
72
+ session = await stack.enter_async_context(ClientSession(read, write))
73
+ await session.initialize()
74
+
75
+ tools = await session.list_tools()
76
+ for tool_def in tools.tools:
77
+ wrapper = MCPToolWrapper(session, name, tool_def)
78
+ registry.register(wrapper)
79
+ logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
80
+
81
+ logger.info(
82
+ f"MCP server '{name}': connected, {len(tools.tools)} tools registered"
83
+ )
84
+ except Exception as e:
85
+ logger.error(f"MCP server '{name}': failed to connect: {e}")
@@ -0,0 +1,235 @@
1
+ """Message tool for sending messages and media to users."""
2
+
3
+ import mimetypes
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Awaitable
6
+
7
+ from loguru import logger
8
+
9
+ from flowly_code.agent.tools.base import Tool
10
+ from flowly_code.bus.events import OutboundMessage
11
+
12
+
13
+ # Supported media MIME types
14
+ SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
15
+ SUPPORTED_DOCUMENT_TYPES = {"application/pdf", "text/plain", "application/zip"}
16
+ SUPPORTED_MEDIA_TYPES = SUPPORTED_IMAGE_TYPES | SUPPORTED_DOCUMENT_TYPES
17
+
18
+ # Maximum media file size (10MB)
19
+ MAX_MEDIA_SIZE = 10 * 1024 * 1024
20
+
21
+
22
+ class MessageTool(Tool):
23
+ """
24
+ Tool to send messages and media to users on chat channels.
25
+
26
+ Supports:
27
+ - Text messages
28
+ - Images (jpg, png, gif, webp)
29
+ - Documents (pdf, txt, zip)
30
+
31
+ Media files are validated for existence, type, and size before sending.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
37
+ default_channel: str = "",
38
+ default_chat_id: str = ""
39
+ ):
40
+ """
41
+ Initialize the message tool.
42
+
43
+ Args:
44
+ send_callback: Async function to send OutboundMessage.
45
+ default_channel: Default channel for messages.
46
+ default_chat_id: Default chat ID for messages.
47
+ """
48
+ self._send_callback = send_callback
49
+ self._default_channel = default_channel
50
+ self._default_chat_id = default_chat_id
51
+
52
+ def set_context(self, channel: str, chat_id: str) -> None:
53
+ """Set the current message context (channel and chat_id)."""
54
+ self._default_channel = channel
55
+ self._default_chat_id = chat_id
56
+
57
+ def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
58
+ """Set the callback for sending messages."""
59
+ self._send_callback = callback
60
+
61
+ @property
62
+ def name(self) -> str:
63
+ return "message"
64
+
65
+ @property
66
+ def description(self) -> str:
67
+ return (
68
+ "Send a message to the user, optionally with media attachments (images, documents). "
69
+ "Use media_paths to attach files like screenshots. "
70
+ "The content will be sent as the caption for media messages."
71
+ )
72
+
73
+ @property
74
+ def parameters(self) -> dict[str, Any]:
75
+ return {
76
+ "type": "object",
77
+ "properties": {
78
+ "content": {
79
+ "type": "string",
80
+ "description": "The message text to send. Used as caption when sending media."
81
+ },
82
+ "media_paths": {
83
+ "type": "array",
84
+ "items": {"type": "string"},
85
+ "description": (
86
+ "Optional list of file paths to send as media attachments. "
87
+ "Supports images (jpg, png, gif) and documents (pdf, txt). "
88
+ "Example: [\"/path/to/screenshot.png\"]"
89
+ )
90
+ },
91
+ "channel": {
92
+ "type": "string",
93
+ "description": "Optional: target channel (telegram, whatsapp, etc.)"
94
+ },
95
+ "chat_id": {
96
+ "type": "string",
97
+ "description": "Optional: target chat/user ID"
98
+ }
99
+ },
100
+ "required": ["content"]
101
+ }
102
+
103
+ async def execute(
104
+ self,
105
+ content: str,
106
+ media_paths: list[str] | None = None,
107
+ channel: str | None = None,
108
+ chat_id: str | None = None,
109
+ **kwargs: Any
110
+ ) -> str:
111
+ """
112
+ Send a message, optionally with media attachments.
113
+
114
+ Args:
115
+ content: Message text content.
116
+ media_paths: Optional list of file paths to attach.
117
+ channel: Target channel (uses default if not specified).
118
+ chat_id: Target chat ID (uses default if not specified).
119
+
120
+ Returns:
121
+ Success or error message.
122
+ """
123
+ # Use defaults if not specified
124
+ channel = channel or self._default_channel
125
+ chat_id = chat_id or self._default_chat_id
126
+
127
+ # Validate required fields
128
+ if not channel or not chat_id:
129
+ return "Error: No target channel/chat specified. Cannot send message."
130
+
131
+ if not self._send_callback:
132
+ return "Error: Message sending not configured. Internal error."
133
+
134
+ # Validate and filter media paths
135
+ validated_media: list[str] = []
136
+ media_errors: list[str] = []
137
+
138
+ if media_paths:
139
+ for path_str in media_paths:
140
+ validation_result = self._validate_media_file(path_str)
141
+ if validation_result is None:
142
+ validated_media.append(path_str)
143
+ else:
144
+ media_errors.append(validation_result)
145
+
146
+ # Build outbound message
147
+ msg = OutboundMessage(
148
+ channel=channel,
149
+ chat_id=chat_id,
150
+ content=content,
151
+ media=validated_media,
152
+ metadata={
153
+ "has_media": len(validated_media) > 0,
154
+ "media_count": len(validated_media)
155
+ }
156
+ )
157
+
158
+ # Send the message
159
+ try:
160
+ await self._send_callback(msg)
161
+
162
+ # Build response
163
+ result_parts = [f"Message sent to {channel}:{chat_id}"]
164
+
165
+ if validated_media:
166
+ result_parts.append(f"Attached {len(validated_media)} media file(s)")
167
+
168
+ if media_errors:
169
+ result_parts.append(f"Skipped {len(media_errors)} invalid file(s):")
170
+ for error in media_errors:
171
+ result_parts.append(f" - {error}")
172
+
173
+ return "\n".join(result_parts)
174
+
175
+ except Exception as e:
176
+ logger.error(f"Failed to send message: {e}")
177
+ return f"Error sending message: {str(e)}"
178
+
179
+ def _validate_media_file(self, path_str: str) -> str | None:
180
+ """
181
+ Validate a media file for sending.
182
+
183
+ Args:
184
+ path_str: Path to the file.
185
+
186
+ Returns:
187
+ None if valid, error message if invalid.
188
+ """
189
+ try:
190
+ path = Path(path_str).expanduser().resolve()
191
+
192
+ # Check existence
193
+ if not path.exists():
194
+ return f"File not found: {path_str}"
195
+
196
+ if not path.is_file():
197
+ return f"Not a file: {path_str}"
198
+
199
+ # Check file size
200
+ file_size = path.stat().st_size
201
+ if file_size == 0:
202
+ return f"File is empty: {path_str}"
203
+
204
+ if file_size > MAX_MEDIA_SIZE:
205
+ size_mb = file_size / 1024 / 1024
206
+ return f"File too large ({size_mb:.1f}MB > 10MB): {path_str}"
207
+
208
+ # Check MIME type
209
+ mime_type, _ = mimetypes.guess_type(str(path))
210
+ if mime_type is None:
211
+ # Allow files with common image extensions even without MIME detection
212
+ ext = path.suffix.lower()
213
+ if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
214
+ return None # Accept based on extension
215
+ return f"Unknown file type: {path_str}"
216
+
217
+ if mime_type not in SUPPORTED_MEDIA_TYPES:
218
+ return f"Unsupported file type ({mime_type}): {path_str}"
219
+
220
+ return None # Valid
221
+
222
+ except Exception as e:
223
+ return f"Error validating file: {str(e)}"
224
+
225
+ @staticmethod
226
+ def is_image(path: str) -> bool:
227
+ """Check if a file path points to an image."""
228
+ mime_type, _ = mimetypes.guess_type(path)
229
+ return mime_type in SUPPORTED_IMAGE_TYPES if mime_type else False
230
+
231
+ @staticmethod
232
+ def is_document(path: str) -> bool:
233
+ """Check if a file path points to a document."""
234
+ mime_type, _ = mimetypes.guess_type(path)
235
+ return mime_type in SUPPORTED_DOCUMENT_TYPES if mime_type else False