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.
Files changed (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. 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]}...")
@@ -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)")