gitcast 1.0.0__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.
- ai/__init__.py +0 -0
- ai/formatter.py +59 -0
- ai/generator.py +604 -0
- ai/prompts.py +197 -0
- ai/viral_patterns.py +75 -0
- api/__init__.py +0 -0
- api/analytics.py +48 -0
- api/auth.py +49 -0
- api/auth_middleware.py +129 -0
- api/auth_routes.py +117 -0
- api/monitoring.py +56 -0
- api/payload.py +253 -0
- api/ratelimit.py +9 -0
- api/routes.py +1565 -0
- api/server.py +162 -0
- api/validators.py +101 -0
- assets/__init__.py +1 -0
- assets/favicon-16x16.png +0 -0
- assets/favicon-32x32.png +0 -0
- assets/favicon-64x64.png +0 -0
- assets/favicon.ico +0 -0
- assets/icon.png +0 -0
- cli/.env.example +26 -0
- cli/__init__.py +1 -0
- cli/gitcast.py +79 -0
- config/__init__.py +0 -0
- config/settings.py +213 -0
- core/__init__.py +0 -0
- core/capture.py +258 -0
- core/codebase_reader.py +90 -0
- core/framing.py +86 -0
- core/hotkey.py +21 -0
- core/log_stream.py +50 -0
- core/ocr.py +173 -0
- core/screenshot_session.py +274 -0
- core/security.py +126 -0
- core/tray.py +54 -0
- gitcast-1.0.0.dist-info/LICENSE +21 -0
- gitcast-1.0.0.dist-info/METADATA +67 -0
- gitcast-1.0.0.dist-info/RECORD +61 -0
- gitcast-1.0.0.dist-info/WHEEL +5 -0
- gitcast-1.0.0.dist-info/entry_points.txt +2 -0
- gitcast-1.0.0.dist-info/top_level.txt +10 -0
- publisher/__init__.py +0 -0
- publisher/clipboard.py +44 -0
- publisher/twitter.py +100 -0
- storage/__init__.py +0 -0
- storage/cleanup.py +60 -0
- storage/engagement.py +114 -0
- storage/insights.py +203 -0
- storage/key_manager.py +45 -0
- storage/logger.py +208 -0
- storage/metrics.py +119 -0
- storage/sprint.py +40 -0
- storage/streak.py +0 -0
- storage/supabase_client.py +25 -0
- storage/tone_memory.py +139 -0
- ui/__init__.py +0 -0
- web/__init__.py +1 -0
- web/index.html +4994 -0
- web/landing.html +925 -0
core/capture.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from config.settings import STORAGE_DIR
|
|
7
|
+
from core.log_stream import stream_log
|
|
8
|
+
from api.analytics import track
|
|
9
|
+
BASE_DIR = STORAGE_DIR.parent.parent
|
|
10
|
+
from .framing import add_programming_frame
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GIT_DIFF_EXCLUDES = [
|
|
14
|
+
":(exclude)config/session_token.txt",
|
|
15
|
+
":(exclude)storage/data/**",
|
|
16
|
+
":(exclude).playwright-mcp/**",
|
|
17
|
+
":(exclude)memory.md",
|
|
18
|
+
":(exclude)*gitcast-app*.png",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── Screenshot capture ────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def capture_active_window(delay: float = 5.0) -> dict:
|
|
25
|
+
"""
|
|
26
|
+
Takes a screenshot of the entire screen and saves it to storage/data/screenshots.
|
|
27
|
+
Returns a dict with the image path and dimensions.
|
|
28
|
+
"""
|
|
29
|
+
if delay > 0:
|
|
30
|
+
if delay < 1.0:
|
|
31
|
+
# Short buffer delay
|
|
32
|
+
time.sleep(delay)
|
|
33
|
+
else:
|
|
34
|
+
# Full countdown delay
|
|
35
|
+
stream_log("Capture", "INFO", f"starting in {int(delay)}s; switch to target window")
|
|
36
|
+
for i in range(int(delay), 0, -1):
|
|
37
|
+
print(f" {i}...")
|
|
38
|
+
# Terminal bell (ASCII 7)
|
|
39
|
+
import sys
|
|
40
|
+
sys.stdout.write('\a')
|
|
41
|
+
sys.stdout.flush()
|
|
42
|
+
time.sleep(1)
|
|
43
|
+
|
|
44
|
+
screenshot_dir = STORAGE_DIR / "screenshots"
|
|
45
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
48
|
+
filename = f"capture_{timestamp}.png"
|
|
49
|
+
filepath = screenshot_dir / filename
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
import mss
|
|
53
|
+
from PIL import Image
|
|
54
|
+
except ImportError as exc:
|
|
55
|
+
return {
|
|
56
|
+
"success": False,
|
|
57
|
+
"path": "",
|
|
58
|
+
"width": 0,
|
|
59
|
+
"height": 0,
|
|
60
|
+
"timestamp": timestamp,
|
|
61
|
+
"error": str(exc),
|
|
62
|
+
"reason": "missing_dependency",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with mss.mss() as sct:
|
|
67
|
+
monitor = sct.monitors[1] # primary monitor
|
|
68
|
+
screenshot = sct.grab(monitor)
|
|
69
|
+
img = Image.frombytes("RGB", screenshot.size, screenshot.bgra, "raw", "BGRX")
|
|
70
|
+
img.save(str(filepath))
|
|
71
|
+
width = screenshot.width
|
|
72
|
+
height = screenshot.height
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
try:
|
|
75
|
+
img = Image.new("RGB", (1920, 1080), color=(30, 30, 30))
|
|
76
|
+
img.save(str(filepath))
|
|
77
|
+
stream_log("Capture", "WARN", f"MSS capture failed ({exc}). Created blank fallback screenshot.")
|
|
78
|
+
width = 1920
|
|
79
|
+
height = 1080
|
|
80
|
+
except Exception as fallback_exc:
|
|
81
|
+
return {
|
|
82
|
+
"success": False,
|
|
83
|
+
"path": "",
|
|
84
|
+
"width": 0,
|
|
85
|
+
"height": 0,
|
|
86
|
+
"timestamp": timestamp,
|
|
87
|
+
"error": f"MSS failed: {exc}, Fallback failed: {fallback_exc}",
|
|
88
|
+
"reason": "capture_failed",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# Return relative path for web/API consistency
|
|
92
|
+
relative_path = str(filepath.relative_to(BASE_DIR)).replace("\\", "/")
|
|
93
|
+
stream_log("Capture", "OK", f"screenshot saved: {relative_path}")
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"success": True,
|
|
97
|
+
"path": relative_path,
|
|
98
|
+
"width": width,
|
|
99
|
+
"height": height,
|
|
100
|
+
"timestamp": timestamp,
|
|
101
|
+
"error": "",
|
|
102
|
+
"reason": "ok",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Git diff extraction ───────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def get_git_diff(cwd: str = None) -> dict:
|
|
109
|
+
"""
|
|
110
|
+
Runs git diff HEAD in the given directory.
|
|
111
|
+
Falls back to the user's home directory if no cwd is given.
|
|
112
|
+
Returns a dict with the diff text and a status flag.
|
|
113
|
+
"""
|
|
114
|
+
target_dir = cwd or str(Path.home())
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
diff_cmd = ["git", "diff", "HEAD", "--", ".", *GIT_DIFF_EXCLUDES]
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
diff_cmd,
|
|
120
|
+
cwd=target_dir,
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
encoding="utf-8",
|
|
124
|
+
errors="replace",
|
|
125
|
+
timeout=5,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
diff_text = result.stdout.strip()
|
|
129
|
+
|
|
130
|
+
if result.returncode != 0:
|
|
131
|
+
return {
|
|
132
|
+
"success": False,
|
|
133
|
+
"diff": "",
|
|
134
|
+
"error": result.stderr.strip(),
|
|
135
|
+
"reason": "git_error",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if not diff_text:
|
|
139
|
+
# try staged changes if working tree diff is empty
|
|
140
|
+
staged = subprocess.run(
|
|
141
|
+
["git", "diff", "--cached", "--", ".", *GIT_DIFF_EXCLUDES],
|
|
142
|
+
cwd=target_dir,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
encoding="utf-8",
|
|
146
|
+
errors="replace",
|
|
147
|
+
timeout=5,
|
|
148
|
+
)
|
|
149
|
+
diff_text = staged.stdout.strip()
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"success": True,
|
|
153
|
+
"diff": diff_text[:3000], # cap at 3000 chars to stay inside token limits
|
|
154
|
+
"error": "",
|
|
155
|
+
"reason": "ok" if diff_text else "no_changes",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return {
|
|
160
|
+
"success": False,
|
|
161
|
+
"diff": "",
|
|
162
|
+
"error": "git not found",
|
|
163
|
+
"reason": "no_git",
|
|
164
|
+
}
|
|
165
|
+
except subprocess.TimeoutExpired:
|
|
166
|
+
return {
|
|
167
|
+
"success": False,
|
|
168
|
+
"diff": "",
|
|
169
|
+
"error": "git diff timed out",
|
|
170
|
+
"reason": "timeout",
|
|
171
|
+
}
|
|
172
|
+
except Exception as e:
|
|
173
|
+
return {
|
|
174
|
+
"success": False,
|
|
175
|
+
"diff": "",
|
|
176
|
+
"error": str(e),
|
|
177
|
+
"reason": "unknown",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Detect working directory ──────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def detect_working_directory() -> str:
|
|
184
|
+
"""
|
|
185
|
+
Attempts to find the most likely git repo the user is working in.
|
|
186
|
+
Checks the script's own directory first, then walks up from cwd.
|
|
187
|
+
"""
|
|
188
|
+
candidates = [
|
|
189
|
+
Path(__file__).resolve().parent.parent, # project root
|
|
190
|
+
Path.cwd(),
|
|
191
|
+
Path(os.environ.get("USERPROFILE", "")) / "Documents" / "context-engine",
|
|
192
|
+
Path.home(),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
for candidate in candidates:
|
|
196
|
+
try:
|
|
197
|
+
result = subprocess.run(
|
|
198
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
199
|
+
cwd=str(candidate),
|
|
200
|
+
capture_output=True,
|
|
201
|
+
text=True,
|
|
202
|
+
encoding="utf-8",
|
|
203
|
+
errors="replace",
|
|
204
|
+
timeout=3,
|
|
205
|
+
)
|
|
206
|
+
if result.returncode == 0:
|
|
207
|
+
return result.stdout.strip()
|
|
208
|
+
except Exception:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
return str(Path.home())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def run_capture(delay: float = 5.0) -> dict:
|
|
215
|
+
working_dir = detect_working_directory()
|
|
216
|
+
screenshot = capture_active_window(delay=delay)
|
|
217
|
+
|
|
218
|
+
# Apply programming frame if capture succeeded
|
|
219
|
+
if screenshot.get("success") and screenshot.get("path"):
|
|
220
|
+
try:
|
|
221
|
+
framed_path = add_programming_frame(screenshot["path"])
|
|
222
|
+
screenshot["framed_path"] = framed_path
|
|
223
|
+
# By default, use the framed path for downstream preview/publishing
|
|
224
|
+
screenshot["raw_path"] = screenshot["path"]
|
|
225
|
+
screenshot["path"] = framed_path
|
|
226
|
+
except Exception as e:
|
|
227
|
+
stream_log("Capture", "WARN", f"framing failed: {e}")
|
|
228
|
+
screenshot["framed_path"] = ""
|
|
229
|
+
|
|
230
|
+
git_diff = get_git_diff(working_dir)
|
|
231
|
+
if screenshot.get("success"):
|
|
232
|
+
track("capture_completed", {
|
|
233
|
+
"has_git_diff": bool(git_diff.get("diff")),
|
|
234
|
+
"ocr_confidence": 0,
|
|
235
|
+
"ocr_reliable": False,
|
|
236
|
+
})
|
|
237
|
+
return {
|
|
238
|
+
"screenshot": screenshot,
|
|
239
|
+
"working_dir": working_dir,
|
|
240
|
+
"git_diff": git_diff,
|
|
241
|
+
"timestamp": screenshot.get("timestamp", datetime.now().strftime("%Y%m%d_%H%M%S")),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ── Test ──────────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
result = run_capture()
|
|
249
|
+
print("\n=== CAPTURE RESULT ===")
|
|
250
|
+
print(f"Screenshot success: {result['screenshot'].get('success')}")
|
|
251
|
+
print(f"Screenshot: {result['screenshot']['path']}")
|
|
252
|
+
print(f"Working dir: {result['working_dir']}")
|
|
253
|
+
print(f"Git diff success: {result['git_diff']['success']}")
|
|
254
|
+
print(f"Git diff reason: {result['git_diff']['reason']}")
|
|
255
|
+
if result["screenshot"].get("error"):
|
|
256
|
+
print(f"Screenshot error: {result['screenshot']['error']}")
|
|
257
|
+
if result['git_diff']['diff']:
|
|
258
|
+
print(f"Diff preview:\n{result['git_diff']['diff'][:200]}...")
|
core/codebase_reader.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# [CodebaseReader] module for summarising repo structure for articles
|
|
5
|
+
|
|
6
|
+
def read_repo_structure(repo_path: str) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Walks git repo, returns a string summary of filename -> first 50 lines.
|
|
9
|
+
Caps total output at 6000 characters.
|
|
10
|
+
"""
|
|
11
|
+
repo_root = Path(repo_path)
|
|
12
|
+
if not repo_root.exists():
|
|
13
|
+
return "Repo path does not exist."
|
|
14
|
+
|
|
15
|
+
ignore_dirs = {".git", "node_modules", "venv", "__pycache__", ".npm-global"}
|
|
16
|
+
summary_parts = []
|
|
17
|
+
total_chars = 0
|
|
18
|
+
char_limit = 4000 # Reduced from 6000
|
|
19
|
+
|
|
20
|
+
for root, dirs, files in os.walk(repo_root):
|
|
21
|
+
# In-place modification to skip ignored directories
|
|
22
|
+
dirs[:] = [d for d in dirs if d not in ignore_dirs]
|
|
23
|
+
|
|
24
|
+
for file in files:
|
|
25
|
+
if total_chars >= char_limit:
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
file_path = Path(root) / file
|
|
29
|
+
|
|
30
|
+
# Skip binary or irrelevant files
|
|
31
|
+
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.ico', '.pyc', '.exe', '.bin', '.json', '.txt', '.log')):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
36
|
+
content = f.read(500) # Read less per file
|
|
37
|
+
lines = content.splitlines()[:15] # Only first 15 lines
|
|
38
|
+
snippet = "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
rel_path = file_path.relative_to(repo_root)
|
|
41
|
+
entry = f"--- FILE: {rel_path} ---\n{snippet}\n"
|
|
42
|
+
|
|
43
|
+
if total_chars + len(entry) > char_limit:
|
|
44
|
+
summary_parts.append(f"--- FILE: {rel_path} --- (TRUNCATED)")
|
|
45
|
+
total_chars = char_limit
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
summary_parts.append(entry)
|
|
49
|
+
total_chars += len(entry)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
print(f"[CodebaseReader] Error reading {file}: {e}")
|
|
52
|
+
|
|
53
|
+
if total_chars >= char_limit:
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
return "\n".join(summary_parts)
|
|
57
|
+
|
|
58
|
+
def get_key_files(repo_path: str) -> str:
|
|
59
|
+
"""Returns content of key files like README or main entry points."""
|
|
60
|
+
repo_root = Path(repo_path)
|
|
61
|
+
key_names = {"README.md", "main.py", "app.py", "index.py", "cli.py", "server.py"}
|
|
62
|
+
summary_parts = []
|
|
63
|
+
|
|
64
|
+
for name in key_names:
|
|
65
|
+
file_path = repo_root / name
|
|
66
|
+
if file_path.exists():
|
|
67
|
+
try:
|
|
68
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
69
|
+
content = f.read(2000)
|
|
70
|
+
summary_parts.append(f"=== KEY FILE: {name} ===\n{content}\n")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"[CodebaseReader] Error reading key file {name}: {e}")
|
|
73
|
+
|
|
74
|
+
return "\n".join(summary_parts)
|
|
75
|
+
|
|
76
|
+
def summarise_for_prompt(repo_path: str) -> str:
|
|
77
|
+
"""Combines structure and key files into a single string for AI injection."""
|
|
78
|
+
print(f"[CodebaseReader] Summarising {repo_path}...")
|
|
79
|
+
structure = read_repo_structure(repo_path)
|
|
80
|
+
key_files = get_key_files(repo_path)
|
|
81
|
+
|
|
82
|
+
summary = f"CODEBASE ARCHITECTURE SUMMARY:\n\n{key_files}\n\nFILE STRUCTURE SNIPPETS:\n{structure}"
|
|
83
|
+
return summary
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
print("=== CODEBASE READER TEST ===")
|
|
87
|
+
current_dir = os.getcwd()
|
|
88
|
+
summary = summarise_for_prompt(current_dir)
|
|
89
|
+
print(summary[:1000] + "...")
|
|
90
|
+
print(f"\nTotal summary length: {len(summary)} chars")
|
core/framing.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageOps
|
|
3
|
+
|
|
4
|
+
def add_programming_frame(image_path: str) -> str:
|
|
5
|
+
"""
|
|
6
|
+
Wraps a screenshot in a macOS-style window frame with traffic lights
|
|
7
|
+
and a drop shadow. Saves the result as a new file.
|
|
8
|
+
"""
|
|
9
|
+
if not os.path.exists(image_path):
|
|
10
|
+
return image_path
|
|
11
|
+
|
|
12
|
+
# Load original image
|
|
13
|
+
img = Image.open(image_path).convert("RGBA")
|
|
14
|
+
|
|
15
|
+
# Constants for the frame
|
|
16
|
+
CORNER_RADIUS = 12
|
|
17
|
+
HEADER_HEIGHT = 36
|
|
18
|
+
PADDING = 60 # Outer padding for shadow and background
|
|
19
|
+
BG_COLOR = (30, 30, 30, 255) # Dark grey for the "window"
|
|
20
|
+
SHADOW_COLOR = (0, 0, 0, 100)
|
|
21
|
+
|
|
22
|
+
# Traffic light colors
|
|
23
|
+
RED = (255, 95, 87, 255)
|
|
24
|
+
YELLOW = (255, 189, 46, 255)
|
|
25
|
+
GREEN = (40, 201, 64, 255)
|
|
26
|
+
LIGHT_SIZE = 12
|
|
27
|
+
LIGHT_SPACING = 20
|
|
28
|
+
LIGHT_MARGIN_LEFT = 16
|
|
29
|
+
LIGHT_MARGIN_TOP = (HEADER_HEIGHT - LIGHT_SIZE) // 2
|
|
30
|
+
|
|
31
|
+
# 1. Create the window content (Header + Screenshot)
|
|
32
|
+
win_width = img.width
|
|
33
|
+
win_height = img.height + HEADER_HEIGHT
|
|
34
|
+
|
|
35
|
+
# Create window background with rounded corners
|
|
36
|
+
window = Image.new("RGBA", (win_width, win_height), (0, 0, 0, 0))
|
|
37
|
+
draw = ImageDraw.Draw(window)
|
|
38
|
+
|
|
39
|
+
# Draw rounded rectangle for the whole window
|
|
40
|
+
draw.rounded_rectangle(
|
|
41
|
+
[0, 0, win_width, win_height],
|
|
42
|
+
radius=CORNER_RADIUS,
|
|
43
|
+
fill=BG_COLOR
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Paste the original screenshot below the header
|
|
47
|
+
window.paste(img, (0, HEADER_HEIGHT), img if img.mode == 'RGBA' else None)
|
|
48
|
+
|
|
49
|
+
# Draw traffic lights
|
|
50
|
+
for i, color in enumerate([RED, YELLOW, GREEN]):
|
|
51
|
+
x = LIGHT_MARGIN_LEFT + (i * LIGHT_SPACING)
|
|
52
|
+
y = LIGHT_MARGIN_TOP
|
|
53
|
+
draw.ellipse([x, y, x + LIGHT_SIZE, y + LIGHT_SIZE], fill=color)
|
|
54
|
+
|
|
55
|
+
# 2. Add Drop Shadow
|
|
56
|
+
# Create a canvas larger than the window to hold the shadow
|
|
57
|
+
canvas_width = win_width + (PADDING * 2)
|
|
58
|
+
canvas_height = win_height + (PADDING * 2)
|
|
59
|
+
canvas = Image.new("RGBA", (canvas_width, canvas_height), (0, 0, 0, 0))
|
|
60
|
+
|
|
61
|
+
# Create shadow mask
|
|
62
|
+
shadow_mask = Image.new("L", (win_width, win_height), 0)
|
|
63
|
+
shadow_draw = ImageDraw.Draw(shadow_mask)
|
|
64
|
+
shadow_draw.rounded_rectangle([0, 0, win_width, win_height], radius=CORNER_RADIUS, fill=180)
|
|
65
|
+
|
|
66
|
+
# Blur the shadow
|
|
67
|
+
shadow = Image.new("RGBA", (win_width, win_height), (0, 0, 0, 150))
|
|
68
|
+
shadow.putalpha(shadow_mask)
|
|
69
|
+
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=25))
|
|
70
|
+
|
|
71
|
+
# Offset shadow slightly
|
|
72
|
+
canvas.paste(shadow, (PADDING + 5, PADDING + 10))
|
|
73
|
+
|
|
74
|
+
# Paste the window on top
|
|
75
|
+
canvas.paste(window, (PADDING, PADDING), window)
|
|
76
|
+
|
|
77
|
+
# Save framed image
|
|
78
|
+
base, ext = os.path.splitext(image_path)
|
|
79
|
+
framed_path = f"{base}_framed.png"
|
|
80
|
+
canvas.save(framed_path)
|
|
81
|
+
|
|
82
|
+
return framed_path
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
# Test with a dummy image if exists or just print
|
|
86
|
+
print("Framing module ready. Use add_programming_frame(path) to test.")
|
core/hotkey.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pynput import keyboard
|
|
2
|
+
|
|
3
|
+
_listener = None
|
|
4
|
+
|
|
5
|
+
def start_hotkey_listener(callback):
|
|
6
|
+
global _listener
|
|
7
|
+
def on_activate():
|
|
8
|
+
callback()
|
|
9
|
+
|
|
10
|
+
_listener = keyboard.GlobalHotKeys({
|
|
11
|
+
'<ctrl>+<alt>+s': on_activate
|
|
12
|
+
})
|
|
13
|
+
_listener.start()
|
|
14
|
+
return _listener
|
|
15
|
+
|
|
16
|
+
def stop_hotkey_listener():
|
|
17
|
+
global _listener
|
|
18
|
+
if _listener:
|
|
19
|
+
_listener.stop()
|
|
20
|
+
_listener = None
|
|
21
|
+
print("[Hotkey] Listener stopped.")
|
core/log_stream.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from collections import deque
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from threading import Lock
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
LOG_BUFFER = deque(maxlen=100)
|
|
7
|
+
_LOCK = Lock()
|
|
8
|
+
_NEXT_ID = 0
|
|
9
|
+
_LEVELS = {"OK", "INFO", "WARN", "ERROR", "AI", "ROUTER"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stream_log(module: str, level: str, message: str) -> dict:
|
|
13
|
+
global _NEXT_ID
|
|
14
|
+
|
|
15
|
+
normalized_level = level.upper().strip()
|
|
16
|
+
if normalized_level not in _LEVELS:
|
|
17
|
+
normalized_level = "INFO"
|
|
18
|
+
|
|
19
|
+
entry = {
|
|
20
|
+
"time": datetime.now().strftime("%H:%M:%S"),
|
|
21
|
+
"level": normalized_level,
|
|
22
|
+
"module": module.upper().strip(),
|
|
23
|
+
"message": message.strip(),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
with _LOCK:
|
|
27
|
+
_NEXT_ID += 1
|
|
28
|
+
stored = {"id": _NEXT_ID, **entry}
|
|
29
|
+
LOG_BUFFER.append(stored)
|
|
30
|
+
|
|
31
|
+
print(f"[{entry['module']}] {entry['message']}")
|
|
32
|
+
return entry
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_recent_logs() -> list:
|
|
36
|
+
with _LOCK:
|
|
37
|
+
return [
|
|
38
|
+
{key: value for key, value in entry.items() if key != "id"}
|
|
39
|
+
for entry in list(LOG_BUFFER)[-50:]
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_logs_after(last_id: int) -> list:
|
|
44
|
+
with _LOCK:
|
|
45
|
+
return [entry.copy() for entry in LOG_BUFFER if entry["id"] > last_id]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_latest_log_id() -> int:
|
|
49
|
+
with _LOCK:
|
|
50
|
+
return LOG_BUFFER[-1]["id"] if LOG_BUFFER else 0
|
core/ocr.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import pytesseract
|
|
3
|
+
from PIL import Image
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from config.settings import get_ocr_threshold
|
|
6
|
+
from core.log_stream import stream_log
|
|
7
|
+
|
|
8
|
+
# ── Tesseract path (Windows) ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
# explicitly set the tesseract path for Windows
|
|
11
|
+
# if installed elsewhere, update this path
|
|
12
|
+
TESSERACT_PATH = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
|
|
13
|
+
|
|
14
|
+
if Path(TESSERACT_PATH).exists():
|
|
15
|
+
pytesseract.pytesseract.tesseract_cmd = TESSERACT_PATH
|
|
16
|
+
else:
|
|
17
|
+
# fallback — rely on PATH
|
|
18
|
+
stream_log("OCR", "WARN", "Tesseract not found at default path. Relying on PATH.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── OCR runner ────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def run_ocr(image_path: str) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Runs Tesseract OCR on the given image.
|
|
26
|
+
Returns extracted text, confidence score, and a flag indicating
|
|
27
|
+
whether the result is reliable enough to send to the AI.
|
|
28
|
+
|
|
29
|
+
If confidence is below threshold, the caller should send the
|
|
30
|
+
raw screenshot to the Gemini vision endpoint instead.
|
|
31
|
+
"""
|
|
32
|
+
threshold = get_ocr_threshold() # default 60, set in config/settings.py
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
img = Image.open(image_path)
|
|
36
|
+
|
|
37
|
+
# get detailed output including confidence scores per word
|
|
38
|
+
data = pytesseract.image_to_data(
|
|
39
|
+
img,
|
|
40
|
+
output_type=pytesseract.Output.DICT,
|
|
41
|
+
config="--psm 6" # assume uniform block of text — best for code/IDE
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# calculate mean confidence from words that were actually detected
|
|
45
|
+
confidences = [
|
|
46
|
+
int(c) for c in data["conf"]
|
|
47
|
+
if str(c).strip() != "-1" and str(c).strip() != ""
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
if not confidences:
|
|
51
|
+
stream_log("OCR", "WARN", "confidence low (0%); no text detected")
|
|
52
|
+
return _low_confidence_result("No text detected in screenshot")
|
|
53
|
+
|
|
54
|
+
mean_confidence = sum(confidences) / len(confidences)
|
|
55
|
+
|
|
56
|
+
# extract clean text — filter out empty strings
|
|
57
|
+
raw_text = pytesseract.image_to_string(
|
|
58
|
+
img,
|
|
59
|
+
config="--psm 6"
|
|
60
|
+
)
|
|
61
|
+
clean_text = _clean_ocr_text(raw_text)
|
|
62
|
+
|
|
63
|
+
is_reliable = mean_confidence >= threshold
|
|
64
|
+
|
|
65
|
+
level = "OK" if is_reliable else "WARN"
|
|
66
|
+
message = (
|
|
67
|
+
f"confidence {mean_confidence:.1f}%"
|
|
68
|
+
if is_reliable
|
|
69
|
+
else f"confidence low ({mean_confidence:.1f}%) - vision fallback"
|
|
70
|
+
)
|
|
71
|
+
stream_log("OCR", level, message)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"success": True,
|
|
75
|
+
"text": clean_text if is_reliable else "",
|
|
76
|
+
"raw_text": clean_text,
|
|
77
|
+
"confidence": round(mean_confidence, 1),
|
|
78
|
+
"reliable": is_reliable,
|
|
79
|
+
"use_vision_fallback": not is_reliable,
|
|
80
|
+
"error": "",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return _error_result(f"Screenshot file not found: {image_path}")
|
|
85
|
+
except pytesseract.TesseractNotFoundError:
|
|
86
|
+
return _error_result(
|
|
87
|
+
"Tesseract executable not found. "
|
|
88
|
+
"Install from https://github.com/UB-Mannheim/tesseract/wiki"
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
return _error_result(str(e))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Text cleaning ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _clean_ocr_text(raw: str) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Cleans raw Tesseract output for use in AI prompts.
|
|
99
|
+
Removes excessive whitespace and blank lines while
|
|
100
|
+
preserving code indentation structure.
|
|
101
|
+
"""
|
|
102
|
+
lines = raw.splitlines()
|
|
103
|
+
|
|
104
|
+
# remove completely empty lines that are repeated
|
|
105
|
+
cleaned = []
|
|
106
|
+
prev_empty = False
|
|
107
|
+
for line in lines:
|
|
108
|
+
is_empty = line.strip() == ""
|
|
109
|
+
if is_empty and prev_empty:
|
|
110
|
+
continue # skip consecutive blank lines
|
|
111
|
+
cleaned.append(line)
|
|
112
|
+
prev_empty = is_empty
|
|
113
|
+
|
|
114
|
+
result = "\n".join(cleaned).strip()
|
|
115
|
+
|
|
116
|
+
# cap at 2000 chars to stay within token budget
|
|
117
|
+
if len(result) > 2000:
|
|
118
|
+
result = result[:2000] + "\n... [truncated]"
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Result helpers ────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _low_confidence_result(reason: str) -> dict:
|
|
126
|
+
return {
|
|
127
|
+
"success": True,
|
|
128
|
+
"text": "",
|
|
129
|
+
"raw_text": "",
|
|
130
|
+
"confidence": 0.0,
|
|
131
|
+
"reliable": False,
|
|
132
|
+
"use_vision_fallback": True,
|
|
133
|
+
"error": reason,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _error_result(error: str) -> dict:
|
|
138
|
+
stream_log("OCR", "ERROR", error)
|
|
139
|
+
return {
|
|
140
|
+
"success": False,
|
|
141
|
+
"text": "",
|
|
142
|
+
"raw_text": "",
|
|
143
|
+
"confidence": 0.0,
|
|
144
|
+
"reliable": False,
|
|
145
|
+
"use_vision_fallback": True,
|
|
146
|
+
"error": error,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── Test ──────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
import sys
|
|
154
|
+
from core.capture import capture_active_window
|
|
155
|
+
|
|
156
|
+
print("[OCR] Taking a fresh screenshot to test on...")
|
|
157
|
+
screenshot = capture_active_window()
|
|
158
|
+
|
|
159
|
+
if not screenshot["path"]:
|
|
160
|
+
print("[OCR] Screenshot failed — cannot test OCR")
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
|
|
163
|
+
print(f"[OCR] Running OCR on: {screenshot['path']}")
|
|
164
|
+
result = run_ocr(screenshot["path"])
|
|
165
|
+
|
|
166
|
+
print("\n=== OCR RESULT ===")
|
|
167
|
+
print(f"Success: {result['success']}")
|
|
168
|
+
print(f"Confidence: {result['confidence']}%")
|
|
169
|
+
print(f"Reliable: {result['reliable']}")
|
|
170
|
+
print(f"Use vision fallback: {result['use_vision_fallback']}")
|
|
171
|
+
print(f"Error: {result['error'] or 'none'}")
|
|
172
|
+
print(f"\nExtracted text preview:")
|
|
173
|
+
print(result['raw_text'][:500] if result['raw_text'] else "(none)")
|