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.
- emdash_core/__init__.py +3 -0
- emdash_core/agent/__init__.py +37 -0
- emdash_core/agent/agents.py +225 -0
- emdash_core/agent/code_reviewer.py +476 -0
- emdash_core/agent/compaction.py +143 -0
- emdash_core/agent/context_manager.py +140 -0
- emdash_core/agent/events.py +338 -0
- emdash_core/agent/handlers.py +224 -0
- emdash_core/agent/inprocess_subagent.py +377 -0
- emdash_core/agent/mcp/__init__.py +50 -0
- emdash_core/agent/mcp/client.py +346 -0
- emdash_core/agent/mcp/config.py +302 -0
- emdash_core/agent/mcp/manager.py +496 -0
- emdash_core/agent/mcp/tool_factory.py +213 -0
- emdash_core/agent/prompts/__init__.py +38 -0
- emdash_core/agent/prompts/main_agent.py +104 -0
- emdash_core/agent/prompts/subagents.py +131 -0
- emdash_core/agent/prompts/workflow.py +136 -0
- emdash_core/agent/providers/__init__.py +34 -0
- emdash_core/agent/providers/base.py +143 -0
- emdash_core/agent/providers/factory.py +80 -0
- emdash_core/agent/providers/models.py +220 -0
- emdash_core/agent/providers/openai_provider.py +463 -0
- emdash_core/agent/providers/transformers_provider.py +217 -0
- emdash_core/agent/research/__init__.py +81 -0
- emdash_core/agent/research/agent.py +143 -0
- emdash_core/agent/research/controller.py +254 -0
- emdash_core/agent/research/critic.py +428 -0
- emdash_core/agent/research/macros.py +469 -0
- emdash_core/agent/research/planner.py +449 -0
- emdash_core/agent/research/researcher.py +436 -0
- emdash_core/agent/research/state.py +523 -0
- emdash_core/agent/research/synthesizer.py +594 -0
- emdash_core/agent/reviewer_profile.py +475 -0
- emdash_core/agent/rules.py +123 -0
- emdash_core/agent/runner.py +601 -0
- emdash_core/agent/session.py +262 -0
- emdash_core/agent/spec_schema.py +66 -0
- emdash_core/agent/specification.py +479 -0
- emdash_core/agent/subagent.py +397 -0
- emdash_core/agent/subagent_prompts.py +13 -0
- emdash_core/agent/toolkit.py +482 -0
- emdash_core/agent/toolkits/__init__.py +64 -0
- emdash_core/agent/toolkits/base.py +96 -0
- emdash_core/agent/toolkits/explore.py +47 -0
- emdash_core/agent/toolkits/plan.py +55 -0
- emdash_core/agent/tools/__init__.py +141 -0
- emdash_core/agent/tools/analytics.py +436 -0
- emdash_core/agent/tools/base.py +131 -0
- emdash_core/agent/tools/coding.py +484 -0
- emdash_core/agent/tools/github_mcp.py +592 -0
- emdash_core/agent/tools/history.py +13 -0
- emdash_core/agent/tools/modes.py +153 -0
- emdash_core/agent/tools/plan.py +206 -0
- emdash_core/agent/tools/plan_write.py +135 -0
- emdash_core/agent/tools/search.py +412 -0
- emdash_core/agent/tools/spec.py +341 -0
- emdash_core/agent/tools/task.py +262 -0
- emdash_core/agent/tools/task_output.py +204 -0
- emdash_core/agent/tools/tasks.py +454 -0
- emdash_core/agent/tools/traversal.py +588 -0
- emdash_core/agent/tools/web.py +179 -0
- emdash_core/analytics/__init__.py +5 -0
- emdash_core/analytics/engine.py +1286 -0
- emdash_core/api/__init__.py +5 -0
- emdash_core/api/agent.py +308 -0
- emdash_core/api/agents.py +154 -0
- emdash_core/api/analyze.py +264 -0
- emdash_core/api/auth.py +173 -0
- emdash_core/api/context.py +77 -0
- emdash_core/api/db.py +121 -0
- emdash_core/api/embed.py +131 -0
- emdash_core/api/feature.py +143 -0
- emdash_core/api/health.py +93 -0
- emdash_core/api/index.py +162 -0
- emdash_core/api/plan.py +110 -0
- emdash_core/api/projectmd.py +210 -0
- emdash_core/api/query.py +320 -0
- emdash_core/api/research.py +122 -0
- emdash_core/api/review.py +161 -0
- emdash_core/api/router.py +76 -0
- emdash_core/api/rules.py +116 -0
- emdash_core/api/search.py +119 -0
- emdash_core/api/spec.py +99 -0
- emdash_core/api/swarm.py +223 -0
- emdash_core/api/tasks.py +109 -0
- emdash_core/api/team.py +120 -0
- emdash_core/auth/__init__.py +17 -0
- emdash_core/auth/github.py +389 -0
- emdash_core/config.py +74 -0
- emdash_core/context/__init__.py +52 -0
- emdash_core/context/models.py +50 -0
- emdash_core/context/providers/__init__.py +11 -0
- emdash_core/context/providers/base.py +74 -0
- emdash_core/context/providers/explored_areas.py +183 -0
- emdash_core/context/providers/touched_areas.py +360 -0
- emdash_core/context/registry.py +73 -0
- emdash_core/context/reranker.py +199 -0
- emdash_core/context/service.py +260 -0
- emdash_core/context/session.py +352 -0
- emdash_core/core/__init__.py +104 -0
- emdash_core/core/config.py +454 -0
- emdash_core/core/exceptions.py +55 -0
- emdash_core/core/models.py +265 -0
- emdash_core/core/review_config.py +57 -0
- emdash_core/db/__init__.py +67 -0
- emdash_core/db/auth.py +134 -0
- emdash_core/db/models.py +91 -0
- emdash_core/db/provider.py +222 -0
- emdash_core/db/providers/__init__.py +5 -0
- emdash_core/db/providers/supabase.py +452 -0
- emdash_core/embeddings/__init__.py +24 -0
- emdash_core/embeddings/indexer.py +534 -0
- emdash_core/embeddings/models.py +192 -0
- emdash_core/embeddings/providers/__init__.py +7 -0
- emdash_core/embeddings/providers/base.py +112 -0
- emdash_core/embeddings/providers/fireworks.py +141 -0
- emdash_core/embeddings/providers/openai.py +104 -0
- emdash_core/embeddings/registry.py +146 -0
- emdash_core/embeddings/service.py +215 -0
- emdash_core/graph/__init__.py +26 -0
- emdash_core/graph/builder.py +134 -0
- emdash_core/graph/connection.py +692 -0
- emdash_core/graph/schema.py +416 -0
- emdash_core/graph/writer.py +667 -0
- emdash_core/ingestion/__init__.py +7 -0
- emdash_core/ingestion/change_detector.py +150 -0
- emdash_core/ingestion/git/__init__.py +5 -0
- emdash_core/ingestion/git/commit_analyzer.py +196 -0
- emdash_core/ingestion/github/__init__.py +6 -0
- emdash_core/ingestion/github/pr_fetcher.py +296 -0
- emdash_core/ingestion/github/task_extractor.py +100 -0
- emdash_core/ingestion/orchestrator.py +540 -0
- emdash_core/ingestion/parsers/__init__.py +10 -0
- emdash_core/ingestion/parsers/base_parser.py +66 -0
- emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
- emdash_core/ingestion/parsers/class_extractor.py +154 -0
- emdash_core/ingestion/parsers/function_extractor.py +202 -0
- emdash_core/ingestion/parsers/import_analyzer.py +119 -0
- emdash_core/ingestion/parsers/python_parser.py +123 -0
- emdash_core/ingestion/parsers/registry.py +72 -0
- emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
- emdash_core/ingestion/parsers/typescript_parser.py +278 -0
- emdash_core/ingestion/repository.py +346 -0
- emdash_core/models/__init__.py +38 -0
- emdash_core/models/agent.py +68 -0
- emdash_core/models/index.py +77 -0
- emdash_core/models/query.py +113 -0
- emdash_core/planning/__init__.py +7 -0
- emdash_core/planning/agent_api.py +413 -0
- emdash_core/planning/context_builder.py +265 -0
- emdash_core/planning/feature_context.py +232 -0
- emdash_core/planning/feature_expander.py +646 -0
- emdash_core/planning/llm_explainer.py +198 -0
- emdash_core/planning/similarity.py +509 -0
- emdash_core/planning/team_focus.py +821 -0
- emdash_core/server.py +153 -0
- emdash_core/sse/__init__.py +5 -0
- emdash_core/sse/stream.py +196 -0
- emdash_core/swarm/__init__.py +17 -0
- emdash_core/swarm/merge_agent.py +383 -0
- emdash_core/swarm/session_manager.py +274 -0
- emdash_core/swarm/swarm_runner.py +226 -0
- emdash_core/swarm/task_definition.py +137 -0
- emdash_core/swarm/worker_spawner.py +319 -0
- emdash_core/swarm/worktree_manager.py +278 -0
- emdash_core/templates/__init__.py +10 -0
- emdash_core/templates/defaults/agent-builder.md.template +82 -0
- emdash_core/templates/defaults/focus.md.template +115 -0
- emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
- emdash_core/templates/defaults/pr-review.md.template +80 -0
- emdash_core/templates/defaults/project.md.template +85 -0
- emdash_core/templates/defaults/research_critic.md.template +112 -0
- emdash_core/templates/defaults/research_planner.md.template +85 -0
- emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
- emdash_core/templates/defaults/reviewer.md.template +81 -0
- emdash_core/templates/defaults/spec.md.template +41 -0
- emdash_core/templates/defaults/tasks.md.template +78 -0
- emdash_core/templates/loader.py +296 -0
- emdash_core/utils/__init__.py +45 -0
- emdash_core/utils/git.py +84 -0
- emdash_core/utils/image.py +502 -0
- emdash_core/utils/logger.py +51 -0
- emdash_core-0.1.7.dist-info/METADATA +35 -0
- emdash_core-0.1.7.dist-info/RECORD +187 -0
- emdash_core-0.1.7.dist-info/WHEEL +4 -0
- emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
emdash_core/utils/git.py
ADDED
|
@@ -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)
|