emdash-core 0.1.37__py3-none-any.whl → 0.1.60__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.
- emdash_core/agent/agents.py +9 -0
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/inprocess_subagent.py +70 -1
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +53 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +73 -1
- emdash_core/agent/prompts/workflow.py +179 -28
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +145 -19
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- emdash_core/agent/tools/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +16 -19
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +297 -2
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/models/agent.py +10 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/METADATA +6 -6
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/RECORD +47 -52
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
emdash_core/models/agent.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""Pydantic models for agent API."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from enum import Enum
|
|
4
5
|
from typing import Optional
|
|
5
6
|
|
|
6
7
|
from pydantic import BaseModel, Field
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
def _default_use_worktree() -> bool:
|
|
11
|
+
"""Get default worktree setting from environment."""
|
|
12
|
+
return os.environ.get("EMDASH_USE_WORKTREE", "false").lower() in ("true", "1", "yes")
|
|
13
|
+
|
|
14
|
+
|
|
9
15
|
class AgentMode(str, Enum):
|
|
10
16
|
"""Agent operation modes."""
|
|
11
17
|
|
|
@@ -33,6 +39,10 @@ class AgentChatOptions(BaseModel):
|
|
|
33
39
|
default=0.6,
|
|
34
40
|
description="Context window threshold for summarization (0-1)"
|
|
35
41
|
)
|
|
42
|
+
use_worktree: bool = Field(
|
|
43
|
+
default_factory=_default_use_worktree,
|
|
44
|
+
description="Use git worktree for isolated changes (EMDASH_USE_WORKTREE env)"
|
|
45
|
+
)
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
class AgentChatRequest(BaseModel):
|
emdash_core/server.py
CHANGED
|
@@ -89,18 +89,13 @@ async def lifespan(app: FastAPI):
|
|
|
89
89
|
if config.repo_root:
|
|
90
90
|
print(f"Repository root: {config.repo_root}")
|
|
91
91
|
|
|
92
|
-
#
|
|
93
|
-
port_file = Path.home() / ".emdash" / "server.port"
|
|
94
|
-
port_file.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
-
port_file.write_text(str(config.port))
|
|
92
|
+
# Note: Port file management is handled by ServerManager (per-repo files)
|
|
96
93
|
|
|
97
94
|
yield
|
|
98
95
|
|
|
99
96
|
# Shutdown
|
|
100
97
|
print("EmDash Core shutting down...")
|
|
101
98
|
_shutdown_executors()
|
|
102
|
-
if port_file.exists():
|
|
103
|
-
port_file.unlink()
|
|
104
99
|
|
|
105
100
|
|
|
106
101
|
def create_app(
|
emdash_core/sse/stream.py
CHANGED
|
@@ -8,6 +8,8 @@ from typing import Any, AsyncIterator
|
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
11
|
+
from ..utils.logger import log
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
class EventType(str, Enum):
|
|
13
15
|
"""Types of events emitted by agents (matches emdash.agent.events.EventType)."""
|
|
@@ -27,10 +29,12 @@ class EventType(str, Enum):
|
|
|
27
29
|
# Output
|
|
28
30
|
RESPONSE = "response"
|
|
29
31
|
PARTIAL_RESPONSE = "partial_response"
|
|
32
|
+
ASSISTANT_TEXT = "assistant_text"
|
|
30
33
|
|
|
31
34
|
# Interaction
|
|
32
35
|
CLARIFICATION = "clarification"
|
|
33
36
|
CLARIFICATION_RESPONSE = "clarification_response"
|
|
37
|
+
PLAN_MODE_REQUESTED = "plan_mode_requested"
|
|
34
38
|
PLAN_SUBMITTED = "plan_submitted"
|
|
35
39
|
|
|
36
40
|
# Errors
|
|
@@ -100,9 +104,20 @@ class SSEHandler:
|
|
|
100
104
|
if self._closed:
|
|
101
105
|
return
|
|
102
106
|
|
|
107
|
+
# Convert event type, with error handling for unknown types
|
|
108
|
+
try:
|
|
109
|
+
event_type = EventType(event.type.value)
|
|
110
|
+
except ValueError:
|
|
111
|
+
log.warning(
|
|
112
|
+
f"Unknown SSE event type: {event.type.value} - event dropped. "
|
|
113
|
+
"This may indicate EventType enum in sse/stream.py is out of sync "
|
|
114
|
+
"with agent/events.py"
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
|
|
103
118
|
# Convert to SSEEvent
|
|
104
119
|
sse_event = SSEEvent(
|
|
105
|
-
type=
|
|
120
|
+
type=event_type,
|
|
106
121
|
data=event.data,
|
|
107
122
|
timestamp=event.timestamp,
|
|
108
123
|
agent_name=event.agent_name or self._agent_name,
|
emdash_core/utils/__init__.py
CHANGED
|
@@ -7,7 +7,6 @@ from .image import (
|
|
|
7
7
|
read_clipboard_image,
|
|
8
8
|
encode_image_to_base64,
|
|
9
9
|
encode_image_for_llm,
|
|
10
|
-
resize_image_if_needed,
|
|
11
10
|
get_image_info,
|
|
12
11
|
estimate_image_tokens,
|
|
13
12
|
read_and_prepare_image,
|
|
@@ -31,7 +30,6 @@ __all__ = [
|
|
|
31
30
|
"read_clipboard_image",
|
|
32
31
|
"encode_image_to_base64",
|
|
33
32
|
"encode_image_for_llm",
|
|
34
|
-
"resize_image_if_needed",
|
|
35
33
|
"get_image_info",
|
|
36
34
|
"estimate_image_tokens",
|
|
37
35
|
"read_and_prepare_image",
|
emdash_core/utils/git.py
CHANGED
|
@@ -82,3 +82,106 @@ def get_normalized_remote_url(repo_root: Path) -> Optional[str]:
|
|
|
82
82
|
if remote_url:
|
|
83
83
|
return normalize_repo_url(remote_url)
|
|
84
84
|
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_current_branch(repo_root: Path) -> Optional[str]:
|
|
88
|
+
"""Get the current git branch name.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
repo_root: Path to the git repository root
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The current branch name or None if not found/not in a git repo
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
99
|
+
cwd=repo_root,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=5,
|
|
103
|
+
)
|
|
104
|
+
if result.returncode == 0:
|
|
105
|
+
return result.stdout.strip()
|
|
106
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
107
|
+
pass
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_git_status_summary(repo_root: Path) -> Optional[str]:
|
|
112
|
+
"""Get a brief summary of git status.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
repo_root: Path to the git repository root
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Brief status summary (e.g., "3 modified, 2 untracked") or None
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
result = subprocess.run(
|
|
122
|
+
["git", "status", "--porcelain"],
|
|
123
|
+
cwd=repo_root,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
timeout=5,
|
|
127
|
+
)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
132
|
+
if not lines:
|
|
133
|
+
return "clean"
|
|
134
|
+
|
|
135
|
+
# Count by status type
|
|
136
|
+
modified = 0
|
|
137
|
+
untracked = 0
|
|
138
|
+
staged = 0
|
|
139
|
+
deleted = 0
|
|
140
|
+
|
|
141
|
+
for line in lines:
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
status = line[:2]
|
|
145
|
+
if status == "??":
|
|
146
|
+
untracked += 1
|
|
147
|
+
elif status[0] in ("M", "A", "D", "R", "C"):
|
|
148
|
+
staged += 1
|
|
149
|
+
elif status[1] == "M":
|
|
150
|
+
modified += 1
|
|
151
|
+
elif status[1] == "D":
|
|
152
|
+
deleted += 1
|
|
153
|
+
|
|
154
|
+
parts = []
|
|
155
|
+
if staged:
|
|
156
|
+
parts.append(f"{staged} staged")
|
|
157
|
+
if modified:
|
|
158
|
+
parts.append(f"{modified} modified")
|
|
159
|
+
if deleted:
|
|
160
|
+
parts.append(f"{deleted} deleted")
|
|
161
|
+
if untracked:
|
|
162
|
+
parts.append(f"{untracked} untracked")
|
|
163
|
+
|
|
164
|
+
return ", ".join(parts) if parts else "clean"
|
|
165
|
+
|
|
166
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
167
|
+
pass
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_repo_name(repo_root: Path) -> Optional[str]:
|
|
172
|
+
"""Get the repository name from the remote URL or directory name.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
repo_root: Path to the git repository root
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Repository name (e.g., "user/repo") or None
|
|
179
|
+
"""
|
|
180
|
+
remote_url = get_normalized_remote_url(repo_root)
|
|
181
|
+
if remote_url:
|
|
182
|
+
# Extract user/repo from https://github.com/user/repo
|
|
183
|
+
parts = remote_url.rstrip("/").split("/")
|
|
184
|
+
if len(parts) >= 2:
|
|
185
|
+
return f"{parts[-2]}/{parts[-1]}"
|
|
186
|
+
# Fallback to directory name
|
|
187
|
+
return repo_root.name
|
emdash_core/utils/image.py
CHANGED
|
@@ -4,14 +4,16 @@ Provides functions to:
|
|
|
4
4
|
- Read images from system clipboard
|
|
5
5
|
- Encode images to base64 data URLs
|
|
6
6
|
- Check clipboard image availability
|
|
7
|
-
-
|
|
7
|
+
- Get image dimensions
|
|
8
|
+
|
|
9
|
+
Uses pypng for pure-Python PNG handling (no compilation required).
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
import base64
|
|
11
13
|
import io
|
|
12
14
|
import os
|
|
13
15
|
import platform
|
|
14
|
-
import
|
|
16
|
+
import subprocess
|
|
15
17
|
from enum import Enum
|
|
16
18
|
from typing import Optional
|
|
17
19
|
|
|
@@ -19,16 +21,11 @@ from typing import Optional
|
|
|
19
21
|
class ImageFormat(str, Enum):
|
|
20
22
|
"""Supported image formats."""
|
|
21
23
|
PNG = "png"
|
|
22
|
-
JPEG = "jpeg"
|
|
23
|
-
GIF = "gif"
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
# Maximum image size for LLM processing (5MB)
|
|
27
27
|
MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024
|
|
28
28
|
|
|
29
|
-
# Default max dimensions for resized images
|
|
30
|
-
MAX_IMAGE_DIMENSION = 2048
|
|
31
|
-
|
|
32
29
|
# Tokens per image for context estimation
|
|
33
30
|
ESTIMATED_TOKENS_PER_IMAGE = 500
|
|
34
31
|
|
|
@@ -43,11 +40,11 @@ class ImageProcessingError(Exception):
|
|
|
43
40
|
pass
|
|
44
41
|
|
|
45
42
|
|
|
46
|
-
def
|
|
47
|
-
"""Try to import
|
|
43
|
+
def _import_png():
|
|
44
|
+
"""Try to import pypng, return None if not available."""
|
|
48
45
|
try:
|
|
49
|
-
|
|
50
|
-
return
|
|
46
|
+
import png
|
|
47
|
+
return png
|
|
51
48
|
except ImportError:
|
|
52
49
|
return None
|
|
53
50
|
|
|
@@ -66,10 +63,9 @@ def _import_mac_clipboard():
|
|
|
66
63
|
"""Try to import macOS clipboard modules."""
|
|
67
64
|
try:
|
|
68
65
|
import AppKit
|
|
69
|
-
|
|
70
|
-
return AppKit, objc
|
|
66
|
+
return AppKit
|
|
71
67
|
except ImportError:
|
|
72
|
-
return None
|
|
68
|
+
return None
|
|
73
69
|
|
|
74
70
|
|
|
75
71
|
def is_clipboard_image_available() -> bool:
|
|
@@ -108,13 +104,17 @@ def _check_windows_clipboard() -> bool:
|
|
|
108
104
|
|
|
109
105
|
def _check_macos_clipboard() -> bool:
|
|
110
106
|
"""Check macOS clipboard for image data."""
|
|
111
|
-
AppKit
|
|
107
|
+
AppKit = _import_mac_clipboard()
|
|
112
108
|
if AppKit is None:
|
|
113
109
|
return False
|
|
114
110
|
|
|
115
111
|
try:
|
|
116
112
|
pasteboard = AppKit.NSPasteboard.generalPasteboard()
|
|
117
|
-
|
|
113
|
+
# Check for PNG or TIFF (macOS screenshots use TIFF)
|
|
114
|
+
return bool(
|
|
115
|
+
pasteboard.dataForType_("public.png") or
|
|
116
|
+
pasteboard.dataForType_("public.tiff")
|
|
117
|
+
)
|
|
118
118
|
except Exception:
|
|
119
119
|
return False
|
|
120
120
|
|
|
@@ -171,12 +171,16 @@ def _read_windows_clipboard() -> Optional[bytes]:
|
|
|
171
171
|
try:
|
|
172
172
|
win32clipboard.OpenClipboard(0)
|
|
173
173
|
try:
|
|
174
|
+
# Try to get PNG format first
|
|
175
|
+
png_format = win32clipboard.RegisterClipboardFormat("PNG")
|
|
176
|
+
if win32clipboard.IsClipboardFormatAvailable(png_format):
|
|
177
|
+
data = win32clipboard.GetClipboardData(png_format)
|
|
178
|
+
return bytes(data)
|
|
179
|
+
|
|
180
|
+
# Fall back to DIB and convert
|
|
174
181
|
if win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB):
|
|
175
182
|
data = win32clipboard.GetClipboardData(win32con.CF_DIB)
|
|
176
183
|
return _dib_to_png(data)
|
|
177
|
-
elif win32clipboard.IsClipboardFormatAvailable(win32con.CF_BITMAP):
|
|
178
|
-
bitmap = win32clipboard.GetClipboardData(win32con.CF_BITMAP)
|
|
179
|
-
return _bitmap_to_png(bitmap)
|
|
180
184
|
return None
|
|
181
185
|
finally:
|
|
182
186
|
win32clipboard.CloseClipboard()
|
|
@@ -185,47 +189,77 @@ def _read_windows_clipboard() -> Optional[bytes]:
|
|
|
185
189
|
|
|
186
190
|
|
|
187
191
|
def _dib_to_png(dib_data: bytes) -> bytes:
|
|
188
|
-
"""Convert DIB data to PNG bytes.
|
|
189
|
-
Image = _import_pillow()
|
|
190
|
-
if Image is None:
|
|
191
|
-
raise ClipboardImageError("PIL/Pillow is required for image processing")
|
|
192
|
+
"""Convert DIB (Device Independent Bitmap) data to PNG bytes.
|
|
192
193
|
|
|
194
|
+
DIB format: BITMAPINFOHEADER followed by pixel data.
|
|
195
|
+
"""
|
|
193
196
|
import struct
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
png = _import_png()
|
|
199
|
+
if png is None:
|
|
200
|
+
raise ClipboardImageError("pypng is required for image processing")
|
|
201
|
+
|
|
196
202
|
if len(dib_data) < 40:
|
|
197
203
|
raise ClipboardImageError("Invalid DIB data")
|
|
198
204
|
|
|
205
|
+
# Parse BITMAPINFOHEADER (40 bytes)
|
|
199
206
|
header_size = struct.unpack('<I', dib_data[0:4])[0]
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
207
|
+
width = struct.unpack('<i', dib_data[4:8])[0]
|
|
208
|
+
height = struct.unpack('<i', dib_data[8:12])[0]
|
|
209
|
+
planes = struct.unpack('<H', dib_data[12:14])[0]
|
|
210
|
+
bit_count = struct.unpack('<H', dib_data[14:16])[0]
|
|
211
|
+
compression = struct.unpack('<I', dib_data[16:20])[0]
|
|
212
|
+
|
|
213
|
+
# Handle negative height (top-down DIB)
|
|
214
|
+
top_down = height < 0
|
|
215
|
+
height = abs(height)
|
|
216
|
+
|
|
217
|
+
if compression != 0: # BI_RGB = 0
|
|
218
|
+
raise ClipboardImageError(f"Unsupported DIB compression: {compression}")
|
|
219
|
+
|
|
220
|
+
if bit_count not in (24, 32):
|
|
221
|
+
raise ClipboardImageError(f"Unsupported DIB bit depth: {bit_count}")
|
|
222
|
+
|
|
223
|
+
# Calculate row stride (rows are padded to 4-byte boundaries)
|
|
224
|
+
bytes_per_pixel = bit_count // 8
|
|
225
|
+
row_size = ((width * bytes_per_pixel + 3) // 4) * 4
|
|
226
|
+
|
|
227
|
+
# Pixel data starts after header (and color table for indexed images)
|
|
228
|
+
pixel_offset = header_size
|
|
229
|
+
|
|
230
|
+
# Extract rows
|
|
231
|
+
rows = []
|
|
232
|
+
for y in range(height):
|
|
233
|
+
if top_down:
|
|
234
|
+
row_start = pixel_offset + y * row_size
|
|
235
|
+
else:
|
|
236
|
+
# Bottom-up DIB: first row in file is bottom of image
|
|
237
|
+
row_start = pixel_offset + (height - 1 - y) * row_size
|
|
238
|
+
|
|
239
|
+
row_data = dib_data[row_start:row_start + width * bytes_per_pixel]
|
|
240
|
+
|
|
241
|
+
# Convert BGR(A) to RGB(A)
|
|
242
|
+
row = []
|
|
243
|
+
for x in range(width):
|
|
244
|
+
offset = x * bytes_per_pixel
|
|
245
|
+
b = row_data[offset]
|
|
246
|
+
g = row_data[offset + 1]
|
|
247
|
+
r = row_data[offset + 2]
|
|
248
|
+
row.extend([r, g, b])
|
|
249
|
+
rows.append(row)
|
|
250
|
+
|
|
251
|
+
# Write PNG
|
|
252
|
+
output = io.BytesIO()
|
|
253
|
+
writer = png.Writer(width=width, height=height, greyscale=False, alpha=False)
|
|
254
|
+
writer.write(output, rows)
|
|
255
|
+
return output.getvalue()
|
|
224
256
|
|
|
225
257
|
|
|
226
258
|
def _read_macos_clipboard() -> Optional[bytes]:
|
|
227
259
|
"""Read image from macOS clipboard."""
|
|
228
|
-
|
|
260
|
+
import tempfile
|
|
261
|
+
|
|
262
|
+
AppKit = _import_mac_clipboard()
|
|
229
263
|
if AppKit is None:
|
|
230
264
|
raise ClipboardImageError(
|
|
231
265
|
"pyobjc is required for clipboard access on macOS. "
|
|
@@ -240,12 +274,37 @@ def _read_macos_clipboard() -> Optional[bytes]:
|
|
|
240
274
|
if data:
|
|
241
275
|
return bytes(data)
|
|
242
276
|
|
|
243
|
-
# Try
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
277
|
+
# Try TIFF and convert (macOS screenshots use TIFF internally)
|
|
278
|
+
data = pasteboard.dataForType_("public.tiff")
|
|
279
|
+
if data:
|
|
280
|
+
# Use sips command-line tool to convert TIFF to PNG (via temp files)
|
|
281
|
+
try:
|
|
282
|
+
with tempfile.NamedTemporaryFile(suffix='.tiff', delete=False) as tiff_file:
|
|
283
|
+
tiff_path = tiff_file.name
|
|
284
|
+
tiff_file.write(bytes(data))
|
|
285
|
+
|
|
286
|
+
png_path = tiff_path.replace('.tiff', '.png')
|
|
287
|
+
try:
|
|
288
|
+
proc = subprocess.run(
|
|
289
|
+
["sips", "-s", "format", "png", tiff_path, "--out", png_path],
|
|
290
|
+
capture_output=True,
|
|
291
|
+
timeout=5
|
|
292
|
+
)
|
|
293
|
+
if proc.returncode == 0:
|
|
294
|
+
with open(png_path, 'rb') as f:
|
|
295
|
+
return f.read()
|
|
296
|
+
finally:
|
|
297
|
+
# Clean up temp files
|
|
298
|
+
try:
|
|
299
|
+
os.unlink(tiff_path)
|
|
300
|
+
except OSError:
|
|
301
|
+
pass
|
|
302
|
+
try:
|
|
303
|
+
os.unlink(png_path)
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
307
|
+
pass
|
|
249
308
|
|
|
250
309
|
return None
|
|
251
310
|
except Exception as e:
|
|
@@ -258,7 +317,6 @@ def _read_linux_clipboard() -> Optional[bytes]:
|
|
|
258
317
|
result = os.system("which wl-paste > /dev/null 2>&1") == 0
|
|
259
318
|
if result:
|
|
260
319
|
try:
|
|
261
|
-
import subprocess
|
|
262
320
|
proc = subprocess.run(
|
|
263
321
|
["wl-paste", "-t", "image/png"],
|
|
264
322
|
capture_output=True,
|
|
@@ -273,7 +331,6 @@ def _read_linux_clipboard() -> Optional[bytes]:
|
|
|
273
331
|
result = os.system("which xclip > /dev/null 2>&1") == 0
|
|
274
332
|
if result:
|
|
275
333
|
try:
|
|
276
|
-
import subprocess
|
|
277
334
|
proc = subprocess.run(
|
|
278
335
|
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
279
336
|
capture_output=True,
|
|
@@ -291,31 +348,12 @@ def _read_linux_clipboard() -> Optional[bytes]:
|
|
|
291
348
|
)
|
|
292
349
|
|
|
293
350
|
|
|
294
|
-
def _convert_to_png(image_data: bytes) -> bytes:
|
|
295
|
-
"""Convert image data to PNG format."""
|
|
296
|
-
Image = _import_pillow()
|
|
297
|
-
if Image is None:
|
|
298
|
-
raise ClipboardImageError("PIL/Pillow is required for image processing")
|
|
299
|
-
|
|
300
|
-
with io.BytesIO(image_data) as bio:
|
|
301
|
-
img = Image.open(bio)
|
|
302
|
-
return _image_to_png_bytes(img)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def _image_to_png_bytes(img) -> bytes:
|
|
306
|
-
"""Convert PIL Image to PNG bytes."""
|
|
307
|
-
output = io.BytesIO()
|
|
308
|
-
img.convert("RGB") # Ensure RGB mode
|
|
309
|
-
img.save(output, format="PNG")
|
|
310
|
-
return output.getvalue()
|
|
311
|
-
|
|
312
|
-
|
|
313
351
|
def encode_image_to_base64(image_data: bytes, format: ImageFormat = ImageFormat.PNG) -> str:
|
|
314
352
|
"""Encode image bytes to base64 data URL.
|
|
315
353
|
|
|
316
354
|
Args:
|
|
317
355
|
image_data: Raw image bytes.
|
|
318
|
-
format: Image format (PNG
|
|
356
|
+
format: Image format (PNG only supported).
|
|
319
357
|
|
|
320
358
|
Returns:
|
|
321
359
|
Base64 data URL string: data:image/{format};base64,{encoded_data}
|
|
@@ -344,101 +382,41 @@ def encode_image_for_llm(image_data: bytes, format: ImageFormat = ImageFormat.PN
|
|
|
344
382
|
}
|
|
345
383
|
|
|
346
384
|
|
|
347
|
-
def resize_image_if_needed(
|
|
348
|
-
image_data: bytes,
|
|
349
|
-
max_size: int = MAX_IMAGE_SIZE_BYTES,
|
|
350
|
-
max_dimension: int = MAX_IMAGE_DIMENSION
|
|
351
|
-
) -> bytes:
|
|
352
|
-
"""Resize image if it exceeds size or dimension limits.
|
|
353
|
-
|
|
354
|
-
Args:
|
|
355
|
-
image_data: Raw image bytes.
|
|
356
|
-
max_size: Maximum image size in bytes.
|
|
357
|
-
max_dimension: Maximum width/height dimension.
|
|
358
|
-
|
|
359
|
-
Returns:
|
|
360
|
-
Resized image bytes (always PNG format).
|
|
361
|
-
"""
|
|
362
|
-
Image = _import_pillow()
|
|
363
|
-
if Image is None:
|
|
364
|
-
# Can't resize without Pillow, but if it's small enough, return as-is
|
|
365
|
-
if len(image_data) <= max_size:
|
|
366
|
-
return image_data
|
|
367
|
-
raise ImageProcessingError(
|
|
368
|
-
"PIL/Pillow is required to resize large images. "
|
|
369
|
-
"Install with: pip install pillow"
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
with io.BytesIO(image_data) as bio:
|
|
373
|
-
img = Image.open(bio)
|
|
374
|
-
|
|
375
|
-
# Check if resizing is needed
|
|
376
|
-
needs_resize = False
|
|
377
|
-
|
|
378
|
-
if len(image_data) > max_size:
|
|
379
|
-
needs_resize = True
|
|
380
|
-
|
|
381
|
-
width, height = img.size
|
|
382
|
-
if width > max_dimension or height > max_dimension:
|
|
383
|
-
needs_resize = True
|
|
384
|
-
|
|
385
|
-
if not needs_resize:
|
|
386
|
-
# Return original as PNG
|
|
387
|
-
return _image_to_png_bytes(img)
|
|
388
|
-
|
|
389
|
-
# Calculate new dimensions maintaining aspect ratio
|
|
390
|
-
if width > height:
|
|
391
|
-
new_width = min(width, max_dimension)
|
|
392
|
-
new_height = int(height * (new_width / width))
|
|
393
|
-
else:
|
|
394
|
-
new_height = min(height, max_dimension)
|
|
395
|
-
new_width = int(width * (new_height / height))
|
|
396
|
-
|
|
397
|
-
# Resize image
|
|
398
|
-
resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
399
|
-
|
|
400
|
-
# Optimize quality if still too large
|
|
401
|
-
output = io.BytesIO()
|
|
402
|
-
quality = 95
|
|
403
|
-
resized.save(output, format="PNG", quality=quality, optimize=True)
|
|
404
|
-
|
|
405
|
-
# If still too large, reduce quality
|
|
406
|
-
while len(output.getvalue()) > max_size and quality > 50:
|
|
407
|
-
quality -= 10
|
|
408
|
-
output = io.BytesIO()
|
|
409
|
-
resized.save(output, format="PNG", quality=quality, optimize=True)
|
|
410
|
-
if quality <= 50:
|
|
411
|
-
break
|
|
412
|
-
|
|
413
|
-
return output.getvalue()
|
|
414
|
-
|
|
415
|
-
|
|
416
385
|
def get_image_info(image_data: bytes) -> dict:
|
|
417
|
-
"""Get information about
|
|
386
|
+
"""Get information about a PNG image.
|
|
418
387
|
|
|
419
388
|
Args:
|
|
420
|
-
image_data: Raw image bytes.
|
|
389
|
+
image_data: Raw PNG image bytes.
|
|
421
390
|
|
|
422
391
|
Returns:
|
|
423
392
|
Dict with image info: width, height, size, format.
|
|
424
393
|
"""
|
|
425
|
-
|
|
426
|
-
if
|
|
394
|
+
png = _import_png()
|
|
395
|
+
if png is None:
|
|
427
396
|
return {
|
|
428
397
|
"width": None,
|
|
429
398
|
"height": None,
|
|
430
399
|
"size_bytes": len(image_data),
|
|
431
400
|
"format": "unknown",
|
|
432
|
-
"error": "
|
|
401
|
+
"error": "pypng not available"
|
|
433
402
|
}
|
|
434
403
|
|
|
435
|
-
|
|
436
|
-
|
|
404
|
+
try:
|
|
405
|
+
reader = png.Reader(bytes=image_data)
|
|
406
|
+
width, height, rows, metadata = reader.read()
|
|
437
407
|
return {
|
|
438
|
-
"width":
|
|
439
|
-
"height":
|
|
408
|
+
"width": width,
|
|
409
|
+
"height": height,
|
|
440
410
|
"size_bytes": len(image_data),
|
|
441
|
-
"format":
|
|
411
|
+
"format": "PNG"
|
|
412
|
+
}
|
|
413
|
+
except Exception as e:
|
|
414
|
+
return {
|
|
415
|
+
"width": None,
|
|
416
|
+
"height": None,
|
|
417
|
+
"size_bytes": len(image_data),
|
|
418
|
+
"format": "unknown",
|
|
419
|
+
"error": str(e)
|
|
442
420
|
}
|
|
443
421
|
|
|
444
422
|
|
|
@@ -477,14 +455,16 @@ def read_and_prepare_image(
|
|
|
477
455
|
) -> Optional[bytes]:
|
|
478
456
|
"""Read image from clipboard and prepare for LLM.
|
|
479
457
|
|
|
480
|
-
Combines checking, reading
|
|
458
|
+
Combines checking, reading into one call.
|
|
459
|
+
Note: Image resizing is not supported with pypng.
|
|
460
|
+
Large images will be rejected if they exceed max_size.
|
|
481
461
|
|
|
482
462
|
Args:
|
|
483
463
|
max_size: Maximum image size in bytes.
|
|
484
464
|
raise_errors: If True, raises errors on failure. If False, returns None.
|
|
485
465
|
|
|
486
466
|
Returns:
|
|
487
|
-
|
|
467
|
+
Image bytes, or None if no image available.
|
|
488
468
|
"""
|
|
489
469
|
try:
|
|
490
470
|
if not is_clipboard_image_available():
|
|
@@ -494,9 +474,16 @@ def read_and_prepare_image(
|
|
|
494
474
|
if image_data is None:
|
|
495
475
|
return None
|
|
496
476
|
|
|
497
|
-
|
|
477
|
+
# Check size limit (no resize capability with pypng)
|
|
478
|
+
if len(image_data) > max_size:
|
|
479
|
+
raise ImageProcessingError(
|
|
480
|
+
f"Image too large ({len(image_data)} bytes, max {max_size}). "
|
|
481
|
+
"Please use a smaller image."
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return image_data
|
|
498
485
|
|
|
499
|
-
except ClipboardImageError
|
|
486
|
+
except (ClipboardImageError, ImageProcessingError):
|
|
500
487
|
if raise_errors:
|
|
501
488
|
raise
|
|
502
489
|
return None
|