code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""Clipboard image reading and management utilities.
|
|
2
|
+
|
|
3
|
+
Provides cross-platform clipboard image capture:
|
|
4
|
+
- Windows/macOS: Uses PIL.ImageGrab (native)
|
|
5
|
+
- Linux: Falls back to xclip or wl-paste via subprocess
|
|
6
|
+
|
|
7
|
+
Also provides a thread-safe ClipboardAttachmentManager for managing
|
|
8
|
+
pending clipboard image attachments in the CLI.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import logging
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
# Try to import PIL - it's optional but needed for clipboard image support
|
|
20
|
+
try:
|
|
21
|
+
from PIL import Image, ImageGrab
|
|
22
|
+
|
|
23
|
+
PIL_AVAILABLE = True
|
|
24
|
+
# SEC-CLIP-002: Protect against decompression bombs
|
|
25
|
+
# Set explicit limit (PIL default) to prevent memory exhaustion from malicious images
|
|
26
|
+
Image.MAX_IMAGE_PIXELS = 178956970
|
|
27
|
+
except ImportError:
|
|
28
|
+
PIL_AVAILABLE = False
|
|
29
|
+
Image = None # type: ignore[misc, assignment]
|
|
30
|
+
ImageGrab = None # type: ignore[misc, assignment]
|
|
31
|
+
|
|
32
|
+
# Import BinaryContent for pydantic-ai integration
|
|
33
|
+
try:
|
|
34
|
+
from pydantic_ai import BinaryContent
|
|
35
|
+
|
|
36
|
+
BINARY_CONTENT_AVAILABLE = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
BINARY_CONTENT_AVAILABLE = False
|
|
39
|
+
BinaryContent = None # type: ignore[misc, assignment]
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Constants
|
|
44
|
+
MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 # 10MB
|
|
45
|
+
MAX_IMAGE_DIMENSION = 4096 # Max width/height for resize
|
|
46
|
+
MAX_PENDING_IMAGES = (
|
|
47
|
+
10 # SEC-CLIP-001: Limit pending images to prevent memory exhaustion
|
|
48
|
+
)
|
|
49
|
+
CLIPBOARD_RATE_LIMIT_SECONDS: float = 0.5 # SEC-CLIP-004: Max 2 captures per second
|
|
50
|
+
|
|
51
|
+
# Rate limiting state
|
|
52
|
+
_last_clipboard_capture: float = 0.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_open_image(image_bytes: bytes) -> Optional["Image.Image"]:
|
|
56
|
+
"""Safely open and verify an image from bytes.
|
|
57
|
+
|
|
58
|
+
Verifies image integrity to protect against malicious images.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image_bytes: Raw image bytes to open.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
PIL Image if valid, None if verification fails.
|
|
65
|
+
"""
|
|
66
|
+
if not PIL_AVAILABLE or Image is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
# First pass: verify integrity without fully loading
|
|
71
|
+
verify_image = Image.open(io.BytesIO(image_bytes))
|
|
72
|
+
verify_image.verify() # Checks for corruption/malicious data
|
|
73
|
+
|
|
74
|
+
# Re-open after verify (verify() closes the image)
|
|
75
|
+
image = Image.open(io.BytesIO(image_bytes))
|
|
76
|
+
return image
|
|
77
|
+
except Image.DecompressionBombError as e:
|
|
78
|
+
logger.warning(f"Rejected decompression bomb image: {e}")
|
|
79
|
+
return None
|
|
80
|
+
except Image.UnidentifiedImageError as e:
|
|
81
|
+
logger.warning(f"Rejected unidentified image format: {e}")
|
|
82
|
+
return None
|
|
83
|
+
except OSError as e:
|
|
84
|
+
logger.warning(f"Rejected potentially malicious image: {e}")
|
|
85
|
+
return None
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.warning(f"Failed to open/verify image: {type(e).__name__}: {e}")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_linux_clipboard_tool() -> Optional[str]:
|
|
92
|
+
"""Check which Linux clipboard tool is available.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
'xclip', 'wl-paste', or None if neither is available.
|
|
96
|
+
"""
|
|
97
|
+
# Check for wl-paste first (Wayland)
|
|
98
|
+
try:
|
|
99
|
+
subprocess.run(
|
|
100
|
+
["wl-paste", "--version"],
|
|
101
|
+
capture_output=True,
|
|
102
|
+
timeout=5,
|
|
103
|
+
)
|
|
104
|
+
return "wl-paste"
|
|
105
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
# Check for xclip (X11)
|
|
109
|
+
try:
|
|
110
|
+
subprocess.run(
|
|
111
|
+
["xclip", "-version"],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
timeout=5,
|
|
114
|
+
)
|
|
115
|
+
return "xclip"
|
|
116
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get_linux_clipboard_image() -> Optional[bytes]:
|
|
123
|
+
"""Get clipboard image on Linux using xclip or wl-paste.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
PNG bytes if image found, None otherwise.
|
|
127
|
+
"""
|
|
128
|
+
tool = _check_linux_clipboard_tool()
|
|
129
|
+
|
|
130
|
+
if tool is None:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"No clipboard tool found on Linux. "
|
|
133
|
+
"Install 'xclip' (X11) or 'wl-clipboard' (Wayland) for clipboard image support."
|
|
134
|
+
)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
if tool == "wl-paste":
|
|
139
|
+
# wl-paste for Wayland
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["wl-paste", "--type", "image/png"],
|
|
142
|
+
capture_output=True,
|
|
143
|
+
timeout=10,
|
|
144
|
+
)
|
|
145
|
+
if result.returncode == 0 and result.stdout:
|
|
146
|
+
return result.stdout
|
|
147
|
+
elif tool == "xclip":
|
|
148
|
+
# xclip for X11
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
timeout=10,
|
|
153
|
+
)
|
|
154
|
+
if result.returncode == 0 and result.stdout:
|
|
155
|
+
return result.stdout
|
|
156
|
+
except subprocess.TimeoutExpired:
|
|
157
|
+
logger.warning(f"Timeout reading clipboard with {tool}")
|
|
158
|
+
except Exception as e:
|
|
159
|
+
logger.warning(f"Error reading clipboard with {tool}: {e}")
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resize_image_if_needed(image: "Image.Image", max_bytes: int) -> "Image.Image":
|
|
165
|
+
"""Resize image if it exceeds max size when saved as PNG.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
image: PIL Image to potentially resize.
|
|
169
|
+
max_bytes: Maximum allowed size in bytes.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Original or resized image.
|
|
173
|
+
"""
|
|
174
|
+
if Image is None:
|
|
175
|
+
return image
|
|
176
|
+
|
|
177
|
+
# Check current size
|
|
178
|
+
buffer = io.BytesIO()
|
|
179
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
180
|
+
current_size = buffer.tell()
|
|
181
|
+
|
|
182
|
+
if current_size <= max_bytes:
|
|
183
|
+
return image
|
|
184
|
+
|
|
185
|
+
logger.info(
|
|
186
|
+
f"Image size ({current_size / 1024 / 1024:.2f}MB) exceeds limit "
|
|
187
|
+
f"({max_bytes / 1024 / 1024:.2f}MB), resizing..."
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Calculate scale factor to reduce size
|
|
191
|
+
# Rough estimate: size scales with area (width * height)
|
|
192
|
+
scale_factor = (max_bytes / current_size) ** 0.5 * 0.9 # 0.9 for safety margin
|
|
193
|
+
|
|
194
|
+
new_width = int(image.width * scale_factor)
|
|
195
|
+
new_height = int(image.height * scale_factor)
|
|
196
|
+
|
|
197
|
+
# Ensure we don't go below minimum dimensions
|
|
198
|
+
new_width = max(new_width, 100)
|
|
199
|
+
new_height = max(new_height, 100)
|
|
200
|
+
|
|
201
|
+
# Also cap at max dimension
|
|
202
|
+
if new_width > MAX_IMAGE_DIMENSION:
|
|
203
|
+
ratio = MAX_IMAGE_DIMENSION / new_width
|
|
204
|
+
new_width = MAX_IMAGE_DIMENSION
|
|
205
|
+
new_height = int(new_height * ratio)
|
|
206
|
+
if new_height > MAX_IMAGE_DIMENSION:
|
|
207
|
+
ratio = MAX_IMAGE_DIMENSION / new_height
|
|
208
|
+
new_height = MAX_IMAGE_DIMENSION
|
|
209
|
+
new_width = int(new_width * ratio)
|
|
210
|
+
|
|
211
|
+
resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
212
|
+
logger.info(
|
|
213
|
+
f"Resized image from {image.width}x{image.height} to {new_width}x{new_height}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return resized
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def has_image_in_clipboard() -> bool:
|
|
220
|
+
"""Check if clipboard contains an image.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if clipboard contains an image, False otherwise.
|
|
224
|
+
"""
|
|
225
|
+
if sys.platform == "linux":
|
|
226
|
+
# For Linux, we need to actually try to get the image
|
|
227
|
+
# since there's no lightweight "check" method
|
|
228
|
+
tool = _check_linux_clipboard_tool()
|
|
229
|
+
if tool is None:
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
if tool == "wl-paste":
|
|
234
|
+
result = subprocess.run(
|
|
235
|
+
["wl-paste", "--list-types"],
|
|
236
|
+
capture_output=True,
|
|
237
|
+
timeout=5,
|
|
238
|
+
text=True,
|
|
239
|
+
)
|
|
240
|
+
return "image/png" in result.stdout or "image/" in result.stdout
|
|
241
|
+
elif tool == "xclip":
|
|
242
|
+
result = subprocess.run(
|
|
243
|
+
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
244
|
+
capture_output=True,
|
|
245
|
+
timeout=5,
|
|
246
|
+
text=True,
|
|
247
|
+
)
|
|
248
|
+
return "image/png" in result.stdout or "image/" in result.stdout
|
|
249
|
+
except (subprocess.TimeoutExpired, Exception):
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
# Windows/macOS - use PIL
|
|
255
|
+
if not PIL_AVAILABLE:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
image = ImageGrab.grabclipboard()
|
|
260
|
+
return image is not None and isinstance(image, Image.Image)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.debug(f"Error checking clipboard: {e}")
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_clipboard_image() -> Optional[bytes]:
|
|
267
|
+
"""Get clipboard image as PNG bytes.
|
|
268
|
+
|
|
269
|
+
Handles cross-platform clipboard access:
|
|
270
|
+
- Windows/macOS: Uses PIL.ImageGrab
|
|
271
|
+
- Linux: Uses xclip or wl-paste
|
|
272
|
+
|
|
273
|
+
Images larger than 10MB are automatically resized.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
PNG bytes if clipboard contains an image, None otherwise.
|
|
277
|
+
"""
|
|
278
|
+
image_bytes: Optional[bytes] = None
|
|
279
|
+
|
|
280
|
+
# Linux path - use command line tools
|
|
281
|
+
if sys.platform == "linux":
|
|
282
|
+
image_bytes = _get_linux_clipboard_image()
|
|
283
|
+
if image_bytes is None:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
# Check size and resize if needed
|
|
287
|
+
if len(image_bytes) > MAX_IMAGE_SIZE_BYTES:
|
|
288
|
+
if not PIL_AVAILABLE:
|
|
289
|
+
logger.warning(
|
|
290
|
+
f"Image size ({len(image_bytes) / 1024 / 1024:.2f}MB) exceeds limit, "
|
|
291
|
+
"but PIL not available for resizing."
|
|
292
|
+
)
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
# Use safe image opening with verification
|
|
297
|
+
image = _safe_open_image(image_bytes)
|
|
298
|
+
if image is None:
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Image verification failed for Linux clipboard image"
|
|
301
|
+
)
|
|
302
|
+
return None
|
|
303
|
+
image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
|
|
304
|
+
buffer = io.BytesIO()
|
|
305
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
306
|
+
image_bytes = buffer.getvalue()
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.warning(f"Error resizing Linux clipboard image: {e}")
|
|
309
|
+
return None
|
|
310
|
+
else:
|
|
311
|
+
# Verify even small images for safety
|
|
312
|
+
if PIL_AVAILABLE:
|
|
313
|
+
image = _safe_open_image(image_bytes)
|
|
314
|
+
if image is None:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Image verification failed for Linux clipboard image"
|
|
317
|
+
)
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
return image_bytes
|
|
321
|
+
|
|
322
|
+
# Windows/macOS path - use PIL
|
|
323
|
+
if not PIL_AVAILABLE:
|
|
324
|
+
logger.warning("PIL/Pillow not available. Install with: pip install Pillow")
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
image = ImageGrab.grabclipboard()
|
|
329
|
+
|
|
330
|
+
if image is None:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
if not isinstance(image, Image.Image):
|
|
334
|
+
# Could be a list of file paths on some systems
|
|
335
|
+
logger.debug(f"Clipboard contains non-image data: {type(image)}")
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# Log original dimensions
|
|
339
|
+
logger.info(f"Captured clipboard image: {image.width}x{image.height}")
|
|
340
|
+
|
|
341
|
+
# Convert to RGB if necessary (handles RGBA, P mode, etc.)
|
|
342
|
+
if image.mode in ("RGBA", "LA") or (
|
|
343
|
+
image.mode == "P" and "transparency" in image.info
|
|
344
|
+
):
|
|
345
|
+
# Keep alpha channel for PNG
|
|
346
|
+
pass
|
|
347
|
+
elif image.mode != "RGB":
|
|
348
|
+
image = image.convert("RGB")
|
|
349
|
+
|
|
350
|
+
# Resize if needed
|
|
351
|
+
image = _resize_image_if_needed(image, MAX_IMAGE_SIZE_BYTES)
|
|
352
|
+
|
|
353
|
+
# Convert to PNG bytes
|
|
354
|
+
buffer = io.BytesIO()
|
|
355
|
+
image.save(buffer, format="PNG", optimize=True)
|
|
356
|
+
image_bytes = buffer.getvalue()
|
|
357
|
+
|
|
358
|
+
logger.info(f"Clipboard image size: {len(image_bytes) / 1024:.1f}KB")
|
|
359
|
+
return image_bytes
|
|
360
|
+
|
|
361
|
+
except Exception as e:
|
|
362
|
+
logger.warning(f"Error reading clipboard image: {e}")
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_clipboard_image_as_binary_content() -> Optional["BinaryContent"]:
|
|
367
|
+
"""Get clipboard image as pydantic-ai BinaryContent.
|
|
368
|
+
|
|
369
|
+
This is the preferred method for integrating clipboard images
|
|
370
|
+
with pydantic-ai agents.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
BinaryContent with PNG image if available, None otherwise.
|
|
374
|
+
"""
|
|
375
|
+
if not BINARY_CONTENT_AVAILABLE:
|
|
376
|
+
logger.warning("pydantic-ai BinaryContent not available")
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
image_bytes = get_clipboard_image()
|
|
380
|
+
if image_bytes is None:
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
return BinaryContent(data=image_bytes, media_type="image/png")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class ClipboardAttachmentManager:
|
|
387
|
+
"""Thread-safe manager for pending clipboard image attachments.
|
|
388
|
+
|
|
389
|
+
This class manages clipboard images that have been captured but not yet
|
|
390
|
+
sent to the AI model. It provides a simple interface for adding images,
|
|
391
|
+
retrieving them as BinaryContent, and clearing the queue.
|
|
392
|
+
|
|
393
|
+
Usage:
|
|
394
|
+
manager = get_clipboard_manager()
|
|
395
|
+
placeholder = manager.add_image(image_bytes)
|
|
396
|
+
# Later, when sending to AI:
|
|
397
|
+
images = manager.get_pending_images()
|
|
398
|
+
manager.clear_pending()
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self) -> None:
|
|
402
|
+
"""Initialize the clipboard attachment manager."""
|
|
403
|
+
self._pending_images: list[bytes] = []
|
|
404
|
+
self._lock = threading.Lock()
|
|
405
|
+
self._counter = 0
|
|
406
|
+
|
|
407
|
+
def add_image(self, image_bytes: bytes) -> str:
|
|
408
|
+
"""Add image bytes to pending attachments.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
image_bytes: PNG image bytes to add.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Placeholder ID string like '[📋 clipboard image 1]'
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ValueError: If MAX_PENDING_IMAGES limit is reached.
|
|
418
|
+
"""
|
|
419
|
+
with self._lock:
|
|
420
|
+
# SEC-CLIP-001: Check limit BEFORE adding to prevent memory exhaustion
|
|
421
|
+
if len(self._pending_images) >= MAX_PENDING_IMAGES:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f"Maximum of {MAX_PENDING_IMAGES} pending images reached. "
|
|
424
|
+
"Send your message to clear the queue, or use /paste clear."
|
|
425
|
+
)
|
|
426
|
+
self._counter += 1
|
|
427
|
+
self._pending_images.append(image_bytes)
|
|
428
|
+
placeholder = f"[📋 clipboard image {self._counter}]"
|
|
429
|
+
logger.debug(
|
|
430
|
+
f"Added clipboard image {self._counter} "
|
|
431
|
+
f"({len(image_bytes) / 1024:.1f}KB)"
|
|
432
|
+
)
|
|
433
|
+
return placeholder
|
|
434
|
+
|
|
435
|
+
def get_pending_images(self) -> list["BinaryContent"]:
|
|
436
|
+
"""Get all pending images as BinaryContent list.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
List of BinaryContent objects for each pending image.
|
|
440
|
+
Returns empty list if BinaryContent not available.
|
|
441
|
+
"""
|
|
442
|
+
if not BINARY_CONTENT_AVAILABLE:
|
|
443
|
+
logger.warning("BinaryContent not available, returning empty list")
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
with self._lock:
|
|
447
|
+
return [
|
|
448
|
+
BinaryContent(data=img_bytes, media_type="image/png")
|
|
449
|
+
for img_bytes in self._pending_images
|
|
450
|
+
]
|
|
451
|
+
|
|
452
|
+
def clear_pending(self) -> None:
|
|
453
|
+
"""Clear all pending images."""
|
|
454
|
+
with self._lock:
|
|
455
|
+
count = len(self._pending_images)
|
|
456
|
+
self._pending_images.clear()
|
|
457
|
+
if count > 0:
|
|
458
|
+
logger.debug(f"Cleared {count} pending clipboard image(s)")
|
|
459
|
+
|
|
460
|
+
def get_pending_count(self) -> int:
|
|
461
|
+
"""Get count of pending images.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Number of images currently pending.
|
|
465
|
+
"""
|
|
466
|
+
with self._lock:
|
|
467
|
+
return len(self._pending_images)
|
|
468
|
+
|
|
469
|
+
def has_pending(self) -> bool:
|
|
470
|
+
"""Check if there are any pending images.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
True if there are pending images, False otherwise.
|
|
474
|
+
"""
|
|
475
|
+
with self._lock:
|
|
476
|
+
return len(self._pending_images) > 0
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# Global singleton instance
|
|
480
|
+
_clipboard_manager: Optional[ClipboardAttachmentManager] = None
|
|
481
|
+
_manager_lock = threading.Lock()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def get_clipboard_manager() -> ClipboardAttachmentManager:
|
|
485
|
+
"""Get or create the global clipboard manager singleton.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
The global ClipboardAttachmentManager instance.
|
|
489
|
+
"""
|
|
490
|
+
global _clipboard_manager
|
|
491
|
+
|
|
492
|
+
if _clipboard_manager is None:
|
|
493
|
+
with _manager_lock:
|
|
494
|
+
# Double-check locking pattern
|
|
495
|
+
if _clipboard_manager is None:
|
|
496
|
+
_clipboard_manager = ClipboardAttachmentManager()
|
|
497
|
+
|
|
498
|
+
return _clipboard_manager
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def capture_clipboard_image_to_pending() -> Optional[str]:
|
|
502
|
+
"""Convenience function to capture clipboard image and add to pending.
|
|
503
|
+
|
|
504
|
+
This combines get_clipboard_image() and add_image() into a single call.
|
|
505
|
+
Includes rate limiting to prevent rapid captures (SEC-CLIP-004).
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Placeholder string if image captured, None if no image or rate limited.
|
|
509
|
+
"""
|
|
510
|
+
global _last_clipboard_capture
|
|
511
|
+
|
|
512
|
+
# SEC-CLIP-004: Rate limiting to prevent rapid captures
|
|
513
|
+
now = time.monotonic()
|
|
514
|
+
if now - _last_clipboard_capture < CLIPBOARD_RATE_LIMIT_SECONDS:
|
|
515
|
+
logger.debug("Clipboard capture rate limited")
|
|
516
|
+
return None # Rate limited, silently ignore
|
|
517
|
+
|
|
518
|
+
image_bytes = get_clipboard_image()
|
|
519
|
+
if image_bytes is None:
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
manager = get_clipboard_manager()
|
|
523
|
+
placeholder = manager.add_image(image_bytes)
|
|
524
|
+
|
|
525
|
+
# Update timestamp on successful capture
|
|
526
|
+
_last_clipboard_capture = now
|
|
527
|
+
return placeholder
|