emdash-core 0.1.7__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 (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,84 @@
1
+ """Git utilities for repository detection and URL handling."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def get_git_remote_url(repo_root: Path) -> Optional[str]:
9
+ """Get the origin remote URL from git.
10
+
11
+ Args:
12
+ repo_root: Path to the git repository root
13
+
14
+ Returns:
15
+ The remote URL or None if not found
16
+ """
17
+ try:
18
+ result = subprocess.run(
19
+ ["git", "remote", "get-url", "origin"],
20
+ cwd=repo_root,
21
+ capture_output=True,
22
+ text=True,
23
+ timeout=5,
24
+ )
25
+ if result.returncode == 0:
26
+ return result.stdout.strip()
27
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
28
+ pass
29
+ return None
30
+
31
+
32
+ def normalize_repo_url(url: str) -> str:
33
+ """Normalize git URL to https format for matching.
34
+
35
+ Handles various git URL formats:
36
+ - git@github.com:user/repo.git -> https://github.com/user/repo
37
+ - https://github.com/user/repo.git -> https://github.com/user/repo
38
+ - ssh://git@github.com/user/repo.git -> https://github.com/user/repo
39
+
40
+ Args:
41
+ url: Git remote URL in any format
42
+
43
+ Returns:
44
+ Normalized https URL without .git suffix
45
+ """
46
+ url = url.strip()
47
+
48
+ # Remove .git suffix
49
+ if url.endswith(".git"):
50
+ url = url[:-4]
51
+
52
+ # Handle SSH format: git@github.com:user/repo
53
+ if url.startswith("git@"):
54
+ # git@github.com:user/repo -> https://github.com/user/repo
55
+ url = url.replace("git@", "https://", 1)
56
+ url = url.replace(":", "/", 1)
57
+
58
+ # Handle ssh:// format: ssh://git@github.com/user/repo
59
+ elif url.startswith("ssh://"):
60
+ url = url.replace("ssh://git@", "https://", 1)
61
+ url = url.replace("ssh://", "https://", 1)
62
+
63
+ # Ensure https prefix
64
+ if not url.startswith("http://") and not url.startswith("https://"):
65
+ url = "https://" + url
66
+
67
+ return url
68
+
69
+
70
+ def get_normalized_remote_url(repo_root: Path) -> Optional[str]:
71
+ """Get the normalized origin remote URL.
72
+
73
+ Combines get_git_remote_url and normalize_repo_url.
74
+
75
+ Args:
76
+ repo_root: Path to the git repository root
77
+
78
+ Returns:
79
+ Normalized https URL or None if not found
80
+ """
81
+ remote_url = get_git_remote_url(repo_root)
82
+ if remote_url:
83
+ return normalize_repo_url(remote_url)
84
+ return None
@@ -0,0 +1,502 @@
1
+ """Image utilities for clipboard image handling and encoding.
2
+
3
+ Provides functions to:
4
+ - Read images from system clipboard
5
+ - Encode images to base64 data URLs
6
+ - Check clipboard image availability
7
+ - Resize large images for LLM processing
8
+ """
9
+
10
+ import base64
11
+ import io
12
+ import os
13
+ import platform
14
+ import sys
15
+ from enum import Enum
16
+ from typing import Optional
17
+
18
+
19
+ class ImageFormat(str, Enum):
20
+ """Supported image formats."""
21
+ PNG = "png"
22
+ JPEG = "jpeg"
23
+ GIF = "gif"
24
+
25
+
26
+ # Maximum image size for LLM processing (5MB)
27
+ MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024
28
+
29
+ # Default max dimensions for resized images
30
+ MAX_IMAGE_DIMENSION = 2048
31
+
32
+ # Tokens per image for context estimation
33
+ ESTIMATED_TOKENS_PER_IMAGE = 500
34
+
35
+
36
+ class ClipboardImageError(Exception):
37
+ """Error reading image from clipboard."""
38
+ pass
39
+
40
+
41
+ class ImageProcessingError(Exception):
42
+ """Error processing image data."""
43
+ pass
44
+
45
+
46
+ def _import_pillow():
47
+ """Try to import PIL, return None if not available."""
48
+ try:
49
+ from PIL import Image
50
+ return Image
51
+ except ImportError:
52
+ return None
53
+
54
+
55
+ def _import_windows_clipboard():
56
+ """Try to import Windows clipboard modules."""
57
+ try:
58
+ import win32clipboard
59
+ import win32con
60
+ return win32clipboard, win32con
61
+ except ImportError:
62
+ return None, None
63
+
64
+
65
+ def _import_mac_clipboard():
66
+ """Try to import macOS clipboard modules."""
67
+ try:
68
+ import AppKit
69
+ import objc
70
+ return AppKit, objc
71
+ except ImportError:
72
+ return None, None
73
+
74
+
75
+ def is_clipboard_image_available() -> bool:
76
+ """Check if the clipboard contains image data.
77
+
78
+ Returns:
79
+ True if clipboard has image data, False otherwise.
80
+ """
81
+ system = platform.system()
82
+
83
+ if system == "Windows":
84
+ return _check_windows_clipboard()
85
+ elif system == "Darwin": # macOS
86
+ return _check_macos_clipboard()
87
+ elif system == "Linux":
88
+ return _check_linux_clipboard()
89
+ else:
90
+ return False
91
+
92
+
93
+ def _check_windows_clipboard() -> bool:
94
+ """Check Windows clipboard for image data."""
95
+ win32clipboard, win32con = _import_windows_clipboard()
96
+ if win32clipboard is None:
97
+ return False
98
+
99
+ try:
100
+ win32clipboard.OpenClipboard(0)
101
+ try:
102
+ return win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB)
103
+ finally:
104
+ win32clipboard.CloseClipboard()
105
+ except Exception:
106
+ return False
107
+
108
+
109
+ def _check_macos_clipboard() -> bool:
110
+ """Check macOS clipboard for image data."""
111
+ AppKit, objc = _import_mac_clipboard()
112
+ if AppKit is None:
113
+ return False
114
+
115
+ try:
116
+ pasteboard = AppKit.NSPasteboard.generalPasteboard()
117
+ return bool(pasteboard.dataForType_("public.png"))
118
+ except Exception:
119
+ return False
120
+
121
+
122
+ def _check_linux_clipboard() -> bool:
123
+ """Check Linux clipboard for image data (via wl-paste or xclip)."""
124
+ # Try wl-paste (Wayland)
125
+ result = os.system("which wl-paste > /dev/null 2>&1") == 0
126
+ if result:
127
+ # Check if clipboard has image
128
+ return os.system("wl-paste -t image/png > /dev/null 2>&1") == 0
129
+
130
+ # Try xclip (X11)
131
+ result = os.system("which xclip > /dev/null 2>&1") == 0
132
+ if result:
133
+ return os.system("xclip -selection clipboard -t image/png -o > /dev/null 2>&1") == 0
134
+
135
+ return False
136
+
137
+
138
+ def read_clipboard_image() -> Optional[bytes]:
139
+ """Read an image from the system clipboard.
140
+
141
+ Returns:
142
+ Raw image bytes (PNG format), or None if no image available.
143
+
144
+ Raises:
145
+ ClipboardImageError: If clipboard access fails unexpectedly.
146
+ """
147
+ system = platform.system()
148
+
149
+ if system == "Windows":
150
+ return _read_windows_clipboard()
151
+ elif system == "Darwin": # macOS
152
+ return _read_macos_clipboard()
153
+ elif system == "Linux":
154
+ return _read_linux_clipboard()
155
+ else:
156
+ raise ClipboardImageError(
157
+ f"Unsupported platform: {system}. "
158
+ "Image paste is supported on Windows, macOS, and Linux (with wl-paste or xclip)."
159
+ )
160
+
161
+
162
+ def _read_windows_clipboard() -> Optional[bytes]:
163
+ """Read image from Windows clipboard."""
164
+ win32clipboard, win32con = _import_windows_clipboard()
165
+ if win32clipboard is None:
166
+ raise ClipboardImageError(
167
+ "pywin32 is required for clipboard access on Windows. "
168
+ "Install with: pip install pywin32"
169
+ )
170
+
171
+ try:
172
+ win32clipboard.OpenClipboard(0)
173
+ try:
174
+ if win32clipboard.IsClipboardFormatAvailable(win32con.CF_DIB):
175
+ data = win32clipboard.GetClipboardData(win32con.CF_DIB)
176
+ 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
+ return None
181
+ finally:
182
+ win32clipboard.CloseClipboard()
183
+ except Exception as e:
184
+ raise ClipboardImageError(f"Failed to read Windows clipboard: {e}")
185
+
186
+
187
+ 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
+
193
+ import struct
194
+
195
+ # Parse DIB header
196
+ if len(dib_data) < 40:
197
+ raise ClipboardImageError("Invalid DIB data")
198
+
199
+ 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")
224
+
225
+
226
+ def _read_macos_clipboard() -> Optional[bytes]:
227
+ """Read image from macOS clipboard."""
228
+ AppKit, objc = _import_mac_clipboard()
229
+ if AppKit is None:
230
+ raise ClipboardImageError(
231
+ "pyobjc is required for clipboard access on macOS. "
232
+ "Install with: pip install pyobjc"
233
+ )
234
+
235
+ try:
236
+ pasteboard = AppKit.NSPasteboard.generalPasteboard()
237
+
238
+ # Try PNG first
239
+ data = pasteboard.dataForType_("public.png")
240
+ if data:
241
+ return bytes(data)
242
+
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)
249
+
250
+ return None
251
+ except Exception as e:
252
+ raise ClipboardImageError(f"Failed to read macOS clipboard: {e}")
253
+
254
+
255
+ def _read_linux_clipboard() -> Optional[bytes]:
256
+ """Read image from Linux clipboard (wl-paste or xclip)."""
257
+ # Try wl-paste first (Wayland)
258
+ result = os.system("which wl-paste > /dev/null 2>&1") == 0
259
+ if result:
260
+ try:
261
+ import subprocess
262
+ proc = subprocess.run(
263
+ ["wl-paste", "-t", "image/png"],
264
+ capture_output=True,
265
+ timeout=5
266
+ )
267
+ if proc.returncode == 0 and proc.stdout:
268
+ return proc.stdout
269
+ except (subprocess.TimeoutExpired, FileNotFoundError):
270
+ pass
271
+
272
+ # Try xclip (X11)
273
+ result = os.system("which xclip > /dev/null 2>&1") == 0
274
+ if result:
275
+ try:
276
+ import subprocess
277
+ proc = subprocess.run(
278
+ ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
279
+ capture_output=True,
280
+ timeout=5
281
+ )
282
+ if proc.returncode == 0 and proc.stdout:
283
+ return proc.stdout
284
+ except (subprocess.TimeoutExpired, FileNotFoundError):
285
+ pass
286
+
287
+ raise ClipboardImageError(
288
+ "No clipboard image tools found. Install wl-paste (Wayland) or xclip (X11):\n"
289
+ " Wayland: sudo apt install wl-clipboard (Debian/Ubuntu)\n"
290
+ " X11: sudo apt install xclip (Debian/Ubuntu)"
291
+ )
292
+
293
+
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
+ def encode_image_to_base64(image_data: bytes, format: ImageFormat = ImageFormat.PNG) -> str:
314
+ """Encode image bytes to base64 data URL.
315
+
316
+ Args:
317
+ image_data: Raw image bytes.
318
+ format: Image format (PNG, JPEG, GIF).
319
+
320
+ Returns:
321
+ Base64 data URL string: data:image/{format};base64,{encoded_data}
322
+ """
323
+ encoded = base64.b64encode(image_data).decode("utf-8")
324
+ mime_type = f"image/{format.value}"
325
+ return f"data:{mime_type};base64,{encoded}"
326
+
327
+
328
+ def encode_image_for_llm(image_data: bytes, format: ImageFormat = ImageFormat.PNG) -> dict:
329
+ """Encode image for LLM vision API (OpenAI/Anthropic format).
330
+
331
+ Args:
332
+ image_data: Raw image bytes.
333
+ format: Image format.
334
+
335
+ Returns:
336
+ Dict with base64 image data and media type for LLM APIs.
337
+ """
338
+ encoded = base64.b64encode(image_data).decode("utf-8")
339
+ return {
340
+ "type": "image_url",
341
+ "image_url": {
342
+ "url": f"data:image/{format.value};base64,{encoded}"
343
+ }
344
+ }
345
+
346
+
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
+ def get_image_info(image_data: bytes) -> dict:
417
+ """Get information about an image.
418
+
419
+ Args:
420
+ image_data: Raw image bytes.
421
+
422
+ Returns:
423
+ Dict with image info: width, height, size, format.
424
+ """
425
+ Image = _import_pillow()
426
+ if Image is None:
427
+ return {
428
+ "width": None,
429
+ "height": None,
430
+ "size_bytes": len(image_data),
431
+ "format": "unknown",
432
+ "error": "PIL/Pillow not available"
433
+ }
434
+
435
+ with io.BytesIO(image_data) as bio:
436
+ img = Image.open(bio)
437
+ return {
438
+ "width": img.width,
439
+ "height": img.height,
440
+ "size_bytes": len(image_data),
441
+ "format": img.format or "unknown"
442
+ }
443
+
444
+
445
+ def estimate_image_tokens(image_data: bytes) -> int:
446
+ """Estimate token count for an image.
447
+
448
+ This is a rough estimate based on image size and dimensions.
449
+ Actual token count varies by model.
450
+
451
+ Args:
452
+ image_data: Raw image bytes.
453
+
454
+ Returns:
455
+ Estimated token count.
456
+ """
457
+ info = get_image_info(image_data)
458
+
459
+ # Base token estimate
460
+ tokens = ESTIMATED_TOKENS_PER_IMAGE
461
+
462
+ # Adjust based on size (larger images have more detail)
463
+ size_factor = len(image_data) / (1024 * 1024) # MB
464
+ tokens += int(tokens * size_factor * 0.5)
465
+
466
+ # Adjust based on dimensions
467
+ if info["width"] and info["height"]:
468
+ dimension_factor = (info["width"] * info["height"]) / (1024 * 1024) # megapixels
469
+ tokens += int(tokens * dimension_factor * 0.3)
470
+
471
+ return tokens
472
+
473
+
474
+ def read_and_prepare_image(
475
+ max_size: int = MAX_IMAGE_SIZE_BYTES,
476
+ raise_errors: bool = True
477
+ ) -> Optional[bytes]:
478
+ """Read image from clipboard and prepare for LLM.
479
+
480
+ Combines checking, reading, and resizing into one call.
481
+
482
+ Args:
483
+ max_size: Maximum image size in bytes.
484
+ raise_errors: If True, raises errors on failure. If False, returns None.
485
+
486
+ Returns:
487
+ Prepared image bytes, or None if no image available.
488
+ """
489
+ try:
490
+ if not is_clipboard_image_available():
491
+ return None
492
+
493
+ image_data = read_clipboard_image()
494
+ if image_data is None:
495
+ return None
496
+
497
+ return resize_image_if_needed(image_data, max_size)
498
+
499
+ except ClipboardImageError as e:
500
+ if raise_errors:
501
+ raise
502
+ return None
@@ -0,0 +1,51 @@
1
+ """Logging configuration for EmDash.
2
+
3
+ Production mode (default): Minimal logs, only warnings and errors.
4
+ Debug mode (LOG_LEVEL=DEBUG): Full verbose logging with timestamps.
5
+ """
6
+
7
+ import os
8
+ import sys
9
+ from loguru import logger
10
+
11
+
12
+ def _is_debug_mode() -> bool:
13
+ """Check if debug logging is enabled."""
14
+ level = os.environ.get("LOG_LEVEL", "WARNING").upper()
15
+ return level in ("DEBUG", "TRACE")
16
+
17
+
18
+ def setup_logger():
19
+ """Configure logger with appropriate level and format.
20
+
21
+ In production mode (default), logs are minimal - only WARNING and above.
22
+ In debug mode (LOG_LEVEL=DEBUG), full verbose logs are shown.
23
+ """
24
+ # Remove default handler
25
+ logger.remove()
26
+
27
+ # Get log level from environment
28
+ log_level = os.environ.get("LOG_LEVEL", "WARNING").upper()
29
+
30
+ if _is_debug_mode():
31
+ # Debug mode: full verbose format
32
+ logger.add(
33
+ sys.stderr,
34
+ level=log_level,
35
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
36
+ colorize=True,
37
+ )
38
+ else:
39
+ # Production mode: minimal format, only warnings and errors
40
+ logger.add(
41
+ sys.stderr,
42
+ level=log_level,
43
+ format="<level>{level: <8}</level> | <level>{message}</level>",
44
+ colorize=True,
45
+ )
46
+
47
+ return logger
48
+
49
+
50
+ # Create a module-level logger instance
51
+ log = setup_logger()
@@ -0,0 +1,35 @@
1
+ Metadata-Version: 2.4
2
+ Name: emdash-core
3
+ Version: 0.1.7
4
+ Summary: EmDash Core - FastAPI server for code intelligence
5
+ Author: Em Dash Team
6
+ Requires-Python: >=3.10,<4.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: astroid (>=3.0.1,<4.0.0)
14
+ Requires-Dist: beautifulsoup4 (>=4.12.0)
15
+ Requires-Dist: duckduckgo-search (>=6.0.0)
16
+ Requires-Dist: fastapi (>=0.109.0)
17
+ Requires-Dist: gitpython (>=3.1.40,<4.0.0)
18
+ Requires-Dist: httpx (>=0.25.0)
19
+ Requires-Dist: kuzu (>=0.4.0)
20
+ Requires-Dist: loguru (>=0.7.2,<0.8.0)
21
+ Requires-Dist: networkx (>=3.2.1,<4.0.0)
22
+ Requires-Dist: numpy (>=1.26.0)
23
+ Requires-Dist: openai (>=1.0.0)
24
+ Requires-Dist: pillow (>=10.0.0,<11.0.0)
25
+ Requires-Dist: pydantic (>=2.5.0,<3.0.0)
26
+ Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
27
+ Requires-Dist: pygithub (>=2.1.1,<3.0.0)
28
+ Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
29
+ Requires-Dist: python-louvain (>=0.16,<0.17)
30
+ Requires-Dist: scipy (>=1.11.4,<2.0.0)
31
+ Requires-Dist: sentence-transformers (>=2.2.0)
32
+ Requires-Dist: sse-starlette (>=2.0.0)
33
+ Requires-Dist: supabase (>=2.0.0)
34
+ Requires-Dist: tqdm (>=4.66.1,<5.0.0)
35
+ Requires-Dist: uvicorn[standard] (>=0.27.0)