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.
Files changed (60) hide show
  1. emdash_core/agent/agents.py +9 -0
  2. emdash_core/agent/background.py +481 -0
  3. emdash_core/agent/inprocess_subagent.py +70 -1
  4. emdash_core/agent/mcp/config.py +78 -2
  5. emdash_core/agent/prompts/main_agent.py +53 -1
  6. emdash_core/agent/prompts/plan_mode.py +65 -44
  7. emdash_core/agent/prompts/subagents.py +73 -1
  8. emdash_core/agent/prompts/workflow.py +179 -28
  9. emdash_core/agent/providers/models.py +1 -1
  10. emdash_core/agent/providers/openai_provider.py +10 -0
  11. emdash_core/agent/research/researcher.py +154 -45
  12. emdash_core/agent/runner/agent_runner.py +145 -19
  13. emdash_core/agent/runner/sdk_runner.py +29 -2
  14. emdash_core/agent/skills.py +81 -1
  15. emdash_core/agent/toolkit.py +87 -11
  16. emdash_core/agent/tools/__init__.py +2 -0
  17. emdash_core/agent/tools/coding.py +344 -52
  18. emdash_core/agent/tools/lsp.py +361 -0
  19. emdash_core/agent/tools/skill.py +21 -1
  20. emdash_core/agent/tools/task.py +16 -19
  21. emdash_core/agent/tools/task_output.py +262 -32
  22. emdash_core/agent/verifier/__init__.py +11 -0
  23. emdash_core/agent/verifier/manager.py +295 -0
  24. emdash_core/agent/verifier/models.py +97 -0
  25. emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
  26. emdash_core/api/agent.py +297 -2
  27. emdash_core/api/research.py +3 -3
  28. emdash_core/api/router.py +0 -4
  29. emdash_core/context/longevity.py +197 -0
  30. emdash_core/context/providers/explored_areas.py +83 -39
  31. emdash_core/context/reranker.py +35 -144
  32. emdash_core/context/simple_reranker.py +500 -0
  33. emdash_core/context/tool_relevance.py +84 -0
  34. emdash_core/core/config.py +8 -0
  35. emdash_core/graph/__init__.py +8 -1
  36. emdash_core/graph/connection.py +24 -3
  37. emdash_core/graph/writer.py +7 -1
  38. emdash_core/models/agent.py +10 -0
  39. emdash_core/server.py +1 -6
  40. emdash_core/sse/stream.py +16 -1
  41. emdash_core/utils/__init__.py +0 -2
  42. emdash_core/utils/git.py +103 -0
  43. emdash_core/utils/image.py +147 -160
  44. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/METADATA +6 -6
  45. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/RECORD +47 -52
  46. emdash_core/api/swarm.py +0 -223
  47. emdash_core/db/__init__.py +0 -67
  48. emdash_core/db/auth.py +0 -134
  49. emdash_core/db/models.py +0 -91
  50. emdash_core/db/provider.py +0 -222
  51. emdash_core/db/providers/__init__.py +0 -5
  52. emdash_core/db/providers/supabase.py +0 -452
  53. emdash_core/swarm/__init__.py +0 -17
  54. emdash_core/swarm/merge_agent.py +0 -383
  55. emdash_core/swarm/session_manager.py +0 -274
  56. emdash_core/swarm/swarm_runner.py +0 -226
  57. emdash_core/swarm/task_definition.py +0 -137
  58. emdash_core/swarm/worker_spawner.py +0 -319
  59. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
  60. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -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
- # Write port file for CLI/Electron discovery
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=EventType(event.type.value),
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,
@@ -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
@@ -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
- - Resize large images for LLM processing
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 sys
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 _import_pillow():
47
- """Try to import PIL, return None if not available."""
43
+ def _import_png():
44
+ """Try to import pypng, return None if not available."""
48
45
  try:
49
- from PIL import Image
50
- return Image
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
- import objc
70
- return AppKit, objc
66
+ return AppKit
71
67
  except ImportError:
72
- return None, 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, objc = _import_mac_clipboard()
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
- return bool(pasteboard.dataForType_("public.png"))
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
- # Parse DIB header
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
- if header_size == 40: # BITMAPINFOHEADER
202
- width = struct.unpack('<I', dib_data[4:8])[0]
203
- height = struct.unpack('<I', dib_data[8:12])[0]
204
- planes = struct.unpack('<H', dib_data[12:14])[0]
205
- bit_count = struct.unpack('<H', dib_data[14:16])[0]
206
- else:
207
- # Use PIL to handle it
208
- with io.BytesIO(dib_data) as bio:
209
- img = Image.open(bio)
210
- return _image_to_png_bytes(img)
211
-
212
- with io.BytesIO(dib_data) as bio:
213
- img = Image.open(bio)
214
- return _image_to_png_bytes(img)
215
-
216
-
217
- def _bitmap_to_png(bitmap: int) -> bytes:
218
- """Convert Windows bitmap handle to PNG bytes."""
219
- Image = _import_pillow()
220
- if Image is None:
221
- raise ClipboardImageError("PIL/Pillow is required for image processing")
222
-
223
- raise ClipboardImageError("Bitmap handle conversion not implemented")
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
- AppKit, objc = _import_mac_clipboard()
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 other image types
244
- for img_type in ["public.jpeg", "public.tiff", "com.compuserve.gif"]:
245
- data = pasteboard.dataForType_(img_type)
246
- if data:
247
- img_data = bytes(data)
248
- return _convert_to_png(img_data)
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, JPEG, GIF).
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 an image.
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
- Image = _import_pillow()
426
- if Image is None:
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": "PIL/Pillow not available"
401
+ "error": "pypng not available"
433
402
  }
434
403
 
435
- with io.BytesIO(image_data) as bio:
436
- img = Image.open(bio)
404
+ try:
405
+ reader = png.Reader(bytes=image_data)
406
+ width, height, rows, metadata = reader.read()
437
407
  return {
438
- "width": img.width,
439
- "height": img.height,
408
+ "width": width,
409
+ "height": height,
440
410
  "size_bytes": len(image_data),
441
- "format": img.format or "unknown"
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, and resizing into one call.
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
- Prepared image bytes, or None if no image available.
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
- return resize_image_if_needed(image_data, max_size)
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 as e:
486
+ except (ClipboardImageError, ImageProcessingError):
500
487
  if raise_errors:
501
488
  raise
502
489
  return None