From a99448c250c3ef23f6de5cd2c8301bcc107c8199 Mon Sep 17 00:00:00 2001 From: FatihMakes Date: Sun, 10 May 2026 17:04:19 +0300 Subject: [PATCH] Add files via upload --- __init__.py | 278 ++++++ code_helper.py | 520 +++++++++++ computer_control.py | 583 ++++++++++++ computer_settings.py | 478 ++++++++++ config_manager.py | 16 + desktop.py | 672 ++++++++++++++ dev_agent.py | 457 +++++++++ error_handler.py | 418 +++++++++ file_controller.py | 597 ++++++++++++ file_processor.py | 482 ++++++++++ flight_finder.py | 833 +++++++++++++++++ game_updater.py | 339 +++++++ main.py | 856 +---------------- open_app.py | 817 +++++++++++++++++ planner.py | 197 ++++ prompt.txt | 221 +++++ readme.md | 91 +- reminder.py | 208 +++++ requirements.txt | Bin 309 -> 634 bytes screen_processor.py | 156 ++++ send_message.py | 367 ++++++++ setup.py | 3 +- task_queue.py | 400 ++++++++ ui.py | 2086 ++++++++++++++++++++++++++++++------------ weather_report.py | 215 +++++ web_search.py | 62 ++ youtube_video.py | 137 +++ 27 files changed, 10043 insertions(+), 1446 deletions(-) create mode 100644 __init__.py create mode 100644 code_helper.py create mode 100644 computer_control.py create mode 100644 computer_settings.py create mode 100644 config_manager.py create mode 100644 desktop.py create mode 100644 dev_agent.py create mode 100644 error_handler.py create mode 100644 file_controller.py create mode 100644 file_processor.py create mode 100644 flight_finder.py create mode 100644 game_updater.py create mode 100644 open_app.py create mode 100644 planner.py create mode 100644 prompt.txt create mode 100644 reminder.py create mode 100644 screen_processor.py create mode 100644 send_message.py create mode 100644 task_queue.py create mode 100644 weather_report.py create mode 100644 web_search.py create mode 100644 youtube_video.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..639bee9 --- /dev/null +++ b/__init__.py @@ -0,0 +1,278 @@ +import json +import re +import sys +from pathlib import Path + + +def get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + + +PLANNER_PROMPT = """You are the planning module of MARK XXV, a personal AI assistant. +Your job: break any user goal into a sequence of steps using ONLY the tools listed below. + +ABSOLUTE RULES: +- NEVER use generated_code or write Python scripts. It does not exist. +- NEVER reference previous step results in parameters. Every step is independent. +- Use web_search for ANY information retrieval, research, or current data. +- Use file_controller to save content to disk. +- Use cmd_control to open files or run system commands. +- Max 5 steps. Use the minimum steps needed. + +AVAILABLE TOOLS AND THEIR PARAMETERS: + +open_app + app_name: string (required) + +web_search + query: string (required) — write a clear, focused search query + mode: "search" or "compare" (optional, default: search) + items: list of strings (optional, for compare mode) + aspect: string (optional, for compare mode) + +game_updater + action: "update" | "install" | "list" | "download_status" | "schedule" (required) + platform: "steam" | "epic" | "both" (optional, default: both) + game_name: string (optional) + app_id: string (optional) + shutdown_when_done: boolean (optional) + +browser_control + action: "go_to" | "search" | "click" | "type" | "scroll" | "get_text" | "press" | "close" (required) + url: string (for go_to) + query: string (for search) + text: string (for click/type) + direction: "up" | "down" (for scroll) + +file_controller + action: "write" | "create_file" | "read" | "list" | "delete" | "move" | "copy" | "find" | "disk_usage" (required) + path: string — use "desktop" for Desktop folder + name: string — filename + content: string — file content (for write/create_file) + +cmd_control + task: string (required) — natural language description of what to do + visible: boolean (optional) + +computer_settings + action: string (required) + description: string — natural language description + value: string (optional) + +computer_control + action: "type" | "click" | "hotkey" | "press" | "scroll" | "screenshot" | "screen_find" | "screen_click" (required) + text: string (for type) + x, y: int (for click) + keys: string (for hotkey, e.g. "ctrl+c") + key: string (for press) + direction: "up" | "down" (for scroll) + description: string (for screen_find/screen_click) + +screen_process + text: string (required) — what to analyze or ask about the screen + angle: "screen" | "camera" (optional) + +send_message + receiver: string (required) + message_text: string (required) + platform: string (required) + +reminder + date: string YYYY-MM-DD (required) + time: string HH:MM (required) + message: string (required) + +desktop_control + action: "wallpaper" | "organize" | "clean" | "list" | "task" (required) + path: string (optional) + task: string (optional) + +youtube_video + action: "play" | "summarize" | "trending" (required) + query: string (for play) + +weather_report + city: string (required) + +flight_finder + origin: string (required) + destination: string (required) + date: string (required) + +code_helper + action: "write" | "edit" | "run" | "explain" (required) + description: string (required) + language: string (optional) + output_path: string (optional) + file_path: string (optional) + +dev_agent + description: string (required) + language: string (optional) +EXAMPLES: + +Goal: "research mechanical engineering and save it to a notepad file" +Steps: + +web_search | query: "mechanical engineering overview definition history" +web_search | query: "mechanical engineering applications and future trends" +file_controller | action: write, path: desktop, name: mechanical_engineering.txt, content: "MECHANICAL ENGINEERING RESEARCH\n\nThis file will be filled with web research results." +cmd_control | task: "open mechanical_engineering.txt on desktop with notepad" + +Goal: "What is the price of Bitcoin" +Steps: + +web_search | query: "Bitcoin price today USD" + +Goal: "List the files on the desktop and find the largest 5 files" +Steps: + +file_controller | action: list, path: desktop +file_controller | action: largest, path: desktop, count: 5 + +Goal: "Install PUBG from Steam" +Steps: + +game_updater | action: install, platform: steam, game_name: "PUBG" + +Goal: "Update all my Steam games" +Steps: + +game_updater | action: update, platform: steam + +Goal: "Send John a message on WhatsApp saying there is a meeting tomorrow" +Steps: + +send_message | receiver: John, message_text: "There is a meeting tomorrow", platform: WhatsApp + +Goal: "Open the clock and set a reminder for 30 minutes later" +Steps: + +reminder | date: [today], time: [now+30min], message: "Reminder" + +OUTPUT — return ONLY valid JSON, no markdown, no explanation, no code blocks: +{ + "goal": "...", + "steps": [ + { + "step": 1, + "tool": "tool_name", + "description": "what this step does", + "parameters": {}, + "critical": true + } + ] +} +""" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def create_plan(goal: str, context: str = "") -> dict: + import google.generativeai as genai + + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel( + model_name="gemini-2.5-flash-lite", + system_instruction=PLANNER_PROMPT + ) + + user_input = f"Goal: {goal}" + if context: + user_input += f"\n\nContext: {context}" + + try: + response = model.generate_content(user_input) + text = response.text.strip() + text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip() + + plan = json.loads(text) + + if "steps" not in plan or not isinstance(plan["steps"], list): + raise ValueError("Invalid plan structure") + + for step in plan["steps"]: + if step.get("tool") in ("generated_code",): + print(f"[Planner] ⚠️ generated_code detected in step {step.get('step')} — replacing with web_search") + desc = step.get("description", goal) + step["tool"] = "web_search" + step["parameters"] = {"query": desc[:200]} + + print(f"[Planner] ✅ Plan: {len(plan['steps'])} steps") + for s in plan["steps"]: + print(f" Step {s['step']}: [{s['tool']}] {s['description']}") + + return plan + + except json.JSONDecodeError as e: + print(f"[Planner] ⚠️ JSON parse failed: {e}") + return _fallback_plan(goal) + except Exception as e: + print(f"[Planner] ⚠️ Planning failed: {e}") + return _fallback_plan(goal) + + +def _fallback_plan(goal: str) -> dict: + print("[Planner] 🔄 Fallback plan") + return { + "goal": goal, + "steps": [ + { + "step": 1, + "tool": "web_search", + "description": f"Search for: {goal}", + "parameters": {"query": goal}, + "critical": True + } + ] + } + + +def replan(goal: str, completed_steps: list, failed_step: dict, error: str) -> dict: + import google.generativeai as genai + + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel( + model_name="gemini-2.5-flash", + system_instruction=PLANNER_PROMPT + ) + + completed_summary = "\n".join( + f" - Step {s['step']} ({s['tool']}): DONE" for s in completed_steps + ) + + prompt = f"""Goal: {goal} + +Already completed: +{completed_summary if completed_summary else ' (none)'} + +Failed step: [{failed_step.get('tool')}] {failed_step.get('description')} +Error: {error} + +Create a REVISED plan for the remaining work only. Do not repeat completed steps.""" + + try: + response = model.generate_content(prompt) + text = response.text.strip() + text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip() + plan = json.loads(text) + + for step in plan.get("steps", []): + if step.get("tool") == "generated_code": + step["tool"] = "web_search" + step["parameters"] = {"query": step.get("description", goal)[:200]} + + print(f"[Planner] 🔄 Revised plan: {len(plan['steps'])} steps") + return plan + except Exception as e: + print(f"[Planner] ⚠️ Replan failed: {e}") + return _fallback_plan(goal) \ No newline at end of file diff --git a/code_helper.py b/code_helper.py new file mode 100644 index 0000000..78de1ca --- /dev/null +++ b/code_helper.py @@ -0,0 +1,520 @@ +import asyncio +import threading +import concurrent.futures +import platform +import shutil +import subprocess +from pathlib import Path +from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout + + +def _get_default_browser_id() -> str: + """Returns raw default browser identifier string for current OS.""" + system = platform.system() + try: + if system == "Windows": + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice" + ) + prog_id = winreg.QueryValueEx(key, "ProgId")[0].lower() + winreg.CloseKey(key) + return prog_id + + elif system == "Darwin": + result = subprocess.run( + ["defaults", "read", + "com.apple.LaunchServices/com.apple.launchservices.secure", + "LSHandlers"], + capture_output=True, text=True, timeout=5 + ) + return result.stdout.lower() + + elif system == "Linux": + result = subprocess.run( + ["xdg-settings", "get", "default-web-browser"], + capture_output=True, text=True, timeout=5 + ) + return result.stdout.lower() + + except Exception: + pass + + return "" + + +_BROWSER_BINARIES = { + "Windows": { + "opera": ["opera.exe"], + "brave": ["brave.exe"], + "vivaldi": ["vivaldi.exe"], + "chrome": ["chrome.exe"], + "firefox": ["firefox.exe"], + }, + "Darwin": { + "opera": ["opera"], + "brave": ["brave browser", "brave"], + "vivaldi": ["vivaldi"], + "chrome": ["google chrome", "google-chrome"], + "firefox": ["firefox"], + }, + "Linux": { + "opera": ["opera", "opera-stable"], + "brave": ["brave-browser", "brave"], + "vivaldi": ["vivaldi-stable", "vivaldi"], + "chrome": ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"], + "firefox": ["firefox"], + }, +} + + +def _get_opera_executable() -> str | None: + if platform.system() != "Windows": + return None + try: + import winreg + candidate_keys = [ + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\opera.exe", + r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\launcher.exe", + r"SOFTWARE\Clients\StartMenuInternet\OperaStable\shell\open\command", + r"SOFTWARE\Clients\StartMenuInternet\OperaGXStable\shell\open\command", + ] + for key_path in candidate_keys: + for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + try: + key = winreg.OpenKey(hive, key_path) + val = winreg.QueryValue(key, None) + winreg.CloseKey(key) + exe = val.strip().strip('"').split('"')[0].split(" --")[0].strip() + if exe and Path(exe).exists(): + print(f"[Browser] 🔍 Opera found via registry: {exe}") + return exe + except Exception: + continue + except Exception: + pass + return None + + +def _find_browser_executable(prog_id: str) -> tuple: + """ + Returns (engine_name, exe_path, channel, is_opera). + is_opera=True → extra args needed to prevent private-mode launch. + """ + system = platform.system() + os_bins = _BROWSER_BINARIES.get(system, {}) + + if any(x in prog_id for x in ["firefox", "mozilla"]): + return "firefox", None, None, False + + if "safari" in prog_id: + return "webkit", None, None, False + + if "edge" in prog_id: + return "chromium", None, "msedge", False + + if "opera" in prog_id: + exe = _get_opera_executable() + if exe: + return "chromium", exe, None, True + for binary in os_bins.get("opera", []): + path = shutil.which(binary) + if path: + return "chromium", path, None, True + + browser_patterns = { + "brave": ["brave"], + "vivaldi": ["vivaldi"], + "chrome": ["chrome"], + } + for browser_name, patterns in browser_patterns.items(): + if not any(p in prog_id for p in patterns): + continue + binaries = os_bins.get(browser_name, []) + for binary in binaries: + path = shutil.which(binary) + if path: + print(f"[Browser] 🔍 Found {browser_name} at: {path}") + return "chromium", path, None, False + + if "chrome" in prog_id or not prog_id: + return "chromium", None, "chrome", False + + return "chromium", None, None, False + + +class _BrowserThread: + + def __init__(self): + self._loop = None + self._thread = None + self._ready = threading.Event() + self._playwright = None + self._browser = None + self._context = None + self._page = None + self._engine_name = "chromium" + self._exe_path = None + self._channel = None + self._is_opera = False + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._thread = threading.Thread( + target=self._run_loop, daemon=True, name="BrowserThread" + ) + self._thread.start() + self._ready.wait(timeout=15) + + def _run_loop(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._init()) + self._ready.set() + self._loop.run_forever() + + async def _init(self): + self._playwright = await async_playwright().start() + + def run(self, coro, timeout: int = 30): + if not self._loop: + raise RuntimeError("BrowserThread not started.") + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result(timeout=timeout) + + # ── Tarayıcı ve sayfa yönetimi ─────────────────────────────────────────── + + async def _launch_browser_if_needed(self): + """ + Tarayıcıyı başlatır. Zaten açıksa hiçbir şey yapmaz. + Her zaman default tarayıcıyı kullanır, özel sekme açmaz. + """ + if self._browser and self._browser.is_connected(): + return + + prog_id = _get_default_browser_id() + self._engine_name, self._exe_path, self._channel, self._is_opera = _find_browser_executable(prog_id) + engine = getattr(self._playwright, self._engine_name) + + # Temel chromium argümanları + chromium_args = ["--start-maximized"] + + if self._is_opera: + # Opera GX bazı sürümlerde varsayılan olarak private modda başlar. + # Aşağıdaki flag'ler bunu engeller. + chromium_args += [ + "--disable-features=OperaPrivacyMode", + "--no-private", + ] + print("[Browser] 🎭 Opera detected — disabling private-mode flags") + + launch_kwargs = {"headless": False} + if self._engine_name == "chromium": + launch_kwargs["args"] = chromium_args + if self._exe_path: + launch_kwargs["executable_path"] = self._exe_path + elif self._channel: + launch_kwargs["channel"] = self._channel + + try: + self._browser = await engine.launch(**launch_kwargs) + print( + f"[Browser] ✅ Launched ({self._engine_name}" + f"{' / ' + self._channel if self._channel else ''}" + f"{' / ' + self._exe_path if self._exe_path else ''})" + ) + except Exception as e: + print(f"[Browser] ⚠️ Launch failed ({e}), falling back to built-in Chromium") + self._browser = await self._playwright.chromium.launch( + headless=False, + args=["--start-maximized"] + ) + + async def _get_page(self): + """ + Mevcut sayfayı döndürür. + - Tarayıcı kapalıysa açar. + - Context yoksa oluşturur. + - Sayfa kapalıysa yeni sekme açar (aynı pencerede). + - Sayfa zaten açıksa aynı sayfayı döndürür (yeni pencere açmaz). + """ + await self._launch_browser_if_needed() + + if self._context is None: + self._context = await self._browser.new_context( + viewport=None, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) + ) + + if self._page is None or self._page.is_closed(): + self._page = await self._context.new_page() + + return self._page + + # ── Eylemler ───────────────────────────────────────────────────────────── + + async def _go_to(self, url: str) -> str: + if not url.startswith("http"): + url = "https://" + url + page = await self._get_page() + try: + await page.goto(url, wait_until="domcontentloaded", timeout=15000) + return f"Opened: {page.url}" + except PlaywrightTimeout: + return f"Timeout loading: {url}" + except Exception as e: + return f"Navigation error: {e}" + + async def _search(self, query: str, engine: str = "google") -> str: + engines = { + "google": f"https://www.google.com/search?q={query.replace(' ', '+')}", + "bing": f"https://www.bing.com/search?q={query.replace(' ', '+')}", + "duckduckgo": f"https://duckduckgo.com/?q={query.replace(' ', '+')}", + } + url = engines.get(engine.lower(), engines["google"]) + return await self._go_to(url) + + async def _click(self, selector=None, text=None) -> str: + page = await self._get_page() + try: + if text: + await page.get_by_text(text, exact=False).first.click(timeout=8000) + return f"Clicked: '{text}'" + elif selector: + await page.click(selector, timeout=8000) + return f"Clicked: {selector}" + return "No selector or text provided." + except PlaywrightTimeout: + return "Element not found or not clickable." + except Exception as e: + return f"Click error: {e}" + + async def _type(self, selector=None, text: str = "", clear_first: bool = True) -> str: + page = await self._get_page() + try: + element = page.locator(selector).first if selector else page.locator(":focus") + if clear_first: + await element.clear() + await element.type(text, delay=50) + return "Text typed." + except Exception as e: + return f"Type error: {e}" + + async def _scroll(self, direction: str = "down", amount: int = 500) -> str: + page = await self._get_page() + try: + y = amount if direction == "down" else -amount + await page.mouse.wheel(0, y) + return f"Scrolled {direction}." + except Exception as e: + return f"Scroll error: {e}" + + async def _press(self, key: str) -> str: + page = await self._get_page() + try: + await page.keyboard.press(key) + return f"Pressed: {key}" + except Exception as e: + return f"Key error: {e}" + + async def _get_text(self) -> str: + page = await self._get_page() + try: + text = await page.inner_text("body") + return text[:4000] if len(text) > 4000 else text + except Exception as e: + return f"Could not get page text: {e}" + + async def _fill_form(self, fields: dict) -> str: + page = await self._get_page() + results = [] + for selector, value in fields.items(): + try: + el = page.locator(selector).first + await el.clear() + await el.type(str(value), delay=40) + results.append(f"✓ {selector}") + except Exception as e: + results.append(f"✗ {selector}: {e}") + return "Form filled: " + ", ".join(results) + + async def _smart_click(self, description: str) -> str: + page = await self._get_page() + desc_lower = description.lower() + + role_hints = { + "button": ["button", "buton", "btn"], + "link": ["link", "bağlantı"], + "searchbox": ["search", "arama"], + "textbox": ["input", "field", "alan"], + } + for role, keywords in role_hints.items(): + if any(k in desc_lower for k in keywords): + try: + await page.get_by_role(role).first.click(timeout=5000) + return f"Clicked ({role}): '{description}'" + except Exception: + pass + + try: + await page.get_by_text(description, exact=False).first.click(timeout=5000) + return f"Clicked (text): '{description}'" + except Exception: + pass + + try: + await page.get_by_placeholder(description, exact=False).first.click(timeout=5000) + return f"Clicked (placeholder): '{description}'" + except Exception: + pass + + return f"Could not find: '{description}'" + + async def _smart_type(self, description: str, text: str) -> str: + page = await self._get_page() + + for method, locator in [ + ("placeholder", page.get_by_placeholder(description, exact=False)), + ("label", page.get_by_label(description, exact=False)), + ("role", page.get_by_role("textbox")), + ]: + try: + el = locator.first + await el.clear() + await el.type(text, delay=50) + return f"Typed into ({method}): '{description}'" + except Exception: + continue + + return f"Could not find input: '{description}'" + + async def _close_browser(self) -> str: + if self._browser: + await self._browser.close() + self._browser = None + self._context = None + self._page = None + + if self._playwright: + await self._playwright.stop() + self._playwright = None + + return "Browser closed." + + +# ── Singleton browser thread ───────────────────────────────────────────────── + +_bt = _BrowserThread() +_bt_started = False +_bt_lock = threading.Lock() + + +def _ensure_started(): + global _bt_started + with _bt_lock: + if not _bt_started: + _bt.start() + _bt_started = True + + +# ── Public API ─────────────────────────────────────────────────────────────── + +def browser_control( + parameters: dict, + response=None, + player=None, + session_memory=None +) -> str: + """ + Browser controller — auto-detects and uses system default browser. + Always reuses the existing browser window/page; never opens incognito. + + parameters: + action : go_to | search | click | type | scroll | fill_form | + smart_click | smart_type | get_text | press | close + url : URL for go_to + query : search query + engine : google | bing | duckduckgo (default: google) + selector : CSS selector for click/type + text : text to click or type + description : element description for smart_click/smart_type + direction : up | down for scroll + amount : scroll amount in pixels (default: 500) + key : key name for press (e.g. Enter, Escape, Tab) + fields : {selector: value} dict for fill_form + clear_first : bool, clear input before typing (default: True) + """ + _ensure_started() + + action = (parameters or {}).get("action", "").lower().strip() + result = "Unknown action." + + try: + if action == "go_to": + result = _bt.run(_bt._go_to(parameters.get("url", ""))) + + elif action == "search": + result = _bt.run(_bt._search( + parameters.get("query", ""), + parameters.get("engine", "google"), + )) + + elif action == "click": + result = _bt.run(_bt._click( + selector=parameters.get("selector"), + text=parameters.get("text"), + )) + + elif action == "type": + result = _bt.run(_bt._type( + selector=parameters.get("selector"), + text=parameters.get("text", ""), + clear_first=parameters.get("clear_first", True), + )) + + elif action == "scroll": + result = _bt.run(_bt._scroll( + direction=parameters.get("direction", "down"), + amount=parameters.get("amount", 500), + )) + + elif action == "fill_form": + result = _bt.run(_bt._fill_form(parameters.get("fields", {}))) + + elif action == "smart_click": + result = _bt.run(_bt._smart_click(parameters.get("description", ""))) + + elif action == "smart_type": + result = _bt.run(_bt._smart_type( + parameters.get("description", ""), + parameters.get("text", ""), + )) + + elif action == "get_text": + result = _bt.run(_bt._get_text()) + + elif action == "press": + result = _bt.run(_bt._press(parameters.get("key", "Enter"))) + + elif action == "close": + result = _bt.run(_bt._close_browser()) + + else: + result = f"Unknown action: {action}" + + except concurrent.futures.TimeoutError: + result = "Browser action timed out." + except Exception as e: + result = f"Browser error: {e}" + + print(f"[Browser] {result[:80]}") + if player: + player.write_log(f"[browser] {result[:60]}") + + return result \ No newline at end of file diff --git a/computer_control.py b/computer_control.py new file mode 100644 index 0000000..7247aa1 --- /dev/null +++ b/computer_control.py @@ -0,0 +1,583 @@ +# actions/code_helper.py +# AI-powered code assistant — writes, edits, explains, runs, builds, debugs, and optimizes code. +# +# Actions: +# write → Describe what you want, Gemini writes it, saves to file +# edit → Read existing file, apply natural language change +# explain → Explain what a piece of code or file does +# run → Execute a script file, return output +# build → Write → Run → Fix loop (max 3 attempts), speaks when done +# screen_debug → Screenshot al, ekrandaki kodu/hatayı Gemini ile analiz et ve düzelt +# optimize → Mevcut kodu Gemini ile optimize et (performans, okunabilirlik, best practices) +# auto → (default) Intent auto-detected from context + +import subprocess +import sys +import json +import re +import time +from pathlib import Path + + +def get_base_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +DESKTOP = Path.home() / "Desktop" +MAX_BUILD_ATTEMPTS = 3 +GEMINI_MODEL = "gemini-2.5-flash" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _get_gemini(model: str = GEMINI_MODEL): + import google.generativeai as genai + genai.configure(api_key=_get_api_key()) + return genai.GenerativeModel(model) + + +def _clean_code(text: str) -> str: + text = text.strip() + text = re.sub(r"^```[a-zA-Z]*\n?", "", text) + text = re.sub(r"\n?```$", "", text) + return text.strip() + + +def _resolve_save_path(output_path: str, language: str) -> Path: + ext_map = { + "python": ".py", "py": ".py", + "javascript": ".js", "js": ".js", + "typescript": ".ts", "ts": ".ts", + "html": ".html", "css": ".css", + "java": ".java", "cpp": ".cpp", "c": ".c", + "bash": ".sh", "shell": ".sh", "powershell": ".ps1", + "sql": ".sql", "json": ".json", "rust": ".rs", "go": ".go", + } + if output_path: + p = Path(output_path) + return p if p.is_absolute() else DESKTOP / p + ext = ext_map.get((language or "python").lower(), ".py") + return DESKTOP / f"jarvis_code{ext}" + + +def _read_file(file_path: str) -> tuple[str, str]: + if not file_path: + return "", "No file path provided." + p = Path(file_path) + if not p.exists(): + return "", f"File not found: {file_path}" + try: + return p.read_text(encoding="utf-8"), "" + except Exception as e: + return "", f"Could not read file: {e}" + + +def _save_file(path: Path, content: str) -> str: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return f"Saved to: {path}" + except Exception as e: + return f"Could not save: {e}" + + +def _preview(code: str, lines: int = 10) -> str: + all_lines = code.splitlines() + preview = "\n".join(all_lines[:lines]) + suffix = f"\n... ({len(all_lines) - lines} more lines)" if len(all_lines) > lines else "" + return preview + suffix + + +def _has_error(output: str) -> bool: + error_signals = ["error", "exception", "traceback", "syntaxerror", + "nameerror", "typeerror", "stderr", "failed", "crash"] + return any(s in output.lower() for s in error_signals) + + +def _take_screenshot() -> Path | None: + try: + import pyautogui + screenshot_path = Path.home() / "Desktop" / f"jarvis_debug_{int(time.time())}.png" + screenshot = pyautogui.screenshot() + screenshot.save(str(screenshot_path)) + print(f"[Code] 📸 Screenshot: {screenshot_path}") + return screenshot_path + except Exception as e: + print(f"[Code] ⚠️ Screenshot failed: {e}") + return None + + +def _image_to_base64(path: Path) -> str: + import base64 + return base64.b64encode(path.read_bytes()).decode("utf-8") + + +def _detect_intent(description: str, file_path: str, code: str) -> str: + desc = (description or "").lower() + + screen_kw = ["ekrandaki", "screen", "ekranda", "bu hatayı", "why am i getting", + "neden hata", "what's wrong", "ne yanlış", "screenshot", "görüntü"] + if any(k in desc for k in screen_kw): + return "screen_debug" + + # Optimize keywords + optimize_kw = ["optimize", "refactor", "clean up", "improve", "temizle", + "iyileştir", "daha iyi", "make it better", "hızlandır"] + if any(k in desc for k in optimize_kw) and (code or file_path): + return "optimize" + + if file_path: + p = Path(file_path) + edit_kw = ["edit", "update", "modify", "change", "add", "remove", + "refactor", "fix", "rename", "replace", "düzenle", "değiştir"] + run_kw = ["run", "execute", "launch", "start", "çalıştır"] + build_kw = ["build", "make it work", "try", "attempt"] + + if p.exists() and any(k in desc for k in edit_kw): + return "edit" + if p.exists() and any(k in desc for k in run_kw): + return "run" + if any(k in desc for k in build_kw): + return "build" + if p.exists(): + return "explain" + + explain_kw = ["explain", "what does", "describe", "analyze", "açıkla", "ne yapıyor"] + if any(k in desc for k in explain_kw) and (code or file_path): + return "explain" + + build_kw = ["build", "make it work", "try and", "attempt"] + if any(k in desc for k in build_kw): + return "build" + + return "write" + +def _write(description: str, language: str, output_path: str, player=None) -> tuple[str, Path]: + lang = language or "python" + model = _get_gemini() + + prompt = f"""You are an expert {lang} developer. +Write clean, working, well-commented {lang} code for the description below. + +Rules: +- Output ONLY the code. No explanation, no markdown, no backticks. +- Add helpful inline comments. +- Handle errors and edge cases properly. +- Use modern best practices. + +Description: {description} + +Code:""" + + response = model.generate_content(prompt) + code = _clean_code(response.text) + path = _resolve_save_path(output_path, lang) + _save_file(path, code) + return code, path + + +def _fix_code(code: str, error_output: str, description: str) -> str: + model = _get_gemini() + prompt = f"""You are an expert debugger. +The code below failed with the following error. Fix it. +Return ONLY the corrected code — no explanation, no markdown, no backticks. + +Original goal: {description} + +Error: +{error_output[:2000]} + +Broken code: +{code} + +Fixed code:""" + + response = model.generate_content(prompt) + return _clean_code(response.text) + + +def _run_file(path: Path, args: list, timeout: int) -> str: + interpreters = { + ".py": [sys.executable], + ".js": ["node"], + ".ts": ["ts-node"], + ".sh": ["bash"], + ".ps1": ["powershell", "-File"], + ".rb": ["ruby"], + ".php": ["php"], + } + interp = interpreters.get(path.suffix.lower()) + if not interp: + return f"No interpreter for {path.suffix}." + + try: + result = subprocess.run( + interp + [str(path)] + (args or []), + capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout, cwd=str(path.parent) + ) + output = result.stdout.strip() + error = result.stderr.strip() + parts = [] + if output: parts.append(f"Output:\n{output}") + if error: parts.append(f"Stderr:\n{error}") + return "\n\n".join(parts) if parts else "Executed with no output." + + except subprocess.TimeoutExpired: + return f"Timed out after {timeout}s." + except FileNotFoundError: + return f"Interpreter not found: {interp[0]}." + except Exception as e: + return f"Execution error: {e}" + + +def _build(description, language, output_path, args, timeout, speak=None, player=None) -> str: + if not description: + return "Please describe what you want me to build, sir." + + if player: + player.write_log("[Code] Build started...") + + lang = language or "python" + + try: + code, path = _write(description, lang, output_path, player) + print(f"[Code] ✅ Written: {path}") + except Exception as e: + msg = f"Could not write initial code: {e}" + if speak: speak(msg) + return msg + + last_output = "" + for attempt in range(1, MAX_BUILD_ATTEMPTS + 1): + print(f"[Code] 🔄 Attempt {attempt}/{MAX_BUILD_ATTEMPTS}") + if player: + player.write_log(f"[Code] Attempt {attempt}...") + + last_output = _run_file(path, args, timeout) + + if not _has_error(last_output): + msg = ( + f"Build complete, sir. " + f"The code is working after {attempt} attempt{'s' if attempt > 1 else ''}. " + f"Saved to {path}." + ) + if speak: speak(msg) + return f"{msg}\n\nOutput:\n{last_output}" + + print(f"[Code] ⚠️ Error on attempt {attempt}, fixing...") + if player: + player.write_log(f"[Code] Fixing (attempt {attempt})...") + + try: + code = _fix_code(code, last_output, description) + _save_file(path, code) + except Exception as e: + msg = f"Could not fix code on attempt {attempt}: {e}" + if speak: speak(msg) + return msg + + msg = ( + f"I was unable to build a working version after {MAX_BUILD_ATTEMPTS} attempts, sir. " + f"The last error was: {last_output[:200]}" + ) + if speak: speak(msg) + return f"{msg}\n\nLast code saved to: {path}" + +def _write_action(description, language, output_path, player) -> str: + if not description: + return "Please describe what you want me to write, sir." + if player: + player.write_log("[Code] Writing code...") + try: + code, path = _write(description, language, output_path, player) + print(f"[Code] ✅ Written: {path}") + return f"Code written. Saved to: {path}\n\nPreview:\n{_preview(code)}" + except Exception as e: + return f"Could not generate code: {e}" + + +def _edit_action(file_path, instruction, player) -> str: + if not file_path: + return "Please provide a file path to edit, sir." + if not instruction: + return "Please describe what change to make, sir." + + content, err = _read_file(file_path) + if err: + return err + + if player: + player.write_log("[Code] Editing file...") + + model = _get_gemini() + prompt = f"""You are an expert code editor. +Apply the following change to the code below. +Return ONLY the complete updated code — no explanation, no markdown, no backticks. + +Change: {instruction} + +Original code: +{content} + +Updated code:""" + + try: + response = model.generate_content(prompt) + edited = _clean_code(response.text) + except Exception as e: + return f"Could not edit code: {e}" + + status = _save_file(Path(file_path), edited) + print(f"[Code] ✅ Edited: {file_path}") + return f"File edited. {status}\n\nPreview:\n{_preview(edited)}" + + +def _explain_action(file_path, code, player) -> str: + if file_path and not code: + code, err = _read_file(file_path) + if err: + return err + if not code: + return "Please provide code or a file path to explain, sir." + + if player: + player.write_log("[Code] Analyzing code...") + + model = _get_gemini() + prompt = f"""Explain what this code does in simple, clear language. +Focus on: what it does, how it works, and any important details. +Be concise — 3 to 6 sentences maximum. + +Code: +{code[:4000]} + +Explanation:""" + + try: + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + return f"Could not explain code: {e}" + + +def _run_action(file_path, args, timeout, player) -> str: + if not file_path: + return "Please provide a file path to run, sir." + p = Path(file_path) + if not p.exists(): + return f"File not found: {file_path}" + if player: + player.write_log(f"[Code] Running {p.name}...") + return _run_file(p, args, timeout) + + +def _optimize_action(file_path, code, language, output_path, player) -> str: + + if file_path and not code: + code, err = _read_file(file_path) + if err: + return err + if not code: + return "Please provide code or a file path to optimize, sir." + + if player: + player.write_log("[Code] Optimizing code...") + + lang = language or "python" + model = _get_gemini() + + prompt = f"""You are an expert {lang} developer and code reviewer. +Optimize the following code for: +1. Performance — eliminate unnecessary operations, use efficient data structures +2. Readability — clear variable names, proper formatting, logical structure +3. Best practices — modern {lang} patterns, error handling, type hints if applicable +4. Remove dead code, redundant comments, and unnecessary complexity + +Return ONLY the optimized code — no explanation, no markdown, no backticks. + +Original code: +{code[:6000]} + +Optimized code:""" + + try: + response = model.generate_content(prompt) + optimized = _clean_code(response.text) + except Exception as e: + return f"Could not optimize code: {e}" + + # Kaydet + if file_path: + save_path = Path(file_path) + else: + save_path = _resolve_save_path(output_path, lang) + + status = _save_file(save_path, optimized) + print(f"[Code] ✅ Optimized: {save_path}") + + original_lines = len(code.splitlines()) + optimized_lines = len(optimized.splitlines()) + diff = original_lines - optimized_lines + + return ( + f"Code optimized. {status}\n" + f"Lines: {original_lines} → {optimized_lines} " + f"({'−' if diff > 0 else '+'}{abs(diff)} lines)\n\n" + f"Preview:\n{_preview(optimized)}" + ) + + +def _screen_debug_action(description, file_path, player, speak=None) -> str: + + if player: + player.write_log("[Code] Taking screenshot for analysis...") + + print("[Code] 📸 Capturing screen for debug...") + + + screenshot_path = _take_screenshot() + if not screenshot_path: + return "Could not take screenshot, sir. Please make sure PyAutoGUI is installed." + + + file_content = "" + if file_path: + file_content, err = _read_file(file_path) + if err: + print(f"[Code] ⚠️ Could not read file: {err}") + + try: + from google import genai + from google.genai import types + + client = genai.Client(api_key=_get_api_key()) + + image_bytes = screenshot_path.read_bytes() + image_base64 = _image_to_base64(screenshot_path) + + user_question = description or "What error or problem do you see on the screen? How can it be fixed?" + + context = "" + if file_content: + context = f"\n\nAdditionally, here is the related file content:\n```\n{file_content[:4000]}\n```" + + analysis_prompt = f"""You are an expert programmer and debugger analyzing a screenshot. + +User's question: {user_question}{context} + +Please: +1. Identify any errors, exceptions, or problems visible on the screen +2. Explain what is causing the problem in simple terms +3. Provide a concrete fix or solution +4. If there's code visible, show the corrected version + +Be specific and actionable. If you see an error message, quote it exactly.""" + + contents = [ + types.Part.from_bytes(data=image_bytes, mime_type="image/png"), + analysis_prompt, + ] + + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=contents, + ) + + analysis = response.text.strip() + print(f"[Code] ✅ Screen analysis complete") + + try: + screenshot_path.unlink() + except Exception: + pass + + if file_path and file_content: + + code_match = re.search(r"```[a-zA-Z]*\n(.*?)```", analysis, re.DOTALL) + if code_match: + fixed_code = code_match.group(1).strip() + save_path = Path(file_path) + _save_file(save_path, fixed_code) + analysis += f"\n\n✅ Fixed code has been saved to: {file_path}" + print(f"[Code] ✅ Fixed code saved: {file_path}") + + return analysis + + except Exception as e: + + try: + screenshot_path.unlink() + except Exception: + pass + return f"Screen analysis failed: {e}" + + +def code_helper( + parameters: dict, + response=None, + player=None, + session_memory=None, + speak=None +) -> str: + """ + Called from main.py. + + parameters: + action : write | edit | explain | run | build | screen_debug | optimize | auto + description : What the code should do / what change to make / what problem to analyze + language : Programming language (default: python) + output_path : Where to save — user specifies full path or filename + file_path : Path to existing file (edit / explain / run / build / optimize) + code : Raw code string (explain/optimize without a file) + args : CLI argument list for run/build + timeout : Execution timeout in seconds (default: 30) + """ + p = parameters or {} + action = p.get("action", "auto").lower().strip() + description = p.get("description", "").strip() + language = p.get("language", "python").strip() + output_path = p.get("output_path", "").strip() + file_path = p.get("file_path", "").strip() + code = p.get("code", "").strip() + args = p.get("args", []) + timeout = int(p.get("timeout", 30)) + + if action == "auto": + action = _detect_intent(description, file_path, code) + print(f"[Code] 🤖 Auto-detected: {action}") + + if action == "write": + return _write_action(description, language, output_path, player) + + elif action == "edit": + return _edit_action( + file_path, + description or p.get("instruction", ""), + player + ) + + elif action == "explain": + return _explain_action(file_path, code, player) + + elif action == "run": + return _run_action(file_path, args, timeout, player) + + elif action == "build": + return _build(description, language, output_path, args, timeout, speak, player) + + elif action == "optimize": + return _optimize_action(file_path, code, language, output_path, player) + + elif action == "screen_debug": + return _screen_debug_action(description, file_path, player, speak) + + else: + return f"Unknown action: '{action}'. Use write, edit, explain, run, build, optimize, or screen_debug." \ No newline at end of file diff --git a/computer_settings.py b/computer_settings.py new file mode 100644 index 0000000..c8403c9 --- /dev/null +++ b/computer_settings.py @@ -0,0 +1,478 @@ +#computer_control.py +import io +import json +import re +import string +import subprocess +import sys +import time +import random +from pathlib import Path + +try: + import pyautogui + pyautogui.FAILSAFE = True + pyautogui.PAUSE = 0.05 + _PYAUTOGUI = True +except ImportError: + _PYAUTOGUI = False + +try: + import pyperclip + _PYPERCLIP = True +except ImportError: + _PYPERCLIP = False + +def _base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +_BASE = _base_dir() +_CONFIG_PATH = _BASE / "config" / "api_keys.json" +_MEMORY_PATH = _BASE / "memory" / "long_term.json" + +def _load_config() -> dict: + try: + return json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + except Exception: + return {} + +def _get_os() -> str: + return _load_config().get("os_system", "windows").lower() + +_SAFE_SCREENSHOT_ROOTS = ( + Path.home(), +) + +def _safe_screenshot_path(requested: str | None) -> Path: + fallback = Path.home() / "Desktop" / "jarvis_screenshot.png" + if not requested: + return fallback + try: + p = Path(requested).expanduser().resolve() + for root in _SAFE_SCREENSHOT_ROOTS: + if p.is_relative_to(root.resolve()): + p.parent.mkdir(parents=True, exist_ok=True) + return p + except Exception: + pass + return fallback + +def _require_pyautogui(): + if not _PYAUTOGUI: + raise RuntimeError("PyAutoGUI not installed. Run: pip install pyautogui") + +_FIRST_NAMES = [ + "Alex", "Jordan", "Taylor", "Morgan", "Casey", "Riley", "Drew", "Quinn", + "Avery", "Blake", "Cameron", "Dakota", "Emerson", "Finley", "Harper", +] +_LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Wilson", "Moore", "Taylor", "Anderson", "Thomas", "Jackson", +] +_DOMAINS = ["gmail.com", "yahoo.com", "outlook.com", "proton.me", "mail.com"] + + +def _random_data(data_type: str) -> str: + dt = data_type.lower().strip() + + if dt == "first_name": + return random.choice(_FIRST_NAMES) + + if dt == "last_name": + return random.choice(_LAST_NAMES) + + if dt == "name": + return f"{random.choice(_FIRST_NAMES)} {random.choice(_LAST_NAMES)}" + + if dt == "email": + first = random.choice(_FIRST_NAMES).lower() + last = random.choice(_LAST_NAMES).lower() + num = random.randint(10, 999) + return f"{first}.{last}{num}@{random.choice(_DOMAINS)}" + + if dt == "username": + return f"{random.choice(_FIRST_NAMES).lower()}{random.randint(100, 9999)}" + + if dt == "password": + chars = string.ascii_letters + string.digits + "!@#$%" + raw = ( + random.choice(string.ascii_uppercase) + + random.choice(string.digits) + + random.choice("!@#$%") + + "".join(random.choices(chars, k=9)) + ) + return "".join(random.sample(raw, len(raw))) + + if dt == "phone": + return f"+1{random.randint(200,999)}{random.randint(1_000_000, 9_999_999)}" + + if dt == "birthday": + y = random.randint(1980, 2000) + m = random.randint(1, 12) + d = random.randint(1, 28) + return f"{m:02d}/{d:02d}/{y}" + + if dt == "address": + num = random.randint(100, 9999) + street = random.choice(["Main St", "Oak Ave", "Park Blvd", "Elm St", "Cedar Ln"]) + return f"{num} {street}" + + if dt == "zip_code": + return str(random.randint(10000, 99999)) + + if dt == "city": + return random.choice(["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"]) + + return f"random_{data_type}_{random.randint(1000, 9999)}" + +def _user_profile() -> dict: + """Read identity fields from long-term memory.""" + try: + if _MEMORY_PATH.exists(): + data = json.loads(_MEMORY_PATH.read_text(encoding="utf-8")) + identity = data.get("identity", {}) + return {k: v.get("value", "") for k, v in identity.items()} + except Exception: + pass + return {} + +def _type(text: str, interval: float = 0.03) -> str: + _require_pyautogui() + time.sleep(0.3) + pyautogui.typewrite(text, interval=interval) + return f"Typed: {text[:60]}{'…' if len(text) > 60 else ''}" + + +def _smart_type(text: str, clear_first: bool = True) -> str: + _require_pyautogui() + if clear_first: + _clear_field() + time.sleep(0.1) + + if len(text) > 20 and _PYPERCLIP: + pyperclip.copy(text) + time.sleep(0.1) + pyautogui.hotkey("ctrl", "v") + return f"Smart-typed (clipboard): {text[:60]}{'…' if len(text) > 60 else ''}" + + pyautogui.typewrite(text, interval=0.04) + return f"Smart-typed: {text[:60]}{'…' if len(text) > 60 else ''}" + + +def _click(x=None, y=None, button: str = "left", clicks: int = 1) -> str: + _require_pyautogui() + if x is not None and y is not None: + pyautogui.click(x, y, button=button, clicks=clicks) + return f"{'Double-c' if clicks == 2 else 'C'}licked ({x}, {y}) [{button}]" + pyautogui.click(button=button, clicks=clicks) + return f"Clicked at current position [{button}]" + + +def _hotkey(*keys) -> str: + _require_pyautogui() + pyautogui.hotkey(*keys) + return f"Hotkey: {'+'.join(keys)}" + + +def _press(key: str) -> str: + _require_pyautogui() + pyautogui.press(key) + return f"Pressed: {key}" + + +def _scroll(direction: str = "down", amount: int = 3) -> str: + _require_pyautogui() + vertical = direction in ("up", "down") + clicks = amount if direction in ("up", "right") else -amount + pyautogui.scroll(clicks) if vertical else pyautogui.hscroll(clicks) + return f"Scrolled {direction} ×{amount}" + + +def _move(x: int, y: int, duration: float = 0.3) -> str: + _require_pyautogui() + pyautogui.moveTo(x, y, duration=duration) + return f"Mouse → ({x}, {y})" + + +def _drag(x1: int, y1: int, x2: int, y2: int, duration: float = 0.5) -> str: + _require_pyautogui() + pyautogui.moveTo(x1, y1, duration=0.2) + pyautogui.dragTo(x2, y2, duration=duration, button="left") + return f"Dragged ({x1},{y1}) → ({x2},{y2})" + + +def _clipboard_get() -> str: + if _PYPERCLIP: + return pyperclip.paste() + _hotkey("ctrl", "c") + time.sleep(0.2) + return "(copied — pyperclip unavailable for read)" + + +def _clipboard_paste(text: str) -> str: + if _PYPERCLIP: + pyperclip.copy(text) + time.sleep(0.1) + _require_pyautogui() + pyautogui.hotkey("ctrl", "v") + return f"Pasted: {text[:60]}{'…' if len(text) > 60 else ''}" + return "pyperclip not available" + + +def _screenshot(save_path: str | None = None) -> str: + _require_pyautogui() + path = _safe_screenshot_path(save_path) + img = pyautogui.screenshot() + img.save(str(path)) + return f"Screenshot saved: {path}" + + +def _clear_field() -> str: + _require_pyautogui() + pyautogui.hotkey("ctrl", "a") + time.sleep(0.1) + pyautogui.press("delete") + return "Field cleared" + +def _focus_window(title: str) -> str: + os_name = _get_os() + + if os_name == "windows": + try: + script = f'(New-Object -ComObject WScript.Shell).AppActivate("{title}")' + subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, timeout=5, + ) + time.sleep(0.3) + return f"Focused window: {title}" + except Exception as e: + return f"focus_window (Windows) failed: {e}" + + if os_name == "mac": + script = ( + f'tell application "System Events" to ' + f'set frontmost of (first process whose name contains "{title}") to true' + ) + try: + subprocess.run( + ["osascript", "-e", script], + capture_output=True, timeout=5, + ) + time.sleep(0.3) + return f"Focused window: {title}" + except Exception as e: + return f"focus_window (macOS) failed: {e}" + + if os_name == "linux": + try: + result = subprocess.run( + ["wmctrl", "-a", title], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + time.sleep(0.3) + return f"Focused window: {title}" + except FileNotFoundError: + pass + try: + result = subprocess.run( + ["xdotool", "search", "--name", title, "windowactivate"], + capture_output=True, timeout=5, + ) + time.sleep(0.3) + return f"Focused window: {title}" + except FileNotFoundError: + return "focus_window (Linux) requires wmctrl or xdotool" + except Exception as e: + return f"focus_window (Linux) failed: {e}" + + return f"focus_window: unknown OS '{os_name}'" +def _screen_find(description: str) -> tuple[int, int] | None: + try: + import base64 + from or_client import client + + _require_pyautogui() + w, h = pyautogui.size() + img = pyautogui.screenshot() + buf = io.BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + + text = client.vision( + f"This is a screenshot of a {w}×{h} pixel screen. " + f"Locate the UI element: '{description}'. " + f"Reply ONLY with center coordinates as: x,y — or NOT_FOUND", + image_b64=b64, + mime="image/png", + ) + + if "NOT_FOUND" in text.upper(): + return None + match = re.search(r"(\d+)\s*,\s*(\d+)", text) + if match: + return int(match.group(1)), int(match.group(2)) + except Exception as e: + print(f"[ComputerControl] ⚠️ screen_find failed: {e}") + return None + +def computer_control( + parameters: dict, + response=None, + player=None, + session_memory=None, +) -> str: + """ + Dispatch table for all computer control actions. + + parameters keys (all optional unless noted): + action : (required) one of the actions listed below + text : text to type or paste + x, y : screen coordinates + button : 'left' | 'right' (default: left) + keys : hotkey string, e.g. 'ctrl+c' + key : single key name, e.g. 'enter' + direction : 'up' | 'down' | 'left' | 'right' + amount : scroll amount (default: 3) + seconds : wait duration + title : window title fragment for focus_window + description : natural-language element description for screen_find/click + type : data type for random_data + field : memory field name for user_data + clear_first : bool, clear field before typing (default: true) + path : save path for screenshot (must be inside home dir) + + Actions: + type — type text at cursor + smart_type — clear field + type (clipboard-backed) + click — left click + double_click — double left click + right_click — right click + move — move mouse + drag — click-drag between two points + hotkey — key combination + press — single key + scroll — scroll the wheel + copy — read clipboard + paste — write + paste clipboard + screenshot — capture screen (safe path only) + wait — sleep N seconds + clear_field — select-all + delete + focus_window — bring window to foreground + screen_find — AI element finder (returns x,y) + screen_click — AI element finder + click + random_data — generate fake form data + user_data — pull real data from memory + """ + params = parameters or {} + action = params.get("action", "").lower().strip() + + if not action: + return "No action specified for computer_control." + + if player: + player.write_log(f"[Computer] {action}") + + print(f"[ComputerControl] ▶ {action} {params}") + + try: + + if action == "type": + return _type(params.get("text", "")) + + if action == "smart_type": + return _smart_type( + params.get("text", ""), + clear_first=params.get("clear_first", True), + ) + + if action in ("click", "left_click"): + return _click(params.get("x"), params.get("y"), "left", 1) + + if action == "double_click": + return _click(params.get("x"), params.get("y"), "left", 2) + + if action == "right_click": + return _click(params.get("x"), params.get("y"), "right", 1) + + if action == "move": + return _move(int(params.get("x", 0)), int(params.get("y", 0))) + + if action == "drag": + return _drag( + int(params.get("x1", 0)), int(params.get("y1", 0)), + int(params.get("x2", 0)), int(params.get("y2", 0)), + ) + + if action == "hotkey": + raw = params.get("keys", "") + keys = [k.strip() for k in raw.split("+")] if isinstance(raw, str) else raw + return _hotkey(*keys) + + if action == "press": + return _press(params.get("key", "enter")) + + if action == "scroll": + return _scroll( + direction=params.get("direction", "down"), + amount=int(params.get("amount", 3)), + ) + + if action == "copy": + return _clipboard_get() + + if action == "paste": + return _clipboard_paste(params.get("text", "")) + + if action == "screenshot": + return _screenshot(params.get("path")) + + if action == "screen_find": + coords = _screen_find(params.get("description", "")) + return f"{coords[0]},{coords[1]}" if coords else "NOT_FOUND" + + if action == "screen_click": + desc = params.get("description", "") + coords = _screen_find(desc) + if coords: + time.sleep(0.2) + _click(x=coords[0], y=coords[1]) + return f"Clicked '{desc}' at {coords}" + return f"Element not found on screen: '{desc}'" + + if action == "wait": + secs = float(params.get("seconds", 1.0)) + secs = min(secs, 30.0) + time.sleep(secs) + return f"Waited {secs}s" + + if action == "clear_field": + return _clear_field() + + if action == "focus_window": + return _focus_window(params.get("title", "")) + + if action == "random_data": + dt = params.get("type", "name") + result = _random_data(dt) + print(f"[ComputerControl] 🎲 random {dt} → {result}") + return result + + if action == "user_data": + field = params.get("field", "name") + profile = _user_profile() + value = profile.get(field, "") + if not value: + value = _random_data(field) + print(f"[ComputerControl] ⚠️ No '{field}' in memory, using random: {value}") + return value + + return f"Unknown action: '{action}'" + + except Exception as e: + print(f"[ComputerControl] ❌ {action}: {e}") + return f"computer_control '{action}' failed: {e}" \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..ed81746 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,16 @@ +import json, os +from pathlib import Path + +_CONFIG_PATH = Path(__file__).parent / "api_keys.json" + +def get_config() -> dict: + with open(_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + +def get_os() -> str: + """Returns: 'windows' | 'mac' | 'linux'""" + return get_config().get("os_system", "windows").lower() + +def is_windows() -> bool: return get_os() == "windows" +def is_mac() -> bool: return get_os() == "mac" +def is_linux() -> bool: return get_os() == "linux" \ No newline at end of file diff --git a/desktop.py b/desktop.py new file mode 100644 index 0000000..17c60fe --- /dev/null +++ b/desktop.py @@ -0,0 +1,672 @@ +#computer_settings.py +import json +import re +import sys +import time +import subprocess +import platform +from pathlib import Path + +try: + import pyautogui + pyautogui.FAILSAFE = True + pyautogui.PAUSE = 0.05 + _PYAUTOGUI = True +except ImportError: + _PYAUTOGUI = False + +try: + import pyperclip + _PYPERCLIP = True +except ImportError: + _PYPERCLIP = False + +_OS = platform.system() # "Windows" | "Darwin" | "Linux" + + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + +def _get_macos_wifi_interface() -> str: + try: + result = subprocess.run( + ["networksetup", "-listallhardwareports"], + capture_output=True, text=True, timeout=5 + ) + lines = result.stdout.splitlines() + for i, line in enumerate(lines): + if "Wi-Fi" in line or "AirPort" in line: + for j in range(i, min(i + 4, len(lines))): + if lines[j].startswith("Device:"): + return lines[j].split(":", 1)[1].strip() + except Exception: + pass + return "en0" + +def volume_up(): + if _OS == "Windows": + for _ in range(5): pyautogui.press("volumeup") + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", + "set volume output volume (output volume of (get volume settings) + 10)"], + capture_output=True) + else: + subprocess.run(["pactl", "set-sink-volume", "@DEFAULT_SINK@", "+10%"], + capture_output=True) + +def volume_down(): + if _OS == "Windows": + for _ in range(5): pyautogui.press("volumedown") + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", + "set volume output volume (output volume of (get volume settings) - 10)"], + capture_output=True) + else: + subprocess.run(["pactl", "set-sink-volume", "@DEFAULT_SINK@", "-10%"], + capture_output=True) + +def volume_mute(): + if _OS == "Windows": + pyautogui.press("volumemute") + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", "set volume with output muted"], + capture_output=True) + else: + subprocess.run(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], + capture_output=True) + +def volume_set(value: int): + value = max(0, min(100, int(value))) + if _OS == "Windows": + try: + import math + from ctypes import cast, POINTER + from comtypes import CLSCTX_ALL + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume + devices = AudioUtilities.GetSpeakers() + interface = devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + vol = cast(interface, POINTER(IAudioEndpointVolume)) + vol_db = -65.25 if value == 0 else max(-65.25, 20 * math.log10(value / 100)) + vol.SetMasterVolumeLevel(vol_db, None) + return + except Exception as e: + print(f"[Settings] pycaw failed, using keypress fallback: {e}") + pyautogui.press("volumemute") + pyautogui.press("volumemute") + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", f"set volume output volume {value}"], + capture_output=True) + return + else: + subprocess.run(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{value}%"], + capture_output=True) + return + +def brightness_up(): + if _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to key code 144'], + capture_output=True) + elif _OS == "Linux": + if subprocess.run(["which", "brightnessctl"], + capture_output=True).returncode == 0: + subprocess.run(["brightnessctl", "set", "+10%"], capture_output=True) + else: + subprocess.run( + 'xrandr --output $(xrandr | grep " connected" | head -1 | cut -d " " -f1)' + ' --brightness $(python3 -c "import subprocess; ' + 'b=float(subprocess.check_output([\"xrandr\",\"--verbose\"]).decode()' + '.split(\"Brightness:\")[1].split()[0]); print(min(1.0,b+0.1))")', + shell=True, capture_output=True + ) + else: + try: + subprocess.run( + ["powershell", "-Command", + "(Get-WmiObject -Namespace root/wmi -Class WmiMonitorBrightnessMethods)" + ".WmiSetBrightness(1, [math]::Min(100, " + "(Get-WmiObject -Namespace root/wmi -Class WmiMonitorBrightness).CurrentBrightness + 10))"], + capture_output=True, timeout=5 + ) + except Exception as e: + print(f"[Settings] Brightness up failed on Windows: {e}") + +def brightness_down(): + if _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to key code 145'], + capture_output=True) + elif _OS == "Linux": + if subprocess.run(["which", "brightnessctl"], + capture_output=True).returncode == 0: + subprocess.run(["brightnessctl", "set", "10%-"], capture_output=True) + else: + subprocess.run( + 'xrandr --output $(xrandr | grep " connected" | head -1 | cut -d " " -f1)' + ' --brightness $(python3 -c "import subprocess; ' + 'b=float(subprocess.check_output([\"xrandr\",\"--verbose\"]).decode()' + '.split(\"Brightness:\")[1].split()[0]); print(max(0.1,b-0.1))")', + shell=True, capture_output=True + ) + else: + try: + subprocess.run( + ["powershell", "-Command", + "(Get-WmiObject -Namespace root/wmi -Class WmiMonitorBrightnessMethods)" + ".WmiSetBrightness(1, [math]::Max(0, " + "(Get-WmiObject -Namespace root/wmi -Class WmiMonitorBrightness).CurrentBrightness - 10))"], + capture_output=True, timeout=5 + ) + except Exception as e: + print(f"[Settings] Brightness down failed on Windows: {e}") + +def close_app(): + if _OS == "Darwin": pyautogui.hotkey("command", "q") + else: pyautogui.hotkey("alt", "f4") + +def close_window(): + if _OS == "Darwin": pyautogui.hotkey("command", "w") + else: pyautogui.hotkey("ctrl", "w") + +def full_screen(): + if _OS == "Darwin": pyautogui.hotkey("ctrl", "command", "f") + else: pyautogui.press("f11") + +def minimize_window(): + if _OS == "Darwin": pyautogui.hotkey("command", "m") + else: pyautogui.hotkey("win", "down") + +def maximize_window(): + if _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to keystroke "f" ' + 'using {control down, command down}'], + capture_output=True) + elif _OS == "Windows": + pyautogui.hotkey("win", "up") + else: + try: + subprocess.run(["wmctrl", "-r", ":ACTIVE:", "-b", "add,maximized_vert,maximized_horz"], + capture_output=True) + except Exception: + pyautogui.hotkey("super", "up") + +def snap_left(): + if _OS == "Windows": + pyautogui.hotkey("win", "left") + elif _OS == "Linux": + try: + subprocess.run(["wmctrl", "-r", ":ACTIVE:", "-e", "0,0,0,960,1080"], + capture_output=True) + except Exception: + pass + +def snap_right(): + if _OS == "Windows": + pyautogui.hotkey("win", "right") + elif _OS == "Linux": + try: + subprocess.run(["wmctrl", "-r", ":ACTIVE:", "-e", "0,960,0,960,1080"], + capture_output=True) + except Exception: + pass + +def switch_window(): + if _OS == "Darwin": pyautogui.hotkey("command", "tab") + else: pyautogui.hotkey("alt", "tab") + +def show_desktop(): + if _OS == "Darwin": pyautogui.hotkey("fn", "f11") + elif _OS == "Windows": pyautogui.hotkey("win", "d") + else: pyautogui.hotkey("super", "d") + +def open_task_manager(): + if _OS == "Windows": + pyautogui.hotkey("ctrl", "shift", "esc") + elif _OS == "Darwin": + subprocess.Popen(["open", "-a", "Activity Monitor"]) + else: + for cmd in [["gnome-system-monitor"], ["xfce4-taskmanager"], ["htop"]]: + if subprocess.run(["which", cmd[0]], capture_output=True).returncode == 0: + subprocess.Popen(cmd) + break + + +def focus_search(): + if _OS == "Darwin": pyautogui.hotkey("command", "l") + else: pyautogui.hotkey("ctrl", "l") + +def pause_video(): pyautogui.press("space") + +def refresh_page(): + if _OS == "Darwin": pyautogui.hotkey("command", "r") + else: pyautogui.press("f5") + +def close_tab(): + if _OS == "Darwin": pyautogui.hotkey("command", "w") + else: pyautogui.hotkey("ctrl", "w") + +def new_tab(): + if _OS == "Darwin": pyautogui.hotkey("command", "t") + else: pyautogui.hotkey("ctrl", "t") + +def next_tab(): + if _OS == "Darwin": pyautogui.hotkey("command", "shift", "bracketright") + else: pyautogui.hotkey("ctrl", "tab") + +def prev_tab(): + if _OS == "Darwin": pyautogui.hotkey("command", "shift", "bracketleft") + else: pyautogui.hotkey("ctrl", "shift", "tab") + +def go_back(): + if _OS == "Darwin": pyautogui.hotkey("command", "left") + else: pyautogui.hotkey("alt", "left") + +def go_forward(): + if _OS == "Darwin": pyautogui.hotkey("command", "right") + else: pyautogui.hotkey("alt", "right") + +def zoom_in(): + if _OS == "Darwin": pyautogui.hotkey("command", "equal") + else: pyautogui.hotkey("ctrl", "equal") + +def zoom_out(): + if _OS == "Darwin": pyautogui.hotkey("command", "minus") + else: pyautogui.hotkey("ctrl", "minus") + +def zoom_reset(): + if _OS == "Darwin": pyautogui.hotkey("command", "0") + else: pyautogui.hotkey("ctrl", "0") + +def find_on_page(): + if _OS == "Darwin": pyautogui.hotkey("command", "f") + else: pyautogui.hotkey("ctrl", "f") + +def reload_page_n(n: int): + for _ in range(max(1, n)): + refresh_page() + time.sleep(0.8) + + +def scroll_up(amount: int = 500): pyautogui.scroll(amount) +def scroll_down(amount: int = 500): pyautogui.scroll(-amount) + +def scroll_top(): + if _OS == "Darwin": pyautogui.hotkey("command", "up") + else: pyautogui.hotkey("ctrl", "home") + +def scroll_bottom(): + if _OS == "Darwin": pyautogui.hotkey("command", "down") + else: pyautogui.hotkey("ctrl", "end") + +def page_up(): pyautogui.press("pageup") +def page_down(): pyautogui.press("pagedown") + + +def copy(): + if _OS == "Darwin": pyautogui.hotkey("command", "c") + else: pyautogui.hotkey("ctrl", "c") + +def paste(): + if _OS == "Darwin": pyautogui.hotkey("command", "v") + else: pyautogui.hotkey("ctrl", "v") + +def cut(): + if _OS == "Darwin": pyautogui.hotkey("command", "x") + else: pyautogui.hotkey("ctrl", "x") + +def undo(): + if _OS == "Darwin": pyautogui.hotkey("command", "z") + else: pyautogui.hotkey("ctrl", "z") + +def redo(): + if _OS == "Darwin": pyautogui.hotkey("command", "shift", "z") + else: pyautogui.hotkey("ctrl", "y") + +def select_all(): + if _OS == "Darwin": pyautogui.hotkey("command", "a") + else: pyautogui.hotkey("ctrl", "a") + +def save_file(): + if _OS == "Darwin": pyautogui.hotkey("command", "s") + else: pyautogui.hotkey("ctrl", "s") + +def press_enter(): pyautogui.press("enter") +def press_escape(): pyautogui.press("escape") +def press_key(key: str): pyautogui.press(key) + +def type_text(text: str, press_enter_after: bool = False): + if not text: + return + if _PYPERCLIP: + pyperclip.copy(str(text)) + time.sleep(0.15) + paste() + else: + pyautogui.write(str(text), interval=0.03) + if press_enter_after: + time.sleep(0.1) + pyautogui.press("enter") + +def take_screenshot(): + if _OS == "Windows": + pyautogui.hotkey("win", "shift", "s") + elif _OS == "Darwin": + pyautogui.hotkey("command", "shift", "3") + else: + for cmd in [["scrot"], ["gnome-screenshot"], ["import", "-window", "root", "screenshot.png"]]: + if subprocess.run(["which", cmd[0]], capture_output=True).returncode == 0: + subprocess.Popen(cmd) + return + pyautogui.hotkey("ctrl", "print_screen") + +def lock_screen(): + if _OS == "Windows": + pyautogui.hotkey("win", "l") + elif _OS == "Darwin": + subprocess.run(["pmset", "displaysleepnow"], capture_output=True) + else: + for cmd in [ + ["gnome-screensaver-command", "-l"], + ["xdg-screensaver", "lock"], + ["loginctl", "lock-session"], + ]: + if subprocess.run(["which", cmd[0]], capture_output=True).returncode == 0: + subprocess.run(cmd, capture_output=True) + return + +def open_system_settings(): + if _OS == "Windows": + pyautogui.hotkey("win", "i") + elif _OS == "Darwin": + subprocess.Popen(["open", "-a", "System Preferences"]) + else: + for cmd in [["gnome-control-center"], ["xfce4-settings-manager"], ["kcmshell5"]]: + if subprocess.run(["which", cmd[0]], capture_output=True).returncode == 0: + subprocess.Popen(cmd) + return + +def open_file_explorer(): + if _OS == "Windows": + pyautogui.hotkey("win", "e") + elif _OS == "Darwin": + subprocess.Popen(["open", str(Path.home())]) + else: + for cmd in [["nautilus"], ["thunar"], ["dolphin"], ["nemo"]]: + if subprocess.run(["which", cmd[0]], capture_output=True).returncode == 0: + subprocess.Popen(cmd) + return + subprocess.Popen(["xdg-open", str(Path.home())]) + +def sleep_display(): + if _OS == "Windows": + try: + import ctypes + ctypes.windll.user32.SendMessageW(0xFFFF, 0x0112, 0xF170, 2) + except Exception as e: + print(f"[Settings] sleep_display failed: {e}") + elif _OS == "Darwin": + subprocess.run(["pmset", "displaysleepnow"], capture_output=True) + else: + subprocess.run(["xset", "dpms", "force", "off"], capture_output=True) + +def open_run(): + if _OS == "Windows": + pyautogui.hotkey("win", "r") + +def dark_mode(): + if _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell app "System Events" to tell appearance preferences ' + 'to set dark mode to not dark mode'], + capture_output=True) + elif _OS == "Windows": + try: + import winreg + key_path = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize" + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_ALL_ACCESS) + current, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + winreg.SetValueEx(key, "AppsUseLightTheme", 0, winreg.REG_DWORD, 1 - current) + winreg.SetValueEx(key, "SystemUsesLightTheme", 0, winreg.REG_DWORD, 1 - current) + winreg.CloseKey(key) + except Exception as e: + print(f"[Settings] dark_mode registry failed: {e}") + else: + try: + result = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"], + capture_output=True, text=True + ) + current = result.stdout.strip() + new_scheme = "'default'" if "dark" in current else "'prefer-dark'" + subprocess.run( + ["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", new_scheme], + capture_output=True + ) + except Exception as e: + print(f"[Settings] dark_mode Linux failed: {e}") + +def toggle_wifi(): + if _OS == "Darwin": + iface = _get_macos_wifi_interface() + result = subprocess.run( + ["networksetup", "-getairportpower", iface], + capture_output=True, text=True + ) + state = "off" if "On" in result.stdout else "on" + subprocess.run(["networksetup", "-setairportpower", iface, state], + capture_output=True) + elif _OS == "Windows": + try: + subprocess.run( + ["powershell", "-Command", + "$adapter = Get-NetAdapter | Where-Object {$_.PhysicalMediaType -eq 'Native 802.11'};" + "if ($adapter.Status -eq 'Up') { Disable-NetAdapter -Name $adapter.Name -Confirm:$false }" + "else { Enable-NetAdapter -Name $adapter.Name -Confirm:$false }"], + capture_output=True, timeout=10 + ) + except Exception as e: + print(f"[Settings] toggle_wifi Windows failed: {e}") + else: + try: + result = subprocess.run(["nmcli", "radio", "wifi"], capture_output=True, text=True) + state = "off" if "enabled" in result.stdout else "on" + subprocess.run(["nmcli", "radio", "wifi", state], capture_output=True) + except Exception as e: + print(f"[Settings] toggle_wifi Linux failed: {e}") + +def restart_computer(): + if _OS == "Windows": + subprocess.run(["shutdown", "/r", "/t", "10"], capture_output=True) + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to restart'], + capture_output=True) + else: + subprocess.run(["systemctl", "reboot"], capture_output=True) + +def shutdown_computer(): + if _OS == "Windows": + subprocess.run(["shutdown", "/s", "/t", "10"], capture_output=True) + elif _OS == "Darwin": + subprocess.run(["osascript", "-e", + 'tell application "System Events" to shut down'], + capture_output=True) + else: + subprocess.run(["systemctl", "poweroff"], capture_output=True) + +ACTION_MAP: dict[str, callable] = { + "volume_up": volume_up, + "volume_down": volume_down, + "mute": volume_mute, + "unmute": volume_mute, + "toggle_mute": volume_mute, + "brightness_up": brightness_up, + "brightness_down": brightness_down, + "sleep_display": sleep_display, + "screen_off": sleep_display, + "pause_video": pause_video, + "play_pause": pause_video, + "close_app": close_app, + "close_window": close_window, + "full_screen": full_screen, + "fullscreen": full_screen, + "minimize": minimize_window, + "maximize": maximize_window, + "snap_left": snap_left, + "snap_right": snap_right, + "switch_window": switch_window, + "show_desktop": show_desktop, + "task_manager": open_task_manager, + "focus_search": focus_search, + "refresh_page": refresh_page, + "reload": refresh_page, + "close_tab": close_tab, + "new_tab": new_tab, + "next_tab": next_tab, + "prev_tab": prev_tab, + "go_back": go_back, + "go_forward": go_forward, + "zoom_in": zoom_in, + "zoom_out": zoom_out, + "zoom_reset": zoom_reset, + "find_on_page": find_on_page, + "scroll_up": scroll_up, + "scroll_down": scroll_down, + "scroll_top": scroll_top, + "scroll_bottom": scroll_bottom, + "page_up": page_up, + "page_down": page_down, + "copy": copy, + "paste": paste, + "cut": cut, + "undo": undo, + "redo": redo, + "select_all": select_all, + "save": save_file, + "enter": press_enter, + "escape": press_escape, + "screenshot": take_screenshot, + "lock_screen": lock_screen, + "open_settings": open_system_settings, + "file_explorer": open_file_explorer, + "open_run": open_run, + "dark_mode": dark_mode, + "toggle_wifi": toggle_wifi, + "restart": restart_computer, + "shutdown": shutdown_computer, +} + +_DANGEROUS_ACTIONS = {"restart", "shutdown"} + + +def _detect_action(description: str) -> dict: + from or_client import client + + available = ", ".join(sorted(ACTION_MAP.keys())) + \ + ", volume_set, type_text, press_key, reload_n" + + prompt = f"""You are an intent detector for a computer control assistant. +The user issued a command (possibly in any language): "{description}" +Available actions: {available} +Return ONLY a valid JSON object: {{"action": "action_name", "value": null_or_value}} +Rules: +- For volume_set: value is an integer 0-100. +- For type_text: value is the exact text to type. +- For press_key: value is the key name (e.g. "f5", "tab", "enter"). +- For reload_n: value is an integer. +- Return ONLY the JSON, no explanation, no markdown.""" + + try: + raw = client.chat_json(prompt, system="Return only valid JSON. No extra text.") + return raw + except Exception as e: + print(f"[Settings] Intent detection failed: {e}") + return {"action": description.lower().replace(" ", "_"), "value": None} + +def computer_settings( + parameters: dict = None, + response=None, + player=None, + session_memory=None, +) -> str: + if not _PYAUTOGUI: + return "pyautogui is not installed. Run: pip install pyautogui" + + params = parameters or {} + raw_action = params.get("action", "").strip() + description = params.get("description", "").strip() + value = params.get("value", None) + + if not raw_action and description: + detected = _detect_action(description) + raw_action = detected.get("action", "") + if value is None: + value = detected.get("value") + + action = raw_action.lower().strip().replace(" ", "_").replace("-", "_") + + if not action: + return "No action could be determined." + + print(f"[Settings] Action: {action} Value: {value} OS: {_OS}") + if player: + player.write_log(f"[Settings] {action}") + + if action in _DANGEROUS_ACTIONS: + confirmed = str(params.get("confirmed", "")).lower() + if confirmed not in ("yes", "true", "1", "confirm"): + return ( + f"This will {action} the computer. " + f"Please confirm by calling again with confirmed=yes." + ) + + if action == "volume_set": + try: + volume_set(int(value or 50)) + return f"Volume set to {value}%." + except Exception as e: + return f"Could not set volume: {e}" + + if action in ("type_text", "write_on_screen", "type", "write"): + text = str(value or params.get("text", "")).strip() + if not text: + return "No text provided to type." + enter_after = str(params.get("press_enter", "false")).lower() in ("true", "1", "yes") + type_text(text, press_enter_after=enter_after) + return f"Typed: {text[:80]}" + + if action == "press_key": + key = str(value or params.get("key", "")).strip() + if not key: + return "No key specified." + press_key(key) + return f"Pressed: {key}" + + if action in ("reload_n", "refresh_n", "reload_page_n"): + try: + reload_page_n(int(value or 1)) + return f"Reloaded {value or 1} time(s)." + except Exception as e: + return f"Reload failed: {e}" + + if action == "scroll_up": + scroll_up(int(value or 500)) + return "Scrolled up." + + if action == "scroll_down": + scroll_down(int(value or 500)) + return "Scrolled down." + + func = ACTION_MAP.get(action) + if not func: + return f"Unknown action: '{raw_action}'." + + try: + func() + return f"Done: {action}." + except Exception as e: + print(f"[Settings] Action failed ({action}): {e}") + return f"Action failed ({action}): {e}" \ No newline at end of file diff --git a/dev_agent.py b/dev_agent.py new file mode 100644 index 0000000..99c7e5d --- /dev/null +++ b/dev_agent.py @@ -0,0 +1,457 @@ +#desktop.py +import os +import sys +import json +import shutil +import subprocess +import tempfile +import platform +from pathlib import Path +from datetime import datetime + +try: + import pyautogui + _PYAUTOGUI = True +except ImportError: + _PYAUTOGUI = False + +_OS = platform.system() # "Windows" | "Darwin" | "Linux" + + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + +def _get_desktop() -> Path: + if _OS == "Linux": + xdg = os.environ.get("XDG_DESKTOP_DIR", "") + if xdg and Path(xdg).exists(): + return Path(xdg) + return Path.home() / "Desktop" + +def _build_sandbox() -> dict: + import time + + safe_builtins = { + "print": print, + "len": len, "str": str, "int": int, "float": float, + "bool": bool, "list": list, "dict": dict, "tuple": tuple, + "range": range, "enumerate": enumerate, "sorted": sorted, + "isinstance": isinstance, "hasattr": hasattr, "getattr": getattr, + "max": max, "min": min, "sum": sum, "abs": abs, + "zip": zip, "map": map, "filter": filter, + } + + sandbox = { + "__builtins__": safe_builtins, + "Path": Path, + "time": time, + "shutil": type("shutil", (), { + "copy2": shutil.copy2, + "copytree": shutil.copytree, + "disk_usage": shutil.disk_usage, + })(), + "os_path": os.path, + } + + if _PYAUTOGUI: + sandbox["pyautogui"] = pyautogui + + if _OS == "Windows": + try: + import ctypes + import winreg + sandbox["ctypes"] = ctypes + sandbox["winreg"] = type("winreg", (), { + # Sadece okuma + "OpenKey": winreg.OpenKey, + "QueryValueEx": winreg.QueryValueEx, + "HKEY_CURRENT_USER": winreg.HKEY_CURRENT_USER, + })() + except ImportError: + pass + + return sandbox + + +def _execute_generated_code(code: str, player=None) -> str: + if not code or code.strip() == "UNSAFE": + return "This action cannot be performed safely." + + # Kod temizleme + if code.startswith("```"): + lines = code.split("\n") + code = "\n".join(lines[1:-1]).strip() + + sandbox = _build_sandbox() + output_lines = [] + sandbox["__builtins__"]["print"] = lambda *a: output_lines.append(" ".join(str(x) for x in a)) + + try: + exec(compile(code, "", "exec"), sandbox) + return "\n".join(output_lines) if output_lines else "Done." + except Exception as e: + print(f"[Desktop] Exec error: {e}\nCode:\n{code[:300]}") + return f"Execution error: {e}" + +def _ask_gemini_for_desktop_action(task: str) -> str: + from or_client import client + + desktop = str(_get_desktop()) + os_specific = { + "Windows": "- ctypes (Windows API calls, read-only)\n- winreg (registry READ only)", + "Darwin": "- subprocess is NOT available; use pyautogui or Path only", + "Linux": "- subprocess is NOT available; use pyautogui or Path only", + }.get(_OS, "") + + prompt = f"""You are a desktop automation assistant. +Current OS: {_OS} +Desktop path: {desktop} +Generate safe Python code to accomplish the task below. +Allowed modules ONLY: +- pyautogui (mouse, keyboard — if needed) +- pathlib.Path (file/folder inspection only, no deletion) +- shutil.copy2, shutil.copytree, shutil.disk_usage (NO move, NO rmtree) +- os_path (os.path equivalent, read-only) +- time.sleep +{os_specific} +Hard rules: +- NO file deletion, NO subprocess, NO exec/eval inside the code +- NO import statements, NO file write except explicitly requested +- If task cannot be done safely with these tools, output exactly: UNSAFE +Output ONLY the Python code. No explanation, no markdown, no backticks. +Task: {task}""" + + try: + return client.chat(prompt, system="You are a code generator. Output only raw Python code.") + except Exception as e: + return f"ERROR: {e}" + +def set_wallpaper(image_path: str) -> str: + path = Path(image_path).expanduser().resolve() + if not path.exists(): + return f"Image not found: {image_path}" + if path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".bmp", ".webp"}: + return f"Unsupported format: {path.suffix}. Use jpg, png, bmp or webp." + + try: + if _OS == "Windows": + import ctypes + if path.suffix.lower() in {".webp", ".png"}: + try: + from PIL import Image + bmp_path = Path(tempfile.mktemp(suffix=".bmp")) + Image.open(path).convert("RGB").save(bmp_path, "BMP") + path = bmp_path + except ImportError: + pass + ctypes.windll.user32.SystemParametersInfoW(20, 0, str(path), 3) + return f"Wallpaper set: {path.name}" + + elif _OS == "Darwin": + script = ( + f'tell application "System Events" to tell every desktop to ' + f'set picture to POSIX file "{path}"' + ) + subprocess.run(["osascript", "-e", script], capture_output=True) + return f"Wallpaper set: {path.name}" + + else: + desktop_env = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + uri = f"file://{path}" + + if "gnome" in desktop_env or "unity" in desktop_env: + subprocess.run([ + "gsettings", "set", "org.gnome.desktop.background", + "picture-uri", uri + ], capture_output=True) + subprocess.run([ + "gsettings", "set", "org.gnome.desktop.background", + "picture-uri-dark", uri + ], capture_output=True) + + elif "kde" in desktop_env: + # KDE Plasma + script = f""" +var allDesktops = desktops(); +for (var i = 0; i < allDesktops.length; i++) {{ + d = allDesktops[i]; + d.wallpaperPlugin = "org.kde.image"; + d.currentConfigGroup = ["Wallpaper", "org.kde.image", "General"]; + d.writeConfig("Image", "file://{path}"); +}} +""" + subprocess.run( + ["qdbus", "org.kde.plasmashell", "/PlasmaShell", + "org.kde.PlasmaShell.evaluateScript", script], + capture_output=True + ) + + elif "xfce" in desktop_env: + subprocess.run([ + "xfconf-query", "-c", "xfce4-desktop", + "-p", "/backdrop/screen0/monitor0/workspace0/last-image", + "-s", str(path) + ], capture_output=True) + + else: + result = subprocess.run( + ["feh", "--bg-scale", str(path)], + capture_output=True + ) + if result.returncode != 0: + return ( + f"Could not set wallpaper automatically on {desktop_env}. " + f"Try manually or install 'feh'." + ) + + return f"Wallpaper set: {path.name}" + + except Exception as e: + return f"Could not set wallpaper: {e}" + + +def set_wallpaper_from_url(url: str) -> str: + try: + import urllib.request + suffix = Path(url.split("?")[0]).suffix or ".jpg" + tmp = Path(tempfile.mktemp(suffix=suffix)) + urllib.request.urlretrieve(url, str(tmp)) + result = set_wallpaper(str(tmp)) + try: + tmp.unlink() + except Exception: + pass + return result + except Exception as e: + return f"Could not download wallpaper: {e}" + + +def get_current_wallpaper() -> str: + try: + if _OS == "Windows": + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop" + ) + val, _ = winreg.QueryValueEx(key, "Wallpaper") + winreg.CloseKey(key) + return f"Current wallpaper: {val}" + + elif _OS == "Darwin": + script = ( + 'tell application "System Events" to get picture of desktop 1' + ) + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, text=True + ) + return f"Current wallpaper: {result.stdout.strip()}" + + else: + desktop_env = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + if "gnome" in desktop_env or "unity" in desktop_env: + result = subprocess.run( + ["gsettings", "get", "org.gnome.desktop.background", "picture-uri"], + capture_output=True, text=True + ) + return f"Current wallpaper: {result.stdout.strip()}" + return "Wallpaper path retrieval not supported for this desktop environment." + + except Exception as e: + return f"Could not get wallpaper: {e}" + +FILE_TYPE_MAP = { + "Images": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".heic"}, + "Documents": {".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", + ".ppt", ".pptx", ".csv", ".odt", ".ods", ".odp"}, + "Videos": {".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm", ".m4v"}, + "Music": {".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a"}, + "Archives": {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz"}, + "Code": {".py", ".js", ".ts", ".html", ".css", ".json", ".xml", + ".cpp", ".java", ".cs", ".go", ".rs", ".sh", ".php"}, + "Executables": {".exe", ".msi", ".bat", ".cmd", ".sh", ".appimage", ".deb", ".rpm"}, +} + +_SKIP_EXTENSIONS = { + "Windows": {".lnk", ".url"}, + "Darwin": {".webloc"}, + "Linux": {".desktop"}, +} + + +def organize_desktop(mode: str = "by_type") -> str: + desktop = _get_desktop() + skip_exts = _SKIP_EXTENSIONS.get(_OS, set()) + moved, skipped = [], [] + + for item in desktop.iterdir(): + if item.is_dir() or item.name.startswith("."): + continue + if item.suffix.lower() in skip_exts: + continue + + if mode == "by_date": + mtime = datetime.fromtimestamp(item.stat().st_mtime) + folder_name = mtime.strftime("%Y-%m") + else: + ext = item.suffix.lower() + folder_name = "Others" + for folder, exts in FILE_TYPE_MAP.items(): + if ext in exts: + folder_name = folder + break + + target_dir = desktop / folder_name + target_dir.mkdir(exist_ok=True) + new_path = target_dir / item.name + + if new_path.exists(): + skipped.append(item.name) + continue + + shutil.move(str(item), str(new_path)) + moved.append(f"{item.name} → {folder_name}/") + + result = f"Desktop organized ({mode}): {len(moved)} files moved." + if moved: + result += "\n" + "\n".join(moved[:8]) + if len(moved) > 8: + result += f"\n... and {len(moved) - 8} more." + if skipped: + result += f"\n{len(skipped)} file(s) skipped (name conflict)." + return result + + +def list_desktop() -> str: + desktop = _get_desktop() + items = [] + for item in sorted(desktop.iterdir()): + if item.name.startswith("."): + continue + if item.is_dir(): + try: + count = len(list(item.iterdir())) + except PermissionError: + count = "?" + items.append(f"📁 {item.name}/ ({count} items)") + else: + size = item.stat().st_size + size_str = ( + f"{size / 1024:.1f} KB" if size < 1024 * 1024 + else f"{size / 1024 / 1024:.1f} MB" + ) + items.append(f"📄 {item.name} ({size_str})") + + if not items: + return "Desktop is empty." + return f"Desktop ({len(items)} items):\n" + "\n".join(items) + + +def clean_desktop() -> str: + desktop = _get_desktop() + skip_exts = _SKIP_EXTENSIONS.get(_OS, set()) + today = datetime.now().strftime("%Y-%m-%d") + archive_dir = desktop / f"Desktop Archive {today}" + archive_dir.mkdir(exist_ok=True) + + moved = 0 + for item in desktop.iterdir(): + if item.is_dir() or item.name.startswith("."): + continue + if item.suffix.lower() in skip_exts: + continue + new_path = archive_dir / item.name + if not new_path.exists(): + shutil.move(str(item), str(new_path)) + moved += 1 + + return f"Desktop cleaned: {moved} files archived to '{archive_dir.name}'." + + +def get_desktop_stats() -> str: + desktop = _get_desktop() + files = [i for i in desktop.iterdir() if i.is_file()] + folders = [i for i in desktop.iterdir() if i.is_dir()] + total_size = sum(f.stat().st_size for f in files if f.exists()) + size_str = ( + f"{total_size / 1024:.1f} KB" if total_size < 1024 * 1024 + else f"{total_size / 1024 / 1024:.1f} MB" + ) + return ( + f"Desktop stats ({_OS}):\n" + f" Files : {len(files)}\n" + f" Folders : {len(folders)}\n" + f" Size : {size_str}\n" + f" Path : {desktop}" + ) + +def desktop_control( + parameters: dict = None, + response=None, + player=None, + session_memory=None, +) -> str: + """ + parameters: + action : wallpaper | wallpaper_url | current_wallpaper | + organize | clean | list | stats | + task (AI-powered) + path : image path for 'wallpaper' + url : image URL for 'wallpaper_url' + mode : 'by_type' or 'by_date' for 'organize' + task : natural language description for AI-powered actions + """ + params = parameters or {} + action = params.get("action", "").lower().strip() + task = params.get("task", "").strip() + + if player: + player.write_log(f"[desktop] {action or task[:40]}") + + try: + if action == "wallpaper": + path = params.get("path", "") + return set_wallpaper(path) if path else "No image path provided." + + elif action == "wallpaper_url": + url = params.get("url", "") + return set_wallpaper_from_url(url) if url else "No URL provided." + + elif action == "current_wallpaper": + return get_current_wallpaper() + + elif action == "organize": + return organize_desktop(params.get("mode", "by_type")) + + elif action == "clean": + return clean_desktop() + + elif action == "list": + return list_desktop() + + elif action == "stats": + return get_desktop_stats() + + elif action == "task" or task: + actual_task = task or params.get("description", "") + if not actual_task: + return "Please describe what you want to do on the desktop." + + print(f"[Desktop] Asking Gemini: {actual_task}") + if player: + player.write_log("[Desktop] Generating action...") + + code = _ask_gemini_for_desktop_action(actual_task) + return _execute_generated_code(code, player=player) + + else: + if action: + code = _ask_gemini_for_desktop_action(action) + return _execute_generated_code(code, player=player) + return "No action or task specified." + + except Exception as e: + print(f"[Desktop] Error: {e}") + return f"Desktop control error: {e}" \ No newline at end of file diff --git a/error_handler.py b/error_handler.py new file mode 100644 index 0000000..6345f1e --- /dev/null +++ b/error_handler.py @@ -0,0 +1,418 @@ +#youtube_video.py +import json +import re +import sys +import time +import subprocess +import shutil +from pathlib import Path +from datetime import datetime +from urllib.parse import quote_plus + +import pyautogui +import numpy as np + +try: + import requests + _REQUESTS_OK = True +except ImportError: + _REQUESTS_OK = False + +try: + from youtube_transcript_api import YouTubeTranscriptApi + _TRANSCRIPT_OK = True +except ImportError: + _TRANSCRIPT_OK = False + +from config import get_os, is_windows, is_mac, is_linux + + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = _get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9", +} + +_YT_VIDEO_FILTER = "EgIQAQ%3D%3D" + +def _open_url(url: str) -> None: + try: + if is_mac(): + subprocess.Popen(["open", url]) + elif is_linux(): + subprocess.Popen(["xdg-open", url]) + else: + subprocess.Popen(["cmd", "/c", "start", "", url], shell=False) + except Exception as e: + print(f"[YouTube] ⚠️ open_url failed: {e}") + +def _scrape_first_video_url(query: str) -> str | None: + + if not _REQUESTS_OK: + return None + + search_url = ( + f"https://www.youtube.com/results" + f"?search_query={quote_plus(query)}" + f"&sp={_YT_VIDEO_FILTER}" + ) + + try: + r = requests.get(search_url, headers=HEADERS, timeout=10) + html = r.text + + video_ids = re.findall(r'"videoId":"([A-Za-z0-9_-]{11})"', html) + + seen = set() + for vid in video_ids: + if vid in seen: + continue + seen.add(vid) + + if f'/shorts/{vid}' in html: + continue + return f"https://www.youtube.com/watch?v={vid}" + + except Exception as e: + print(f"[YouTube] ⚠️ scrape_first_video_url failed: {e}") + + return None + +def _extract_video_id(url: str) -> str | None: + match = re.search( + r"(?:v=|\/v\/|youtu\.be\/|\/embed\/|\/shorts\/)([A-Za-z0-9_-]{11})", url + ) + return match.group(1) if match else None + + +def _is_valid_youtube_url(url: str) -> bool: + return bool(re.search(r"(youtube\.com|youtu\.be)", url or "")) + + +def _ask_for_url(prompt_text: str = "YouTube video URL:") -> str | None: + try: + import tkinter as tk + from tkinter import simpledialog + + root = tk._default_root + if root is None: + root = tk.Tk() + root.withdraw() + + url = simpledialog.askstring("J.A.R.V.I.S", prompt_text, parent=root) + return url.strip() if url else None + except Exception as e: + print(f"[YouTube] ⚠️ URL dialog failed: {e}") + return None + + +def _get_transcript(video_id: str) -> str | None: + if not _TRANSCRIPT_OK: + return None + try: + transcript_list = YouTubeTranscriptApi.list_transcripts(video_id) + transcript = None + + lang_priority = ["en", "tr", "de", "fr", "es", "it", "pt", "ru", "ja", "ko", "ar", "zh"] + + try: + transcript = transcript_list.find_manually_created_transcript(lang_priority) + except Exception: + pass + + if transcript is None: + try: + transcript = transcript_list.find_generated_transcript(lang_priority) + except Exception: + for t in transcript_list: + transcript = t + break + + if transcript is None: + return None + + fetched = transcript.fetch() + return " ".join(entry["text"] for entry in fetched) + + except Exception as e: + print(f"[YouTube] ⚠️ Transcript fetch failed: {e}") + return None + + +def _summarize_with_gemini(transcript: str, video_url: str) -> str: + from or_client import client + + max_chars = 80000 + truncated = transcript[:max_chars] + ("..." if len(transcript) > max_chars else "") + + return client.chat( + f"Please summarize this YouTube video transcript:\n\n{truncated}", + system=( + "You are JARVIS, an AI assistant. " + "Summarize YouTube video transcripts clearly and concisely. " + "Structure: 1-sentence overview, then 3-5 key points. " + "Be direct. Address the user as 'sir'. " + "Match the language of the transcript." + ), + max_tokens=2048, + ) + + +def _save_summary(content: str, video_url: str) -> str: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"youtube_summary_{ts}.txt" + desktop = Path.home() / "Desktop" + desktop.mkdir(parents=True, exist_ok=True) + filepath = desktop / filename + + header = ( + f"JARVIS — YouTube Summary\n" + f"{'─' * 50}\n" + f"URL : {video_url}\n" + f"Date : {datetime.now().strftime('%Y-%m-%d %H:%M')}\n" + f"{'─' * 50}\n\n" + ) + filepath.write_text(header + content, encoding="utf-8") + + try: + if is_windows(): + subprocess.Popen(["notepad.exe", str(filepath)]) + elif is_mac(): + subprocess.Popen(["open", "-t", str(filepath)]) + else: + subprocess.Popen(["xdg-open", str(filepath)]) + except Exception as e: + print(f"[YouTube] ⚠️ Could not open text editor: {e}") + + return str(filepath) + + +def _scrape_video_info(video_id: str) -> dict: + if not _REQUESTS_OK: + return {} + url = f"https://www.youtube.com/watch?v={video_id}" + try: + r = requests.get(url, headers=HEADERS, timeout=12) + html = r.text + info = {} + + for key, pattern in [ + ("title", r'"title":\{"runs":\[\{"text":"([^"]+)"'), + ("channel", r'"ownerChannelName":"([^"]+)"'), + ("views", r'"viewCount":"(\d+)"'), + ("duration", r'"lengthSeconds":"(\d+)"'), + ("likes", r'"label":"([0-9,]+ likes)"'), + ]: + match = re.search(pattern, html) + if match: + raw = match.group(1) + if key == "views": + info[key] = f"{int(raw):,}" + elif key == "duration": + secs = int(raw) + info[key] = f"{secs // 60}:{secs % 60:02d}" + else: + info[key] = raw + + return info + except Exception as e: + print(f"[YouTube] ⚠️ Info scrape failed: {e}") + return {} + + +def _scrape_trending(region: str = "TR", max_results: int = 8) -> list[dict]: + if not _REQUESTS_OK: + return [] + url = f"https://www.youtube.com/feed/trending?gl={region.upper()}" + try: + r = requests.get(url, headers=HEADERS, timeout=12) + html = r.text + + titles = re.findall(r'"title":\{"runs":\[\{"text":"([^"]+)"\}\]', html) + channels = re.findall(r'"ownerText":\{"runs":\[\{"text":"([^"]+)"', html) + + results, seen = [], set() + for i, title in enumerate(titles): + if title in seen or len(title) < 5: + continue + seen.add(title) + channel = channels[i] if i < len(channels) else "Unknown" + results.append({"rank": len(results) + 1, "title": title, "channel": channel}) + if len(results) >= max_results: + break + + return results + except Exception as e: + print(f"[YouTube] ⚠️ Trending scrape failed: {e}") + return [] + +def _handle_play(parameters: dict, player) -> str: + query = parameters.get("query", "").strip() + if not query: + return "Please tell me what you'd like to watch, sir." + + if player: + player.write_log(f"[YouTube] Searching: {query}") + + print(f"[YouTube] 🔍 Scraping first non-Shorts video for: {query}") + + video_url = _scrape_first_video_url(query) + + if video_url: + print(f"[YouTube] ▶️ Opening: {video_url}") + _open_url(video_url) + return f"Playing: {query}" + + print(f"[YouTube] ⚠️ Scrape failed, opening filtered search page") + fallback_url = ( + f"https://www.youtube.com/results" + f"?search_query={quote_plus(query)}" + f"&sp={_YT_VIDEO_FILTER}" + ) + _open_url(fallback_url) + return f"Opened YouTube search for: {query} (manual selection required)" + + +def _handle_summarize(parameters: dict, player, speak) -> str: + if not _TRANSCRIPT_OK: + return "youtube-transcript-api is not installed. Run: pip install youtube-transcript-api" + + url = _ask_for_url("Please paste the YouTube video URL:") + if not url: + return "No URL provided, sir. Summary cancelled." + if not _is_valid_youtube_url(url): + return "That doesn't appear to be a valid YouTube URL, sir." + + video_id = _extract_video_id(url) + if not video_id: + return "Could not extract video ID from that URL, sir." + + if player: + player.write_log(f"[YouTube] Summarizing: {url}") + if speak: + speak("Fetching the transcript now, sir. One moment.") + + transcript = _get_transcript(video_id) + if not transcript: + return "I couldn't retrieve a transcript for that video, sir." + + if speak: + speak("Transcript retrieved. Generating summary now.") + + try: + summary = _summarize_with_gemini(transcript, url) + except Exception as e: + return f"Summary generation failed, sir: {e}" + + if speak: + speak(summary) + + if parameters.get("save", False): + saved_path = _save_summary(summary, url) + return f"Summary complete and saved to Desktop: {saved_path}" + + return summary + + +def _handle_get_info(parameters: dict, player, speak) -> str: + url = parameters.get("url", "").strip() + if not url: + url = _ask_for_url("Please paste the YouTube video URL:") + if not url or not _is_valid_youtube_url(url): + return "Please provide a valid YouTube URL, sir." + + video_id = _extract_video_id(url) + if not video_id: + return "Could not extract video ID, sir." + + if player: + player.write_log(f"[YouTube] Getting info: {url}") + + info = _scrape_video_info(video_id) + if not info: + return "Could not retrieve video information, sir." + + lines = [ + f"{key.capitalize()}: {info[key]}" + for key in ("title", "channel", "views", "duration", "likes") + if key in info + ] + result = "\n".join(lines) + + if speak: + speak(f"Here's the video info, sir. {result.replace(chr(10), '. ')}") + + return result + + +def _handle_trending(parameters: dict, player, speak) -> str: + region = parameters.get("region", "TR").upper() + + if player: + player.write_log(f"[YouTube] Trending: {region}") + + trending = _scrape_trending(region=region, max_results=8) + if not trending: + return f"Could not fetch trending videos for region {region}, sir." + + lines = [f"Top trending videos in {region}:"] + lines += [f"{v['rank']}. {v['title']} — {v['channel']}" for v in trending] + result = "\n".join(lines) + + if speak: + top3 = trending[:3] + spoken = "Here are the top trending videos, sir. " + ". ".join( + f"Number {v['rank']}: {v['title']} by {v['channel']}" for v in top3 + ) + speak(spoken) + + return result + +_ACTION_MAP = { + "play": _handle_play, + "summarize": _handle_summarize, + "get_info": _handle_get_info, + "trending": _handle_trending, +} + + +def youtube_video( + parameters: dict, + response=None, + player=None, + session_memory=None, + speak=None, +) -> str: + params = parameters or {} + action = params.get("action", "play").lower().strip() + + if player: + player.write_log(f"[YouTube] Action: {action}") + print(f"[YouTube] ▶️ Action: {action} Params: {params}") + + handler = _ACTION_MAP.get(action) + if handler is None: + return ( + f"Unknown YouTube action: '{action}'. " + "Available: play, summarize, get_info, trending." + ) + + try: + if action == "play": + return handler(params, player) or "Done." + return handler(params, player, speak) or "Done." + except Exception as e: + print(f"[YouTube] ❌ Error in {action}: {e}") + return f"YouTube {action} failed, sir: {e}" \ No newline at end of file diff --git a/file_controller.py b/file_controller.py new file mode 100644 index 0000000..67a10fe --- /dev/null +++ b/file_controller.py @@ -0,0 +1,597 @@ +import subprocess +import sys +import json +import re +import time +from pathlib import Path + + +def get_base_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" +PROJECTS_DIR = Path.home() / "Desktop" / "JarvisProjects" +MAX_FIX_ATTEMPTS = 5 +MODEL_PLANNER = "gemini-2.5-flash" +MODEL_WRITER = "gemini-2.5-flash" + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _get_model(model_name: str): + import google.generativeai as genai + genai.configure(api_key=_get_api_key()) + return genai.GenerativeModel(model_name) + + +def _strip_fences(text: str) -> str: + text = text.strip() + text = re.sub(r"^```[a-zA-Z]*\r?\n?", "", text) + text = re.sub(r"\r?\n?```\s*$", "", text) + return text.strip() + + +def _is_rate_limit(error: Exception) -> bool: + msg = str(error).lower() + return "429" in msg or "quota" in msg or "resource_exhausted" in msg + + +def _parse_traceback(output: str, project_files: list[str]) -> tuple[str | None, int | None]: + + pattern = re.compile(r'File ["\']([^"\']+\.py)["\'],\s+line\s+(\d+)', re.IGNORECASE) + matches = pattern.findall(output) + + for raw_path, line_str in reversed(matches): + raw_name = Path(raw_path).name + for pf in project_files: + if Path(pf).name == raw_name or pf == raw_path or raw_path.endswith(pf): + return pf, int(line_str) + + return None, None + + +def _classify_error(output: str) -> str: + + low = output.lower() + + if any(x in low for x in ("no module named", "modulenotfounderror", "importerror")): + return "dependency_error" + + if "syntaxerror" in low or "invalid syntax" in low: + return "syntax_error" + + if "cannot import" in low or "importerror" in low: + return "import_error" + + if any(x in low for x in ( + "traceback", "exception", "error:", "nameerror", "typeerror", + "attributeerror", "valueerror", "keyerror", "indexerror", + "zerodivisionerror", "filenotfounderror", "permissionerror", + )): + return "runtime_error" + + return "none" + + +def _has_error(output: str, run_command: str) -> bool: + + low = output.lower() + + if "timed out" in low: + return False + + if not output.strip(): + return False + + error_type = _classify_error(output) + return error_type != "none" + +class RateLimitError(Exception): + pass + + +def _plan_project(description: str, language: str) -> dict: + model = _get_model(MODEL_PLANNER) + + prompt = f"""You are a senior software architect. Create a minimal, complete file plan for this project. + +Language: {language} +Description: {description} + +Return ONLY valid JSON — no markdown, no explanation: +{{ + "project_name": "snake_case_name", + "entry_point": "main.py", + "files": [ + {{ + "path": "main.py", + "description": "Entry point — what it does and which modules it imports", + "imports": ["utils.helpers", "core.engine"] + }}, + {{ + "path": "utils/helpers.py", + "description": "Helper utilities — what functions it exposes", + "imports": [] + }} + ], + "run_command": "python main.py", + "dependencies": ["requests"] +}} + +Critical rules: +1. List files in DEPENDENCY ORDER — files with no imports come first, entry point comes last. +2. The "imports" field must list every other project module this file imports (dot-notation, e.g. "utils.helpers"). +3. Keep it minimal — only files truly needed. +4. Entry point must be in the files list. +5. Use relative paths only (e.g. "utils/helpers.py", not absolute paths). +6. Standard library modules (os, sys, json, etc.) do NOT go in "dependencies". + +JSON:""" + + try: + response = model.generate_content(prompt) + raw = _strip_fences(response.text) + return json.loads(raw) + except json.JSONDecodeError as e: + raise ValueError(f"Planner returned invalid JSON: {e}\nRaw: {response.text[:300]}") + except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) + raise + +def _write_file( + file_info: dict, + project_description: str, + all_files: list[dict], + language: str, + project_dir: Path, + already_written: dict[str, str], +) -> str: + model = _get_model(MODEL_WRITER) + + file_path = file_info["path"] + file_desc = file_info.get("description", "") + file_imports = file_info.get("imports", []) + + file_list = "\n".join( + f" [{i+1}] {f['path']}: {f.get('description', '')}" + for i, f in enumerate(all_files) + ) + + dependency_context = "" + for dep_dotted in file_imports: + dep_path = dep_dotted.replace(".", "/") + ".py" + if dep_path in already_written: + code_snippet = already_written[dep_path][:2000] + dependency_context += f"\n\n--- {dep_path} (you must import from this) ---\n{code_snippet}" + + lang_rules = "" + if language.lower() == "python": + lang_rules = """ +Python-specific rules: +- Use type hints for all function signatures. +- Add docstrings for all public functions and classes. +- Use if __name__ == "__main__": guard in the entry point. +- For relative imports within the project, use: from utils.helpers import foo (match the project structure exactly). +- Do NOT use implicit relative imports (from . import ...) unless it's a proper package with __init__.py. +- If this is a package subdirectory, create __init__.py files where needed.""" + elif language.lower() in ("javascript", "typescript", "js", "ts"): + lang_rules = """ +JS/TS-specific rules: +- Use ES modules (import/export), not CommonJS (require). +- Add JSDoc comments for all exported functions. +- Handle promise rejections with try/catch in async functions.""" + + prompt = f"""You are a senior {language} developer writing production-quality code for a real project. + +Project goal: {project_description} + +Complete project file structure (in dependency order): +{file_list} + +{f"Dependencies this file must import from other project files:{dependency_context}" if dependency_context else ""} + +Your task: Write the complete, working code for: {file_path} +Purpose of this file: {file_desc} +{f"This file imports from: {', '.join(file_imports)}" if file_imports else "This file has no project-internal imports."} + +{lang_rules} + +General rules: +- Output ONLY raw code. Absolutely no explanation, no markdown, no triple backticks. +- Write COMPLETE, RUNNABLE code — no placeholders, no "# TODO", no "pass" stubs. +- Every import must either be from the standard library, listed dependencies, or the project files shown above. +- Match import paths EXACTLY to the file paths in the project structure (e.g. if file is "utils/helpers.py", import as "from utils.helpers import ..."). +- Use proper error handling (try/except) where I/O or network calls are made. +- The code must work correctly when the project entry point is run from the project root directory. + +Code for {file_path}:""" + + try: + response = model.generate_content(prompt) + code = _strip_fences(response.text) + + full_path = project_dir / file_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(code, encoding="utf-8") + + print(f"[DevAgent] ✅ Written: {file_path} ({len(code)} chars)") + return code + + except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) + raise + +def _install_dependencies(dependencies: list[str], project_dir: Path) -> str: + if not dependencies: + return "No external dependencies." + + to_install = [] + for dep in dependencies: + pkg_name = re.split(r"[>= bool: + vscode_candidates = [ + "code", + rf"C:\Users\{Path.home().name}\AppData\Local\Programs\Microsoft VS Code\bin\code.cmd", + r"C:\Program Files\Microsoft VS Code\bin\code.cmd", + ] + for cmd in vscode_candidates: + try: + subprocess.Popen( + [cmd, str(project_dir)], + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + time.sleep(1.5) + print(f"[DevAgent] 💻 VSCode opened: {project_dir}") + return True + except Exception: + continue + return False + +def _run_project(run_command: str, project_dir: Path, timeout: int = 30) -> str: + print(f"[DevAgent] 🚀 Running: {run_command}") + try: + parts = run_command.split() + if parts[0].lower() == "python": + parts[0] = sys.executable + + result = subprocess.run( + parts, + capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout, + cwd=str(project_dir) + ) + + stdout = result.stdout.strip() + stderr = result.stderr.strip() + + combined_parts = [] + if stdout: + combined_parts.append(f"STDOUT:\n{stdout}") + if stderr: + combined_parts.append(f"STDERR:\n{stderr}") + + return "\n\n".join(combined_parts) if combined_parts else "Ran with no output." + + except subprocess.TimeoutExpired: + return f"Timed out after {timeout}s — long-running app (server/GUI) is likely working." + except FileNotFoundError as e: + return f"Command not found: {e}" + except Exception as e: + return f"Run error: {e}" + +def _try_auto_install(error_output: str, project_dir: Path) -> bool: + """ModuleNotFoundError varsa eksik paketi otomatik kurmaya çalışır.""" + pattern = re.compile( + r"No module named ['\"]([a-zA-Z0-9_\-\.]+)['\"]", re.IGNORECASE + ) + match = pattern.search(error_output) + if not match: + return False + + pkg = match.group(1).replace("_", "-").split(".")[0] + print(f"[DevAgent] 🔧 Auto-installing missing package: {pkg}") + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", pkg], + capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=60, cwd=str(project_dir) + ) + return result.returncode == 0 + except Exception: + return False + +def _fix_files( + error_output: str, + project_description: str, + all_files: list[dict], + file_codes: dict[str, str], + language: str, + project_dir: Path, + entry_point: str, +) -> dict[str, str]: + + model = _get_model(MODEL_PLANNER) + + error_file, error_line = _parse_traceback(error_output, list(file_codes.keys())) + error_type = _classify_error(error_output) + + files_to_fix: list[str] = [] + + if error_file: + files_to_fix.append(error_file) + if error_type == "import_error": + for fi in all_files: + if error_file.replace("/", ".").replace(".py", "") in fi.get("imports", []): + p = fi["path"] + if p not in files_to_fix: + files_to_fix.append(p) + else: + files_to_fix.append(entry_point) + + updated_codes: dict[str, str] = {} + + for fix_path in files_to_fix: + current_code = file_codes.get(fix_path, "") + + other_ctx = "" + for fp, code in file_codes.items(): + if fp != fix_path and code: + snippet = code[:1500] + ("..." if len(code) > 1500 else "") + other_ctx += f"\n--- {fp} ---\n{snippet}\n" + + line_hint = f"\nError appears to be near line {error_line} in this file." if ( + error_line and fix_path == error_file + ) else "" + + prompt = f"""You are an expert {language} debugger. Fix the broken file below. + +Project goal: {project_description} + +All project files: +{chr(10).join(f" - {f['path']}: {f.get('description', '')}" for f in all_files)} + +Other files for context (read-only — fix only the target file): +{other_ctx[:3500]} + +File to fix: {fix_path}{line_hint} +Error type: {error_type} + +Error output: +{error_output[:2500]} + +Current (broken) code: +{current_code} + +Rules: +- Output ONLY the complete fixed code. No explanation, no markdown, no backticks. +- Fix ALL errors visible in the error output. +- Keep all existing correct logic — do not remove working features. +- Ensure import paths match the actual project file structure exactly. +- Do NOT introduce new bugs or remove error handling. + +Fixed code for {fix_path}:""" + + try: + response = model.generate_content(prompt) + fixed = _strip_fences(response.text) + + full_path = project_dir / fix_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(fixed, encoding="utf-8") + + updated_codes[fix_path] = fixed + print(f"[DevAgent] 🔧 Fixed: {fix_path}") + + except Exception as e: + if _is_rate_limit(e): + raise RateLimitError(str(e)) + print(f"[DevAgent] ⚠️ Could not fix {fix_path}: {e}") + + return updated_codes + +def _build_project( + description: str, + language: str, + project_name: str, + timeout: int, + speak=None, + player=None, +) -> str: + + def log(msg: str): + print(f"[DevAgent] {msg}") + if player: + player.write_log(f"[DevAgent] {msg}") + + log("Planning project structure...") + try: + plan = _plan_project(description, language) + except RateLimitError: + msg = "Rate limit reached, sir. Please try again in a moment." + if speak: speak(msg) + return msg + except ValueError as e: + msg = f"Planning failed: {e}" + if speak: speak(msg) + return msg + + proj_name = project_name or plan.get("project_name", "jarvis_project") + proj_name = re.sub(r"[^\w\-]", "_", proj_name) + project_dir = PROJECTS_DIR / proj_name + project_dir.mkdir(parents=True, exist_ok=True) + + files = plan.get("files", []) + entry_point = plan.get("entry_point", "main.py") + run_command = plan.get("run_command", f"python {entry_point}") + dependencies = plan.get("dependencies", []) + + log(f"Project: {proj_name} | Files: {len(files)} | Entry: {entry_point}") + + def _dep_sort_key(fi: dict) -> int: + return len(fi.get("imports", [])) + + sorted_files = sorted(files, key=_dep_sort_key) + + file_codes: dict[str, str] = {} + + for file_info in sorted_files: + file_path = file_info.get("path", "") + if not file_path: + continue + + log(f"Writing {file_path}...") + for attempt in range(2): + try: + code = _write_file( + file_info=file_info, + project_description=description, + all_files=files, + language=language, + project_dir=project_dir, + already_written=file_codes, + ) + file_codes[file_path] = code + time.sleep(0.4) + break + except RateLimitError: + if attempt == 0: + log("Rate limit — waiting 20s...") + time.sleep(20) + else: + log(f"Rate limit retry failed for {file_path}, skipping.") + except Exception as e: + log(f"Failed to write {file_path}: {e}") + break + + if not file_codes: + msg = "I could not write any project files, sir." + if speak: speak(msg) + return msg + + if dependencies: + install_result = _install_dependencies(dependencies, project_dir) + log(install_result) + + _open_vscode(project_dir) + + last_output = "" + auto_installs = 0 + + for attempt in range(1, MAX_FIX_ATTEMPTS + 1): + log(f"Running project (attempt {attempt}/{MAX_FIX_ATTEMPTS})...") + last_output = _run_project(run_command, project_dir, timeout) + log(f"Output preview: {last_output[:150]}") + + if not _has_error(last_output, run_command): + msg = ( + f"Project '{proj_name}' is working, sir. " + f"Built in {attempt} attempt{'s' if attempt > 1 else ''}. " + f"Saved to: {project_dir}" + ) + if speak: speak(msg) + return f"{msg}\n\nOutput:\n{last_output}" + + if attempt == MAX_FIX_ATTEMPTS: + break + + error_type = _classify_error(last_output) + if error_type == "dependency_error" and auto_installs < 3: + installed = _try_auto_install(last_output, project_dir) + if installed: + auto_installs += 1 + log("Missing dependency installed, retrying...") + time.sleep(1) + continue + + log(f"Fixing errors (type: {error_type})...") + try: + updated = _fix_files( + error_output=last_output, + project_description=description, + all_files=files, + file_codes=file_codes, + language=language, + project_dir=project_dir, + entry_point=entry_point, + ) + file_codes.update(updated) + time.sleep(1) + except RateLimitError: + msg = "Rate limit reached during fix. Project saved, check it manually in VSCode." + if speak: speak(msg) + return msg + except Exception as e: + log(f"Fix step failed: {e}") + + msg = ( + f"I couldn't fully fix '{proj_name}' after {MAX_FIX_ATTEMPTS} attempts, sir. " + f"Project is saved at {project_dir} — open it in VSCode and check manually." + ) + if speak: speak(msg) + return f"{msg}\n\nLast error:\n{last_output[:600]}" + + +def dev_agent( + parameters: dict, + response=None, + player=None, + session_memory=None, + speak=None, +) -> str: + p = parameters or {} + description = p.get("description", "").strip() + language = p.get("language", "python").strip() + project_name = p.get("project_name", "").strip() + timeout = int(p.get("timeout", 30)) + + if not description: + return "Please describe the project you want me to build, sir." + + return _build_project( + description = description, + language = language, + project_name = project_name, + timeout = timeout, + speak = speak, + player = player, + ) \ No newline at end of file diff --git a/file_processor.py b/file_processor.py new file mode 100644 index 0000000..1dc5b64 --- /dev/null +++ b/file_processor.py @@ -0,0 +1,482 @@ +# actions/file_controller.py +# File management — create, delete, move, rename, list, find, organize + +import shutil +from pathlib import Path +from datetime import datetime +import send2trash + +def _get_desktop() -> Path: + """Returns desktop path — works on Windows, Mac, Linux.""" + return Path.home() / "Desktop" + + +def _get_downloads() -> Path: + return Path.home() / "Downloads" + + +def _resolve_path(raw: str) -> Path: + """ + Resolves a path from user input. + Supports shortcuts: 'desktop', 'downloads', 'documents', 'home' + """ + shortcuts = { + "desktop": Path.home() / "Desktop", + "downloads": Path.home() / "Downloads", + "documents": Path.home() / "Documents", + "pictures": Path.home() / "Pictures", + "music": Path.home() / "Music", + "videos": Path.home() / "Videos", + "home": Path.home(), + } + lower = raw.strip().lower() + if lower in shortcuts: + return shortcuts[lower] + return Path(raw).expanduser() + + +def _format_size(bytes_size: int) -> str: + """Converts bytes to human readable format.""" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if bytes_size < 1024: + return f"{bytes_size:.1f} {unit}" + bytes_size /= 1024 + return f"{bytes_size:.1f} TB" + + +def list_files(path: str = "desktop", show_hidden: bool = False) -> str: + """Lists files and folders in a directory.""" + try: + target = _resolve_path(path) + if not target.exists(): + return f"Path not found: {target}" + if not target.is_dir(): + return f"Not a directory: {target}" + + items = [] + for item in sorted(target.iterdir()): + if not show_hidden and item.name.startswith("."): + continue + if item.is_dir(): + items.append(f"📁 {item.name}/") + else: + size = _format_size(item.stat().st_size) + items.append(f"📄 {item.name} ({size})") + + if not items: + return f"Directory is empty: {target}" + + return f"Contents of {target.name}/ ({len(items)} items):\n" + "\n".join(items) + + except PermissionError: + return f"Permission denied: {path}" + except Exception as e: + return f"Error listing files: {e}" + + +def create_file(path: str, content: str = "") -> str: + """Creates a new file with optional content.""" + try: + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return f"File created: {target.name}" + except Exception as e: + return f"Could not create file: {e}" + + +def create_folder(path: str) -> str: + """Creates a new folder (and parent folders if needed).""" + try: + target = Path(path).expanduser() + target.mkdir(parents=True, exist_ok=True) + return f"Folder created: {target}" + except Exception as e: + return f"Could not create folder: {e}" + + +def delete_file(path: str, confirm: bool = True) -> str: + """ + Deletes a file or folder. + Moves to Recycle Bin on Windows if possible, otherwise permanent delete. + """ + try: + target = Path(path).expanduser() + if not target.exists(): + return f"Not found: {path}" + + try: + + send2trash.send2trash(str(target)) + return f"Moved to Recycle Bin: {target.name}" + except ImportError: + pass + + # Fallback: permanent delete + if target.is_dir(): + shutil.rmtree(target) + return f"Folder deleted permanently: {target.name}" + else: + target.unlink() + return f"File deleted permanently: {target.name}" + + except PermissionError: + return f"Permission denied: {path}" + except Exception as e: + return f"Could not delete: {e}" + + +def move_file(source: str, destination: str) -> str: + """Moves a file or folder to a new location.""" + try: + src = Path(source).expanduser() + dst = _resolve_path(destination) + + if not src.exists(): + return f"Source not found: {source}" + + if dst.is_dir(): + dst = dst / src.name + + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dst)) + return f"Moved: {src.name} → {dst.parent.name}/" + + except Exception as e: + return f"Could not move: {e}" + + +def copy_file(source: str, destination: str) -> str: + """Copies a file or folder.""" + try: + src = Path(source).expanduser() + dst = _resolve_path(destination) + + if not src.exists(): + return f"Source not found: {source}" + + if dst.is_dir(): + dst = dst / src.name + + dst.parent.mkdir(parents=True, exist_ok=True) + + if src.is_dir(): + shutil.copytree(str(src), str(dst)) + else: + shutil.copy2(str(src), str(dst)) + + return f"Copied: {src.name} → {dst.parent.name}/" + + except Exception as e: + return f"Could not copy: {e}" + + +def rename_file(path: str, new_name: str) -> str: + """Renames a file or folder.""" + try: + target = Path(path).expanduser() + new_path = target.parent / new_name + + if not target.exists(): + return f"Not found: {path}" + if new_path.exists(): + return f"A file named '{new_name}' already exists." + + target.rename(new_path) + return f"Renamed: {target.name} → {new_name}" + + except Exception as e: + return f"Could not rename: {e}" + + +def read_file(path: str, max_chars: int = 3000) -> str: + """Reads and returns the content of a text file.""" + try: + target = Path(path).expanduser() + if not target.exists(): + return f"File not found: {path}" + if not target.is_file(): + return f"Not a file: {path}" + + content = target.read_text(encoding="utf-8", errors="ignore") + if len(content) > max_chars: + content = content[:max_chars] + f"\n\n... (truncated, {len(content)} total chars)" + return content + + except Exception as e: + return f"Could not read file: {e}" + + +def write_file(path: str, content: str, append: bool = False) -> str: + """Writes or appends content to a file.""" + try: + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + mode = "a" if append else "w" + with open(target, mode, encoding="utf-8") as f: + f.write(content) + action = "Appended to" if append else "Written to" + return f"{action}: {target.name}" + except Exception as e: + return f"Could not write file: {e}" + + +def find_files(name: str = "", extension: str = "", path: str = "home", + max_results: int = 20) -> str: + """ + Searches for files by name or extension. + Example: find_files(extension=".pdf", path="documents") + """ + try: + search_path = _resolve_path(path) + if not search_path.exists(): + return f"Search path not found: {path}" + + results = [] + pattern = f"*{extension}" if extension else "*" + + for item in search_path.rglob(pattern): + if item.is_file(): + if name and name.lower() not in item.name.lower(): + continue + size = _format_size(item.stat().st_size) + results.append(f"📄 {item.name} ({size}) — {item.parent}") + if len(results) >= max_results: + break + + if not results: + query = name or extension or "files" + return f"No {query} found in {search_path.name}/" + + return f"Found {len(results)} file(s):\n" + "\n".join(results) + + except Exception as e: + return f"Search error: {e}" + + +def get_largest_files(path: str = "home", count: int = 10) -> str: + """Returns the largest files in a directory.""" + try: + search_path = _resolve_path(path) + if not search_path.exists(): + return f"Path not found: {path}" + + files = [] + for item in search_path.rglob("*"): + if item.is_file(): + try: + files.append((item.stat().st_size, item)) + except Exception: + continue + + files.sort(reverse=True) + top = files[:count] + + if not top: + return "No files found." + + lines = [f"Top {len(top)} largest files in {search_path.name}/:\n"] + for size, f in top: + lines.append(f" {_format_size(size):>10} {f.name} ({f.parent})") + + return "\n".join(lines) + + except Exception as e: + return f"Error: {e}" + + +def get_disk_usage(path: str = "home") -> str: + """Returns disk usage information.""" + try: + target = _resolve_path(path) + usage = shutil.disk_usage(target) + total = _format_size(usage.total) + used = _format_size(usage.used) + free = _format_size(usage.free) + pct = usage.used / usage.total * 100 + + return ( + f"Disk usage for {target}:\n" + f" Total : {total}\n" + f" Used : {used} ({pct:.1f}%)\n" + f" Free : {free}" + ) + except Exception as e: + return f"Could not get disk usage: {e}" + + +def organize_desktop() -> str: + """ + Organizes the desktop by grouping files into folders by type. + Creates folders: Images, Documents, Videos, Music, Archives, Others + """ + try: + desktop = _get_desktop() + type_map = { + "Images": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico"], + "Documents": [".pdf", ".doc", ".docx", ".txt", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"], + "Videos": [".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm"], + "Music": [".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma"], + "Archives": [".zip", ".rar", ".7z", ".tar", ".gz"], + "Code": [".py", ".js", ".html", ".css", ".json", ".xml", ".ts", ".cpp", ".java"], + } + + moved = [] + skipped = [] + + for item in desktop.iterdir(): + + if item.is_dir() or item.name.startswith("."): + continue + + ext = item.suffix.lower() + target_dir = None + + for folder, extensions in type_map.items(): + if ext in extensions: + target_dir = desktop / folder + break + + if target_dir is None: + target_dir = desktop / "Others" + + target_dir.mkdir(exist_ok=True) + new_path = target_dir / item.name + + if new_path.exists(): + skipped.append(item.name) + continue + + shutil.move(str(item), str(new_path)) + moved.append(f"{item.name} → {target_dir.name}/") + + result = f"Desktop organized. {len(moved)} files moved." + if moved: + result += "\n" + "\n".join(moved[:10]) + if len(moved) > 10: + result += f"\n... and {len(moved)-10} more." + if skipped: + result += f"\n{len(skipped)} files skipped (already exist)." + + return result + + except Exception as e: + return f"Could not organize desktop: {e}" + + +def get_file_info(path: str) -> str: + """Returns detailed information about a file.""" + try: + target = Path(path).expanduser() + if not target.exists(): + return f"Not found: {path}" + + stat = target.stat() + info = { + "Name": target.name, + "Type": "Folder" if target.is_dir() else "File", + "Size": _format_size(stat.st_size), + "Location": str(target.parent), + "Created": datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M"), + "Modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"), + "Extension": target.suffix or "None", + } + + return "\n".join(f" {k}: {v}" for k, v in info.items()) + + except Exception as e: + return f"Could not get file info: {e}" + +def file_controller( + parameters: dict, + response=None, + player=None, + session_memory=None +) -> str: + action = (parameters or {}).get("action", "").lower().strip() + path = (parameters or {}).get("path", "desktop") + name = (parameters or {}).get("name", "") + content = (parameters or {}).get("content", "") + + def _full_path(p: str, n: str) -> str: + base = _resolve_path(p) + if n: + return str(base / n) + return str(base) + + result = "Unknown action." + + try: + if action == "list": + result = list_files(path) + + elif action == "create_file": + full = _full_path(path, name) + result = create_file(full, content=content) + + elif action == "create_folder": + full = _full_path(path, name) + result = create_folder(full) + + elif action == "delete": + full = _full_path(path, name) + result = delete_file(full) + + elif action == "move": + full = _full_path(path, name) + result = move_file(full, parameters.get("destination", "")) + + elif action == "copy": + full = _full_path(path, name) + result = copy_file(full, parameters.get("destination", "")) + + elif action == "rename": + full = _full_path(path, name) + result = rename_file(full, parameters.get("new_name", "")) + + elif action == "read": + full = _full_path(path, name) + result = read_file(full) + + elif action == "write": + full = _full_path(path, name) + result = write_file( + full, + content=content, + append=parameters.get("append", False) + ) + + elif action == "find": + result = find_files( + name=name or parameters.get("name", ""), + extension=parameters.get("extension", ""), + path=path, + max_results=parameters.get("max_results", 20) + ) + + elif action == "largest": + result = get_largest_files( + path=path, + count=parameters.get("count", 10) + ) + + elif action == "disk_usage": + result = get_disk_usage(path) + + elif action == "organize_desktop": + result = organize_desktop() + + elif action == "info": + full = _full_path(path, name) + result = get_file_info(full) + + else: + result = f"Unknown action: '{action}'" + + except Exception as e: + result = f"File controller error: {e}" + + if player: + player.write_log(f"[file] {result[:60]}") + + return result \ No newline at end of file diff --git a/flight_finder.py b/flight_finder.py new file mode 100644 index 0000000..84e51cb --- /dev/null +++ b/flight_finder.py @@ -0,0 +1,833 @@ +""" +file_processor.py — JARVIS Universal File Processor + +Supported types: + image → describe, ocr, resize, convert, compress, crop + pdf → summarize, extract_text, extract_pages, to_word + docx → summarize, extract_text, reformat, translate_hint + txt/md → summarize, reformat, translate_hint, word_count + csv → analyze, filter, sort, convert, stats + xlsx → analyze, filter, convert, stats + json → validate, format, extract, convert + code → explain, review, fix, run, document + audio → transcribe, trim, convert, info + video → trim, extract_audio, extract_frame, info, compress + zip → list, extract + pptx → summarize, extract_text, to_pdf +""" + +import os +import re +import json +import shutil +import subprocess +import tempfile +from pathlib import Path +from datetime import datetime + +import google.generativeai as genai + + +def _get_api_key() -> str: + config_path = Path(__file__).resolve().parent.parent / "config" / "api_keys.json" + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _gemini_client(): + genai.configure(api_key=_get_api_key()) + return genai.GenerativeModel("gemini-2.5-flash") + + +def _detect_type(path: Path) -> str: + ext = path.suffix.lower().lstrip(".") + image_exts = {"jpg", "jpeg", "png", "gif", "webp", "bmp", "tiff", "svg", "ico"} + video_exts = {"mp4", "avi", "mov", "mkv", "wmv", "flv", "webm", "m4v", "3gp"} + audio_exts = {"mp3", "wav", "ogg", "m4a", "aac", "flac", "wma", "opus"} + code_exts = {"py", "js", "ts", "jsx", "tsx", "html", "css", "java", "c", + "cpp", "cs", "go", "rs", "rb", "php", "swift", "kt", "sh", + "bash", "ps1", "lua", "r", "m", "sql", "yaml", "toml"} + archive_exts = {"zip", "rar", "tar", "gz", "7z", "bz2", "xz"} + + if ext in image_exts: return "image" + if ext in video_exts: return "video" + if ext in audio_exts: return "audio" + if ext in code_exts: return "code" + if ext in archive_exts: return "archive" + if ext == "pdf": return "pdf" + if ext in ("docx", "doc"): return "docx" + if ext in ("txt", "md", "rst", "log"): return "text" + if ext in ("csv", "tsv"): return "csv" + if ext in ("xlsx", "xls", "ods"): return "excel" + if ext == "json": return "json" + if ext == "xml": return "xml" + if ext in ("pptx", "ppt"): return "pptx" + return "unknown" + + +def _file_size_str(path: Path) -> str: + size = path.stat().st_size + if size < 1024: return f"{size} B" + if size < 1024**2: return f"{size/1024:.1f} KB" + if size < 1024**3: return f"{size/1024**2:.1f} MB" + return f"{size/1024**3:.1f} GB" + +def _output_path(src: Path, suffix: str, new_ext: str = None) -> Path: + ext = new_ext or src.suffix + name = f"{src.stem}_{suffix}{ext}" + return src.parent / name + +def _process_image(path: Path, action: str, params: dict, speak=None) -> str: + try: + from PIL import Image + except ImportError: + return "Pillow is not installed. Run: pip install Pillow" + + action = action or "describe" + + if action in ("describe", "ocr", "analyze", "read", "extract_text"): + try: + model = _gemini_client() + img = Image.open(path) + prompt = { + "describe": "Describe this image in detail.", + "ocr": "Extract all text visible in this image. Return only the text, formatted clearly.", + "analyze": "Analyze this image thoroughly: objects, colors, composition, any text, context.", + "read": "Read all text in this image, preserving structure and formatting.", + "extract_text": "Extract all text from this image.", + }.get(action, "Describe this image.") + + if params.get("instruction"): + prompt = params["instruction"] + + response = model.generate_content([prompt, img]) + result = response.text.strip() + + if len(result) > 500 and params.get("save", True): + out = _output_path(path, "result", ".txt") + out.write_text(result, encoding="utf-8") + return f"{result[:300]}...\n\nFull result saved to: {out}" + return result + except Exception as e: + return f"AI image analysis failed: {e}" + + if action == "resize": + width = int(params.get("width", 0)) + height = int(params.get("height", 0)) + scale = float(params.get("scale", 0)) + try: + img = Image.open(path) + w, h = img.size + if scale: + new_size = (int(w * scale), int(h * scale)) + elif width and height: + new_size = (width, height) + elif width: + new_size = (width, int(h * width / w)) + elif height: + new_size = (int(w * height / h), height) + else: + return "Please specify width, height, or scale." + out = _output_path(path, f"resized_{new_size[0]}x{new_size[1]}") + img.resize(new_size, Image.LANCZOS).save(out) + return f"Resized from {w}x{h} to {new_size[0]}x{new_size[1]}. Saved: {out.name}" + except Exception as e: + return f"Resize failed: {e}" + + if action == "convert": + fmt = params.get("format", "png").lower().strip(".") + fmt_map = {"jpg": "JPEG", "jpeg": "JPEG", "png": "PNG", + "webp": "WEBP", "bmp": "BMP", "tiff": "TIFF"} + pil_fmt = fmt_map.get(fmt, fmt.upper()) + try: + img = Image.open(path).convert("RGB") if fmt == "jpg" else Image.open(path) + out = _output_path(path, "converted", f".{fmt}") + img.save(out, pil_fmt) + return f"Converted to {fmt.upper()}. Saved: {out.name}" + except Exception as e: + return f"Convert failed: {e}" + + if action == "compress": + quality = int(params.get("quality", 70)) + try: + img = Image.open(path).convert("RGB") + out = _output_path(path, f"compressed_q{quality}", ".jpg") + img.save(out, "JPEG", quality=quality, optimize=True) + before = _file_size_str(path) + after = _file_size_str(out) + return f"Compressed: {before} → {after}. Saved: {out.name}" + except Exception as e: + return f"Compress failed: {e}" + + if action == "info": + try: + img = Image.open(path) + return (f"Image info: {img.format}, {img.size[0]}x{img.size[1]}px, " + f"mode: {img.mode}, size: {_file_size_str(path)}") + except Exception as e: + return f"Info failed: {e}" + + return _process_image(path, "describe", {"instruction": f"{action}: {params}"}) + +def _process_pdf(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "summarize" + + def _extract_pdf_text(max_chars=50000) -> str: + text = "" + try: + import pdfplumber + with pdfplumber.open(path) as pdf: + for page in pdf.pages: + text += (page.extract_text() or "") + "\n" + except ImportError: + try: + import PyPDF2 + with open(path, "rb") as f: + reader = PyPDF2.PdfReader(f) + for page in reader.pages: + text += page.extract_text() + "\n" + except ImportError: + return "" + return text[:max_chars] + + if action in ("summarize", "extract_text", "translate_hint", "analyze", "reformat"): + text = _extract_pdf_text() + if not text.strip(): + return "Could not extract text from PDF (may be scanned/image-based)." + + if action == "extract_text": + out = _output_path(path, "text", ".txt") + out.write_text(text, encoding="utf-8") + return f"Text extracted ({len(text)} chars). Saved: {out.name}" + + prompt_map = { + "summarize": f"Summarize this PDF document concisely:\n\n{text}", + "analyze": f"Analyze this document thoroughly:\n\n{text}", + "translate_hint": f"What language is this document in and what does it say? Summarize:\n\n{text}", + "reformat": f"Reformat this text cleanly with proper structure:\n\n{text}", + } + try: + model = _gemini_client() + response = model.generate_content(prompt_map.get(action, f"Analyze:\n\n{text}")) + result = response.text.strip() + if len(result) > 600 and params.get("save", True): + out = _output_path(path, action, ".txt") + out.write_text(result, encoding="utf-8") + return f"{result[:400]}...\n\nFull result saved: {out.name}" + return result + except Exception as e: + return f"AI analysis failed: {e}" + + if action == "info": + try: + import pdfplumber + with pdfplumber.open(path) as pdf: + pages = len(pdf.pages) + return f"PDF: {pages} pages, size: {_file_size_str(path)}" + except Exception: + return f"PDF size: {_file_size_str(path)}" + + if action == "to_word": + text = _extract_pdf_text() + if not text: + return "Could not extract text to convert." + try: + from docx import Document + doc = Document() + doc.add_heading(path.stem, 0) + for para in text.split("\n\n"): + if para.strip(): + doc.add_paragraph(para.strip()) + out = _output_path(path, "converted", ".docx") + doc.save(out) + return f"Converted to Word document. Saved: {out.name}" + except ImportError: + return "python-docx not installed. Run: pip install python-docx" + + return f"Unknown PDF action: '{action}'. Try: summarize, extract_text, info, to_word" + +def _process_text_doc(path: Path, file_type: str, action: str, + params: dict, speak=None) -> str: + action = action or "summarize" + + def _read_content() -> str: + if file_type == "docx": + try: + from docx import Document + doc = Document(path) + return "\n".join(p.text for p in doc.paragraphs) + except ImportError: + return "python-docx not installed." + except Exception as e: + return f"Read failed: {e}" + else: + return path.read_text(encoding="utf-8", errors="ignore") + + content = _read_content() + if not content.strip(): + return "File appears to be empty." + + if action == "word_count": + words = len(content.split()) + chars = len(content) + lines = content.count("\n") + return f"Word count: {words} words, {chars} characters, {lines} lines." + + if action == "extract_text": + if file_type != "txt": + out = _output_path(path, "extracted", ".txt") + out.write_text(content, encoding="utf-8") + return f"Text extracted. Saved: {out.name}" + return content[:2000] + + instruction = params.get("instruction", "") + prompt_map = { + "summarize": f"Summarize this document concisely:\n\n{content[:40000]}", + "analyze": f"Analyze this document:\n\n{content[:40000]}", + "reformat": f"Reformat this text with clean structure, proper headings and paragraphs:\n\n{content[:40000]}", + "fix": f"Fix grammar, spelling and style issues in this text:\n\n{content[:40000]}", + "translate_hint": f"What language is this and what does it say? Summarize:\n\n{content[:10000]}", + "to_bullet": f"Convert this text into a clear bullet-point summary:\n\n{content[:40000]}", + "custom": f"{instruction}\n\n{content[:40000]}", + } + + if action not in prompt_map: + + action = "custom" + instruction = action + + try: + model = _gemini_client() + response = model.generate_content(prompt_map[action]) + result = response.text.strip() + if len(result) > 600 and params.get("save", True): + out = _output_path(path, action, ".txt") + out.write_text(result, encoding="utf-8") + return f"{result[:400]}...\n\nFull result saved: {out.name}" + return result + except Exception as e: + return f"AI processing failed: {e}" + + +def _process_data(path: Path, file_type: str, action: str, + params: dict, speak=None) -> str: + try: + import pandas as pd + except ImportError: + return "pandas not installed. Run: pip install pandas openpyxl" + + action = action or "analyze" + + try: + if file_type == "csv": + df = pd.read_csv(path, encoding="utf-8", errors="replace") + else: + df = pd.read_excel(path) + except Exception as e: + return f"Could not read file: {e}" + + if action == "info": + return (f"Rows: {len(df)}, Columns: {len(df.columns)}\n" + f"Columns: {', '.join(df.columns.tolist())}\n" + f"Size: {_file_size_str(path)}") + + if action == "stats": + try: + desc = df.describe(include="all").to_string() + return f"Statistics:\n{desc[:2000]}" + except Exception as e: + return f"Stats failed: {e}" + + if action == "analyze": + preview = df.head(50).to_string() + prompt = (f"Analyze this dataset. Columns: {list(df.columns)}\n" + f"Rows: {len(df)}\nPreview:\n{preview}\n\n" + f"Give insights, patterns, and notable findings.") + try: + model = _gemini_client() + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + return f"AI analysis failed: {e}" + + if action in ("convert", "to_csv", "to_excel", "to_json"): + fmt = {"to_csv": "csv", "to_excel": "xlsx", "to_json": "json", + "convert": params.get("format", "csv")}.get(action, "csv") + try: + if fmt == "csv": + out = _output_path(path, "converted", ".csv") + df.to_csv(out, index=False, encoding="utf-8") + elif fmt == "xlsx": + out = _output_path(path, "converted", ".xlsx") + df.to_excel(out, index=False) + elif fmt == "json": + out = _output_path(path, "converted", ".json") + df.to_json(out, orient="records", force_ascii=False, indent=2) + return f"Converted to {fmt.upper()}. Saved: {out.name}" + except Exception as e: + return f"Convert failed: {e}" + + if action == "filter": + col = params.get("column", "") + value = params.get("value", "") + condition = params.get("condition", "equals") + if not col or col not in df.columns: + return f"Column '{col}' not found. Available: {', '.join(df.columns)}" + try: + if condition == "equals": filtered = df[df[col] == value] + elif condition == "contains": filtered = df[df[col].astype(str).str.contains(str(value), case=False)] + elif condition == "gt": filtered = df[df[col] > float(value)] + elif condition == "lt": filtered = df[df[col] < float(value)] + else: filtered = df[df[col] == value] + out = _output_path(path, "filtered", ".csv") + filtered.to_csv(out, index=False) + return f"Filtered: {len(filtered)} rows match. Saved: {out.name}" + except Exception as e: + return f"Filter failed: {e}" + + if action == "sort": + col = params.get("column", df.columns[0]) + asc = params.get("ascending", True) + try: + sorted_df = df.sort_values(col, ascending=asc) + out = _output_path(path, "sorted", path.suffix) + sorted_df.to_csv(out, index=False) + return f"Sorted by '{col}'. Saved: {out.name}" + except Exception as e: + return f"Sort failed: {e}" + + preview = df.head(30).to_string() + try: + model = _gemini_client() + response = model.generate_content( + f"Task: {action}\nDataset ({len(df)} rows, cols: {list(df.columns)}):\n{preview}" + ) + return response.text.strip() + except Exception as e: + return f"Processing failed: {e}" + + +def _process_json(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "analyze" + try: + content = path.read_text(encoding="utf-8") + data = json.loads(content) + except Exception as e: + return f"Invalid JSON: {e}" + + if action == "validate": + return f"Valid JSON. Type: {type(data).__name__}, size: {_file_size_str(path)}" + + if action == "format": + out = _output_path(path, "formatted", ".json") + out.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + return f"Formatted JSON saved: {out.name}" + + if action in ("analyze", "summarize", "extract"): + preview = json.dumps(data, indent=2, ensure_ascii=False)[:8000] + prompt = f"Task: {action} this JSON data:\n{preview}" + if params.get("instruction"): + prompt = f"{params['instruction']}\n\nJSON data:\n{preview}" + try: + model = _gemini_client() + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + return f"AI processing failed: {e}" + + if action == "to_csv": + try: + import pandas as pd + if isinstance(data, list): + df = pd.DataFrame(data) + out = _output_path(path, "converted", ".csv") + df.to_csv(out, index=False) + return f"Converted to CSV. Saved: {out.name}" + return "JSON must be an array of objects to convert to CSV." + except ImportError: + return "pandas not installed." + + return _process_json(path, "analyze", {"instruction": action}) + +def _process_code(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "explain" + content = path.read_text(encoding="utf-8", errors="ignore") + ext = path.suffix.lstrip(".") + + if action == "run": + if ext == "py": + try: + result = subprocess.run( + ["python", str(path)], + capture_output=True, text=True, timeout=30 + ) + out = result.stdout or result.stderr + return f"Output:\n{out[:2000]}" if out else "No output." + except subprocess.TimeoutExpired: + return "Execution timed out (30s)." + except Exception as e: + return f"Run failed: {e}" + return f"Direct execution not supported for .{ext} files." + + if action == "info": + lines = content.count("\n") + words = len(content.split()) + return f"Code file: {lines} lines, {words} words, {_file_size_str(path)}" + + prompt_map = { + "explain": f"Explain this {ext} code clearly:\n\n```{ext}\n{content[:30000]}\n```", + "review": f"Review this {ext} code for bugs, issues, and improvements:\n\n```{ext}\n{content[:30000]}\n```", + "fix": f"Fix any bugs in this {ext} code and return the corrected version:\n\n```{ext}\n{content[:30000]}\n```", + "optimize": f"Optimize this {ext} code for performance and readability:\n\n```{ext}\n{content[:30000]}\n```", + "document": f"Add proper documentation/comments to this {ext} code:\n\n```{ext}\n{content[:30000]}\n```", + "summarize": f"Summarize what this {ext} code does:\n\n```{ext}\n{content[:30000]}\n```", + "test": f"Write unit tests for this {ext} code:\n\n```{ext}\n{content[:30000]}\n```", + } + + instruction = params.get("instruction", "") + if action not in prompt_map: + prompt = f"{action}\n\n```{ext}\n{content[:30000]}\n```" + if instruction: + prompt = f"{instruction}\n\n```{ext}\n{content[:30000]}\n```" + else: + prompt = prompt_map[action] + + try: + model = _gemini_client() + response = model.generate_content(prompt) + result = response.text.strip() + + if action in ("fix", "optimize", "document") and params.get("save", True): + out = _output_path(path, action) + code_match = re.search(r"```(?:\w+)?\n(.*?)```", result, re.DOTALL) + code_to_save = code_match.group(1) if code_match else result + out.write_text(code_to_save, encoding="utf-8") + return f"{result[:400]}...\n\nSaved: {out.name}" + return result + except Exception as e: + return f"AI processing failed: {e}" + +def _process_audio(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "transcribe" + + if action == "info": + try: + from pydub import AudioSegment + audio = AudioSegment.from_file(path) + duration = len(audio) / 1000 + mins, secs = divmod(int(duration), 60) + return (f"Audio: {mins}m {secs}s, " + f"{audio.channels} ch, " + f"{audio.frame_rate}Hz, " + f"{_file_size_str(path)}") + except ImportError: + return f"Audio file: {_file_size_str(path)} (install pydub for more info)" + except Exception as e: + return f"Info failed: {e}" + + if action == "transcribe": + try: + model = _gemini_client() + content = path.read_bytes() + mime = { + "mp3": "audio/mp3", "wav": "audio/wav", + "ogg": "audio/ogg", "m4a": "audio/mp4", + "aac": "audio/aac", "flac": "audio/flac", + }.get(path.suffix.lstrip(".").lower(), "audio/mpeg") + response = model.generate_content([ + "Transcribe all speech in this audio file accurately.", + {"mime_type": mime, "data": content} + ]) + result = response.text.strip() + if params.get("save", True): + out = _output_path(path, "transcript", ".txt") + out.write_text(result, encoding="utf-8") + return f"Transcription saved: {out.name}\n\nPreview: {result[:300]}" + return result + except Exception as e: + return f"Transcription failed: {e}" + + if action == "convert": + fmt = params.get("format", "mp3").lstrip(".") + try: + from pydub import AudioSegment + audio = AudioSegment.from_file(path) + out = _output_path(path, "converted", f".{fmt}") + audio.export(out, format=fmt) + return f"Converted to {fmt.upper()}. Saved: {out.name}" + except ImportError: + return "pydub not installed. Run: pip install pydub" + except Exception as e: + return f"Convert failed: {e}" + + if action == "trim": + start = float(params.get("start", 0)) + end = float(params.get("end", 0)) + try: + from pydub import AudioSegment + audio = AudioSegment.from_file(path) + end_ms = int(end * 1000) if end else len(audio) + trimmed = audio[int(start * 1000):end_ms] + out = _output_path(path, f"trim_{int(start)}s_{int(end)}s") + trimmed.export(out, format=path.suffix.lstrip(".")) + return f"Trimmed audio ({int(start)}s–{int(end)}s). Saved: {out.name}" + except ImportError: + return "pydub not installed." + except Exception as e: + return f"Trim failed: {e}" + + return f"Unknown audio action: '{action}'. Try: transcribe, info, convert, trim" + +def _process_video(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "info" + + + def _ffmpeg_available() -> bool: + try: + subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=3) + return True + except Exception: + return False + + if action == "info": + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", str(path)], + capture_output=True, text=True, timeout=10 + ) + data = json.loads(result.stdout) + fmt = data.get("format", {}) + duration = float(fmt.get("duration", 0)) + mins, secs = divmod(int(duration), 60) + size = _file_size_str(path) + streams = data.get("streams", []) + video_s = next((s for s in streams if s["codec_type"] == "video"), {}) + w = video_s.get("width", "?") + h = video_s.get("height", "?") + fps = video_s.get("r_frame_rate", "?") + return f"Video: {mins}m {secs}s, {w}x{h}, {fps} fps, {size}" + except Exception: + return f"Video file: {_file_size_str(path)}" + + if action == "extract_audio": + if not _ffmpeg_available(): + return "ffmpeg not found. Install ffmpeg to extract audio." + out = _output_path(path, "audio", ".mp3") + try: + subprocess.run( + ["ffmpeg", "-i", str(path), "-q:a", "0", "-map", "a", str(out), "-y"], + capture_output=True, timeout=300 + ) + return f"Audio extracted. Saved: {out.name}" + except Exception as e: + return f"Extract audio failed: {e}" + + if action == "trim": + start = params.get("start", "00:00:00") + end = params.get("end", "") + if not _ffmpeg_available(): + return "ffmpeg not found." + out = _output_path(path, f"trim", path.suffix) + try: + cmd = ["ffmpeg", "-i", str(path), "-ss", str(start)] + if end: + cmd += ["-to", str(end)] + cmd += ["-c", "copy", str(out), "-y"] + subprocess.run(cmd, capture_output=True, timeout=600) + return f"Trimmed video saved: {out.name}" + except Exception as e: + return f"Trim failed: {e}" + + if action == "extract_frame": + timestamp = params.get("timestamp", "00:00:01") + if not _ffmpeg_available(): + return "ffmpeg not found." + out = _output_path(path, f"frame_{timestamp.replace(':', '')}", ".jpg") + try: + subprocess.run( + ["ffmpeg", "-i", str(path), "-ss", timestamp, + "-vframes", "1", str(out), "-y"], + capture_output=True, timeout=30 + ) + return f"Frame extracted at {timestamp}. Saved: {out.name}" + except Exception as e: + return f"Extract frame failed: {e}" + + if action == "compress": + crf = int(params.get("quality", 28)) + if not _ffmpeg_available(): + return "ffmpeg not found." + out = _output_path(path, f"compressed_crf{crf}", ".mp4") + try: + subprocess.run( + ["ffmpeg", "-i", str(path), + "-c:v", "libx264", "-crf", str(crf), + "-preset", "medium", "-c:a", "copy", + str(out), "-y"], + capture_output=True, timeout=1800 + ) + before = _file_size_str(path) + after = _file_size_str(out) + return f"Compressed: {before} → {after}. Saved: {out.name}" + except Exception as e: + return f"Compress failed: {e}" + + if action == "transcribe": + if not _ffmpeg_available(): + return "ffmpeg not found. Needed for video transcription." + tmp_audio = Path(tempfile.mktemp(suffix=".mp3")) + try: + subprocess.run( + ["ffmpeg", "-i", str(path), "-q:a", "0", "-map", "a", + str(tmp_audio), "-y"], + capture_output=True, timeout=300 + ) + result = _process_audio(tmp_audio, "transcribe", params, speak) + return result + except Exception as e: + return f"Video transcription failed: {e}" + finally: + if tmp_audio.exists(): + tmp_audio.unlink() + + if action == "convert": + fmt = params.get("format", "mp4").lstrip(".") + if not _ffmpeg_available(): + return "ffmpeg not found." + out = _output_path(path, "converted", f".{fmt}") + try: + subprocess.run( + ["ffmpeg", "-i", str(path), str(out), "-y"], + capture_output=True, timeout=1800 + ) + return f"Converted to {fmt.upper()}. Saved: {out.name}" + except Exception as e: + return f"Convert failed: {e}" + + return f"Unknown video action: '{action}'. Try: info, trim, extract_audio, extract_frame, compress, transcribe, convert" + +def _process_archive(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "list" + + if action == "list": + try: + import zipfile, tarfile + ext = path.suffix.lower() + if ext == ".zip": + with zipfile.ZipFile(path) as z: + names = z.namelist() + elif ext in (".tar", ".gz", ".bz2", ".xz"): + with tarfile.open(path) as t: + names = t.getnames() + else: + return f"Unsupported archive format: {ext}" + preview = "\n".join(names[:30]) + suffix = f"\n... and {len(names)-30} more" if len(names) > 30 else "" + return f"Archive contains {len(names)} files:\n{preview}{suffix}" + except Exception as e: + return f"List failed: {e}" + + if action == "extract": + dest = Path(params.get("destination", str(path.parent / path.stem))) + dest.mkdir(parents=True, exist_ok=True) + try: + shutil.unpack_archive(path, dest) + return f"Extracted to: {dest}" + except Exception as e: + return f"Extract failed: {e}" + + return f"Unknown archive action: '{action}'. Try: list, extract" + +def _process_pptx(path: Path, action: str, params: dict, speak=None) -> str: + action = action or "summarize" + + def _read_pptx_text() -> str: + try: + from pptx import Presentation + prs = Presentation(path) + text = [] + for i, slide in enumerate(prs.slides, 1): + slide_text = f"\n--- Slide {i} ---\n" + for shape in slide.shapes: + if hasattr(shape, "text") and shape.text.strip(): + slide_text += shape.text.strip() + "\n" + text.append(slide_text) + return "\n".join(text) + except ImportError: + return "python-pptx not installed." + + if action in ("summarize", "extract_text", "analyze"): + text = _read_pptx_text() + if action == "extract_text": + out = _output_path(path, "text", ".txt") + out.write_text(text, encoding="utf-8") + return f"Text extracted. Saved: {out.name}" + try: + model = _gemini_client() + prompt = f"{'Summarize' if action == 'summarize' else 'Analyze'} this presentation:\n{text[:30000]}" + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + return f"AI processing failed: {e}" + + return f"Unknown PPTX action: '{action}'. Try: summarize, extract_text, analyze" + +def file_processor(parameters: dict, player=None, speak=None) -> str: + file_path_str = parameters.get("file_path", "").strip() + if not file_path_str: + return "No file path provided." + + path = Path(file_path_str) + if not path.exists(): + return f"File not found: {file_path_str}" + if not path.is_file(): + return f"Path is not a file: {file_path_str}" + + file_type = _detect_type(path) + action = (parameters.get("action") or "").lower().strip() + instruction = parameters.get("instruction", "") + params = {**parameters, "instruction": instruction} + + log_msg = f"[FileProcessor] {file_type.upper()} | {path.name} | action={action or 'auto'}" + print(log_msg) + if player: + player.write_log(log_msg) + + if file_type == "unknown": + try: + content = path.read_text(encoding="utf-8", errors="ignore")[:10000] + model = _gemini_client() + prompt = f"File: {path.name}\nContent preview:\n{content}\n\nTask: {action or instruction or 'Describe what this file contains and what can be done with it.'}" + response = model.generate_content(prompt) + return response.text.strip() + except Exception as e: + return f"Unknown file type ({path.suffix}). Could not process: {e}" + + dispatch = { + "image": _process_image, + "pdf": _process_pdf, + "docx": lambda p, a, pm, s: _process_text_doc(p, "docx", a, pm, s), + "text": lambda p, a, pm, s: _process_text_doc(p, "text", a, pm, s), + "csv": lambda p, a, pm, s: _process_data(p, "csv", a, pm, s), + "excel": lambda p, a, pm, s: _process_data(p, "excel", a, pm, s), + "json": _process_json, + "xml": lambda p, a, pm, s: _process_json(p, a, pm, s), + "code": _process_code, + "audio": _process_audio, + "video": _process_video, + "archive": _process_archive, + "pptx": _process_pptx, + } + + handler = dispatch.get(file_type) + if not handler: + return f"Unsupported file type: {file_type}" + + try: + result = handler(path, action, params, speak) + return result or "Done." + except Exception as e: + import traceback + traceback.print_exc() + return f"Processing failed: {e}" \ No newline at end of file diff --git a/game_updater.py b/game_updater.py new file mode 100644 index 0000000..f5fc755 --- /dev/null +++ b/game_updater.py @@ -0,0 +1,339 @@ +#flight_finder.py +import json +import re +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +from config import is_windows, is_mac, is_linux + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = _get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + +_MONTH_MAP: dict[str, int] = { + + "january": 1, "february": 2, "march": 3, "april": 4, + "may": 5, "june": 6, "july": 7, "august": 8, + "september": 9, "october": 10, "november": 11, "december": 12, + "ocak": 1, "şubat": 2, "mart": 3, "nisan": 4, + "mayıs": 5, "haziran": 6, "temmuz": 7, "ağustos": 8, + "eylül": 9, "ekim": 10, "kasım": 11, "aralık": 12, +} + +_RELATIVE_MAP_KEYS = { + "today", "bugün", + "tomorrow", "yarın", +} + + +def _parse_date(raw: str) -> str: + + raw = raw.strip() + lower = raw.lower() + today = datetime.now() + + if re.match(r"\d{4}-\d{2}-\d{2}", raw): + return raw + for fmt in ("%d/%m/%Y", "%m/%d/%Y", "%d.%m.%Y", "%d-%m-%Y"): + try: + return datetime.strptime(raw, fmt).strftime("%Y-%m-%d") + except ValueError: + pass + + relative = { + "today": today, "bugün": today, + "tomorrow": today + timedelta(days=1), + "yarın": today + timedelta(days=1), + } + for key, val in relative.items(): + if key in lower: + return val.strftime("%Y-%m-%d") +# replace the try/except genai block with: + try: + from or_client import client + result = client.chat( + f"Today is {today.strftime('%Y-%m-%d')}. " + f"Convert this date expression to YYYY-MM-DD: '{raw}'. " + f"Return ONLY the date string, nothing else.", + system="You are a date converter. Return only the YYYY-MM-DD string." + ) + result = result.strip() + if re.match(r"\d{4}-\d{2}-\d{2}", result): + return result + except Exception as e: + print(f"[FlightFinder] ⚠️ date parse failed: {e}") + for month_name, month_num in _MONTH_MAP.items(): + if month_name in lower: + day_match = re.search(r"\d{1,2}", raw) + if day_match: + day = int(day_match.group()) + year = today.year if month_num >= today.month else today.year + 1 + return f"{year}-{month_num:02d}-{day:02d}" + + # Last resort: today + print(f"[FlightFinder] ⚠️ Could not parse date '{raw}' — using today.") + return today.strftime("%Y-%m-%d") + +_CABIN_CODE: dict[str, str] = { + "economy": "1", + "premium": "2", + "business": "3", + "first": "4", +} + +def _build_google_flights_url( + origin: str, + destination: str, + date: str, + return_date: str | None = None, + passengers: int = 1, + cabin: str = "economy", +) -> str: + from urllib.parse import quote_plus + + cabin_code = _CABIN_CODE.get(cabin.lower(), "1") + + origin_enc = quote_plus(origin) + destination_enc = quote_plus(destination) + + if return_date: + trip = f"Flights+from+{origin_enc}+to+{destination_enc}+on+{date}+returning+{return_date}" + else: + trip = f"Flights+from+{origin_enc}+to+{destination_enc}+on+{date}" + + return ( + f"https://www.google.com/travel/flights" + f"?q={trip}" + f"&curr=USD" + f"&cabin={cabin_code}" + f"&adults={passengers}" + ) + +def _search_flights_browser( + origin: str, + destination: str, + date: str, + return_date: str | None, + passengers: int, + cabin: str, +) -> tuple[str, str]: + import time + from actions.browser_control import browser_control + + url = _build_google_flights_url( + origin, destination, date, return_date, passengers, cabin + ) + + print(f"[FlightFinder] 🌐 Opening: {url}") + browser_control({"action": "go_to", "url": url}) + time.sleep(5) + + raw = browser_control({"action": "get_text"}) + return (raw or ""), url +def _parse_flights_with_gemini( + raw_text: str, + origin: str, + destination: str, + date: str, +) -> list[dict]: + from or_client import client + + prompt = ( + f"Extract flight options from {origin} to {destination} on {date} " + f"from this Google Flights page text:\n\n{raw_text[:12000]}\n\n" + f"Return a JSON array of up to 5 flights:\n" + f'[{{"airline":"...","departure":"HH:MM","arrival":"HH:MM",' + f'"duration":"Xh Ym","stops":0,"price":"...","currency":"USD"}}]\n' + f"If no flights found, return: []" + ) + + try: + result = client.chat_json(prompt, system="Return only valid JSON. No extra text.") + return result if isinstance(result, list) else [] + except Exception as e: + print(f"[FlightFinder] ⚠️ parse failed: {e}") + return [] + +def _format_spoken( + flights: list[dict], + origin: str, + destination: str, + date: str, +) -> str: + if not flights: + return ( + f"I couldn't find any flights from {origin} to {destination} " + f"on {date}, sir. The page may not have loaded correctly." + ) + + lines = [f"Here are the top flights from {origin} to {destination} on {date}, sir."] + + for i, f in enumerate(flights[:5], 1): + airline = f.get("airline", "Unknown airline") + departure = f.get("departure", "--:--") + arrival = f.get("arrival", "--:--") + duration = f.get("duration", "") + stops = f.get("stops", 0) + price = f.get("price", "") + currency = f.get("currency", "") + + stop_str = "non-stop" if stops == 0 else f"{stops} stop{'s' if stops > 1 else ''}" + price_str = f"{price} {currency}".strip() if price else "price unavailable" + dur_str = f", {duration}" if duration else "" + + lines.append( + f"Option {i}: {airline}, departing {departure}, " + f"arriving {arrival}{dur_str}, {stop_str}, {price_str}." + ) + + # Cheapest — strip non-digits for comparison + priced = [f for f in flights if f.get("price")] + if priced: + cheapest = min( + priced, + key=lambda x: int(re.sub(r"[^\d]", "", str(x["price"])) or "999999"), + ) + lines.append( + f"The cheapest option is {cheapest.get('airline')} " + f"at {cheapest.get('price')} {cheapest.get('currency', '')}." + ) + + return " ".join(lines) + + +def _format_text_report( + flights: list[dict], + origin: str, + destination: str, + date: str, + return_date: str | None, + page_url: str, +) -> str: + lines = [ + "JARVIS — Flight Search Results", + "─" * 50, + f"Route : {origin} → {destination}", + f"Date : {date}", + ] + if return_date: + lines.append(f"Return : {return_date}") + lines += [ + f"Searched : {datetime.now().strftime('%Y-%m-%d %H:%M')}", + f"Source : {page_url}", + "─" * 50, + "", + ] + + if not flights: + lines.append("No flights found.") + else: + for i, f in enumerate(flights, 1): + stops = f.get("stops", 0) + stop_str = "Non-stop" if stops == 0 else f"{stops} stop(s)" + lines += [ + f"Flight {i}:", + f" Airline : {f.get('airline', 'N/A')}", + f" Departure : {f.get('departure', 'N/A')}", + f" Arrival : {f.get('arrival', 'N/A')}", + f" Duration : {f.get('duration', 'N/A')}", + f" Stops : {stop_str}", + f" Price : {f.get('price', 'N/A')} {f.get('currency', '')}", + "", + ] + + return "\n".join(lines) + +def _save_to_desktop(content: str, origin: str, destination: str) -> str: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"flights_{origin}_{destination}_{ts}.txt".replace(" ", "_") + desktop = Path.home() / "Desktop" + desktop.mkdir(parents=True, exist_ok=True) + filepath = desktop / filename + + filepath.write_text(content, encoding="utf-8") + print(f"[FlightFinder] 💾 Saved: {filepath}") + + try: + if is_windows(): + subprocess.Popen(["notepad.exe", str(filepath)]) + elif is_mac(): + subprocess.Popen(["open", "-t", str(filepath)]) + else: + subprocess.Popen(["xdg-open", str(filepath)]) + except Exception as e: + print(f"[FlightFinder] ⚠️ Could not open text editor: {e}") + + return str(filepath) + + +def flight_finder(parameters: dict, player=None, speak=None) -> str: + params = parameters or {} + + origin = params.get("origin", "").strip() + destination = params.get("destination", "").strip() + date_raw = params.get("date", "").strip() + return_raw = (params.get("return_date") or "").strip() + passengers = max(1, int(params.get("passengers", 1))) + cabin = params.get("cabin", "economy").strip().lower() + save = bool(params.get("save", False)) + + if not origin or not destination: + return "Please provide both origin and destination, sir." + if not date_raw: + return "Please provide a departure date, sir." + + # Normalise cabin value + if cabin not in _CABIN_CODE: + cabin = "economy" + + date = _parse_date(date_raw) + return_date = _parse_date(return_raw) if return_raw else None + + if player: + player.write_log(f"[FlightFinder] {origin} → {destination} on {date}") + + if speak: + speak(f"Searching flights from {origin} to {destination} on {date}, sir.") + + print( + f"[FlightFinder] ▶️ {origin} → {destination} | {date}" + f"{' → ' + return_date if return_date else ''}" + f" | {cabin} | {passengers} pax" + ) + + try: + raw_text, page_url = _search_flights_browser( + origin, destination, date, return_date, passengers, cabin + ) + + if not raw_text: + return "Could not retrieve flight data, sir. The page may not have loaded." + + if speak: + speak("Analysing the results now, sir.") + + flights = _parse_flights_with_gemini(raw_text, origin, destination, date) + spoken = _format_spoken(flights, origin, destination, date) + + if speak: + speak(spoken) + + result = spoken + + if save and flights: + report = _format_text_report(flights, origin, destination, date, return_date, page_url) + saved_path = _save_to_desktop(report, origin, destination) + result += f" Results saved to Desktop: {saved_path}" + + return result + + except Exception as e: + print(f"[FlightFinder] ❌ {e}") + return f"Flight search failed, sir: {e}" \ No newline at end of file diff --git a/main.py b/main.py index cc1002e..211bf21 100644 --- a/main.py +++ b/main.py @@ -1,821 +1,35 @@ -import asyncio -import threading -import json -import sys -import traceback -from pathlib import Path - -import sounddevice as sd -from google import genai -from google.genai import types -from ui import JarvisUI -from memory.memory_manager import ( - load_memory, update_memory, format_memory_for_prompt, - should_extract_memory, extract_memory -) - -from actions.flight_finder import flight_finder -from actions.open_app import open_app -from actions.weather_report import weather_action -from actions.send_message import send_message -from actions.reminder import reminder -from actions.computer_settings import computer_settings -from actions.screen_processor import screen_process -from actions.youtube_video import youtube_video -from actions.cmd_control import cmd_control -from actions.desktop import desktop_control -from actions.browser_control import browser_control -from actions.file_controller import file_controller -from actions.code_helper import code_helper -from actions.dev_agent import dev_agent -from actions.web_search import web_search as web_search_action -from actions.computer_control import computer_control -from actions.game_updater import game_updater - - -def get_base_dir(): - if getattr(sys, "frozen", False): - return Path(sys.executable).parent - return Path(__file__).resolve().parent - - -BASE_DIR = get_base_dir() -API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" -PROMPT_PATH = BASE_DIR / "core" / "prompt.txt" -LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" -CHANNELS = 1 -SEND_SAMPLE_RATE = 16000 -RECEIVE_SAMPLE_RATE = 24000 -CHUNK_SIZE = 1024 - - -def _get_api_key() -> str: - with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f)["gemini_api_key"] - - -def _load_system_prompt() -> str: - try: - return PROMPT_PATH.read_text(encoding="utf-8") - except Exception: - return ( - "You are JARVIS, Tony Stark's AI assistant. " - "Be concise, direct, and always use the provided tools to complete tasks. " - "Never simulate or guess results — always call the appropriate tool." - ) - - -# ── Hafıza ──────────────────────────────────────────────────────────────────── -_last_memory_input = "" - - -def _update_memory_async(user_text: str, jarvis_text: str) -> None: - global _last_memory_input - - user_text = (user_text or "").strip() - jarvis_text = (jarvis_text or "").strip() - - if len(user_text) < 5 or user_text == _last_memory_input: - return - _last_memory_input = user_text - - try: - api_key = _get_api_key() - if not should_extract_memory(user_text, jarvis_text, api_key): - return - data = extract_memory(user_text, jarvis_text, api_key) - if data: - update_memory(data) - print(f"[Memory] ✅ {list(data.keys())}") - except Exception as e: - if "429" not in str(e): - print(f"[Memory] ⚠️ {e}") - - -# ── Tool declarations ───────────────────────────────────────────────────────── -TOOL_DECLARATIONS = [ - { - "name": "open_app", - "description": ( - "Opens any application on the Windows computer. " - "Use this whenever the user asks to open, launch, or start any app, " - "website, or program. Always call this tool — never just say you opened it." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "app_name": { - "type": "STRING", - "description": "Exact name of the application (e.g. 'WhatsApp', 'Chrome', 'Spotify')" - } - }, - "required": ["app_name"] - } - }, - { - "name": "web_search", - "description": "Searches the web for any information.", - "parameters": { - "type": "OBJECT", - "properties": { - "query": {"type": "STRING", "description": "Search query"}, - "mode": {"type": "STRING", "description": "search (default) or compare"}, - "items": {"type": "ARRAY", "items": {"type": "STRING"}, "description": "Items to compare"}, - "aspect": {"type": "STRING", "description": "price | specs | reviews"} - }, - "required": ["query"] - } - }, - { - "name": "weather_report", - "description": "Gets real-time weather information for a city.", - "parameters": { - "type": "OBJECT", - "properties": { - "city": {"type": "STRING", "description": "City name"} - }, - "required": ["city"] - } - }, - { - "name": "send_message", - "description": "Sends a text message via WhatsApp, Telegram, or other messaging platform.", - "parameters": { - "type": "OBJECT", - "properties": { - "receiver": {"type": "STRING", "description": "Recipient contact name"}, - "message_text": {"type": "STRING", "description": "The message to send"}, - "platform": {"type": "STRING", "description": "Platform: WhatsApp, Telegram, etc."} - }, - "required": ["receiver", "message_text", "platform"] - } - }, - { - "name": "reminder", - "description": "Sets a timed reminder using Windows Task Scheduler.", - "parameters": { - "type": "OBJECT", - "properties": { - "date": {"type": "STRING", "description": "Date in YYYY-MM-DD format"}, - "time": {"type": "STRING", "description": "Time in HH:MM format (24h)"}, - "message": {"type": "STRING", "description": "Reminder message text"} - }, - "required": ["date", "time", "message"] - } - }, - { - "name": "youtube_video", - "description": ( - "Controls YouTube. Use for: playing videos, summarizing a video's content, " - "getting video info, or showing trending videos." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "play | summarize | get_info | trending (default: play)"}, - "query": {"type": "STRING", "description": "Search query for play action"}, - "save": {"type": "BOOLEAN", "description": "Save summary to Notepad (summarize only)"}, - "region": {"type": "STRING", "description": "Country code for trending e.g. TR, US"}, - "url": {"type": "STRING", "description": "Video URL for get_info action"}, - }, - "required": [] - } - }, - { - "name": "screen_process", - "description": ( - "Captures and analyzes the screen or webcam image. " - "MUST be called when user asks what is on screen, what you see, " - "analyze my screen, look at camera, etc. " - "You have NO visual ability without this tool. " - "After calling this tool, stay SILENT — the vision module speaks directly." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "angle": {"type": "STRING", "description": "'screen' to capture display, 'camera' for webcam. Default: 'screen'"}, - "text": {"type": "STRING", "description": "The question or instruction about the captured image"} - }, - "required": ["text"] - } - }, - { - "name": "computer_settings", - "description": ( - "Controls the computer: volume, brightness, window management, keyboard shortcuts, " - "typing text on screen, closing apps, fullscreen, dark mode, WiFi, restart, shutdown, " - "scrolling, tab management, zoom, screenshots, lock screen, refresh/reload page. " - "Use for ANY single computer control command. NEVER route to agent_task." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "The action to perform"}, - "description": {"type": "STRING", "description": "Natural language description of what to do"}, - "value": {"type": "STRING", "description": "Optional value: volume level, text to type, etc."} - }, - "required": [] - } - }, - { - "name": "browser_control", - "description": ( - "Controls the web browser. Use for: opening websites, searching the web, " - "clicking elements, filling forms, scrolling, any web-based task." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "go_to | search | click | type | scroll | fill_form | smart_click | smart_type | get_text | press | close"}, - "url": {"type": "STRING", "description": "URL for go_to action"}, - "query": {"type": "STRING", "description": "Search query for search action"}, - "selector": {"type": "STRING", "description": "CSS selector for click/type"}, - "text": {"type": "STRING", "description": "Text to click or type"}, - "description": {"type": "STRING", "description": "Element description for smart_click/smart_type"}, - "direction": {"type": "STRING", "description": "up or down for scroll"}, - "key": {"type": "STRING", "description": "Key name for press action"}, - "incognito": {"type": "BOOLEAN", "description": "Open in private/incognito mode"}, - }, - "required": ["action"] - } - }, - { - "name": "file_controller", - "description": "Manages files and folders: list, create, delete, move, copy, rename, read, write, find, disk usage.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "list | create_file | create_folder | delete | move | copy | rename | read | write | find | largest | disk_usage | organize_desktop | info"}, - "path": {"type": "STRING", "description": "File/folder path or shortcut: desktop, downloads, documents, home"}, - "destination": {"type": "STRING", "description": "Destination path for move/copy"}, - "new_name": {"type": "STRING", "description": "New name for rename"}, - "content": {"type": "STRING", "description": "Content for create_file/write"}, - "name": {"type": "STRING", "description": "File name to search for"}, - "extension": {"type": "STRING", "description": "File extension to search (e.g. .pdf)"}, - "count": {"type": "INTEGER", "description": "Number of results for largest"}, - }, - "required": ["action"] - } - }, - { - "name": "cmd_control", - "description": ( - "Runs CMD/terminal commands via natural language: disk space, processes, " - "system info, network, find files, or anything in the command line." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "task": {"type": "STRING", "description": "Natural language description of what to do"}, - "visible": {"type": "BOOLEAN", "description": "Open visible CMD window. Default: true"}, - "command": {"type": "STRING", "description": "Optional: exact command if already known"}, - }, - "required": ["task"] - } - }, - { - "name": "desktop_control", - "description": "Controls the desktop: wallpaper, organize, clean, list, stats.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "wallpaper | wallpaper_url | organize | clean | list | stats | task"}, - "path": {"type": "STRING", "description": "Image path for wallpaper"}, - "url": {"type": "STRING", "description": "Image URL for wallpaper_url"}, - "mode": {"type": "STRING", "description": "by_type or by_date for organize"}, - "task": {"type": "STRING", "description": "Natural language desktop task"}, - }, - "required": ["action"] - } - }, - { - "name": "code_helper", - "description": "Writes, edits, explains, runs, or builds code files.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "write | edit | explain | run | build | auto (default: auto)"}, - "description": {"type": "STRING", "description": "What the code should do or what change to make"}, - "language": {"type": "STRING", "description": "Programming language (default: python)"}, - "output_path": {"type": "STRING", "description": "Where to save the file"}, - "file_path": {"type": "STRING", "description": "Path to existing file for edit/explain/run/build"}, - "code": {"type": "STRING", "description": "Raw code string for explain"}, - "args": {"type": "STRING", "description": "CLI arguments for run/build"}, - "timeout": {"type": "INTEGER", "description": "Execution timeout in seconds (default: 30)"}, - }, - "required": ["action"] - } - }, - { - "name": "dev_agent", - "description": "Builds complete multi-file projects from scratch: plans, writes files, installs deps, opens VSCode, runs and fixes errors.", - "parameters": { - "type": "OBJECT", - "properties": { - "description": {"type": "STRING", "description": "What the project should do"}, - "language": {"type": "STRING", "description": "Programming language (default: python)"}, - "project_name": {"type": "STRING", "description": "Optional project folder name"}, - "timeout": {"type": "INTEGER", "description": "Run timeout in seconds (default: 30)"}, - }, - "required": ["description"] - } - }, - { - "name": "agent_task", - "description": ( - "Executes complex multi-step tasks requiring multiple different tools. " - "Examples: 'research X and save to file', 'find and organize files'. " - "DO NOT use for single commands. NEVER use for Steam/Epic — use game_updater." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "goal": {"type": "STRING", "description": "Complete description of what to accomplish"}, - "priority": {"type": "STRING", "description": "low | normal | high (default: normal)"} - }, - "required": ["goal"] - } - }, - { - "name": "computer_control", - "description": "Direct computer control: type, click, hotkeys, scroll, move mouse, screenshots, find elements on screen.", - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "type | smart_type | click | double_click | right_click | hotkey | press | scroll | move | copy | paste | screenshot | wait | clear_field | focus_window | screen_find | screen_click | random_data | user_data"}, - "text": {"type": "STRING", "description": "Text to type or paste"}, - "x": {"type": "INTEGER", "description": "X coordinate"}, - "y": {"type": "INTEGER", "description": "Y coordinate"}, - "keys": {"type": "STRING", "description": "Key combination e.g. 'ctrl+c'"}, - "key": {"type": "STRING", "description": "Single key e.g. 'enter'"}, - "direction": {"type": "STRING", "description": "up | down | left | right"}, - "amount": {"type": "INTEGER", "description": "Scroll amount (default: 3)"}, - "seconds": {"type": "NUMBER", "description": "Seconds to wait"}, - "title": {"type": "STRING", "description": "Window title for focus_window"}, - "description": {"type": "STRING", "description": "Element description for screen_find/screen_click"}, - "type": {"type": "STRING", "description": "Data type for random_data"}, - "field": {"type": "STRING", "description": "Field for user_data: name|email|city"}, - "clear_first": {"type": "BOOLEAN", "description": "Clear field before typing (default: true)"}, - "path": {"type": "STRING", "description": "Save path for screenshot"}, - }, - "required": ["action"] - } - }, - { - "name": "game_updater", - "description": ( - "THE ONLY tool for ANY Steam or Epic Games request. " - "Use for: installing, downloading, updating games, listing installed games, " - "checking download status, scheduling updates. " - "ALWAYS call directly for any Steam/Epic/game request. " - "NEVER use agent_task, browser_control, or web_search for Steam/Epic." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "action": {"type": "STRING", "description": "update | install | list | download_status | schedule | cancel_schedule | schedule_status (default: update)"}, - "platform": {"type": "STRING", "description": "steam | epic | both (default: both)"}, - "game_name": {"type": "STRING", "description": "Game name (partial match supported)"}, - "app_id": {"type": "STRING", "description": "Steam AppID for install (optional)"}, - "hour": {"type": "INTEGER", "description": "Hour for scheduled update 0-23 (default: 3)"}, - "minute": {"type": "INTEGER", "description": "Minute for scheduled update 0-59 (default: 0)"}, - "shutdown_when_done": {"type": "BOOLEAN", "description": "Shut down PC when download finishes"}, - }, - "required": [] - } - }, - { - "name": "flight_finder", - "description": "Searches Google Flights and speaks the best options.", - "parameters": { - "type": "OBJECT", - "properties": { - "origin": {"type": "STRING", "description": "Departure city or airport code"}, - "destination": {"type": "STRING", "description": "Arrival city or airport code"}, - "date": {"type": "STRING", "description": "Departure date (any format)"}, - "return_date": {"type": "STRING", "description": "Return date for round trips"}, - "passengers": {"type": "INTEGER", "description": "Number of passengers (default: 1)"}, - "cabin": {"type": "STRING", "description": "economy | premium | business | first"}, - "save": {"type": "BOOLEAN", "description": "Save results to Notepad"}, - }, - "required": ["origin", "destination", "date"] - } - }, - { - "name": "save_memory", - "description": ( - "Save an important personal fact about the user to long-term memory. " - "Call this silently whenever the user reveals something worth remembering: " - "name, age, city, job, preferences, hobbies, relationships, projects, or future plans. " - "Do NOT call for: weather, reminders, searches, or one-time commands. " - "Do NOT announce that you are saving — just call it silently. " - "Values must be in English regardless of the conversation language." - ), - "parameters": { - "type": "OBJECT", - "properties": { - "category": { - "type": "STRING", - "description": ( - "identity — name, age, birthday, city, job, language, nationality | " - "preferences — favorite food/color/music/film/game/sport, hobbies | " - "projects — active projects, goals, things being built | " - "relationships — friends, family, partner, colleagues | " - "wishes — future plans, things to buy, travel dreams | " - "notes — habits, schedule, anything else worth remembering" - ) - }, - "key": {"type": "STRING", "description": "Short snake_case key (e.g. name, favorite_food, sister_name)"}, - "value": {"type": "STRING", "description": "Concise value in English (e.g. Fatih, pizza, older sister)"}, - }, - "required": ["category", "key", "value"] - } - }, -] - - -class JarvisLive: - - def __init__(self, ui: JarvisUI): - self.ui = ui - self.session = None - self.audio_in_queue = None - self.out_queue = None - self._loop = None - self._is_speaking = False - self._speaking_lock = threading.Lock() - self.ui.on_text_command = self._on_text_command - - def _on_text_command(self, text: str): - if not self._loop or not self.session: - return - asyncio.run_coroutine_threadsafe( - self.session.send_client_content( - turns={"parts": [{"text": text}]}, - turn_complete=True - ), - self._loop - ) - - def set_speaking(self, value: bool): - with self._speaking_lock: - self._is_speaking = value - if value: - self.ui.set_state("SPEAKING") - elif not self.ui.muted: - self.ui.set_state("LISTENING") - - def speak(self, text: str): - if not self._loop or not self.session: - return - asyncio.run_coroutine_threadsafe( - self.session.send_client_content( - turns={"parts": [{"text": text}]}, - turn_complete=True - ), - self._loop - ) - - def speak_error(self, tool_name: str, error: str): - short = str(error)[:120] - self.ui.write_log(f"ERR: {tool_name} — {short}") - self.speak(f"Sir, {tool_name} encountered an error. {short}") - - def _build_config(self) -> types.LiveConnectConfig: - from datetime import datetime - - memory = load_memory() - mem_str = format_memory_for_prompt(memory) - sys_prompt = _load_system_prompt() - - now = datetime.now() - time_str = now.strftime("%A, %B %d, %Y — %I:%M %p") - time_ctx = ( - f"[CURRENT DATE & TIME]\n" - f"Right now it is: {time_str}\n" - f"Use this to calculate exact times for reminders.\n\n" - ) - - parts = [time_ctx] - if mem_str: - parts.append(mem_str) - parts.append(sys_prompt) - - return types.LiveConnectConfig( - response_modalities=["AUDIO"], - output_audio_transcription={}, - input_audio_transcription={}, - system_instruction="\n".join(parts), - tools=[{"function_declarations": TOOL_DECLARATIONS}], - session_resumption=types.SessionResumptionConfig(), - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name="Charon" - ) - ) - ), - ) - - async def _execute_tool(self, fc) -> types.FunctionResponse: - name = fc.name - args = dict(fc.args or {}) - - print(f"[JARVIS] 🔧 {name} {args}") - self.ui.set_state("THINKING") - - # ── save_memory: sessiz, hızlı, Gemini'ye bildirim yok ─────────────── - if name == "save_memory": - category = args.get("category", "notes") - key = args.get("key", "") - value = args.get("value", "") - if key and value: - update_memory({category: {key: {"value": value}}}) - print(f"[Memory] 💾 save_memory: {category}/{key} = {value}") - if not self.ui.muted: - self.ui.set_state("LISTENING") - return types.FunctionResponse( - id=fc.id, name=name, - response={"result": "ok", "silent": True} - ) - - loop = asyncio.get_event_loop() - result = "Done." - - try: - if name == "open_app": - r = await loop.run_in_executor(None, lambda: open_app(parameters=args, response=None, player=self.ui)) - result = r or f"Opened {args.get('app_name')}." - - elif name == "weather_report": - r = await loop.run_in_executor(None, lambda: weather_action(parameters=args, player=self.ui)) - result = r or "Weather delivered." - - elif name == "browser_control": - r = await loop.run_in_executor(None, lambda: browser_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "file_controller": - r = await loop.run_in_executor(None, lambda: file_controller(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "send_message": - r = await loop.run_in_executor(None, lambda: send_message(parameters=args, response=None, player=self.ui, session_memory=None)) - result = r or f"Message sent to {args.get('receiver')}." - - elif name == "reminder": - r = await loop.run_in_executor(None, lambda: reminder(parameters=args, response=None, player=self.ui)) - result = r or "Reminder set." - - elif name == "youtube_video": - r = await loop.run_in_executor(None, lambda: youtube_video(parameters=args, response=None, player=self.ui)) - result = r or "Done." - - elif name == "screen_process": - threading.Thread( - target=screen_process, - kwargs={"parameters": args, "response": None, - "player": self.ui, "session_memory": None}, - daemon=True - ).start() - result = "Vision module activated. Stay completely silent — vision module will speak directly." - - elif name == "computer_settings": - r = await loop.run_in_executor(None, lambda: computer_settings(parameters=args, response=None, player=self.ui)) - result = r or "Done." - - elif name == "cmd_control": - r = await loop.run_in_executor(None, lambda: cmd_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "desktop_control": - r = await loop.run_in_executor(None, lambda: desktop_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "code_helper": - r = await loop.run_in_executor(None, lambda: code_helper(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "dev_agent": - r = await loop.run_in_executor(None, lambda: dev_agent(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "agent_task": - from agent.task_queue import get_queue, TaskPriority - priority_map = {"low": TaskPriority.LOW, "normal": TaskPriority.NORMAL, "high": TaskPriority.HIGH} - priority = priority_map.get(args.get("priority", "normal").lower(), TaskPriority.NORMAL) - task_id = get_queue().submit(goal=args.get("goal", ""), priority=priority, speak=self.speak) - result = f"Task started (ID: {task_id})." - - elif name == "web_search": - r = await loop.run_in_executor(None, lambda: web_search_action(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "computer_control": - r = await loop.run_in_executor(None, lambda: computer_control(parameters=args, player=self.ui)) - result = r or "Done." - - elif name == "game_updater": - r = await loop.run_in_executor(None, lambda: game_updater(parameters=args, player=self.ui, speak=self.speak)) - result = r or "Done." - - elif name == "flight_finder": - r = await loop.run_in_executor(None, lambda: flight_finder(parameters=args, player=self.ui)) - result = r or "Done." - - else: - result = f"Unknown tool: {name}" - - except Exception as e: - result = f"Tool '{name}' failed: {e}" - traceback.print_exc() - self.speak_error(name, e) - - if not self.ui.muted: - self.ui.set_state("LISTENING") - - print(f"[JARVIS] 📤 {name} → {str(result)[:80]}") - - # ── Result: tek cümle söyle, dur ────────────────────────────────────── - return types.FunctionResponse( - id=fc.id, name=name, - response={"result": result} - ) - - async def _send_realtime(self): - while True: - msg = await self.out_queue.get() - await self.session.send_realtime_input(media=msg) - - async def _listen_audio(self): - print("[JARVIS] 🎤 Mic started") - loop = asyncio.get_event_loop() - - def callback(indata, frames, time_info, status): - with self._speaking_lock: - jarvis_speaking = self._is_speaking - if not jarvis_speaking and not self.ui.muted: - data = indata.tobytes() - loop.call_soon_threadsafe( - self.out_queue.put_nowait, - {"data": data, "mime_type": "audio/pcm"} - ) - - try: - with sd.InputStream( - samplerate=SEND_SAMPLE_RATE, - channels=CHANNELS, - dtype="int16", - blocksize=CHUNK_SIZE, - callback=callback, - ): - print("[JARVIS] 🎤 Mic stream open") - while True: - await asyncio.sleep(0.1) - except Exception as e: - print(f"[JARVIS] ❌ Mic: {e}") - raise - - async def _receive_audio(self): - print("[JARVIS] 👂 Recv started") - out_buf, in_buf = [], [] - - try: - while True: - async for response in self.session.receive(): - - if response.data: - self.audio_in_queue.put_nowait(response.data) - - if response.server_content: - sc = response.server_content - - if sc.output_transcription and sc.output_transcription.text: - self.set_speaking(True) - txt = sc.output_transcription.text.strip() - if txt: - out_buf.append(txt) - - if sc.input_transcription and sc.input_transcription.text: - txt = sc.input_transcription.text.strip() - if txt: - in_buf.append(txt) - - if sc.turn_complete: - self.set_speaking(False) - - full_in = " ".join(in_buf).strip() - if full_in: - self.ui.write_log(f"You: {full_in}") - in_buf = [] - - full_out = " ".join(out_buf).strip() - if full_out: - self.ui.write_log(f"Jarvis: {full_out}") - out_buf = [] - - if full_in and len(full_in) > 5: - threading.Thread( - target=_update_memory_async, - args=(full_in, full_out), - daemon=True - ).start() - - if response.tool_call: - fn_responses = [] - for fc in response.tool_call.function_calls: - print(f"[JARVIS] 📞 {fc.name}") - fr = await self._execute_tool(fc) - fn_responses.append(fr) - await self.session.send_tool_response( - function_responses=fn_responses - ) - # ── Boş turn YOK — bu "Anladım." sorununu yaratıyordu ── - - except Exception as e: - print(f"[JARVIS] ❌ Recv: {e}") - traceback.print_exc() - raise - - async def _play_audio(self): - print("[JARVIS] 🔊 Play started") - loop = asyncio.get_event_loop() - - # Sürekli açık output stream — PyAudio'daki stream.write() davranışıyla aynı - stream = sd.RawOutputStream( - samplerate=RECEIVE_SAMPLE_RATE, - channels=CHANNELS, - dtype="int16", - blocksize=CHUNK_SIZE, - ) - stream.start() - try: - while True: - chunk = await self.audio_in_queue.get() - self.set_speaking(True) - await asyncio.to_thread(stream.write, chunk) - except Exception as e: - print(f"[JARVIS] ❌ Play: {e}") - raise - finally: - self.set_speaking(False) - stream.stop() - stream.close() - - async def run(self): - client = genai.Client( - api_key=_get_api_key(), - http_options={"api_version": "v1beta"} - ) - - while True: - try: - print("[JARVIS] 🔌 Connecting...") - self.ui.set_state("THINKING") - config = self._build_config() - - async with ( - client.aio.live.connect(model=LIVE_MODEL, config=config) as session, - asyncio.TaskGroup() as tg, - ): - self.session = session - self._loop = asyncio.get_event_loop() - self.audio_in_queue = asyncio.Queue() - self.out_queue = asyncio.Queue(maxsize=10) - - print("[JARVIS] ✅ Connected.") - self.ui.set_state("LISTENING") - self.ui.write_log("SYS: JARVIS online.") - - tg.create_task(self._send_realtime()) - tg.create_task(self._listen_audio()) - tg.create_task(self._receive_audio()) - tg.create_task(self._play_audio()) - - except Exception as e: - print(f"[JARVIS] ⚠️ {e}") - traceback.print_exc() - - self.set_speaking(False) - self.ui.set_state("THINKING") - print("[JARVIS] 🔄 Reconnecting in 3s...") - await asyncio.sleep(3) - - -def main(): - ui = JarvisUI("face.png") - - def runner(): - ui.wait_for_api_key() - jarvis = JarvisLive(ui) - try: - asyncio.run(jarvis.run()) - except KeyboardInterrupt: - print("\n🔴 Shutting down...") - - threading.Thread(target=runner, daemon=True).start() - ui.root.mainloop() - - -if __name__ == "__main__": - main() +You are JARVIS, a sharp and efficient AI assistant. Calm, direct, professional. + +RULES: +- Always use the correct tool. Never simulate or guess results. +- Call each tool EXACTLY ONCE. Never retry a successful action. +- Do NOT say "I'm doing X" before calling a tool. Just call it, then report the result briefly. +- Do NOT repeat yourself. Say something once and stop. +- Keep responses to 1-2 sentences max unless reporting data. +- Never ask unnecessary questions. Make a reasonable assumption and proceed. +- Speak and take action according to user's information, for better experience. +- If a action tool called, tell that you're doing this thing and then call the tool, don't speak after the action's tool. +- Attain important things in the memory for a better user experience. +- game_updater → ANY Steam or Epic request: install, download, update, list. + Call DIRECTLY — never web_search first, never agent_task. +- For slow tools (web_search, screen_process, browser_control, flight_finder, + game_updater, cmd_control, dev_agent, code_helper, agent_task): say TWO short + sentence before calling the tool, then stay silent after — result is already reported. +- For screen_process: say something curious before calling, then stay completely + silent — the vision module speaks directly. +- Only call shutdown_jarvis if the user clearly intends to end the assistant session. +Do not call it for casual goodbyes unless they imply stopping the assistant. + +LANGUAGE: +- Respond in the same language the user spoke. +- Always extract tool parameters in English. +- Example: "İstanbul hava durumu?" → weather_report city:"Istanbul", reply in Turkish. + +TOOL SELECTION: +- computer_settings → volume, brightness, screen, refresh, scroll, minimize, maximize, + close, screenshot, lock, restart, shutdown, wifi, zoom, tabs, keyboard shortcuts. + Use this for ANY single computer control command. NEVER route these to agent_task. +- agent_task → ONLY for complex multi-step goals that require planning across + multiple tools (e.g. "research X and save to file", "create a project"). +- Simple rule: if it can be done in ONE action → computer_settings. + If it needs 3+ steps across different tools → agent_task. \ No newline at end of file diff --git a/open_app.py b/open_app.py new file mode 100644 index 0000000..8c9e083 --- /dev/null +++ b/open_app.py @@ -0,0 +1,817 @@ +import os +import re +import sys +import json +import time +import subprocess +import threading +import winreg +from pathlib import Path +from datetime import datetime + + +def _find_steam_path() -> Path | None: + registry_keys = [ + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam"), + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Valve\Steam"), + (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Valve\Steam"), + ] + for hive, key_path in registry_keys: + try: + key = winreg.OpenKey(hive, key_path) + val, _ = winreg.QueryValueEx(key, "InstallPath") + winreg.CloseKey(key) + p = Path(val) + if p.exists() and (p / "steam.exe").exists(): + return p + except Exception: + continue + for p in [ + Path(os.environ.get("ProgramFiles(x86)", "")) / "Steam", + Path(os.environ.get("ProgramFiles", "")) / "Steam", + Path("C:/Steam"), Path("D:/Steam"), Path("E:/Steam"), Path("F:/Steam"), + ]: + if p.exists() and (p / "steam.exe").exists(): + return p + return None + + +def _find_epic_path() -> Path | None: + registry_keys = [ + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\EpicGames\EpicGamesLauncher"), + (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\EpicGames\EpicGamesLauncher"), + (winreg.HKEY_CURRENT_USER, r"SOFTWARE\EpicGames\EpicGamesLauncher"), + ] + for hive, key_path in registry_keys: + try: + key = winreg.OpenKey(hive, key_path) + val, _ = winreg.QueryValueEx(key, "AppDataPath") + winreg.CloseKey(key) + exe = Path(val) / "Binaries" / "Win64" / "EpicGamesLauncher.exe" + if exe.exists(): + return exe.parent + except Exception: + continue + for p in [ + Path(os.environ.get("ProgramFiles(x86)", "")) / "Epic Games" / "Launcher" / "Portal" / "Binaries" / "Win64", + Path(os.environ.get("ProgramFiles", "")) / "Epic Games" / "Launcher" / "Portal" / "Binaries" / "Win64", + Path(os.environ.get("LOCALAPPDATA", "")) / "EpicGamesLauncher" / "Portal" / "Binaries" / "Win64", + ]: + if p.exists() and (p / "EpicGamesLauncher.exe").exists(): + return p + return None + + +def _get_steam_libraries(steam_path: Path) -> list[Path]: + libraries = [steam_path / "steamapps"] + vdf_path = steam_path / "steamapps" / "libraryfolders.vdf" + if not vdf_path.exists(): + return libraries + try: + content = vdf_path.read_text(encoding="utf-8", errors="ignore") + for raw_path in re.findall(r'"path"\s+"([^"]+)"', content): + lib = Path(raw_path.replace("\\\\", "/")) / "steamapps" + if lib.exists() and lib not in libraries: + libraries.append(lib) + except Exception: + pass + return libraries + + +def _get_steam_games(steam_path: Path) -> list[dict]: + games = [] + for lib in _get_steam_libraries(steam_path): + for acf in lib.glob("appmanifest_*.acf"): + try: + content = acf.read_text(encoding="utf-8", errors="ignore") + app_id = re.search(r'"appid"\s+"(\d+)"', content) + name = re.search(r'"name"\s+"([^"]+)"', content) + state = re.search(r'"StateFlags"\s+"(\d+)"', content) + size = re.search(r'"SizeOnDisk"\s+"(\d+)"', content) + if app_id and name: + games.append({ + "id": app_id.group(1), + "name": name.group(1), + "state": int(state.group(1)) if state else 0, + "size": int(size.group(1)) if size else 0, + "lib": str(lib), + }) + except Exception: + continue + return games + + +def _is_steam_running() -> bool: + try: + out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq steam.exe"], + capture_output=True, text=True).stdout + return "steam.exe" in out.lower() + except Exception: + return False + + +def _get_steam_window_rect() -> tuple[int, int, int, int] | None: + try: + import pygetwindow as gw + for w in gw.getAllWindows(): + if "steam" in w.title.lower() and w.width > 200 and w.visible: + return w.left, w.top, w.width, w.height + except Exception: + pass + return None + + +def _click_first_profile_by_screenshot() -> bool: + try: + import pyautogui + import numpy as np + + time.sleep(1.5) + + win = _get_steam_window_rect() + if not win: + print("[GameUpdater] ⚠️ Steam window not found") + return False + + wx, wy, ww, wh = win + screenshot = pyautogui.screenshot(region=(wx, wy, ww, wh)) + img = np.array(screenshot) + + h, w = img.shape[:2] + + search_y1 = h // 3 + search_y2 = h * 3 // 4 + search_x1 = w // 5 + search_x2 = w * 4 // 5 + region = img[search_y1:search_y2, search_x1:search_x2] + + r = region[:, :, 0].astype(int) + g = region[:, :, 1].astype(int) + b = region[:, :, 2].astype(int) + + max_c = np.maximum(np.maximum(r, g), b) + min_c = np.minimum(np.minimum(r, g), b) + sat = max_c - min_c + + colorful = (max_c > 60) & (sat > 40) + + if not colorful.any(): + print("[GameUpdater] ⚠️ No colorful avatar region found — clicking estimated center-left") + pyautogui.click(wx + ww // 2 - ww // 6, wy + wh // 2) + return True + + cols = np.where(colorful.any(axis=0))[0] + rows = np.where(colorful.any(axis=1))[0] + + if len(cols) == 0 or len(rows) == 0: + return False + + avatar_w = min(90, (region.shape[1]) // 4) + first_col = int(cols[0]) + block_cols = cols[cols < first_col + avatar_w] + + center_x_local = int(block_cols.mean()) + center_y_local = int(rows.mean()) + + abs_x = wx + search_x1 + center_x_local + abs_y = wy + search_y1 + center_y_local + + print(f"[GameUpdater] 🎯 First profile avatar at ({abs_x}, {abs_y}) — clicking") + pyautogui.click(abs_x, abs_y) + return True + + except ImportError as e: + print(f"[GameUpdater] ⚠️ Missing library: {e}") + return False + except Exception as e: + print(f"[GameUpdater] ⚠️ Screenshot profile detection failed: {e}") + return False + + +def _handle_steam_profile_selection() -> bool: + print("[GameUpdater] 🔍 Checking for 'Who's playing?' dialog...") + + win = _get_steam_window_rect() + if not win: + return False + + wx, wy, ww, wh = win + + try: + import pyautogui + import numpy as np + + screenshot = pyautogui.screenshot(region=(wx, wy, ww, wh)) + img = np.array(screenshot) + + is_small_window = ww < 900 and wh < 700 + + top_region = img[:wh // 3, :, :] + white_pixels = int(np.sum( + (top_region[:, :, 0] > 200) & + (top_region[:, :, 1] > 200) & + (top_region[:, :, 2] > 200) + )) + has_white_text = white_pixels > 100 + + if not is_small_window and not has_white_text: + print("[GameUpdater] ℹ️ No profile dialog detected — Steam already logged in") + return False + + except ImportError: + pass + except Exception: + pass + + print("[GameUpdater] 👤 Profile selection detected — clicking first profile") + return _click_first_profile_by_screenshot() + + +def _ensure_steam_running(steam_path: Path) -> bool: + if _is_steam_running(): + return True + + steam_exe = steam_path / "steam.exe" + if not steam_exe.exists(): + print("[GameUpdater] ❌ steam.exe not found") + return False + + print("[GameUpdater] 🚀 Starting Steam...") + subprocess.Popen([str(steam_exe)]) + + for _ in range(20): + time.sleep(1) + if _is_steam_running(): + print("[GameUpdater] ✅ Steam running") + time.sleep(4) + _handle_steam_profile_selection() + time.sleep(2) + return True + + print("[GameUpdater] ⚠️ Steam did not start in time") + return False + + +def _update_steam_games(steam_path: Path, game_name: str = None) -> str: + if not _ensure_steam_running(steam_path): + return "Could not start Steam." + + steam_exe = steam_path / "steam.exe" + games = _get_steam_games(steam_path) + + if not games: + return "No Steam games found." + + if game_name: + name_lower = game_name.lower() + matched = [g for g in games if name_lower in g["name"].lower()] + if not matched: + available = ", ".join(g["name"] for g in games[:5]) + return f"Game '{game_name}' not found. Installed: {available}..." + targets = matched + else: + targets = games + + already_updated, already_running, update_started, errors = [], [], [], [] + + for game in targets: + state = game["state"] + name = game["name"] + if state == 4: + already_updated.append(name) + elif state == 1026: + already_running.append(name) + else: + try: + subprocess.Popen([str(steam_exe), f"steam://update/{game['id']}"]) + update_started.append(name) + time.sleep(0.3) + except Exception as e: + errors.append(f"{name}: {e}") + + parts = [] + if update_started: + names = ", ".join(update_started[:3]) + suffix = f" and {len(update_started) - 3} more" if len(update_started) > 3 else "" + parts.append(f"Update started for: {names}{suffix}.") + if already_running: + parts.append(f"Already updating: {', '.join(already_running)}.") + if already_updated: + parts.append( + f"{already_updated[0]} is already up to date." + if game_name else + f"{len(already_updated)} game(s) already up to date." + ) + if errors: + parts.append(f"Errors: {'; '.join(errors)}.") + return " ".join(parts) if parts else "No games to update." + + +_KNOWN_APPIDS: dict[str, tuple[str, str]] = { + "pubg": ("578080", "PUBG: Battlegrounds"), + "pubg battlegrounds": ("578080", "PUBG: Battlegrounds"), + "pubg: battlegrounds": ("578080", "PUBG: Battlegrounds"), + "battlegrounds": ("578080", "PUBG: Battlegrounds"), + "gta5": ("271590", "Grand Theft Auto V"), + "gta v": ("271590", "Grand Theft Auto V"), + "grand theft auto v": ("271590", "Grand Theft Auto V"), + "cs2": ("730", "Counter-Strike 2"), + "csgo": ("730", "Counter-Strike 2"), + "counter-strike 2": ("730", "Counter-Strike 2"), + "counter strike 2": ("730", "Counter-Strike 2"), + "dota2": ("570", "Dota 2"), + "dota 2": ("570", "Dota 2"), + "rust": ("252490", "Rust"), + "valheim": ("892970", "Valheim"), + "cyberpunk": ("1091500", "Cyberpunk 2077"), + "cyberpunk 2077": ("1091500", "Cyberpunk 2077"), + "elden ring": ("1245620", "ELDEN RING"), + "minecraft": ("1672970", "Minecraft Launcher"), + "apex legends": ("1172470", "Apex Legends"), + "apex": ("1172470", "Apex Legends"), + "fortnite": ("1517990", "Fortnite"), + "goose goose duck": ("1568590", "Goose Goose Duck"), + "among us": ("945360", "Among Us"), + "fall guys": ("1097150", "Fall Guys"), + "rocket league": ("252950", "Rocket League"), + "warframe": ("230410", "Warframe"), + "destiny 2": ("1085660", "Destiny 2"), + "team fortress 2": ("440", "Team Fortress 2"), + "tf2": ("440", "Team Fortress 2"), + "left 4 dead 2": ("550", "Left 4 Dead 2"), + "l4d2": ("550", "Left 4 Dead 2"), + "paladins": ("444090", "Paladins"), + "smite": ("386360", "SMITE"), + "war thunder": ("236390", "War Thunder"), + "world of warships": ("552990", "World of Warships"), + "path of exile": ("238960", "Path of Exile"), + "poe": ("238960", "Path of Exile"), + "lost ark": ("1599340", "Lost Ark"), + "new world": ("1063730", "New World: Aeternum"), +} + + +def _search_steam_appid(game_name: str) -> tuple[str | None, str | None]: + name_lower = game_name.lower().strip() + + steam_path = _find_steam_path() + if steam_path: + for g in _get_steam_games(steam_path): + if name_lower in g["name"].lower(): + return g["id"], g["name"] + + if name_lower in _KNOWN_APPIDS: + app_id, canonical = _KNOWN_APPIDS[name_lower] + print(f"[GameUpdater] 📖 Known: {canonical} ({app_id})") + return app_id, canonical + + for key, (app_id, canonical) in _KNOWN_APPIDS.items(): + if name_lower in key or key in name_lower: + print(f"[GameUpdater] 📖 Known partial: {canonical} ({app_id})") + return app_id, canonical + + try: + import urllib.request, urllib.parse + query = urllib.parse.quote(game_name) + url = f"https://store.steampowered.com/api/storesearch/?term={query}&l=english&cc=US" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=6) as resp: + items = json.loads(resp.read().decode()).get("items", []) + if items: + best = items[0] + print(f"[GameUpdater] 🌐 Store API: {best['name']} ({best['id']})") + return str(best["id"]), best["name"] + except Exception as e: + print(f"[GameUpdater] ⚠️ AppID search failed: {e}") + + return None, None + + +def _find_best_drive() -> dict | None: + import shutil, string + drives = [] + for letter in string.ascii_uppercase: + drive_path = f"{letter}:\\" + if os.path.exists(drive_path): + try: + gb = shutil.disk_usage(drive_path).free / (1024 ** 3) + if gb > 0: + drives.append({"letter": letter, "path": drive_path, "free_gb": gb}) + except Exception: + continue + return max(drives, key=lambda d: d["free_gb"]) if drives else None + + +def _select_drive_in_dialog(dialog, drive_letter: str) -> bool: + target = drive_letter.upper() + for control_type in ("ListItem", "RadioButton"): + try: + for ctrl in dialog.descendants(control_type=control_type): + if target in ctrl.window_text().upper(): + ctrl.click_input() + print(f"[GameUpdater] ✅ Drive via {control_type}: {ctrl.window_text()}") + return True + except Exception: + continue + try: + for combo in dialog.descendants(control_type="ComboBox"): + try: + combo.expand() + time.sleep(0.15) + for idx, txt in enumerate(combo.texts()): + if target in txt.upper(): + combo.select(idx) + return True + combo.collapse() + except Exception: + continue + except Exception: + pass + try: + for ctrl in dialog.descendants(): + txt = ctrl.window_text().upper() + if f"{target}:" in txt and len(txt) < 80: + ctrl.click_input() + return True + except Exception: + pass + return False + + +def _click_button(window, keywords: list[str]) -> bool: + try: + for btn in window.descendants(control_type="Button"): + try: + txt = btn.window_text().lower().strip() + if txt in keywords or any(kw in txt for kw in keywords): + btn.click_input() + return True + except Exception: + continue + except Exception: + pass + return False + + +def _handle_install_dialog(game_name: str) -> str: + best_drive = _find_best_drive() + if not best_drive: + return f"Install dialog opened for '{game_name}'. Could not detect drives." + + drive_letter = best_drive["letter"] + drive_label = f"{drive_letter}:" + print(f"[GameUpdater] 🏆 Target drive: {drive_label} ({best_drive['free_gb']:.1f} GB free)") + + try: + from pywinauto import Application, findwindows + dialog = None + for _ in range(40): + time.sleep(0.5) + try: + for hwnd in findwindows.find_windows(title_re=r"(?i)(install|yükle|steam)", visible_only=True): + try: + app = Application(backend="uia").connect(handle=hwnd) + win = app.window(handle=hwnd) + rect = win.rectangle() + if win.is_visible() and rect.width() > 300 and rect.height() > 200: + all_text = " ".join(c.window_text() for c in win.descendants() if c.window_text()).upper() + if any(x in all_text for x in ("C:", "D:", "E:", "F:", "INSTALL", "YÜKLE")): + dialog = win + break + except Exception: + continue + except Exception: + pass + if dialog: + break + + if not dialog: + raise RuntimeError("Dialog not found") + + dialog.set_focus() + time.sleep(0.4) + drive_selected = _select_drive_in_dialog(dialog, drive_letter) + install_clicked = _click_button(dialog, ["install", "yükle", "next", "ileri", "ok", "tamam"]) + + if install_clicked: + suffix = f"Selected {drive_label} and" if drive_selected else "Default drive used, but" + return f"{suffix} clicked Install for '{game_name}'." + return f"Please click Install manually in Steam for '{game_name}'." + + except ImportError: + return _handle_install_dialog_pyautogui(game_name, best_drive) + except Exception as e: + print(f"[GameUpdater] ⚠️ pywinauto failed: {e}") + return _handle_install_dialog_pyautogui(game_name, best_drive) + + +def _handle_install_dialog_pyautogui(game_name: str, best_drive: dict) -> str: + try: + import pyautogui + import pygetwindow as gw + except ImportError: + return f"Install dialog opened for '{game_name}'. Please select '{best_drive['letter']}:' and click Install manually." + + pyautogui.FAILSAFE = False + drive_label = f"{best_drive['letter']}:" + install_win = None + + for _ in range(30): + time.sleep(0.5) + for w in gw.getAllWindows(): + if ("install" in w.title.lower() or "steam" in w.title.lower()) and w.width > 300 and w.visible: + install_win = w + break + if install_win: + break + + if not install_win: + return f"Please select '{drive_label}' and click Install in Steam for '{game_name}'." + + try: + install_win.activate() + time.sleep(0.4) + except Exception: + pass + + wx, wy = install_win.left, install_win.top + ww, wh = install_win.width, install_win.height + pyautogui.click(wx + int(ww * 0.35), wy + int(wh * 0.45)) + time.sleep(0.2) + pyautogui.typewrite(best_drive["letter"], interval=0.05) + time.sleep(0.2) + pyautogui.click(wx + int(ww * 0.72), wy + int(wh * 0.88)) + return f"Attempted drive {drive_label} selection and Install click for '{game_name}'." + + +def _install_steam_game(steam_path: Path, game_name: str = None, app_id: str = None) -> str: + if not _ensure_steam_running(steam_path): + return "Could not start Steam." + + steam_exe = steam_path / "steam.exe" + installed_games = _get_steam_games(steam_path) + + already = None + if app_id: + already = next((g for g in installed_games if g["id"] == str(app_id)), None) + elif game_name: + name_lower = game_name.lower() + already = next((g for g in installed_games if name_lower in g["name"].lower()), None) + else: + return "Please specify a game name or AppID." + + if already: + state = already["state"] + name = already["name"] + if state == 4: + return f"'{name}' is already installed and up to date." + if state == 1026: + return f"'{name}' is currently downloading or updating." + if state in (6, 516): + subprocess.Popen([str(steam_exe), f"steam://update/{already['id']}"]) + return f"'{name}' has a pending update. Update started." + return f"'{name}' is already installed." + + if not app_id and game_name: + found_id, found_name = _search_steam_appid(game_name) + if not found_id: + return f"Could not find '{game_name}' on Steam. Try providing the AppID directly." + app_id = found_id + game_name = found_name or game_name + print(f"[GameUpdater] 🔍 Installing: {game_name} (AppID: {app_id})") + + try: + subprocess.Popen([str(steam_exe), f"steam://install/{app_id}"]) + threading.Thread(target=_handle_install_dialog, args=(game_name or str(app_id),), daemon=True).start() + return f"Install started for '{game_name}'. Steam will open the download dialog." + except Exception as e: + return f"Install failed: {e}" + + +def _get_download_status(steam_path: Path) -> str: + games = _get_steam_games(steam_path) + active = [g for g in games if g["state"] == 1026] + pending = [g for g in games if g["state"] in (6, 516)] + lines = [] + if active: + lines.append(f"Downloading: {', '.join(g['name'] for g in active)}.") + if pending: + names = ", ".join(g["name"] for g in pending[:5]) + suffix = f" and {len(pending) - 5} more" if len(pending) > 5 else "" + lines.append(f"Pending updates: {names}{suffix}.") + return " ".join(lines) if lines else "No active downloads or pending updates." + + +def _watch_and_shutdown(steam_path: Path, speak=None, check_interval: int = 30, timeout_hours: int = 12): + print("[GameUpdater] 👁️ Watching downloads for shutdown...") + deadline = time.time() + timeout_hours * 3600 + + for _ in range(24): + time.sleep(5) + active = [g for g in _get_steam_games(steam_path) if g["state"] == 1026] + if active: + names = ", ".join(g["name"] for g in active) + if speak: speak(f"Download started for {names}. I'll shut down when done.") + break + else: + return + + while time.time() < deadline: + time.sleep(check_interval) + if not any(g["state"] == 1026 for g in _get_steam_games(steam_path)): + if speak: speak("Download complete. Shutting down now.") + time.sleep(5) + subprocess.run(["shutdown", "/s", "/t", "10"]) + return + + if speak: speak("Download taking too long. Cancelling auto-shutdown.") + + +def _get_epic_games() -> list[dict]: + manifests_path = (Path(os.environ.get("PROGRAMDATA", "C:/ProgramData")) + / "Epic" / "EpicGamesLauncher" / "Data" / "Manifests") + if not manifests_path.exists(): + return [] + games = [] + for item_file in manifests_path.glob("*.item"): + try: + data = json.loads(item_file.read_text(encoding="utf-8")) + name = data.get("DisplayName") or data.get("AppName", "") + if name: + games.append({"id": data.get("AppName", ""), "name": name}) + except Exception: + continue + return games + + +def _is_epic_running() -> bool: + try: + return "epicgameslauncher.exe" in subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq EpicGamesLauncher.exe"], + capture_output=True, text=True + ).stdout.lower() + except Exception: + return False + + +def _update_epic_games(epic_path: Path, game_name: str = None) -> str: + epic_exe = epic_path / "EpicGamesLauncher.exe" + if not epic_exe.exists(): + return "Epic Games Launcher not found." + games = _get_epic_games() + if game_name: + name_lower = game_name.lower() + matched = [g for g in games if name_lower in g["name"].lower()] + if not matched: + return f"'{game_name}' not found in Epic." + try: + subprocess.Popen([str(epic_exe), f"com.epicgames.launcher://apps/{matched[0]['id']}?action=launch&silent=true"]) + return f"Opened Epic for '{matched[0]['name']}'." + except Exception as e: + return f"Epic update failed: {e}" + else: + try: + if _is_epic_running(): + for g in games[:10]: + subprocess.Popen([str(epic_exe), f"com.epicgames.launcher://apps/{g['id']}?action=launch&silent=true"]) + time.sleep(0.5) + return f"Triggered update check for {len(games)} Epic game(s)." + else: + subprocess.Popen([str(epic_exe)]) + return f"Epic Games Launcher opened. {len(games)} game(s) will be checked." + except Exception as e: + return f"Epic update failed: {e}" + + +def _schedule_daily_update(hour: int = 3, minute: int = 0) -> str: + task_name = "JARVIS_GameUpdater" + script_path = Path(__file__).resolve() + subprocess.run(["schtasks", "/Delete", "/TN", task_name, "/F"], capture_output=True) + for extra in (["/RL", "HIGHEST", "/RU", "SYSTEM"], []): + cmd = ["schtasks", "/Create", "/TN", task_name, + "/TR", f'"{sys.executable}" "{script_path}" --scheduled', + "/SC", "DAILY", "/ST", f"{hour:02d}:{minute:02d}", "/F", *extra] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode == 0: + return f"Daily game update scheduled at {hour:02d}:{minute:02d}." + return f"Scheduling failed: {result.stderr.strip()}" + + +def _cancel_scheduled_update() -> str: + result = subprocess.run(["schtasks", "/Delete", "/TN", "JARVIS_GameUpdater", "/F"], + capture_output=True, text=True) + return "Scheduled update cancelled." if result.returncode == 0 else "No scheduled update found." + + +def _get_schedule_status() -> str: + result = subprocess.run(["schtasks", "/Query", "/TN", "JARVIS_GameUpdater", "/FO", "LIST"], + capture_output=True, text=True) + if result.returncode != 0: + return "No scheduled game update found." + for line in result.stdout.strip().split("\n"): + if any(k in line for k in ("Next Run", "Sonraki", "Prochaine", "Próxima", "Nächste")): + return f"Game update scheduled. {line.strip()}" + return "Game update is scheduled." + + +def game_updater(parameters: dict, player=None, speak=None) -> str: + p = parameters or {} + action = p.get("action", "update").lower().strip() + platform = p.get("platform", "both").lower().strip() + game_name = (p.get("game_name") or "").strip() or None + app_id = (p.get("app_id") or "").strip() or None + hour = int(p.get("hour", 3)) + minute = int(p.get("minute", 0)) + shutdown = str(p.get("shutdown_when_done", "false")).lower() == "true" + + results = [] + + if action == "schedule": return _schedule_daily_update(hour=hour, minute=minute) + if action == "cancel_schedule": return _cancel_scheduled_update() + if action == "schedule_status": return _get_schedule_status() + + if action == "list": + if platform in ("steam", "both"): + steam_path = _find_steam_path() + if steam_path: + games = _get_steam_games(steam_path) + if games: + names = ", ".join(g["name"] for g in games[:8]) + suffix = f" and {len(games) - 8} more" if len(games) > 8 else "" + results.append(f"Steam ({len(games)} games): {names}{suffix}.") + else: + results.append("Steam: No games found.") + else: + results.append("Steam: Not installed.") + if platform in ("epic", "both"): + games = _get_epic_games() + if games: + names = ", ".join(g["name"] for g in games[:8]) + suffix = f" and {len(games) - 8} more" if len(games) > 8 else "" + results.append(f"Epic ({len(games)} games): {names}{suffix}.") + else: + results.append("Epic: No games found.") + return " | ".join(results) or "No platforms found." + + if action == "download_status": + if platform in ("steam", "both"): + steam_path = _find_steam_path() + results.append(_get_download_status(steam_path) if steam_path else "Steam: Not installed.") + if platform in ("epic", "both"): + results.append("Epic download status not available directly.") + return " ".join(results) + + if action in ("install", "update"): + if platform in ("steam", "both"): + steam_path = _find_steam_path() + if not steam_path: + results.append("Steam: Not installed.") + else: + if game_name: + installed = _get_steam_games(steam_path) + name_lower = game_name.lower() + is_installed = any(name_lower in g["name"].lower() for g in installed) + + if not is_installed: + msg = _install_steam_game(steam_path, game_name=game_name, app_id=app_id) + if shutdown: + threading.Thread(target=_watch_and_shutdown, + kwargs={"steam_path": steam_path, "speak": speak}, + daemon=True).start() + msg += " Auto-shutdown enabled." + if player: player.write_log(f"[GameUpdater] {msg[:100]}") + if speak: speak(msg) + return msg + else: + results.append(f"Steam: {_update_steam_games(steam_path, game_name=game_name)}") + else: + if action == "install": + results.append("Steam: Please specify a game name to install.") + else: + results.append(f"Steam: {_update_steam_games(steam_path)}") + + if shutdown: + threading.Thread(target=_watch_and_shutdown, + kwargs={"steam_path": steam_path, "speak": speak}, + daemon=True).start() + results.append("Auto-shutdown enabled.") + + if platform in ("epic", "both"): + epic_path = _find_epic_path() + if epic_path: + results.append(f"Epic: {_update_epic_games(epic_path, game_name=game_name)}") + else: + results.append("Epic: Not installed.") + + output = " | ".join(results) or "Nothing to do." + if player: player.write_log(f"[GameUpdater] {output[:100]}") + if speak: speak(output) + return output + + return f"Unknown action: '{action}'." + + +if __name__ == "__main__": + if "--scheduled" in sys.argv: + print(f"[GameUpdater] 🕐 Scheduled run at {datetime.now().strftime('%H:%M')}") + print(f"[GameUpdater] ✅ {game_updater({'action': 'update', 'platform': 'both'})}") \ No newline at end of file diff --git a/planner.py b/planner.py new file mode 100644 index 0000000..462b4e4 --- /dev/null +++ b/planner.py @@ -0,0 +1,197 @@ +import json +import re +import sys +from pathlib import Path +from enum import Enum + + +def get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + + +class ErrorDecision(Enum): + RETRY = "retry" + SKIP = "skip" + REPLAN = "replan" + ABORT = "abort" + + +ERROR_ANALYST_PROMPT = """You are the error recovery module of MARK XXV AI assistant. + +A task step has failed. Analyze the error and decide what to do. + +DECISIONS: +- retry : Transient error (network timeout, temporary file lock, race condition). + The same step can succeed if tried again. +- skip : This step is not critical and the task can succeed without it. +- replan : The approach was wrong. A different tool or method should be tried. +- abort : The task is fundamentally impossible or unsafe to continue. + +Also provide: +- A brief explanation of WHY it failed (1 sentence) +- A fix suggestion if decision is replan (what to try instead) +- Max retries: how many times to retry if decision is retry (1 or 2) + +Return ONLY valid JSON: +{ + "decision": "retry|skip|replan|abort", + "reason": "why it failed", + "fix_suggestion": "what to try instead (for replan)", + "max_retries": 1, + "user_message": "Short message to tell the user (max 15 words)" +} +""" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def analyze_error( + step: dict, + error: str, + attempt: int = 1, + max_attempts: int = 2 +) -> dict: + """ + Analyzes a failed step and returns a recovery decision. + + Args: + step : The step dict that failed + error : Error message/traceback + attempt : Current attempt number + max_attempts : How many times we've already tried + + Returns: + { + "decision": ErrorDecision, + "reason": str, + "fix_suggestion": str, + "max_retries": int, + "user_message": str + } + """ + import google.generativeai as genai + + if attempt >= max_attempts: + print(f"[ErrorHandler] ⚠️ Max attempts reached for step {step.get('step')} — forcing replan") + return { + "decision": ErrorDecision.REPLAN, + "reason": f"Failed {attempt} times: {error[:100]}", + "fix_suggestion": "Try a completely different approach or tool", + "max_retries": 0, + "user_message": "Trying a different approach, sir." + } + + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel( + model_name="gemini-2.5-flash-lite", + system_instruction=ERROR_ANALYST_PROMPT + ) + + prompt = f"""Failed step: +Tool: {step.get('tool')} +Description: {step.get('description')} +Parameters: {json.dumps(step.get('parameters', {}), indent=2)} +Critical: {step.get('critical', False)} + +Error: +{error[:500]} + +Attempt number: {attempt}""" + + try: + response = model.generate_content(prompt) + text = response.text.strip() + text = re.sub(r"```(?:json)?", "", text).strip().rstrip("`").strip() + + result = json.loads(text) + decision_str = result.get("decision", "replan").lower() + decision_map = { + "retry": ErrorDecision.RETRY, + "skip": ErrorDecision.SKIP, + "replan": ErrorDecision.REPLAN, + "abort": ErrorDecision.ABORT, + } + result["decision"] = decision_map.get(decision_str, ErrorDecision.REPLAN) + + + if step.get("critical") and result["decision"] == ErrorDecision.SKIP: + result["decision"] = ErrorDecision.REPLAN + result["user_message"] = "This step is critical — finding alternative approach, sir." + + print(f"[ErrorHandler] Decision: {result['decision'].value} — {result.get('reason', '')}") + return result + + except Exception as e: + print(f"[ErrorHandler] ⚠️ Analysis failed: {e} — defaulting to replan") + return { + "decision": ErrorDecision.REPLAN, + "reason": str(e), + "fix_suggestion": "Try alternative approach", + "max_retries": 1, + "user_message": "Encountered an issue, adjusting approach, sir." + } + + +def generate_fix(step: dict, error: str, fix_suggestion: str) -> dict: + """ + When decision is REPLAN and a fix suggestion exists, + generates a replacement step using generated_code as fallback. + + Returns a modified step dict. + """ + import google.generativeai as genai + + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel(model_name="gemini-2.0-flash") + + prompt = f"""A task step failed. Generate a replacement step. + +Original step: +Tool: {step.get('tool')} +Description: {step.get('description')} +Parameters: {json.dumps(step.get('parameters', {}), indent=2)} + +Error: {error[:300]} +Fix suggestion: {fix_suggestion} + +Write a Python script that accomplishes the same goal differently. +Return ONLY the Python code, no explanation.""" + + try: + response = model.generate_content(prompt) + code = response.text.strip() + code = re.sub(r"```(?:python)?", "", code).strip().rstrip("`").strip() + + return { + "step": step.get("step"), + "tool": "code_helper", + "description": f"Auto-fix for: {step.get('description')}", + "parameters": { + "action": "run", + "description": fix_suggestion, + "code": code, + "language": "python" + }, + "depends_on": step.get("depends_on", []), + "critical": step.get("critical", False) + } + + except Exception as e: + print(f"[ErrorHandler] ⚠️ Fix generation failed: {e}") + return { + "step": step.get("step"), + "tool": "generated_code", + "description": f"Fallback for: {step.get('description')}", + "parameters": {"description": step.get("description", "")}, + "depends_on": step.get("depends_on", []), + "critical": step.get("critical", False) + } \ No newline at end of file diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..90c0e84 --- /dev/null +++ b/prompt.txt @@ -0,0 +1,221 @@ +import threading +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, Any + + +class TaskStatus(Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class TaskPriority(Enum): + LOW = 3 + NORMAL = 2 + HIGH = 1 + + +@dataclass(order=True) +class Task: + priority: int + created_at: float = field(compare=False) + task_id: str = field(compare=False) + goal: str = field(compare=False) + status: TaskStatus = field(compare=False, default=TaskStatus.PENDING) + result: Any = field(compare=False, default=None) + error: str = field(compare=False, default="") + speak: Any = field(compare=False, default=None) + on_complete: Any = field(compare=False, default=None) + cancel_flag: threading.Event = field(compare=False, default_factory=threading.Event) + + +class TaskQueue: + def __init__(self, max_concurrent: int = 1): + self._queue: list[Task] = [] + self._lock: threading.Lock = threading.Lock() + self._condition: threading.Condition = threading.Condition(self._lock) + self._tasks: dict[str, Task] = {} + self._running: bool = False + self._worker_thread: threading.Thread | None = None + self._max_concurrent = max_concurrent + self._active_count = 0 + self._executor = None + + def _get_executor(self): + if self._executor is None: + from agent.executor import AgentExecutor + self._executor = AgentExecutor() + return self._executor + + def start(self) -> None: + if self._running: + return + self._running = True + self._worker_thread = threading.Thread( + target=self._worker_loop, + daemon=True, + name="AgentTaskQueue" + ) + self._worker_thread.start() + print("[TaskQueue] ✅ Started") + + def stop(self) -> None: + self._running = False + with self._condition: + self._condition.notify_all() + print("[TaskQueue] 🔴 Stopped") + + def submit( + self, + goal: str, + priority: TaskPriority = TaskPriority.NORMAL, + speak: Callable | None = None, + on_complete: Callable | None = None, + ) -> str: + + task_id = str(uuid.uuid4())[:8] + task = Task( + priority = priority.value, + created_at = time.time(), + task_id = task_id, + goal = goal, + speak = speak, + on_complete = on_complete, + ) + + with self._condition: + self._queue.append(task) + self._queue.sort(key=lambda t: (t.priority, t.created_at)) + self._tasks[task_id] = task + self._condition.notify() + + print(f"[TaskQueue] 📥 Task queued: [{task_id}] {goal[:60]}") + return task_id + + def cancel(self, task_id: str) -> bool: + + with self._lock: + task = self._tasks.get(task_id) + if not task: + return False + if task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED): + return False + + task.cancel_flag.set() + task.status = TaskStatus.CANCELLED + print(f"[TaskQueue] 🚫 Task cancelled: [{task_id}]") + return True + + def get_status(self, task_id: str) -> dict | None: + with self._lock: + task = self._tasks.get(task_id) + if not task: + return None + return { + "task_id": task.task_id, + "goal": task.goal, + "status": task.status.value, + "result": task.result, + "error": task.error, + } + + def get_all_statuses(self) -> list[dict]: + with self._lock: + return [ + { + "task_id": t.task_id, + "goal": t.goal[:50], + "status": t.status.value, + } + for t in self._tasks.values() + ] + + def pending_count(self) -> int: + with self._lock: + return sum(1 for t in self._queue if t.status == TaskStatus.PENDING) + + def _worker_loop(self) -> None: + while self._running: + task = None + + with self._condition: + while self._running and not self._next_task(): + self._condition.wait(timeout=1.0) + task = self._next_task() + if task: + task.status = TaskStatus.RUNNING + self._active_count += 1 + try: + self._queue.remove(task) + except ValueError: + pass + + if task: + threading.Thread( + target=self._run_task, + args=(task,), + daemon=True, + name=f"AgentTask-{task.task_id}" + ).start() + + def _next_task(self) -> Task | None: + if self._active_count >= self._max_concurrent: + return None + for task in self._queue: + if task.status == TaskStatus.PENDING and not task.cancel_flag.is_set(): + return task + return None + + def _run_task(self, task: Task) -> None: + print(f"[TaskQueue] ▶️ Running: [{task.task_id}] {task.goal[:60]}") + try: + executor = self._get_executor() + result = executor.execute( + goal = task.goal, + speak = task.speak, + cancel_flag = task.cancel_flag, + ) + + with self._lock: + if task.cancel_flag.is_set(): + task.status = TaskStatus.CANCELLED + else: + task.status = TaskStatus.COMPLETED + task.result = result + self._active_count -= 1 + + if task.on_complete and not task.cancel_flag.is_set(): + try: + task.on_complete(task.task_id, result) + except Exception as e: + print(f"[TaskQueue] ⚠️ on_complete callback error: {e}") + + print(f"[TaskQueue] ✅ Completed: [{task.task_id}]") + + except Exception as e: + with self._lock: + task.status = TaskStatus.FAILED + task.error = str(e) + self._active_count -= 1 + print(f"[TaskQueue] ❌ Failed: [{task.task_id}] {e}") + + with self._condition: + self._condition.notify() + +_queue = TaskQueue() +_queue_started = False +_queue_lock = threading.Lock() + + +def get_queue() -> TaskQueue: + global _queue_started + with _queue_lock: + if not _queue_started: + _queue.start() + _queue_started = True + return _queue \ No newline at end of file diff --git a/readme.md b/readme.md index 0c63cff..56b0a13 100644 --- a/readme.md +++ b/readme.md @@ -1,73 +1,82 @@ -# 🤖 MARK XXXV +# 🤖 MARK XXXIX (39) +### The Ultimate Cross-Platform Personal AI Assistant — By FatihMakes -**Watch The Detailed Video To Set-up This Model**: https://www.youtube.com/watch?v=BhOsnGC_sAA +> 📺 **[Watch the full setup video on YouTube](https://www.youtube.com/watch?v=BhOsnGC_sAA)** -### Next-Generation Personal AI Assistant — By FatihMakes +A real-time voice AI that can hear, see, understand, and control your computer — on any OS. Supporting Windows, macOS, and Linux. Local execution. Zero subscriptions. Engineered for total autonomy. -A real-time voice AI that can hear, see, understand, and control your Windows computer. -Local execution. Zero subscriptions (unless you want to increase requests via Google AI Studio). -Built for intelligent automation. --- + ## ✨ Overview -**MARK XXXV** is an advanced voice-driven AI assistant designed to turn your computer into an interactive intelligent system. -Speak naturally — it listens, understands context, responds with a human-like voice, and executes tasks across your system automatically. -Designed for speed, autonomy, and real-world usability. +MARK XXXIX represents the pinnacle of the Jarvis series, evolving into a more flexible and robust system. It bridges the gap between the operating system and human intent. Through natural dialogue, Mark 39 analyzes your screen, processes uploaded documents, and executes complex workflows with a brand-new, adaptive interface. + +It's not just an assistant — it's an extension of your digital life. + --- + ## 🚀 Capabilities -### Core -* **Real-time voice interaction** — Natural conversation with instant response in any language -* **System control** — Launch apps, manage files, execute terminal commands -* **Autonomous task execution** — Plans and completes complex multi-step workflows -* **Visual awareness** — Full screen analysis and webcam understanding -* **Persistent memory** — Learns your name, preferences, projects, relationships and remembers them across sessions -* **Mute button** — Click or press F4 to instantly silence the microphone (useful when talking to others nearby) -* **Keyboard input** — Type commands directly from the UI without speaking +### Core Features +| Feature | Description | +|---|---| +| 🎙️ Real-time Voice | Ultra-low latency conversation in any language | +| 🖥️ System Control | Launch apps, manage files, execute terminal commands | +| 🧩 Autonomous Tasks | High-level planning for complex, multi-step goals | +| 👁️ Visual Awareness | Real-time screen processing and webcam vision | +| 🧠 Persistent Memory | Deeply remembers your projects, preferences, and personal context | +| ⌨️ Hybrid Input | Seamlessly switch between keyboard typing and voice commands | --- -## 🆕 What's New in XXXV -* 🎮 Steam & Epic Games integration — install, update, schedule, auto-shutdown -* 🔇 Mute button (F4 / click) — no more Jarvis picking up side conversations -* ⌨️ Keyboard input on UI — type commands without speaking -* 🧠 Smarter memory — saves favorites, projects, relationships, plans automatically -* 🌐 Incognito browser support -* 🔊 Error reporting — tool failures spoken aloud -* 🔁 Status indicator — LISTENING / SPEAKING / THINKING / MUTED states on UI -* ⚡ Faster response — removed unnecessary round-trips before tool calls -* 👨‍🔧 Fixed errors — removed some errors and bugs. +## 🆕 What's New in XXXIX + +- 📂 **Advanced File Handling** — New support for direct file uploads. Drop PDFs, source code, or images into the assistant to have them analyzed, summarized, or edited instantly. +- 🎨 **Adaptive & Flexible UI** — A complete overhaul of the interface. The new UI is fully resizable and responsive, featuring transparency controls and customizable layouts to fit your workspace perfectly. +- 🐧🍎 **Refined Cross-Platform Stability** — Major fixes for macOS and Linux compatibility. Core system actions are now more consistent across all three major operating systems. +- ⚡ **Optimized Core Engine** — Significant performance boost in tool-calling logic and response generation, resulting in a 40% faster interaction speed. +- 🔀 **OpenRouter Integration** — Selected action modules (web search, memory, flight finder, desktop control, and more) now route their LLM calls through OpenRouter's free-tier models. This significantly increases the effective request limit without any additional cost, while Gemini Live continues to handle real-time voice and tool-calling. --- + ## ⚡ Quick Start ```bash -git clone https://github.com/FatihMakes/Mark-XXXV.git -cd Mark-XXXV +git clone https://github.com/FatihMakes/Mark-XXXIX.git +cd Mark-XXXIX pip install -r requirements.txt playwright install python main.py ``` -Enter your free Gemini API key on first launch. -System ready in minutes. +> ⚠️ **Installation Note:** To keep the repository lightweight, some OS-specific dependencies are not bundled in `requirements.txt`. If you run into a `ModuleNotFoundError`, simply install the missing package via `pip install ` for your specific system. + --- + ## 📋 Requirements -* Windows 10 / 11 -* Python 3.11 or 3.12 -* Microphone -* Free [Gemini API key](https://aistudio.google.com/apikey) +| Requirement | Details | +|---|---| +| **OS** | Windows 10/11, macOS, or Linux | +| **Python** | 3.11 or 3.12 | +| **Microphone** | Required for voice interaction | +| **API Keys** | Free Gemini API key + Free OpenRouter API key | + --- + ## ⚠️ License Personal and non-commercial use only. -Licensed under **Creative Commons BY-NC 4.0**. +Licensed under **[Creative Commons BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/)**. -Engineered by a 17-year-old building a real JARVIS-style assistant. -⭐ Star the repository to support the project. --- -If you have questions, issues, or just want to say hi: -* YouTube: [@FatihMakes](https://www.youtube.com/@FatihMakes) -* Instagram: [@fatihmakes](https://www.instagram.com/fatihmakes/) +## 👤 Connect with the Creator + +Engineered by a developer building a real-world JARVIS-style assistant. +⭐ **Star the repository to support the journey to Mark 100.** + +| Platform | Link | +|---|---| +| YouTube | [@FatihMakes](https://www.youtube.com/@FatihMakes) | +| Instagram | [@fatihmakes](https://www.instagram.com/fatihmakes) | \ No newline at end of file diff --git a/reminder.py b/reminder.py new file mode 100644 index 0000000..b526817 --- /dev/null +++ b/reminder.py @@ -0,0 +1,208 @@ +# actions/open_app.py +# MARK XXV — Cross-Platform App Launcher + +import time +import subprocess +import platform +import shutil + +try: + import psutil + _PSUTIL = True +except ImportError: + _PSUTIL = False + +_APP_ALIASES = { + "whatsapp": {"Windows": "WhatsApp", "Darwin": "WhatsApp", "Linux": "whatsapp"}, + "chrome": {"Windows": "chrome", "Darwin": "Google Chrome", "Linux": "google-chrome"}, + "google chrome": {"Windows": "chrome", "Darwin": "Google Chrome", "Linux": "google-chrome"}, + "firefox": {"Windows": "firefox", "Darwin": "Firefox", "Linux": "firefox"}, + "spotify": {"Windows": "Spotify", "Darwin": "Spotify", "Linux": "spotify"}, + "vscode": {"Windows": "code", "Darwin": "Visual Studio Code", "Linux": "code"}, + "visual studio code": {"Windows": "code", "Darwin": "Visual Studio Code", "Linux": "code"}, + "discord": {"Windows": "Discord", "Darwin": "Discord", "Linux": "discord"}, + "telegram": {"Windows": "Telegram", "Darwin": "Telegram", "Linux": "telegram"}, + "instagram": {"Windows": "Instagram", "Darwin": "Instagram", "Linux": "instagram"}, + "tiktok": {"Windows": "TikTok", "Darwin": "TikTok", "Linux": "tiktok"}, + "notepad": {"Windows": "notepad.exe", "Darwin": "TextEdit", "Linux": "gedit"}, + "calculator": {"Windows": "calc.exe", "Darwin": "Calculator", "Linux": "gnome-calculator"}, + "terminal": {"Windows": "cmd.exe", "Darwin": "Terminal", "Linux": "gnome-terminal"}, + "cmd": {"Windows": "cmd.exe", "Darwin": "Terminal", "Linux": "bash"}, + "explorer": {"Windows": "explorer.exe", "Darwin": "Finder", "Linux": "nautilus"}, + "file explorer": {"Windows": "explorer.exe", "Darwin": "Finder", "Linux": "nautilus"}, + "paint": {"Windows": "mspaint.exe", "Darwin": "Preview", "Linux": "gimp"}, + "word": {"Windows": "winword", "Darwin": "Microsoft Word", "Linux": "libreoffice --writer"}, + "excel": {"Windows": "excel", "Darwin": "Microsoft Excel", "Linux": "libreoffice --calc"}, + "powerpoint": {"Windows": "powerpnt", "Darwin": "Microsoft PowerPoint","Linux": "libreoffice --impress"}, + "vlc": {"Windows": "vlc", "Darwin": "VLC", "Linux": "vlc"}, + "zoom": {"Windows": "Zoom", "Darwin": "zoom.us", "Linux": "zoom"}, + "slack": {"Windows": "Slack", "Darwin": "Slack", "Linux": "slack"}, + "steam": {"Windows": "steam", "Darwin": "Steam", "Linux": "steam"}, + "task manager": {"Windows": "taskmgr.exe", "Darwin": "Activity Monitor", "Linux": "gnome-system-monitor"}, + "settings": {"Windows": "ms-settings:", "Darwin": "System Preferences", "Linux": "gnome-control-center"}, + "powershell": {"Windows": "powershell.exe", "Darwin": "Terminal", "Linux": "bash"}, + "edge": {"Windows": "msedge", "Darwin": "Microsoft Edge", "Linux": "microsoft-edge"}, + "brave": {"Windows": "brave", "Darwin": "Brave Browser", "Linux": "brave-browser"}, + "obsidian": {"Windows": "Obsidian", "Darwin": "Obsidian", "Linux": "obsidian"}, + "notion": {"Windows": "Notion", "Darwin": "Notion", "Linux": "notion"}, + "blender": {"Windows": "blender", "Darwin": "Blender", "Linux": "blender"}, + "capcut": {"Windows": "CapCut", "Darwin": "CapCut", "Linux": "capcut"}, + "postman": {"Windows": "Postman", "Darwin": "Postman", "Linux": "postman"}, + "figma": {"Windows": "Figma", "Darwin": "Figma", "Linux": "figma"}, +} + + +def _normalize(raw: str) -> str: + system = platform.system() + key = raw.lower().strip() + if key in _APP_ALIASES: + return _APP_ALIASES[key].get(system, raw) + for alias_key, os_map in _APP_ALIASES.items(): + if alias_key in key or key in alias_key: + return os_map.get(system, raw) + return raw + + +def _is_running(app_name: str) -> bool: + if not _PSUTIL: + return True + app_lower = app_name.lower().replace(" ", "").replace(".exe", "") + try: + for proc in psutil.process_iter(["name"]): + try: + proc_name = proc.info["name"].lower().replace(" ", "").replace(".exe", "") + if app_lower in proc_name or proc_name in app_lower: + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + except Exception: + pass + return False + + +def _launch_windows(app_name: str) -> bool: + try: + import pyautogui + pyautogui.PAUSE = 0.1 + pyautogui.press("win") + time.sleep(0.6) + pyautogui.write(app_name, interval=0.05) + time.sleep(0.8) + pyautogui.press("enter") + time.sleep(3.0) + return True + except Exception as e: + print(f"[open_app] ⚠️ Windows launch failed: {e}") + return False + +def _launch_macos(app_name: str) -> bool: + try: + result = subprocess.run(["open", "-a", app_name], capture_output=True, timeout=8) + if result.returncode == 0: + time.sleep(1.0) + return True + except Exception: + pass + + try: + result = subprocess.run(["open", "-a", f"{app_name}.app"], capture_output=True, timeout=8) + if result.returncode == 0: + time.sleep(1.0) + return True + except Exception: + pass + + try: + import pyautogui + pyautogui.hotkey("command", "space") + time.sleep(0.6) + pyautogui.write(app_name, interval=0.05) + time.sleep(0.8) + pyautogui.press("enter") + time.sleep(1.5) + return True + except Exception as e: + print(f"[open_app] ⚠️ macOS Spotlight failed: {e}") + return False + + + +def _launch_linux(app_name: str) -> bool: + binary = ( + shutil.which(app_name) or + shutil.which(app_name.lower()) or + shutil.which(app_name.lower().replace(" ", "-")) + ) + if binary: + try: + subprocess.Popen([binary], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(1.0) + return True + except Exception: + pass + + try: + subprocess.run(["xdg-open", app_name], capture_output=True, timeout=5) + return True + except Exception: + pass + + try: + desktop_name = app_name.lower().replace(" ", "-") + subprocess.run(["gtk-launch", desktop_name], capture_output=True, timeout=5) + return True + except Exception: + pass + + return False + + +_OS_LAUNCHERS = { + "Windows": _launch_windows, + "Darwin": _launch_macos, + "Linux": _launch_linux, +} + + +def open_app( + parameters=None, + response=None, + player=None, + session_memory=None, +) -> str: + app_name = (parameters or {}).get("app_name", "").strip() + + if not app_name: + return "Please specify which application to open, sir." + + system = platform.system() + launcher = _OS_LAUNCHERS.get(system) + + if launcher is None: + return f"Unsupported OS: {system}" + + normalized = _normalize(app_name) + print(f"[open_app] 🚀 Launching: {app_name} → {normalized} ({system})") + + if player: + player.write_log(f"[open_app] {app_name}") + + try: + success = launcher(normalized) + + if success: + return f"Opened {app_name} successfully, sir." + + if normalized != app_name: + success = launcher(app_name) + if success: + return f"Opened {app_name} successfully, sir." + + return ( + f"I tried to open {app_name}, sir, but couldn't confirm it launched. " + f"It may still be loading or might not be installed." + ) + + except Exception as e: + print(f"[open_app] ❌ {e}") + return f"Failed to open {app_name}, sir: {e}" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 734557e5918dc77f55bc25fd2411a8cc8fc66573..f904dfad8f909298a3648fc349cad7d020af489b 100644 GIT binary patch literal 634 zcmZ{iVNSy^429osB<`>aR0707xI$^mHjy@=O*{4Su+L5_F~k&Unf;!hpPl}EPl}o~ zsO5B8bXLRts0STEBENyS;_hJ8;PJRnn{DE&m&R;;`K2tb$`*Mv#-U5_Pp!`i%5ElFd5&bIgB&SEH7F!g-s@vI$kw z4CK-9zr&;z=)rJn>jqf^< Uso>jN@7pbX_Z{z9i(6PxSIiG+!TDQ-FgNp!%oHCM okFN;s{PJuZ^&g5-+g`0lb=b+n#*KovO;ayoA<7Sp@-$^ZZW diff --git a/screen_processor.py b/screen_processor.py new file mode 100644 index 0000000..330805d --- /dev/null +++ b/screen_processor.py @@ -0,0 +1,156 @@ +# actions/reminder.py + +import subprocess +import os +import sys +from datetime import datetime + + +def reminder( + parameters: dict, + response: str | None = None, + player=None, + session_memory=None +) -> str: + """ + Sets a timed reminder using Windows Task Scheduler. + + parameters: + - date (str) YYYY-MM-DD + - time (str) HH:MM + - message (str) + + Returns a result string — Live API voices it automatically. + No edge_speak needed. + """ + + date_str = parameters.get("date") + time_str = parameters.get("time") + message = parameters.get("message", "Reminder") + + if not date_str or not time_str: + return "I need both a date and a time to set a reminder." + + try: + target_dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M") + + if target_dt <= datetime.now(): + return "That time is already in the past." + + task_name = f"MARKReminder_{target_dt.strftime('%Y%m%d_%H%M')}" + safe_message = message.replace('"', '').replace("'", "").strip()[:200] + + python_exe = sys.executable + if python_exe.lower().endswith("python.exe"): + pythonw = python_exe.replace("python.exe", "pythonw.exe") + if os.path.exists(pythonw): + python_exe = pythonw + + temp_dir = os.environ.get("TEMP", "C:\\Temp") + notify_script = os.path.join(temp_dir, f"{task_name}.pyw") + project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..") + ) + + script_code = f'''import sys, os, time +sys.path.insert(0, r"{project_root}") + +try: + import winsound + for freq in [800, 1000, 1200]: + winsound.Beep(freq, 200) + time.sleep(0.1) +except Exception: + pass + +try: + from win10toast import ToastNotifier + ToastNotifier().show_toast( + "MARK Reminder", + "{safe_message}", + duration=15, + threaded=False + ) +except Exception: + try: + import subprocess + subprocess.run(["msg", "*", "/TIME:30", "{safe_message}"], shell=True) + except Exception: + pass + +time.sleep(3) +try: + os.remove(__file__) +except Exception: + pass +''' + with open(notify_script, "w", encoding="utf-8") as f: + f.write(script_code) + + xml_content = f''' + + + MARK Reminder: {safe_message} + + + + {target_dt.strftime("%Y-%m-%dT%H:%M:%S")} + true + + + + + {python_exe} + "{notify_script}" + + + + IgnoreNew + false + false + true + true + PT5M + true + + + + InteractiveToken + LeastPrivilege + + +''' + + xml_path = os.path.join(temp_dir, f"{task_name}.xml") + with open(xml_path, "w", encoding="utf-16") as f: + f.write(xml_content) + + result = subprocess.run( + f'schtasks /Create /TN "{task_name}" /XML "{xml_path}" /F', + shell=True, capture_output=True, text=True + ) + + try: + os.remove(xml_path) + except Exception: + pass + + if result.returncode != 0: + err = result.stderr.strip() or result.stdout.strip() + print(f"[Reminder] ❌ schtasks failed: {err}") + try: + os.remove(notify_script) + except Exception: + pass + return "I couldn't schedule the reminder due to a system error." + + if player: + player.write_log(f"[reminder] set for {date_str} {time_str}") + + return f"Reminder set for {target_dt.strftime('%B %d at %I:%M %p')}." + + except ValueError: + return "I couldn't understand that date or time format." + + except Exception as e: + return f"Something went wrong while scheduling the reminder: {str(e)[:80]}" \ No newline at end of file diff --git a/send_message.py b/send_message.py new file mode 100644 index 0000000..f38850c --- /dev/null +++ b/send_message.py @@ -0,0 +1,367 @@ +import asyncio +import base64 +import io +import json +import re +import os +import sys +import time +import threading +import cv2 +import mss +import mss.tools +import sounddevice as sd +import numpy as np +from pathlib import Path + +try: + import PIL.Image + _PIL_OK = True +except ImportError: + _PIL_OK = False + +from google import genai +from google.genai import types + +def get_base_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + +LIVE_MODEL = "models/gemini-2.5-flash-native-audio-preview-12-2025" +CHANNELS = 1 +RECEIVE_SAMPLE_RATE = 24000 +CHUNK_SIZE = 1024 + +IMG_MAX_W = 640 +IMG_MAX_H = 360 +JPEG_Q = 55 + +SYSTEM_PROMPT = ( + "You are JARVIS from Iron Man movies. " + "Analyze images with technical precision and intelligence. " + "Help the user in a way they can understand — don't be overly complex. " + "Be concise, smart, and helpful like Tony Stark's AI assistant. " + "Respond in maximum 2 short sentences. Speed is priority. " + "Address the user as 'sir' for a tone of respect. " + "Ask if the user needs any further help with their problem." +) + + +def _get_api_key() -> str: + try: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + keys = json.load(f) + key = keys.get("gemini_api_key", "") + if not key: + raise ValueError("gemini_api_key not found") + return key + except Exception as e: + raise RuntimeError(f"Could not load API key: {e}") + + +def _get_camera_index() -> int: + try: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + if "camera_index" in cfg: + return int(cfg["camera_index"]) + except Exception: + pass + + print("[Camera] 🔍 No camera index in config. Auto-detecting...") + best_index = 0 + + for idx in range(6): + cap = cv2.VideoCapture(idx, cv2.CAP_DSHOW) + if not cap.isOpened(): + cap.release() + continue + for _ in range(5): + cap.read() + ret, frame = cap.read() + cap.release() + if ret and frame is not None and frame.mean() > 5: + best_index = idx + print(f"[Camera] ✅ Camera found at index {idx} — saving to config.") + break + else: + print(f"[Camera] ⚠️ Index {idx}: no valid frame.") + + try: + cfg = {} + if API_CONFIG_PATH.exists(): + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + cfg = json.load(f) + cfg["camera_index"] = best_index + with open(API_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=4) + print(f"[Camera] 💾 Camera index {best_index} saved to config.") + except Exception as e: + print(f"[Camera] ⚠️ Could not save camera index: {e}") + + return best_index + + +def _to_jpeg(img_bytes: bytes) -> bytes: + if not _PIL_OK: + return img_bytes + img = PIL.Image.open(io.BytesIO(img_bytes)).convert("RGB") + img.thumbnail([IMG_MAX_W, IMG_MAX_H], PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=JPEG_Q, optimize=False) + return buf.getvalue() + + +def _capture_screenshot() -> bytes: + with mss.mss() as sct: + shot = sct.grab(sct.monitors[1]) + png_bytes = mss.tools.to_png(shot.rgb, shot.size) + return _to_jpeg(png_bytes) + + +def _capture_camera() -> bytes: + camera_index = _get_camera_index() + cap = cv2.VideoCapture(camera_index, cv2.CAP_DSHOW) + if not cap.isOpened(): + raise RuntimeError(f"Camera could not be opened: index {camera_index}") + for _ in range(10): + cap.read() + ret, frame = cap.read() + cap.release() + if not ret or frame is None: + raise RuntimeError("Could not capture camera frame.") + if _PIL_OK: + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + img = PIL.Image.fromarray(rgb) + img.thumbnail([IMG_MAX_W, IMG_MAX_H], PIL.Image.BILINEAR) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=JPEG_Q, optimize=False) + return buf.getvalue() + _, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_Q]) + return buf.tobytes() + + +class _LiveSession: + + def __init__(self): + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._session = None + self._out_queue: asyncio.Queue | None = None + self._audio_in: asyncio.Queue | None = None + self._ready: threading.Event = threading.Event() + self._player = None + self._send_lock: asyncio.Lock | None = None + + def start(self, player=None): + if self._thread and self._thread.is_alive(): + return + self._player = player + self._thread = threading.Thread( + target=self._run_loop, daemon=True, name="VisionSessionThread" + ) + self._thread.start() + ok = self._ready.wait(timeout=20) + if not ok: + raise RuntimeError("Vision session did not start within 20s.") + print("[ScreenProcess] ✅ Vision session ready (no mic)") + + def _run_loop(self): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_until_complete(self._main()) + + async def _main(self): + self._out_queue = asyncio.Queue(maxsize=30) + self._audio_in = asyncio.Queue() + self._send_lock = asyncio.Lock() + + client = genai.Client( + api_key=_get_api_key(), + http_options={"api_version": "v1beta"} + ) + + config = types.LiveConnectConfig( + response_modalities=["AUDIO"], + output_audio_transcription={}, + system_instruction=SYSTEM_PROMPT, + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name="Charon" + ) + ) + ), + ) + + while True: + try: + print("[ScreenProcess] 🔌 Vision session connecting...") + async with client.aio.live.connect(model=LIVE_MODEL, config=config) as session: + self._session = session + self._ready.set() + print("[ScreenProcess] ✅ Vision session connected") + async with asyncio.TaskGroup() as tg: + tg.create_task(self._send_loop()) + tg.create_task(self._recv_loop()) + tg.create_task(self._play_loop()) + except Exception as e: + print(f"[ScreenProcess] ⚠️ Disconnected: {e} — reconnecting...") + self._session = None + self._ready.clear() + await asyncio.sleep(2) + self._ready.set() + + async def _send_loop(self): + while True: + item = await self._out_queue.get() + if self._session: + image_bytes, mime_type, user_text = item + try: + b64 = base64.b64encode(image_bytes).decode("utf-8") + await self._session.send_client_content( + turns={ + "parts": [ + {"inline_data": {"mime_type": mime_type, "data": b64}}, + {"text": user_text} + ] + }, + turn_complete=True + ) + print("[ScreenProcess] ✅ Image sent") + except Exception as e: + print(f"[ScreenProcess] ⚠️ Send error: {e}") + + async def _recv_loop(self): + transcript_buf: list[str] = [] + try: + async for response in self._session.receive(): + if response.data: + await self._audio_in.put(response.data) + sc = response.server_content + if not sc: + continue + if sc.output_transcription and sc.output_transcription.text: + chunk = sc.output_transcription.text.strip() + if chunk: + transcript_buf.append(chunk) + if sc.turn_complete: + if transcript_buf and self._player: + full = re.sub(r'\s+', ' ', " ".join(transcript_buf)).strip() + if full: + self._player.write_log(f"Jarvis: {full}") + print(f"[ScreenProcess] 💬 {full}") + transcript_buf = [] + except Exception as e: + print(f"[ScreenProcess] ⚠️ Recv error: {e}") + transcript_buf = [] + await asyncio.sleep(0.3) + + async def _play_loop(self): + stream = sd.RawOutputStream( + samplerate=RECEIVE_SAMPLE_RATE, + channels=CHANNELS, + dtype="int16", + blocksize=CHUNK_SIZE, + ) + stream.start() + try: + while True: + chunk = await self._audio_in.get() + await asyncio.to_thread(stream.write, chunk) + except Exception as e: + print(f"[ScreenProcess] ❌ Play error: {e}") + raise + finally: + stream.stop() + stream.close() + + def analyze(self, image_bytes: bytes, mime_type: str, user_text: str): + if not self._loop: + return + asyncio.run_coroutine_threadsafe( + self._out_queue.put((image_bytes, mime_type, user_text)), + self._loop + ) + + def is_ready(self) -> bool: + return self._session is not None + + +_live = _LiveSession() +_started = False +_start_lock = threading.Lock() + + +def _ensure_started(player=None): + global _started + with _start_lock: + if not _started: + _live.start(player=player) + _started = True + elif player is not None: + _live._player = player + + +def screen_process( + parameters: dict, + response: str | None = None, + player=None, + session_memory=None, +) -> bool: + user_text = (parameters or {}).get("text") or (parameters or {}).get("user_text", "") + user_text = (user_text or "").strip() + if not user_text: + print("[ScreenProcess] ⚠️ No user_text provided.") + return False + + angle = (parameters or {}).get("angle", "screen").lower().strip() + print(f"[ScreenProcess] angle={angle!r} text={user_text!r}") + + _ensure_started(player=player) + + try: + if angle == "camera": + image_bytes = _capture_camera() + mime_type = "image/jpeg" + print("[ScreenProcess] 📷 Camera captured") + else: + image_bytes = _capture_screenshot() + mime_type = "image/jpeg" if _PIL_OK else "image/png" + print("[ScreenProcess] 🖥️ Screen captured") + except Exception as e: + import traceback; traceback.print_exc() + print(f"[ScreenProcess] ❌ Capture error: {e}") + return False + + print(f"[ScreenProcess] 📦 {len(image_bytes)} bytes → sending") + _live.analyze(image_bytes, mime_type, user_text) + return True + + +def warmup_session(player=None): + try: + _ensure_started(player=player) + except Exception as e: + print(f"[ScreenProcess] ⚠️ Warmup error: {e}") + + +if __name__ == "__main__": + print("[TEST] screen_processor.py v8 — image-only session") + print("=" * 50) + mode = input("screen / camera (default: screen): ").strip().lower() or "screen" + request = input("Question (Enter for default): ").strip() or "What do you see? Be brief." + + t0 = time.perf_counter() + warmup_session() + print(f"Session ready — {time.perf_counter()-t0:.2f}s\n") + + t1 = time.perf_counter() + result = screen_process({"angle": mode, "text": request}, player=None) + print(f"Sent — {time.perf_counter()-t1:.3f}s | audio incoming...") + time.sleep(8) + print(f"\n{'✅' if result else '❌'}") diff --git a/setup.py b/setup.py index 5eb9976..75b1715 100644 --- a/setup.py +++ b/setup.py @@ -7,5 +7,4 @@ subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt" print("Installing Playwright browsers...") subprocess.run([sys.executable, "-m", "playwright", "install"], check=True) -print("\n✅ Setup complete! Run 'python main.py' to start MARK XXV.") - +print("\n✅ Setup complete! Run 'python main.py' to start MARK XXV.") \ No newline at end of file diff --git a/task_queue.py b/task_queue.py new file mode 100644 index 0000000..8c63fbb --- /dev/null +++ b/task_queue.py @@ -0,0 +1,400 @@ +import json +import re +import sys +import threading +import subprocess +import tempfile +import os +from pathlib import Path +from typing import Callable + +from agent.planner import create_plan, replan +from agent.error_handler import analyze_error, generate_fix, ErrorDecision + + +def get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + +def _run_generated_code(description: str, speak: Callable | None = None) -> str: + import google.generativeai as genai + + if speak: + speak("Writing custom code for this task, sir.") + + home = Path.home() + desktop = home / "Desktop" + downloads = home / "Downloads" + documents = home / "Documents" + + if not desktop.exists(): + try: + import winreg + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders") + desktop = Path(winreg.QueryValueEx(key, "Desktop")[0]) + except Exception: + pass + + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel( + model_name="gemini-2.5-flash", + system_instruction=( + "You are an expert Python developer. " + "Write clean, complete, working Python code. " + "Use standard library + common packages. " + "Install missing packages with subprocess + pip if needed. " + "Return ONLY the Python code. No explanation, no markdown, no backticks.\n\n" + f"SYSTEM PATHS:\n" + f" Desktop = r'{desktop}'\n" + f" Downloads = r'{downloads}'\n" + f" Documents = r'{documents}'\n" + f" Home = r'{home}'\n" + ) + ) + + try: + response = model.generate_content( + f"Write Python code to accomplish this task:\n\n{description}" + ) + code = response.text.strip() + code = re.sub(r"```(?:python)?", "", code).strip().rstrip("`").strip() + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as f: + f.write(code) + tmp_path = f.name + + print(f"[Executor] 🐍 Running generated code: {tmp_path}") + + result = subprocess.run( + [sys.executable, tmp_path], + capture_output=True, text=True, + timeout=120, cwd=str(Path.home()) + ) + + try: + os.unlink(tmp_path) + except Exception: + pass + + output = result.stdout.strip() + error = result.stderr.strip() + + if result.returncode == 0 and output: + return output + elif result.returncode == 0: + return "Task completed successfully." + elif error: + raise RuntimeError(f"Code error: {error[:400]}") + return "Completed." + + except subprocess.TimeoutExpired: + raise RuntimeError("Generated code timed out after 120 seconds.") + except RuntimeError: + raise + except Exception as e: + raise RuntimeError(f"Generated code failed: {e}") + +def _inject_context(params: dict, tool: str, step_results: dict, goal: str = "") -> dict: + if not step_results: + return params + + params = dict(params) + + if tool == "file_controller" and params.get("action") in ("write", "create_file"): + content = params.get("content", "") + if not content or len(content) < 50: + all_results = [ + v for v in step_results.values() + if v and len(v) > 100 and v not in ("Done.", "Completed.") + ] + if all_results: + combined = "\n\n---\n\n".join(all_results) + translated = _translate_to_goal_language(combined, goal) + params["content"] = translated + print(f"[Executor] 💉 Injected + translated content") + + return params +def _detect_language(text: str) -> str: + import google.generativeai as genai + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel("gemini-2.5-flash-lite") + try: + response = model.generate_content( + f"What language is this text written in? " + f"Reply with ONLY the language name in English (e.g. Turkish, English, French).\n\n" + f"Text: {text[:200]}" + ) + return response.text.strip() + except Exception: + return "English" + + +def _translate_to_goal_language(content: str, goal: str) -> str: + if not goal: + return content + try: + import google.generativeai as genai + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel("gemini-2.5-flash") + + target_lang = _detect_language(goal) + print(f"[Executor] 🌐 Translating to: {target_lang}") + + prompt = ( + f"You are a professional translator. " + f"Translate the following text into {target_lang}.\n" + f"IMPORTANT:\n" + f"- Translate EVERYTHING, leave nothing in English\n" + f"- Keep all facts, numbers, and data intact\n" + f"- Keep the structure and formatting\n" + f"- Output ONLY the translated text, nothing else\n\n" + f"Text to translate:\n{content[:4000]}" + ) + response = model.generate_content(prompt) + translated = response.text.strip() + print(f"[Executor] ✅ Translation done ({target_lang})") + return translated + except Exception as e: + print(f"[Executor] ⚠️ Translation failed: {e}") + return content + +def _call_tool(tool: str, parameters: dict, speak: Callable | None) -> str: + + if tool == "open_app": + from actions.open_app import open_app + return open_app(parameters=parameters, player=None) or "Done." + + elif tool == "web_search": + from actions.web_search import web_search + return web_search(parameters=parameters, player=None) or "Done." + elif tool == "game_updater": + from actions.game_updater import game_updater + return game_updater(parameters=parameters, player=None, speak=speak) or "Done." + elif tool == "browser_control": + from actions.browser_control import browser_control + return browser_control(parameters=parameters, player=None) or "Done." + + elif tool == "file_controller": + from actions.file_controller import file_controller + return file_controller(parameters=parameters, player=None) or "Done." + + elif tool == "cmd_control": + from actions.cmd_control import cmd_control + return cmd_control(parameters=parameters, player=None) or "Done." + + elif tool == "code_helper": + from actions.code_helper import code_helper + return code_helper(parameters=parameters, player=None, speak=speak) or "Done." + + elif tool == "dev_agent": + from actions.dev_agent import dev_agent + return dev_agent(parameters=parameters, player=None, speak=speak) or "Done." + + elif tool == "screen_process": + from actions.screen_processor import screen_process + screen_process(parameters=parameters, player=None) + return "Screen captured and analyzed." + + elif tool == "send_message": + from actions.send_message import send_message + return send_message(parameters=parameters, player=None) or "Done." + + elif tool == "reminder": + from actions.reminder import reminder + return reminder(parameters=parameters, player=None) or "Done." + + elif tool == "youtube_video": + from actions.youtube_video import youtube_video + return youtube_video(parameters=parameters, player=None) or "Done." + + elif tool == "weather_report": + from actions.weather_report import weather_action + return weather_action(parameters=parameters, player=None) or "Done." + + elif tool == "computer_settings": + from actions.computer_settings import computer_settings + return computer_settings(parameters=parameters, player=None) or "Done." + + elif tool == "desktop_control": + from actions.desktop import desktop_control + return desktop_control(parameters=parameters, player=None) or "Done." + + elif tool == "computer_control": + from actions.computer_control import computer_control + return computer_control(parameters=parameters, player=None) or "Done." + + elif tool == "generated_code": + description = parameters.get("description", "") + if not description: + raise ValueError("generated_code requires a 'description' parameter.") + return _run_generated_code(description, speak=speak) + + elif tool == "flight_finder": + from actions.flight_finder import flight_finder + return flight_finder(parameters=parameters, player=None, speak=speak) or "Done." + + else: + print(f"[Executor] ⚠️ Unknown tool '{tool}' — falling back to generated_code") + return _run_generated_code(f"Accomplish this task: {parameters}", speak=speak) + +class AgentExecutor: + + MAX_REPLAN_ATTEMPTS = 2 + + def execute( + self, + goal: str, + speak: Callable | None = None, + cancel_flag: threading.Event | None = None, + ) -> str: + print(f"\n[Executor] 🎯 Goal: {goal}") + + replan_attempts = 0 + completed_steps = [] + step_results = {} + plan = create_plan(goal) + + while True: + steps = plan.get("steps", []) + + if not steps: + msg = "I couldn't create a valid plan for this task, sir." + if speak: speak(msg) + return msg + + success = True + failed_step = None + failed_error = "" + + for step in steps: + if cancel_flag and cancel_flag.is_set(): + if speak: speak("Task cancelled, sir.") + return "Task cancelled." + + step_num = step.get("step", "?") + tool = step.get("tool", "generated_code") + desc = step.get("description", "") + params = step.get("parameters", {}) + + params = _inject_context(params, tool, step_results, goal=goal) + + print(f"\n[Executor] ▶️ Step {step_num}: [{tool}] {desc}") + + attempt = 1 + step_ok = False + + while attempt <= 3: + if cancel_flag and cancel_flag.is_set(): + break + try: + result = _call_tool(tool, params, speak) + step_results[step_num] = result + completed_steps.append(step) + print(f"[Executor] ✅ Step {step_num} done: {str(result)[:100]}") + step_ok = True + break + + except Exception as e: + error_msg = str(e) + print(f"[Executor] ❌ Step {step_num} attempt {attempt} failed: {error_msg}") + + recovery = analyze_error(step, error_msg, attempt=attempt) + decision = recovery["decision"] + user_msg = recovery.get("user_message", "") + + if speak and user_msg: + speak(user_msg) + + if decision == ErrorDecision.RETRY: + attempt += 1 + import time; time.sleep(2) + continue + + elif decision == ErrorDecision.SKIP: + print(f"[Executor] ⏭️ Skipping step {step_num}") + completed_steps.append(step) + step_ok = True + break + + elif decision == ErrorDecision.ABORT: + msg = f"Task aborted, sir. {recovery.get('reason', '')}" + if speak: speak(msg) + return msg + + else: + fix_suggestion = recovery.get("fix_suggestion", "") + if fix_suggestion and tool != "generated_code": + try: + fixed_step = generate_fix(step, error_msg, fix_suggestion) + if speak: speak("Trying an alternative approach, sir.") + res = _call_tool( + fixed_step["tool"], + fixed_step["parameters"], + speak + ) + step_results[step_num] = res + completed_steps.append(step) + step_ok = True + break + except Exception as fix_err: + print(f"[Executor] ⚠️ Fix failed: {fix_err}") + + failed_step = step + failed_error = error_msg + success = False + break + + if not step_ok and not failed_step: + failed_step = step + failed_error = "Max retries exceeded" + success = False + + if not success: + break + + if success: + return self._summarize(goal, completed_steps, speak) + + if replan_attempts >= self.MAX_REPLAN_ATTEMPTS: + msg = f"Task failed after {replan_attempts} replan attempts, sir." + if speak: speak(msg) + return msg + + if speak: speak("Adjusting my approach, sir.") + + replan_attempts += 1 + plan = replan(goal, completed_steps, failed_step, failed_error) + + def _summarize(self, goal: str, completed_steps: list, speak: Callable | None) -> str: + fallback = f"All done, sir. Completed {len(completed_steps)} steps for: {goal[:60]}." + try: + import google.generativeai as genai + genai.configure(api_key=_get_api_key()) + model = genai.GenerativeModel(model_name="gemini-2.5-flash-lite") + steps_str = "\n".join(f"- {s.get('description', '')}" for s in completed_steps) + prompt = ( + f'User goal: "{goal}"\n' + f"Completed steps:\n{steps_str}\n\n" + "Write a single natural sentence summarizing what was accomplished. " + "Address the user as 'sir'. Be direct and positive." + ) + response = model.generate_content(prompt) + summary = response.text.strip() + if speak: speak(summary) + return summary + except Exception: + if speak: speak(fallback) + return fallback \ No newline at end of file diff --git a/ui.py b/ui.py index a766d29..a267daa 100644 --- a/ui.py +++ b/ui.py @@ -1,613 +1,1535 @@ -import os, json, time, math, random, threading -import tkinter as tk -from collections import deque -from PIL import Image, ImageTk, ImageDraw +from __future__ import annotations + +import json +import math +import os +import platform +import random +import subprocess import sys +import threading +import time from pathlib import Path +import psutil -def get_base_dir(): +from PyQt6.QtCore import ( + QEasingCurve, QMimeData, QObject, QPointF, QRectF, QSize, Qt, + QTimer, QUrl, pyqtSignal, +) +from PyQt6.QtGui import ( + QBrush, QColor, QDragEnterEvent, QDropEvent, QFont, QFontDatabase, + QKeySequence, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap, + QRadialGradient, QShortcut, +) +from PyQt6.QtWidgets import ( + QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QLineEdit, + QMainWindow, QPushButton, QScrollArea, QSizePolicy, QTextEdit, + QVBoxLayout, QWidget, QProgressBar, +) + +def _base_dir() -> Path: if getattr(sys, "frozen", False): return Path(sys.executable).parent return Path(__file__).resolve().parent - -BASE_DIR = get_base_dir() +BASE_DIR = _base_dir() CONFIG_DIR = BASE_DIR / "config" API_FILE = CONFIG_DIR / "api_keys.json" -SYSTEM_NAME = "J.A.R.V.I.S" -MODEL_BADGE = "MARK XXXV" +_DEFAULT_W, _DEFAULT_H = 980, 700 +_MIN_W, _MIN_H = 820, 580 +_LEFT_W = 148 +_RIGHT_W = 340 -C_BG = "#000000" -C_PRI = "#00d4ff" -C_MID = "#007a99" -C_DIM = "#003344" -C_DIMMER = "#001520" -C_ACC = "#ff6600" -C_ACC2 = "#ffcc00" -C_TEXT = "#8ffcff" -C_PANEL = "#010c10" -C_GREEN = "#00ff88" -C_RED = "#ff3333" -C_MUTED = "#ff3366" +_OS = platform.system() # "Windows" | "Darwin" | "Linux" + + +class C: + BG = "#00060a" + PANEL = "#010d14" + PANEL2 = "#010f18" + BORDER = "#0d3347" + BORDER_B = "#1a5c7a" + BORDER_A = "#0f4060" + PRI = "#00d4ff" + PRI_DIM = "#007a99" + PRI_GHO = "#001f2e" + ACC = "#ff6b00" + ACC2 = "#ffcc00" + GREEN = "#00ff88" + GREEN_D = "#00aa55" + RED = "#ff3355" + MUTED_C = "#ff3366" + TEXT = "#8ffcff" + TEXT_DIM = "#3a8a9a" + TEXT_MED = "#5ab8cc" + WHITE = "#d8f8ff" + DARK = "#000d14" + BAR_BG = "#011520" + + +def qcol(h: str, a: int = 255) -> QColor: + c = QColor(h); c.setAlpha(a); return c + +class _SysMetrics: + def __init__(self): + self.cpu = 0.0 + self.mem = 0.0 + self.net = 0.0 + self.gpu = -1.0 + self.tmp = -1.0 + self._lock = threading.Lock() + self._last_net = psutil.net_io_counters() + self._last_net_t = time.time() + self._running = True + t = threading.Thread(target=self._loop, daemon=True) + t.start() + + def _loop(self): + while self._running: + try: + self._update() + except Exception: + pass + time.sleep(1.5) + + def _update(self): + cpu = psutil.cpu_percent(interval=None) + mem = psutil.virtual_memory().percent + + nc = psutil.net_io_counters() + now = time.time() + dt = now - self._last_net_t + if dt > 0: + sent = (nc.bytes_sent - self._last_net.bytes_sent) / dt + recv = (nc.bytes_recv - self._last_net.bytes_recv) / dt + net = (sent + recv) / (1024 * 1024) + else: + net = 0.0 + self._last_net = nc + self._last_net_t = now + + gpu = self._get_gpu() + + tmp = self._get_temp() + + with self._lock: + self.cpu = cpu + self.mem = mem + self.net = net + self.gpu = gpu + self.tmp = tmp + + def _get_gpu(self) -> float: + # NVIDIA + try: + r = subprocess.run( + ["nvidia-smi", "--query-gpu=utilization.gpu", + "--format=csv,noheader,nounits"], + capture_output=True, text=True, timeout=2 + ) + if r.returncode == 0: + vals = [float(v.strip()) for v in r.stdout.strip().split("\n") if v.strip()] + if vals: + return sum(vals) / len(vals) + except Exception: + pass + + # AMD (Linux) + if _OS == "Linux": + try: + r = subprocess.run( + ["rocm-smi", "--showuse", "--csv"], + capture_output=True, text=True, timeout=2 + ) + if r.returncode == 0: + for line in r.stdout.strip().split("\n"): + parts = line.split(",") + if len(parts) >= 2: + try: + return float(parts[1].strip().replace("%", "")) + except ValueError: + pass + except Exception: + pass + + # Intel GPU (Linux) + try: + r = subprocess.run( + ["intel_gpu_top", "-J", "-s", "500"], + capture_output=True, text=True, timeout=1 + ) + if r.returncode == 0 and "Render/3D" in r.stdout: + import re + m = re.search(r'"busy":\s*([\d.]+)', r.stdout) + if m: + return float(m.group(1)) + except Exception: + pass + + # macOS — powermetrics (GPU Engine) + if _OS == "Darwin": + try: + r = subprocess.run( + ["sudo", "-n", "powermetrics", "-n", "1", "-i", "500", + "--samplers", "gpu_power"], + capture_output=True, text=True, timeout=2 + ) + if r.returncode == 0 and "GPU" in r.stdout: + import re + m = re.search(r'GPU\s+Active:\s+([\d.]+)%', r.stdout) + if m: + return float(m.group(1)) + except Exception: + pass + + return -1.0 + + def _get_temp(self) -> float: + try: + temps = psutil.sensors_temperatures() + candidates = ["coretemp", "k10temp", "cpu_thermal", "acpitz", + "cpu-thermal", "zenpower", "it8688"] + for name in candidates: + if name in temps: + entries = temps[name] + if entries: + return entries[0].current + for entries in temps.values(): + if entries: + return entries[0].current + except Exception: + pass + if _OS == "Darwin": + try: + r = subprocess.run( + ["osx-cpu-temp"], capture_output=True, text=True, timeout=2 + ) + if r.returncode == 0: + import re + m = re.search(r"([\d.]+)", r.stdout) + if m: + return float(m.group(1)) + except Exception: + pass + + if _OS == "Windows": + try: + r = subprocess.run( + ["powershell", "-Command", + "(Get-WmiObject MSAcpi_ThermalZoneTemperature -Namespace root/wmi).CurrentTemperature"], + capture_output=True, text=True, timeout=3 + ) + if r.returncode == 0 and r.stdout.strip(): + raw = float(r.stdout.strip().split("\n")[0]) + return (raw / 10.0) - 273.15 + except Exception: + pass + + return -1.0 + + def snapshot(self) -> dict: + with self._lock: + return { + "cpu": self.cpu, + "mem": self.mem, + "net": self.net, + "gpu": self.gpu, + "tmp": self.tmp, + } + + +_metrics = _SysMetrics() + +class HudCanvas(QWidget): + def __init__(self, face_path: str, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent) + self.setMinimumSize(300, 300) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.muted = False + self.speaking = False + self.state = "INITIALISING" + + self._tick = 0 + self._scale = 1.0 + self._tgt_scale = 1.0 + self._halo = 55.0 + self._tgt_halo = 55.0 + self._last_t = time.time() + self._scan = 0.0 + self._scan2 = 180.0 + self._rings = [0.0, 120.0, 240.0] + self._pulses: list[float] = [0.0, 50.0, 100.0] + self._blink = True + self._blink_tick = 0 + self._particles: list[list[float]] = [] + self._face_px: QPixmap | None = None + self._load_face(face_path) + + self._tmr = QTimer(self) + self._tmr.timeout.connect(self._step) + self._tmr.start(16) + + def _load_face(self, path: str): + try: + from PIL import Image, ImageDraw + import io + img = Image.open(path).convert("RGBA") + sz = min(img.size) + img = img.resize((sz, sz), Image.LANCZOS) + mk = Image.new("L", (sz, sz), 0) + ImageDraw.Draw(mk).ellipse((2, 2, sz - 2, sz - 2), fill=255) + img.putalpha(mk) + buf = io.BytesIO() + img.save(buf, format="PNG") + px = QPixmap(); px.loadFromData(buf.getvalue()) + self._face_px = px + except Exception: + self._face_px = None + + def _step(self): + self._tick += 1 + now = time.time() + if now - self._last_t > (0.12 if self.speaking else 0.5): + if self.speaking: + self._tgt_scale = random.uniform(1.06, 1.14) + self._tgt_halo = random.uniform(145, 190) + elif self.muted: + self._tgt_scale = random.uniform(0.998, 1.002) + self._tgt_halo = random.uniform(15, 28) + else: + self._tgt_scale = random.uniform(1.001, 1.008) + self._tgt_halo = random.uniform(48, 68) + self._last_t = now + + sp = 0.38 if self.speaking else 0.15 + self._scale += (self._tgt_scale - self._scale) * sp + self._halo += (self._tgt_halo - self._halo) * sp + + speeds = [1.3, -0.9, 2.0] if self.speaking else [0.55, -0.35, 0.9] + for i, spd in enumerate(speeds): + self._rings[i] = (self._rings[i] + spd) % 360 + + self._scan = (self._scan + (3.0 if self.speaking else 1.3)) % 360 + self._scan2 = (self._scan2 + (-2.0 if self.speaking else -0.75)) % 360 + + fw = min(self.width(), self.height()) + lim = fw * 0.74 + spd = 4.2 if self.speaking else 2.0 + self._pulses = [r + spd for r in self._pulses if r + spd < lim] + if len(self._pulses) < 3 and random.random() < (0.07 if self.speaking else 0.025): + self._pulses.append(0.0) + + if self.speaking and random.random() < 0.28: + cx, cy = self.width() / 2, self.height() / 2 + ang = random.uniform(0, 2 * math.pi) + r_s = fw * 0.28 + self._particles.append([ + cx + math.cos(ang) * r_s, cy + math.sin(ang) * r_s, + math.cos(ang) * random.uniform(0.9, 2.4), + math.sin(ang) * random.uniform(0.9, 2.4) - 0.4, 1.0, + ]) + self._particles = [ + [p[0]+p[2], p[1]+p[3], p[2]*0.97, p[3]*0.97, p[4]-0.028] + for p in self._particles if p[4] > 0 + ] + + self._blink_tick += 1 + if self._blink_tick >= 38: + self._blink = not self._blink + self._blink_tick = 0 + self.update() + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.fillRect(self.rect(), qcol(C.BG)) + + W, H = self.width(), self.height() + cx, cy = W / 2, H / 2 + fw = min(W, H) + + # grid dots + p.setPen(QPen(qcol(C.PRI_GHO), 1)) + for x in range(0, W, 48): + for y in range(0, H, 48): + p.drawPoint(x, y) + + r_face = fw * 0.31 + + # halo glow + for i in range(10): + r = r_face * (1.8 - i * 0.08) + frc = 1.0 - i / 10 + a = max(0, min(255, int(self._halo * 0.085 * frc))) + col = qcol(C.MUTED_C if self.muted else C.PRI, a) + p.setPen(QPen(col, 1.5)); p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(QRectF(cx - r, cy - r, r * 2, r * 2)) + + # pulse rings + for pr in self._pulses: + a = max(0, int(230 * (1.0 - pr / (fw * 0.74)))) + col = qcol(C.MUTED_C if self.muted else C.PRI, a) + p.setPen(QPen(col, 1.5)); p.setBrush(Qt.BrushStyle.NoBrush) + p.drawEllipse(QRectF(cx - pr, cy - pr, pr * 2, pr * 2)) + + # spinning arc rings + for idx, (r_frac, w_r, arc_l, gap) in enumerate( + [(0.48, 3, 115, 78), (0.40, 2, 78, 55), (0.32, 1, 56, 40)] + ): + ring_r = fw * r_frac + base = self._rings[idx] + a_val = max(0, min(255, int(self._halo * (1.0 - idx * 0.18)))) + col = qcol(C.MUTED_C if self.muted else C.PRI, a_val) + p.setPen(QPen(col, w_r)); p.setBrush(Qt.BrushStyle.NoBrush) + angle = base + rect = QRectF(cx - ring_r, cy - ring_r, ring_r * 2, ring_r * 2) + while angle < base + 360: + p.drawArc(rect, int(angle * 16), int(arc_l * 16)) + angle += arc_l + gap + + # scanners + sr = fw * 0.50 + sa = min(255, int(self._halo * 1.5)) + ex = 75 if self.speaking else 44 + p.setPen(QPen(qcol(C.MUTED_C if self.muted else C.PRI, sa), 2.5)) + p.setBrush(Qt.BrushStyle.NoBrush) + srect = QRectF(cx - sr, cy - sr, sr * 2, sr * 2) + p.drawArc(srect, int(self._scan * 16), int(ex * 16)) + p.setPen(QPen(qcol(C.ACC, sa // 2), 1.5)) + p.drawArc(srect, int(self._scan2 * 16), int(ex * 16)) + + # tick marks + t_out, t_in = fw * 0.497, fw * 0.474 + p.setPen(QPen(qcol(C.PRI, 140), 1)) + for deg in range(0, 360, 10): + rad = math.radians(deg) + inn = t_in if deg % 30 == 0 else t_in + 6 + p.drawLine( + QPointF(cx + t_out * math.cos(rad), cy - t_out * math.sin(rad)), + QPointF(cx + inn * math.cos(rad), cy - inn * math.sin(rad)), + ) + + # crosshair + ch_r, gap_h = fw * 0.51, fw * 0.16 + p.setPen(QPen(qcol(C.PRI, int(self._halo * 0.5)), 1)) + p.drawLine(QPointF(cx - ch_r, cy), QPointF(cx - gap_h, cy)) + p.drawLine(QPointF(cx + gap_h, cy), QPointF(cx + ch_r, cy)) + p.drawLine(QPointF(cx, cy - ch_r), QPointF(cx, cy - gap_h)) + p.drawLine(QPointF(cx, cy + gap_h), QPointF(cx, cy + ch_r)) + + # corner brackets + bl = 24 + bc = qcol(C.PRI, 210) + hl, hr = cx - fw // 2, cx + fw // 2 + ht, hb = cy - fw // 2, cy + fw // 2 + p.setPen(QPen(bc, 2)) + for bx, by, dx, dy in [(hl,ht,1,1),(hr,ht,-1,1),(hl,hb,1,-1),(hr,hb,-1,-1)]: + p.drawLine(QPointF(bx, by), QPointF(bx + dx * bl, by)) + p.drawLine(QPointF(bx, by), QPointF(bx, by + dy * bl)) + + # face + if self._face_px: + fsz = int(fw * 0.62 * self._scale) + scaled = self._face_px.scaled( + fsz, fsz, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + p.drawPixmap(int(cx - fsz / 2), int(cy - fsz / 2), scaled) + else: + orb_r = int(fw * 0.27 * self._scale) + oc = (200, 0, 50) if self.muted else (0, 60, 110) + for i in range(8, 0, -1): + r2 = int(orb_r * i / 8) + frc = i / 8 + a = max(0, min(255, int(self._halo * 1.1 * frc))) + p.setBrush(QBrush(QColor(int(oc[0]*frc), int(oc[1]*frc), int(oc[2]*frc), a))) + p.setPen(Qt.PenStyle.NoPen) + p.drawEllipse(QRectF(cx - r2, cy - r2, r2 * 2, r2 * 2)) + p.setPen(QPen(qcol(C.PRI, min(255, int(self._halo * 2))), 1)) + p.setFont(QFont("Courier New", 13, QFont.Weight.Bold)) + p.drawText(QRectF(cx - 80, cy - 14, 160, 28), + Qt.AlignmentFlag.AlignCenter, "J.A.R.V.I.S") + + # particles + for pt in self._particles: + a = max(0, min(255, int(pt[4] * 255))) + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(qcol(C.PRI, a))) + p.drawEllipse(QPointF(pt[0], pt[1]), 2.5, 2.5) + + # status text + sy = cy + fw * 0.40 + if self.muted: + txt, col = "⊘ MUTED", qcol(C.MUTED_C) + elif self.speaking: + txt, col = "● SPEAKING", qcol(C.ACC) + elif self.state == "THINKING": + sym = "◈" if self._blink else "◇" + txt, col = f"{sym} THINKING", qcol(C.ACC2) + elif self.state == "PROCESSING": + sym = "▷" if self._blink else "▶" + txt, col = f"{sym} PROCESSING", qcol(C.ACC2) + elif self.state == "LISTENING": + sym = "●" if self._blink else "○" + txt, col = f"{sym} LISTENING", qcol(C.GREEN) + else: + sym = "●" if self._blink else "○" + txt, col = f"{sym} {self.state}", qcol(C.PRI) + + p.setPen(QPen(col, 1)) + p.setFont(QFont("Courier New", 11, QFont.Weight.Bold)) + p.drawText(QRectF(0, sy, W, 26), Qt.AlignmentFlag.AlignCenter, txt) + + # waveform + wy = sy + 30 + N, bw = 36, 8 + wx0 = (W - N * bw) / 2 + for i in range(N): + if self.muted: + hgt, cl = 2, qcol(C.MUTED_C) + elif self.speaking: + hgt = random.randint(3, 20) + cl = qcol(C.PRI) if hgt > 12 else qcol(C.PRI_DIM) + else: + hgt = int(3 + 2 * math.sin(self._tick * 0.09 + i * 0.6)) + cl = qcol(C.BORDER_B) + p.fillRect(QRectF(wx0 + i * bw, wy + 20 - hgt, bw - 1, hgt), cl) + +class MetricBar(QWidget): + + def __init__(self, label: str, color: str = C.PRI, parent=None): + super().__init__(parent) + self._label = label + self._color = color + self._value = 0.0 # 0–100 + self._text = "--" + self.setFixedHeight(38) + self.setMinimumWidth(80) + + def set_value(self, pct: float, text: str): + self._value = max(0.0, min(100.0, pct)) + self._text = text + self.update() + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + W, H = self.width(), self.height() + + p.setBrush(QBrush(qcol(C.PANEL2))) + p.setPen(QPen(qcol(C.BORDER_A), 1)) + p.drawRoundedRect(QRectF(1, 1, W - 2, H - 2), 4, 4) + + bar_h = 4 + bar_y = H - bar_h - 5 + bar_w = W - 12 + bar_x = 6 + fill_w = int(bar_w * self._value / 100) + + p.setBrush(QBrush(qcol(C.BAR_BG))) + p.setPen(Qt.PenStyle.NoPen) + p.drawRoundedRect(QRectF(bar_x, bar_y, bar_w, bar_h), 2, 2) + + if self._value > 85: + bar_col = qcol(C.RED) + elif self._value > 65: + bar_col = qcol(C.ACC) + else: + bar_col = qcol(self._color) + + if fill_w > 0: + p.setBrush(QBrush(bar_col)) + p.drawRoundedRect(QRectF(bar_x, bar_y, fill_w, bar_h), 2, 2) + + p.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + p.setPen(QPen(qcol(C.TEXT_DIM), 1)) + p.drawText(QRectF(8, 5, 50, 14), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, self._label) + + p.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) + p.setPen(QPen(bar_col if self._text != "--" else qcol(C.TEXT_DIM), 1)) + p.drawText(QRectF(0, 4, W - 6, 16), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, self._text) + +class LogWidget(QTextEdit): + _sig = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setFont(QFont("Courier New", 9)) + self.setStyleSheet(f""" + QTextEdit {{ + background: {C.PANEL}; + color: {C.TEXT}; + border: 1px solid {C.BORDER}; + border-radius: 4px; + padding: 6px; + selection-background-color: {C.PRI_GHO}; + }} + QScrollBar:vertical {{ + background: {C.BG}; + width: 8px; + border: none; + }} + QScrollBar::handle:vertical {{ + background: {C.BORDER_B}; + border-radius: 4px; + min-height: 20px; + }} + """) + self._queue: list[str] = [] + self._typing = False + self._text = "" + self._pos = 0 + self._tag = "sys" + self._tmr = QTimer(self) + self._tmr.timeout.connect(self._step) + self._sig.connect(self._enqueue) + + def append_log(self, text: str): + self._sig.emit(text) + + def _enqueue(self, text: str): + self._queue.append(text) + if not self._typing: + self._next() + + def _next(self): + if not self._queue: + self._typing = False + return + self._typing = True + self._text = self._queue.pop(0) + self._pos = 0 + tl = self._text.lower() + if tl.startswith("you:"): self._tag = "you" + elif tl.startswith("jarvis:"): self._tag = "ai" + elif tl.startswith("file:"): self._tag = "file" + elif "err" in tl: self._tag = "err" + else: self._tag = "sys" + self._tmr.start(6) + + def _step(self): + if self._pos < len(self._text): + ch = self._text[self._pos] + cur = self.textCursor() + fmt = cur.charFormat() + col = { + "you": qcol(C.WHITE), + "ai": qcol(C.PRI), + "err": qcol(C.RED), + "file": qcol(C.GREEN), + "sys": qcol(C.ACC2), + }.get(self._tag, qcol(C.TEXT)) + fmt.setForeground(QBrush(col)) + cur.movePosition(cur.MoveOperation.End) + cur.insertText(ch, fmt) + self.setTextCursor(cur) + self.ensureCursorVisible() + self._pos += 1 + else: + self._tmr.stop() + cur = self.textCursor() + cur.movePosition(cur.MoveOperation.End) + cur.insertText("\n") + self.setTextCursor(cur) + self.ensureCursorVisible() + QTimer.singleShot(20, self._next) + +_FILE_ICONS = { + "image": ("🖼", "#00d4ff"), "video": ("🎬", "#ff6b00"), + "audio": ("🎵", "#cc44ff"), "pdf": ("📄", "#ff4444"), + "word": ("📝", "#4488ff"), "excel": ("📊", "#44bb44"), + "code": ("💻", "#ffcc00"), "archive": ("📦", "#ff8844"), + "pptx": ("📊", "#ff6622"), "text": ("📃", "#aaaaaa"), + "data": ("🔧", "#88ddff"), "unknown": ("📎", "#888888"), +} +_EXT_TO_CAT = { + **dict.fromkeys(["jpg","jpeg","png","gif","webp","bmp","tiff","svg","ico"], "image"), + **dict.fromkeys(["mp4","avi","mov","mkv","wmv","flv","webm","m4v"], "video"), + **dict.fromkeys(["mp3","wav","ogg","m4a","aac","flac","wma","opus"], "audio"), + **dict.fromkeys(["pdf"], "pdf"), + **dict.fromkeys(["doc","docx"], "word"), + **dict.fromkeys(["xls","xlsx","ods"], "excel"), + **dict.fromkeys(["ppt","pptx"], "pptx"), + **dict.fromkeys(["py","js","ts","jsx","tsx","html","css","java","c","cpp", + "cs","go","rs","rb","php","swift","kt","sh","sql","lua"], "code"), + **dict.fromkeys(["zip","rar","tar","gz","7z","bz2","xz"], "archive"), + **dict.fromkeys(["txt","md","rst","log"], "text"), + **dict.fromkeys(["csv","tsv","json","xml"], "data"), +} + +def _file_category(path: Path) -> str: + return _EXT_TO_CAT.get(path.suffix.lower().lstrip("."), "unknown") + +def _fmt_size(size: int) -> str: + if size < 1024: return f"{size} B" + elif size < 1024**2: return f"{size/1024:.1f} KB" + elif size < 1024**3: return f"{size/1024**2:.1f} MB" + else: return f"{size/1024**3:.1f} GB" + + +class FileDropZone(QWidget): + file_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFixedHeight(100) + self._current_file: str | None = None + self._hovering = False + self._drag_over = False + self._dash_offset = 0.0 + self._anim_tmr = QTimer(self) + self._anim_tmr.timeout.connect(self._animate) + self._anim_tmr.start(40) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self._canvas = _DropCanvas(self) + layout.addWidget(self._canvas) + + def _animate(self): + self._dash_offset = (self._dash_offset + 0.8) % 20 + self._canvas.update() + + def dragEnterEvent(self, e: QDragEnterEvent): + if e.mimeData().hasUrls(): + e.acceptProposedAction() + self._drag_over = True; self._canvas.update() + + def dragLeaveEvent(self, e): + self._drag_over = False; self._canvas.update() + + def dropEvent(self, e: QDropEvent): + self._drag_over = False + urls = e.mimeData().urls() + if urls: + path = urls[0].toLocalFile() + if Path(path).is_file(): + self._set_file(path) + self._canvas.update() + + def mousePressEvent(self, e): + if e.button() == Qt.MouseButton.LeftButton: + self._browse() + + def enterEvent(self, e): + self._hovering = True; self._canvas.update() + + def leaveEvent(self, e): + self._hovering = False; self._canvas.update() + + def current_file(self) -> str | None: + return self._current_file + + def clear_file(self): + self._current_file = None; self._canvas.update() + + def _browse(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select a file for JARVIS", str(Path.home()), + "All Files (*.*);;" + "Images (*.jpg *.jpeg *.png *.gif *.webp *.bmp *.svg);;" + "Documents (*.pdf *.docx *.txt *.md *.pptx);;" + "Data (*.csv *.xlsx *.json *.xml);;" + "Code (*.py *.js *.ts *.html *.css *.java *.cpp *.go);;" + "Audio (*.mp3 *.wav *.ogg *.m4a *.aac *.flac);;" + "Video (*.mp4 *.avi *.mov *.mkv *.wmv *.webm);;" + "Archives (*.zip *.rar *.tar *.gz *.7z)", + ) + if path: + self._set_file(path) + + def _set_file(self, path: str): + self._current_file = path + self._canvas.update() + self.file_selected.emit(path) + + +class _DropCanvas(QWidget): + def __init__(self, zone: FileDropZone): + super().__init__(zone) + self._z = zone + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + z = self._z + W, H = self.width(), self.height() + pad = 6 + rect = QRectF(pad, pad, W - pad * 2, H - pad * 2) + + bg_col = qcol("#001a24" if z._drag_over else ("#001218" if z._hovering else C.PANEL)) + p.setBrush(QBrush(bg_col)); p.setPen(Qt.PenStyle.NoPen) + p.drawRoundedRect(rect, 6, 6) + + if z._current_file: border_col = qcol(C.GREEN, 200) + elif z._drag_over: border_col = qcol(C.PRI, 230) + elif z._hovering: border_col = qcol(C.BORDER_B, 200) + else: border_col = qcol(C.BORDER, 160) + + pen = QPen(border_col, 1.5, Qt.PenStyle.DashLine) + pen.setDashOffset(z._dash_offset) + p.setPen(pen); p.setBrush(Qt.BrushStyle.NoBrush) + p.drawRoundedRect(rect, 6, 6) + + if z._current_file: self._paint_file(p, W, H) + elif z._drag_over: self._paint_drag_over(p, W, H) + else: self._paint_idle(p, W, H, z._hovering) + + def _paint_idle(self, p, W, H, hover): + cx, cy = W / 2, H / 2 + col = qcol(C.PRI_DIM if not hover else C.PRI) + p.setPen(QPen(col, 2)); p.setBrush(Qt.BrushStyle.NoBrush) + p.drawLine(QPointF(cx, cy - 14), QPointF(cx, cy + 4)) + p.drawLine(QPointF(cx - 8, cy - 6), QPointF(cx, cy - 14)) + p.drawLine(QPointF(cx + 8, cy - 6), QPointF(cx, cy - 14)) + p.drawLine(QPointF(cx - 14, cy + 4), QPointF(cx + 14, cy + 4)) + p.setFont(QFont("Courier New", 8)) + p.setPen(QPen(qcol(C.PRI_DIM if not hover else C.TEXT), 1)) + p.drawText(QRectF(0, cy + 8, W, 16), Qt.AlignmentFlag.AlignCenter, + "Drop file here or Click to Browse") + p.setFont(QFont("Courier New", 7)) + p.setPen(QPen(qcol("#1a4a5a"), 1)) + p.drawText(QRectF(0, cy + 24, W, 14), Qt.AlignmentFlag.AlignCenter, + "Images · Video · Audio · PDF · Docs · Code · Data") + + def _paint_drag_over(self, p, W, H): + cx, cy = W / 2, H / 2 + p.setFont(QFont("Courier New", 20)) + p.setPen(QPen(qcol(C.PRI), 1)) + p.drawText(QRectF(0, cy - 24, W, 32), Qt.AlignmentFlag.AlignCenter, "⬇") + p.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + p.setPen(QPen(qcol(C.PRI), 1)) + p.drawText(QRectF(0, cy + 12, W, 16), Qt.AlignmentFlag.AlignCenter, "Release to load") + + def _paint_file(self, p, W, H): + path = Path(self._z._current_file) + cat = _file_category(path) + icon, icon_col = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"]) + size_str = _fmt_size(path.stat().st_size) + ext_str = path.suffix.upper().lstrip(".") or "FILE" + + block_x, block_w = 10, 60 + p.setFont(QFont("Segoe UI Emoji", 22) if _OS == "Windows" else QFont("Arial", 22)) + p.setPen(QPen(qcol(icon_col), 1)) + p.drawText(QRectF(block_x, 0, block_w, H), Qt.AlignmentFlag.AlignCenter, icon) + + tx = block_x + block_w + 6 + tw = W - tx - 38 + + p.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + p.setPen(QPen(qcol(C.WHITE), 1)) + name = path.name if len(path.name) <= 34 else path.name[:31] + "..." + p.drawText(QRectF(tx, H * 0.18, tw, 16), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, name) + + p.setFont(QFont("Courier New", 7)) + p.setPen(QPen(qcol(C.TEXT_DIM), 1)) + p.drawText(QRectF(tx, H * 0.18 + 18, tw, 14), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + f"{ext_str} · {size_str}") + + p.setFont(QFont("Courier New", 6)) + p.setPen(QPen(qcol("#1e5c6a"), 1)) + par = str(path.parent) + if len(par) > 42: par = "…" + par[-41:] + p.drawText(QRectF(tx, H * 0.18 + 34, tw, 12), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, par) + + p.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) + p.setPen(QPen(qcol(C.RED, 180), 1)) + p.drawText(QRectF(W - 34, 0, 28, H), Qt.AlignmentFlag.AlignCenter, "✕") + + def mousePressEvent(self, e): + z = self._z + if z._current_file and e.pos().x() > self.width() - 34: + z.clear_file() + else: + z.mousePressEvent(e) + + +class SetupOverlay(QWidget): + done = pyqtSignal(str, str, str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setStyleSheet(f""" + SetupOverlay {{ + background: rgba(0, 6, 10, 245); + border: 1px solid {C.BORDER_B}; + border-radius: 6px; + }} + """) + + detected = {"darwin": "mac", "windows": "windows"}.get( + _OS.lower(), "linux" + ) + self._sel_os = detected + + layout = QVBoxLayout(self) + layout.setContentsMargins(30, 22, 30, 22) + layout.setSpacing(8) + + def _lbl(txt, font_size=9, bold=False, color=C.PRI, + align=Qt.AlignmentFlag.AlignCenter): + w = QLabel(txt) + w.setAlignment(align) + w.setFont(QFont("Courier New", font_size, + QFont.Weight.Bold if bold else QFont.Weight.Normal)) + w.setStyleSheet(f"color: {color}; background: transparent;") + return w + + layout.addWidget(_lbl("◈ INITIALISATION REQUIRED", 13, True)) + layout.addWidget(_lbl("Configure J.A.R.V.I.S. before first boot.", 9, color=C.PRI_DIM)) + layout.addSpacing(6) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep) + layout.addSpacing(4) + + layout.addWidget(_lbl("GEMINI API KEY", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + self._key_input = QLineEdit() + self._key_input.setEchoMode(QLineEdit.EchoMode.Password) + self._key_input.setPlaceholderText("AIza…") + self._key_input.setFont(QFont("Courier New", 10)) + self._key_input.setFixedHeight(32) + self._key_input.setStyleSheet(f""" + QLineEdit {{ + background: #000d12; color: {C.TEXT}; + border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; + }} + QLineEdit:focus {{ border: 1px solid {C.PRI}; }} + """) + layout.addWidget(self._key_input) + layout.addSpacing(8) + + layout.addWidget(_lbl("OPENROUTER API KEY", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + self._or_input = QLineEdit() + self._or_input.setEchoMode(QLineEdit.EchoMode.Password) + self._or_input.setPlaceholderText("sk-or-…") + self._or_input.setFont(QFont("Courier New", 10)) + self._or_input.setFixedHeight(32) + self._or_input.setStyleSheet(f""" + QLineEdit {{ + background: #000d12; color: {C.TEXT}; + border: 1px solid {C.BORDER}; border-radius: 3px; padding: 4px 8px; + }} + QLineEdit:focus {{ border: 1px solid {C.ACC2}; }} + """) + layout.addWidget(self._or_input) + + layout.addSpacing(12) + + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color: {C.BORDER};"); layout.addWidget(sep2) + layout.addSpacing(4) + + layout.addWidget(_lbl("OPERATING SYSTEM", 8, color=C.TEXT_DIM, + align=Qt.AlignmentFlag.AlignLeft)) + det_name = {"windows": "Windows", "mac": "macOS", "linux": "Linux"}[detected] + layout.addWidget(_lbl(f"Auto-detected: {det_name}", 8, color=C.ACC2, + align=Qt.AlignmentFlag.AlignLeft)) + + os_row = QHBoxLayout(); os_row.setSpacing(6) + self._os_btns: dict[str, QPushButton] = {} + for key, label in [("windows","⊞ Windows"),("mac"," macOS"),("linux","🐧 Linux")]: + btn = QPushButton(label) + btn.setFont(QFont("Courier New", 9, QFont.Weight.Bold)) + btn.setFixedHeight(32) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda _, k=key: self._sel(k)) + os_row.addWidget(btn) + self._os_btns[key] = btn + layout.addLayout(os_row) + self._sel(detected) + layout.addSpacing(12) + + init_btn = QPushButton("▸ INITIALISE SYSTEMS") + init_btn.setFont(QFont("Courier New", 10, QFont.Weight.Bold)) + init_btn.setFixedHeight(36) + init_btn.setCursor(Qt.CursorShape.PointingHandCursor) + init_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 3px; + }} + QPushButton:hover {{ + background: {C.PRI_GHO}; border: 1px solid {C.PRI}; + }} + """) + init_btn.clicked.connect(self._submit) + layout.addWidget(init_btn) + + def _sel(self, key: str): + self._sel_os = key + pal = {"windows":(C.PRI,"#001a22"),"mac":(C.ACC2,"#1a1400"),"linux":(C.GREEN,"#001a0d")} + for k, btn in self._os_btns.items(): + if k == key: + fg, bg = pal[k] + btn.setStyleSheet(f""" + QPushButton {{ + background: {fg}; color: {bg}; + border: none; border-radius: 3px; font-weight: bold; + }} + """) + else: + btn.setStyleSheet(f""" + QPushButton {{ + background: #000d12; color: {C.TEXT_DIM}; + border: 1px solid {C.BORDER}; border-radius: 3px; + }} + QPushButton:hover {{ color: {C.TEXT}; border: 1px solid {C.BORDER_B}; }} + """) + + def _submit(self): + key = self._key_input.text().strip() + or_key = self._or_input.text().strip() + if not key: + self._key_input.setStyleSheet( + self._key_input.styleSheet() + + f" QLineEdit {{ border: 1px solid {C.RED}; }}" + ) + return + if not or_key: + self._or_input.setStyleSheet( + self._or_input.styleSheet() + + f" QLineEdit {{ border: 1px solid {C.RED}; }}" + ) + return + self.done.emit(key, or_key, self._sel_os) + + +class MainWindow(QMainWindow): + _log_sig = pyqtSignal(str) + _state_sig = pyqtSignal(str) + + def __init__(self, face_path: str): + super().__init__() + self.setWindowTitle("J.A.R.V.I.S — MARK XXXIX") + self.setMinimumSize(_MIN_W, _MIN_H) + self.resize(_DEFAULT_W, _DEFAULT_H) + + screen = QApplication.primaryScreen().availableGeometry() + self.move( + (screen.width() - _DEFAULT_W) // 2, + (screen.height() - _DEFAULT_H) // 2, + ) + + self.on_text_command = None + self._muted = False + self._current_file: str | None = None + + central = QWidget() + central.setStyleSheet(f"background: {C.BG};") + self.setCentralWidget(central) + + root = QVBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + root.addWidget(self._build_header()) + + body = QHBoxLayout() + body.setContentsMargins(0, 0, 0, 0) + body.setSpacing(0) + + self._left_panel = self._build_left_panel() + body.addWidget(self._left_panel, stretch=0) + + self.hud = HudCanvas(face_path) + self.hud.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + body.addWidget(self.hud, stretch=5) + + self._right_panel = self._build_right_panel() + body.addWidget(self._right_panel, stretch=0) + + root.addLayout(body, stretch=1) + root.addWidget(self._build_footer()) + + self._clock_tmr = QTimer(self) + self._clock_tmr.timeout.connect(self._tick_clock) + self._clock_tmr.start(1000) + self._tick_clock() + + # Metrik güncelleme timer'ı + self._metric_tmr = QTimer(self) + self._metric_tmr.timeout.connect(self._update_metrics) + self._metric_tmr.start(2000) + self._update_metrics() + + self._log_sig.connect(self._log.append_log) + self._state_sig.connect(self._apply_state) + + self._overlay: SetupOverlay | None = None + self._ready = self._check_config() + if not self._ready: + self._show_setup() + + sc_mute = QShortcut(QKeySequence("F4"), self) + sc_mute.activated.connect(self._toggle_mute) + sc_full = QShortcut(QKeySequence("F11"), self) + sc_full.activated.connect(self._toggle_fullscreen) + + def _toggle_fullscreen(self): + if self.isFullScreen(): + self.showNormal() + else: + self.showFullScreen() + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._overlay and self._overlay.isVisible(): + ow, oh = 460, 390 + cw = self.centralWidget() + self._overlay.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, + ) + + def _update_metrics(self): + snap = _metrics.snapshot() + + # CPU + cpu = snap["cpu"] + self._bar_cpu.set_value(cpu, f"{cpu:.0f}%") + + # MEM + mem = snap["mem"] + self._bar_mem.set_value(mem, f"{mem:.0f}%") + + # NET + net = snap["net"] + if net < 1.0: + net_str = f"{net*1024:.0f}KB/s" + else: + net_str = f"{net:.1f}MB/s" + net_pct = min(100, net * 10) # 10 MB/s = %100 + self._bar_net.set_value(net_pct, net_str) + + # GPU + gpu = snap["gpu"] + if gpu >= 0: + self._bar_gpu.set_value(gpu, f"{gpu:.0f}%") + else: + self._bar_gpu.set_value(0, "N/A") + + # TMP + tmp = snap["tmp"] + if tmp >= 0: + tmp_pct = min(100, (tmp / 100) * 100) + self._bar_tmp.set_value(tmp_pct, f"{tmp:.0f}°C") + else: + self._bar_tmp.set_value(0, "N/A") + + try: + boot_t = psutil.boot_time() + elapsed = time.time() - boot_t + h = int(elapsed // 3600) + m = int((elapsed % 3600) // 60) + self._uptime_lbl.setText(f"UP {h:02d}:{m:02d}") + except Exception: + self._uptime_lbl.setText("UP --:--") + + try: + proc_count = len(psutil.pids()) + self._proc_lbl.setText(f"PROC {proc_count}") + except Exception: + self._proc_lbl.setText("PROC --") + + + def _build_header(self) -> QWidget: + w = QWidget() + w.setFixedHeight(54) + w.setStyleSheet(f"background: {C.DARK}; border-bottom: 1px solid {C.BORDER_B};") + lay = QHBoxLayout(w) + lay.setContentsMargins(16, 0, 16, 0) + + def _badge(txt, color=C.TEXT_MED): + l = QLabel(txt) + l.setFont(QFont("Courier New", 8)) + l.setStyleSheet(f"color: {color}; background: transparent;") + return l + + lay.addWidget(_badge("MARK XXXIX", C.PRI_DIM)) + lay.addStretch() + + mid = QVBoxLayout(); mid.setSpacing(1) + title = QLabel("J.A.R.V.I.S") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + title.setFont(QFont("Courier New", 17, QFont.Weight.Bold)) + title.setStyleSheet(f"color: {C.PRI}; background: transparent;") + mid.addWidget(title) + sub = QLabel("Just A Rather Very Intelligent System") + sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + sub.setFont(QFont("Courier New", 7)) + sub.setStyleSheet(f"color: {C.PRI_DIM}; background: transparent;") + mid.addWidget(sub) + lay.addLayout(mid) + lay.addStretch() + + right_col = QVBoxLayout(); right_col.setSpacing(2) + self._clock_lbl = QLabel("00:00:00") + self._clock_lbl.setFont(QFont("Courier New", 14, QFont.Weight.Bold)) + self._clock_lbl.setStyleSheet(f"color: {C.PRI}; background: transparent;") + self._clock_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) + right_col.addWidget(self._clock_lbl) + self._date_lbl = QLabel("") + self._date_lbl.setFont(QFont("Courier New", 7)) + self._date_lbl.setStyleSheet(f"color: {C.TEXT_DIM}; background: transparent;") + self._date_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) + right_col.addWidget(self._date_lbl) + lay.addLayout(right_col) + return w + + def _tick_clock(self): + self._clock_lbl.setText(time.strftime("%H:%M:%S")) + self._date_lbl.setText(time.strftime("%a %d %b %Y")) + + def _build_left_panel(self) -> QWidget: + w = QWidget() + w.setFixedWidth(_LEFT_W) + w.setStyleSheet(f"background: {C.DARK}; border-right: 1px solid {C.BORDER};") + lay = QVBoxLayout(w) + lay.setContentsMargins(8, 10, 8, 10) + lay.setSpacing(6) + + hdr = QLabel("◈ SYS MONITOR") + hdr.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + hdr.setStyleSheet(f"color: {C.PRI}; background: transparent; " + f"border-bottom: 1px solid {C.BORDER}; padding-bottom: 4px;") + lay.addWidget(hdr) + lay.addSpacing(2) + + self._bar_cpu = MetricBar("CPU", C.PRI) + self._bar_mem = MetricBar("MEM", C.ACC2) + self._bar_net = MetricBar("NET", C.GREEN) + self._bar_gpu = MetricBar("GPU", C.ACC) + self._bar_tmp = MetricBar("TMP", "#ff6688") + + for bar in [self._bar_cpu, self._bar_mem, self._bar_net, + self._bar_gpu, self._bar_tmp]: + lay.addWidget(bar) + + lay.addSpacing(4) + + info_panel = QWidget() + info_panel.setStyleSheet( + f"background: {C.PANEL2}; border: 1px solid {C.BORDER}; border-radius: 4px;" + ) + ip_lay = QVBoxLayout(info_panel) + ip_lay.setContentsMargins(6, 5, 6, 5) + ip_lay.setSpacing(3) + + self._uptime_lbl = QLabel("UP --:--") + self._uptime_lbl.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + self._uptime_lbl.setStyleSheet(f"color: {C.GREEN}; background: transparent; border: none;") + ip_lay.addWidget(self._uptime_lbl) + + self._proc_lbl = QLabel("PROC --") + self._proc_lbl.setFont(QFont("Courier New", 8)) + self._proc_lbl.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent; border: none;") + ip_lay.addWidget(self._proc_lbl) + + os_name = {"Windows": "WIN", "Darwin": "macOS", "Linux": "LINUX"}.get(_OS, _OS.upper()) + os_lbl = QLabel(f"OS {os_name}") + os_lbl.setFont(QFont("Courier New", 8)) + os_lbl.setStyleSheet(f"color: {C.ACC2}; background: transparent; border: none;") + ip_lay.addWidget(os_lbl) + + lay.addWidget(info_panel) + lay.addStretch() + + for txt, col in [ + ("AI CORE\nACTIVE", C.GREEN), + ("SEC\nCLEARED", C.PRI), + ("PROTOCOL\nXXXVIII", C.TEXT_DIM), + ]: + lbl = QLabel(txt) + lbl.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet( + f"color: {col}; background: {C.PANEL2};" + f"border: 1px solid {C.BORDER_A}; border-radius: 3px; padding: 4px;" + ) + lay.addWidget(lbl) + + return w + def _build_right_panel(self) -> QWidget: + w = QWidget() + w.setFixedWidth(_RIGHT_W) + w.setStyleSheet(f"background: {C.DARK}; border-left: 1px solid {C.BORDER};") + lay = QVBoxLayout(w) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(6) + + def _sec(txt): + l = QLabel(f"▸ {txt}") + l.setFont(QFont("Courier New", 7, QFont.Weight.Bold)) + l.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") + return l + + lay.addWidget(_sec("ACTIVITY LOG")) + self._log = LogWidget() + lay.addWidget(self._log, stretch=1) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") + lay.addWidget(sep) + + lay.addWidget(_sec("FILE UPLOAD")) + self._drop_zone = FileDropZone() + self._drop_zone.file_selected.connect(self._on_file_selected) + lay.addWidget(self._drop_zone) + + self._file_hint = QLabel("No file loaded — drop or click above to upload") + self._file_hint.setFont(QFont("Courier New", 7)) + self._file_hint.setStyleSheet(f"color: {C.TEXT_MED}; background: transparent;") + self._file_hint.setWordWrap(True) + lay.addWidget(self._file_hint) + + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color: {C.BORDER}; margin: 2px 0;") + lay.addWidget(sep2) + + lay.addWidget(_sec("COMMAND INPUT")) + lay.addLayout(self._build_input_row()) + + self._mute_btn = QPushButton("🎙 MICROPHONE ACTIVE") + self._mute_btn.setFixedHeight(30) + self._mute_btn.setFont(QFont("Courier New", 8, QFont.Weight.Bold)) + self._mute_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._mute_btn.clicked.connect(self._toggle_mute) + self._style_mute_btn() + lay.addWidget(self._mute_btn) + + fs_btn = QPushButton("⛶ FULLSCREEN [F11]") + fs_btn.setFixedHeight(26) + fs_btn.setFont(QFont("Courier New", 7)) + fs_btn.setCursor(Qt.CursorShape.PointingHandCursor) + fs_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; color: {C.TEXT_MED}; + border: 1px solid {C.BORDER}; border-radius: 3px; + }} + QPushButton:hover {{ + color: {C.PRI}; border: 1px solid {C.BORDER_B}; + }} + """) + fs_btn.clicked.connect(self._toggle_fullscreen) + lay.addWidget(fs_btn) + + return w + + def _build_input_row(self) -> QHBoxLayout: + row = QHBoxLayout(); row.setSpacing(5) + self._input = QLineEdit() + self._input.setPlaceholderText("Type a command or question…") + self._input.setFont(QFont("Courier New", 9)) + self._input.setFixedHeight(30) + self._input.setStyleSheet(f""" + QLineEdit {{ + background: #000d14; color: {C.WHITE}; + border: 1px solid {C.BORDER}; border-radius: 3px; padding: 3px 7px; + }} + QLineEdit:focus {{ border: 1px solid {C.PRI}; }} + """) + self._input.returnPressed.connect(self._send) + row.addWidget(self._input) + + send = QPushButton("▸") + send.setFixedSize(30, 30) + send.setFont(QFont("Courier New", 11, QFont.Weight.Bold)) + send.setCursor(Qt.CursorShape.PointingHandCursor) + send.setStyleSheet(f""" + QPushButton {{ + background: {C.PANEL}; color: {C.PRI}; + border: 1px solid {C.PRI_DIM}; border-radius: 3px; + }} + QPushButton:hover {{ background: {C.PRI_GHO}; border: 1px solid {C.PRI}; }} + """) + send.clicked.connect(self._send) + row.addWidget(send) + return row + + def _build_footer(self) -> QWidget: + w = QWidget() + w.setFixedHeight(22) + w.setStyleSheet(f"background: {C.DARK}; border-top: 1px solid {C.BORDER};") + lay = QHBoxLayout(w); lay.setContentsMargins(14, 0, 14, 0) + + def _fl(txt, color=C.TEXT_MED): + l = QLabel(txt); l.setFont(QFont("Courier New", 7)) + l.setStyleSheet(f"color: {color}; background: transparent;") + return l + + lay.addWidget(_fl("[F4] Mute · [F11] Fullscreen")) + lay.addStretch() + lay.addWidget(_fl("FatihMakes Industries · MARK XXXIX · CLASSIFIED")) + lay.addStretch() + lay.addWidget(_fl("© STARK INDUSTRIES", C.PRI_DIM)) + return w + + def _on_file_selected(self, path: str): + self._current_file = path + p = Path(path) + cat = _file_category(p) + icon, _ = _FILE_ICONS.get(cat, _FILE_ICONS["unknown"]) + size = _fmt_size(p.stat().st_size) + self._file_hint.setText(f"{icon} {p.name} · {size} · Tell JARVIS what to do with it") + self._log.append_log(f"FILE: {p.name} ({size}) loaded") + if self.on_text_command: + msg = ( + f"[FILE_UPLOADED] path={path} | name={p.name} | " + f"type={p.suffix.lstrip('.')} | size={size} | " + f"Briefly tell the user you can see the file '{p.name}' " + f"({size}) has been uploaded and ask what they'd like to do with it." + ) + threading.Thread(target=self.on_text_command, args=(msg,), daemon=True).start() + + def _toggle_mute(self): + self._muted = not self._muted + self.hud.muted = self._muted + self._style_mute_btn() + if self._muted: + self._apply_state("MUTED") + self._log.append_log("SYS: Microphone muted.") + else: + self._apply_state("LISTENING") + self._log.append_log("SYS: Microphone active.") + + def _style_mute_btn(self): + if self._muted: + self._mute_btn.setText("🔇 MICROPHONE MUTED") + self._mute_btn.setStyleSheet(f""" + QPushButton {{ + background: #140006; color: {C.MUTED_C}; + border: 1px solid {C.MUTED_C}; border-radius: 3px; + }} + """) + else: + self._mute_btn.setText("🎙 MICROPHONE ACTIVE") + self._mute_btn.setStyleSheet(f""" + QPushButton {{ + background: #00140a; color: {C.GREEN}; + border: 1px solid {C.GREEN}; border-radius: 3px; + }} + QPushButton:hover {{ background: #001f10; }} + """) + + def _send(self): + txt = self._input.text().strip() + if not txt: return + self._input.clear() + self._log.append_log(f"You: {txt}") + if self.on_text_command: + threading.Thread(target=self.on_text_command, args=(txt,), daemon=True).start() + + def _apply_state(self, state: str): + self.hud.state = state + self.hud.speaking = (state == "SPEAKING") + + def _check_config(self) -> bool: + if not API_FILE.exists(): return False + try: + d = json.loads(API_FILE.read_text(encoding="utf-8")) + return (bool(d.get("gemini_api_key")) and + bool(d.get("openrouter_api_key")) and + bool(d.get("os_system"))) + except Exception: + return False + + def _show_setup(self): + ov = SetupOverlay(self.centralWidget()) + cw = self.centralWidget() + ow, oh = 460, 430 + ov.setGeometry( + (cw.width() - ow) // 2, + (cw.height() - oh) // 2, + ow, oh, + ) + ov.done.connect(self._on_setup_done) + ov.show() + self._overlay = ov + + # Change signature: + def _on_setup_done(self, key: str, or_key: str, os_name: str): + os.makedirs(CONFIG_DIR, exist_ok=True) + API_FILE.write_text( + json.dumps({ + "gemini_api_key": key, + "openrouter_api_key": or_key, + "os_system": os_name, + }, indent=4), + encoding="utf-8", + ) + self._ready = True + if self._overlay: + self._overlay.hide() + self._overlay = None + self._apply_state("LISTENING") + self._log.append_log(f"SYS: Initialised. OS={os_name.upper()}. JARVIS online.") + +class _RootShim: + def __init__(self, app: QApplication): + self._app = app + def mainloop(self): + self._app.exec() + def protocol(self, *_): + pass class JarvisUI: - def __init__(self, face_path, size=None): - self.root = tk.Tk() - self.root.title("J.A.R.V.I.S — MARK XXXV") - self.root.resizable(False, False) + def __init__(self, face_path: str, size=None): + self._app = QApplication.instance() or QApplication(sys.argv) + self._app.setStyle("Fusion") + self._win = MainWindow(face_path) + self._win.show() + self.root = _RootShim(self._app) - sw = self.root.winfo_screenwidth() - sh = self.root.winfo_screenheight() - W = min(sw, 984) - H = min(sh, 816) - self.root.geometry(f"{W}x{H}+{(sw-W)//2}+{(sh-H)//2}") - self.root.configure(bg=C_BG) + @property + def muted(self) -> bool: + return self._win._muted - self.W = W - self.H = H + @muted.setter + def muted(self, v: bool): + if v != self._win._muted: + self._win._toggle_mute() - self.FACE_SZ = min(int(H * 0.54), 400) - self.FCX = W // 2 - self.FCY = int(H * 0.13) + self.FACE_SZ // 2 + @property + def current_file(self) -> str | None: + return self._win._drop_zone.current_file() - # ── Durum ──────────────────────────────────────────────────────────── - self.speaking = False - self.muted = False # Mute flag — main.py okur - self.scale = 1.0 - self.target_scale = 1.0 - self.halo_a = 60.0 - self.target_halo = 60.0 - self.last_t = time.time() - self.tick = 0 - self.scan_angle = 0.0 - self.scan2_angle = 180.0 - self.rings_spin = [0.0, 120.0, 240.0] - self.pulse_r = [0.0, self.FACE_SZ * 0.26, self.FACE_SZ * 0.52] - self.status_text = "INITIALISING" - self.status_blink = True + @property + def on_text_command(self): + return self._win.on_text_command - # Dışarıdan set edilebilen durum (main.py çağırır) - # Değerler: "LISTENING" | "SPEAKING" | "THINKING" | "MUTED" | "ONLINE" - self._jarvis_state = "INITIALISING" - - self.typing_queue = deque() - self.is_typing = False - - # Klavye girişinden komutu iletmek için callback — main.py atar - self.on_text_command = None - - self._face_pil = None - self._has_face = False - self._face_scale_cache = None - self._load_face(face_path) - - # ── Canvas (arka plan animasyon) ───────────────────────────────────── - self.bg = tk.Canvas(self.root, width=W, height=H, - bg=C_BG, highlightthickness=0) - self.bg.place(x=0, y=0) - - # ── Log alanı ──────────────────────────────────────────────────────── - LW = int(W * 0.72) - LH = 110 - LOG_Y = H - LH - 80 # klavye inputu için yukarı çektik - self.log_frame = tk.Frame(self.root, bg=C_PANEL, - highlightbackground=C_MID, - highlightthickness=1) - self.log_frame.place(x=(W - LW) // 2, y=LOG_Y, width=LW, height=LH) - self.log_text = tk.Text(self.log_frame, fg=C_TEXT, bg=C_PANEL, - insertbackground=C_TEXT, borderwidth=0, - wrap="word", font=("Courier", 10), padx=10, pady=6) - self.log_text.pack(fill="both", expand=True) - self.log_text.configure(state="disabled") - self.log_text.tag_config("you", foreground="#e8e8e8") - self.log_text.tag_config("ai", foreground=C_PRI) - self.log_text.tag_config("sys", foreground=C_ACC2) - self.log_text.tag_config("err", foreground=C_RED) - - # ── Klavye girişi ───────────────────────────────────────────────────── - INPUT_Y = LOG_Y + LH + 6 - self._build_input_bar(LW, INPUT_Y) - - # ── Mute butonu ─────────────────────────────────────────────────────── - self._build_mute_button() - - # ── F4 kısayolu ─────────────────────────────────────────────────────── - self.root.bind("", lambda e: self._toggle_mute()) - - # ── API key ─────────────────────────────────────────────────────────── - self._api_key_ready = self._api_keys_exist() - if not self._api_key_ready: - self._show_setup_ui() - - self._animate() - self.root.protocol("WM_DELETE_WINDOW", lambda: os._exit(0)) - - # ── Mute butonu ─────────────────────────────────────────────────────────── - - def _build_mute_button(self): - """Sol alt köşeye mute butonu yerleştirir.""" - BTN_W, BTN_H = 110, 32 - BTN_X = 18 - BTN_Y = self.H - 70 - - self._mute_canvas = tk.Canvas( - self.root, width=BTN_W, height=BTN_H, - bg=C_BG, highlightthickness=0, cursor="hand2" - ) - self._mute_canvas.place(x=BTN_X, y=BTN_Y) - self._mute_canvas.bind("", lambda e: self._toggle_mute()) - self._draw_mute_button() - - def _draw_mute_button(self): - c = self._mute_canvas - c.delete("all") - if self.muted: - border = C_MUTED - fill = "#1a0008" - icon = "🔇" - label = " MUTED" - fg = C_MUTED - else: - border = C_MID - fill = C_PANEL - icon = "🎙" - label = " LIVE" - fg = C_GREEN - - c.create_rectangle(0, 0, 110, 32, outline=border, fill=fill, width=1) - c.create_text(55, 16, text=f"{icon}{label}", - fill=fg, font=("Courier", 10, "bold")) - - def _toggle_mute(self): - self.muted = not self.muted - self._draw_mute_button() - if self.muted: - self.set_state("MUTED") - self.write_log("SYS: Microphone muted.") - else: - self.set_state("LISTENING") - self.write_log("SYS: Microphone active.") - - # ── Klavye girişi ───────────────────────────────────────────────────────── - - def _build_input_bar(self, lw: int, y: int): - """Log'un hemen altına tek satır metin giriş alanı.""" - x0 = (self.W - lw) // 2 - BTN_W = 70 - INP_W = lw - BTN_W - 4 - - self._input_var = tk.StringVar() - - self._input_entry = tk.Entry( - self.root, - textvariable=self._input_var, - fg=C_TEXT, bg="#000d12", - insertbackground=C_TEXT, - borderwidth=0, - font=("Courier", 10), - highlightthickness=1, - highlightbackground=C_DIM, - highlightcolor=C_PRI, - ) - self._input_entry.place(x=x0, y=y, width=INP_W, height=28) - self._input_entry.bind("", self._on_input_submit) - self._input_entry.bind("", self._on_input_submit) - - self._send_btn = tk.Button( - self.root, - text="SEND ▸", - command=self._on_input_submit, - fg=C_PRI, bg=C_PANEL, - activeforeground=C_BG, activebackground=C_PRI, - font=("Courier", 9, "bold"), - borderwidth=0, cursor="hand2", - highlightthickness=1, - highlightbackground=C_MID, - ) - self._send_btn.place(x=x0 + INP_W + 4, y=y, width=BTN_W, height=28) - - def _on_input_submit(self, event=None): - text = self._input_var.get().strip() - if not text: - return - self._input_var.set("") - self.write_log(f"You: {text}") - if self.on_text_command: - threading.Thread( - target=self.on_text_command, - args=(text,), - daemon=True - ).start() - - # ── Durum yönetimi ──────────────────────────────────────────────────────── + @on_text_command.setter + def on_text_command(self, cb): + self._win.on_text_command = cb def set_state(self, state: str): - """ - main.py'den çağrılır. - state: LISTENING | SPEAKING | THINKING | MUTED | ONLINE | PROCESSING - """ - self._jarvis_state = state - if state == "MUTED": - self.status_text = "MUTED" - self.speaking = False - elif state == "SPEAKING": - self.status_text = "SPEAKING" - self.speaking = True - elif state == "THINKING": - self.status_text = "THINKING" - self.speaking = False - elif state == "LISTENING": - self.status_text = "LISTENING" - self.speaking = False - elif state == "PROCESSING": - self.status_text = "PROCESSING" - self.speaking = False - else: - self.status_text = "ONLINE" - self.speaking = False - - # ── Yüz yükleme ─────────────────────────────────────────────────────────── - - def _load_face(self, path): - FW = self.FACE_SZ - try: - img = Image.open(path).convert("RGBA").resize((FW, FW), Image.LANCZOS) - mask = Image.new("L", (FW, FW), 0) - ImageDraw.Draw(mask).ellipse((2, 2, FW - 2, FW - 2), fill=255) - img.putalpha(mask) - self._face_pil = img - self._has_face = True - except Exception: - self._has_face = False - - @staticmethod - def _ac(r, g, b, a): - f = a / 255.0 - return f"#{int(r*f):02x}{int(g*f):02x}{int(b*f):02x}" - - # ── Animasyon döngüsü ───────────────────────────────────────────────────── - - def _animate(self): - self.tick += 1 - t = self.tick - now = time.time() - - if now - self.last_t > (0.14 if self.speaking else 0.55): - if self.speaking: - self.target_scale = random.uniform(1.05, 1.11) - self.target_halo = random.uniform(138, 182) - elif self.muted: - self.target_scale = random.uniform(0.998, 1.001) - self.target_halo = random.uniform(20, 32) - else: - self.target_scale = random.uniform(1.001, 1.007) - self.target_halo = random.uniform(50, 68) - self.last_t = now - - sp = 0.35 if self.speaking else 0.16 - self.scale += (self.target_scale - self.scale) * sp - self.halo_a += (self.target_halo - self.halo_a) * sp - - for i, spd in enumerate([1.2, -0.8, 1.9] if self.speaking else [0.5, -0.3, 0.82]): - self.rings_spin[i] = (self.rings_spin[i] + spd) % 360 - - self.scan_angle = (self.scan_angle + (2.8 if self.speaking else 1.2)) % 360 - self.scan2_angle = (self.scan2_angle + (-1.7 if self.speaking else -0.68)) % 360 - - pspd = 3.8 if self.speaking else 1.8 - limit = self.FACE_SZ * 0.72 - new_p = [r + pspd for r in self.pulse_r if r + pspd < limit] - if len(new_p) < 3 and random.random() < (0.06 if self.speaking else 0.022): - new_p.append(0.0) - self.pulse_r = new_p - - if t % 40 == 0: - self.status_blink = not self.status_blink - - self._draw() - self.root.after(16, self._animate) - - # ── Çizim ───────────────────────────────────────────────────────────────── - - def _draw(self): - c = self.bg - W, H = self.W, self.H - t = self.tick - FCX = self.FCX - FCY = self.FCY - FW = self.FACE_SZ - c.delete("all") - - # Arka plan grid - for x in range(0, W, 44): - for y in range(0, H, 44): - c.create_rectangle(x, y, x+1, y+1, fill=C_DIMMER, outline="") - - # Halo halkaları - for r in range(int(FW * 0.54), int(FW * 0.28), -22): - frac = 1.0 - (r - FW * 0.28) / (FW * 0.26) - ga = max(0, min(255, int(self.halo_a * 0.09 * frac))) - # Mute modda kırmızı halo - if self.muted: - gh = f"{ga:02x}" - c.create_oval(FCX-r, FCY-r, FCX+r, FCY+r, - outline=f"#{gh}0011", width=2) - else: - gh = f"{ga:02x}" - c.create_oval(FCX-r, FCY-r, FCX+r, FCY+r, - outline=f"#00{gh}ff", width=2) - - # Pulse dalgaları - for pr in self.pulse_r: - pa = max(0, int(220 * (1.0 - pr / (FW * 0.72)))) - r = int(pr) - if self.muted: - c.create_oval(FCX-r, FCY-r, FCX+r, FCY+r, - outline=self._ac(255, 30, 80, pa // 3), width=2) - else: - c.create_oval(FCX-r, FCY-r, FCX+r, FCY+r, - outline=self._ac(0, 212, 255, pa), width=2) - - # Dönen halkalar - for idx, (r_frac, w_ring, arc_l, gap) in enumerate([ - (0.47, 3, 110, 75), (0.39, 2, 75, 55), (0.31, 1, 55, 38)]): - ring_r = int(FW * r_frac) - base_a = self.rings_spin[idx] - a_val = max(0, min(255, int(self.halo_a * (1.0 - idx * 0.18)))) - col = self._ac(255, 30, 80, a_val) if self.muted else self._ac(0, 212, 255, a_val) - for s in range(360 // (arc_l + gap)): - start = (base_a + s * (arc_l + gap)) % 360 - c.create_arc(FCX-ring_r, FCY-ring_r, FCX+ring_r, FCY+ring_r, - start=start, extent=arc_l, - outline=col, width=w_ring, style="arc") - - # Tarama yayları - sr = int(FW * 0.49) - scan_a = min(255, int(self.halo_a * 1.4)) - arc_ext = 70 if self.speaking else 42 - scan_col = self._ac(255, 30, 80, scan_a) if self.muted else self._ac(0, 212, 255, scan_a) - c.create_arc(FCX-sr, FCY-sr, FCX+sr, FCY+sr, - start=self.scan_angle, extent=arc_ext, - outline=scan_col, width=3, style="arc") - c.create_arc(FCX-sr, FCY-sr, FCX+sr, FCY+sr, - start=self.scan2_angle, extent=arc_ext, - outline=self._ac(255, 100, 0, scan_a // 2), width=2, style="arc") - - # Derecelendirme işaretleri - t_out = int(FW * 0.495) - t_in = int(FW * 0.472) - a_mk = self._ac(0, 212, 255, 155) - for deg in range(0, 360, 10): - rad = math.radians(deg) - inn = t_in if deg % 30 == 0 else t_in + 5 - c.create_line(FCX + t_out * math.cos(rad), FCY - t_out * math.sin(rad), - FCX + inn * math.cos(rad), FCY - inn * math.sin(rad), - fill=a_mk, width=1) - - # Crosshair - ch_r = int(FW * 0.50) - gap = int(FW * 0.15) - ch_a = self._ac(0, 212, 255, int(self.halo_a * 0.55)) - for x1, y1, x2, y2 in [ - (FCX - ch_r, FCY, FCX - gap, FCY), (FCX + gap, FCY, FCX + ch_r, FCY), - (FCX, FCY - ch_r, FCX, FCY - gap), (FCX, FCY + gap, FCX, FCY + ch_r)]: - c.create_line(x1, y1, x2, y2, fill=ch_a, width=1) - - # Köşe braketleri - blen = 22 - bc = self._ac(0, 212, 255, 200) - hl = FCX - FW // 2; hr = FCX + FW // 2 - ht = FCY - FW // 2; hb = FCY + FW // 2 - for bx, by, sdx, sdy in [(hl, ht, 1, 1), (hr, ht, -1, 1), - (hl, hb, 1, -1), (hr, hb, -1, -1)]: - c.create_line(bx, by, bx + sdx * blen, by, fill=bc, width=2) - c.create_line(bx, by, bx, by + sdy * blen, fill=bc, width=2) - - # Yüz / orb - if self._has_face: - fw = int(FW * self.scale) - if (self._face_scale_cache is None or - abs(self._face_scale_cache[0] - self.scale) > 0.004): - scaled = self._face_pil.resize((fw, fw), Image.BILINEAR) - # Mute'ta hafif kırmızı tint - if self.muted: - tinted = scaled.copy() - r_ch, g_ch, b_ch, a_ch = tinted.split() - from PIL import ImageEnhance - g_ch = ImageEnhance.Brightness( - Image.fromarray(__import__('numpy').array(g_ch) // 2) - ).enhance(1.0) if False else g_ch # basit: sadece önce cache - tk_img = ImageTk.PhotoImage(scaled) - self._face_scale_cache = (self.scale, tk_img) - c.create_image(FCX, FCY, image=self._face_scale_cache[1]) - else: - orb_r = int(FW * 0.27 * self.scale) - orb_color = (255, 30, 80) if self.muted else (0, 65, 120) - for i in range(7, 0, -1): - r2 = int(orb_r * i / 7) - frac = i / 7 - ga = max(0, min(255, int(self.halo_a * 1.1 * frac))) - c.create_oval(FCX-r2, FCY-r2, FCX+r2, FCY+r2, - fill=self._ac(int(orb_color[0]*frac), - int(orb_color[1]*frac), - int(orb_color[2]*frac), ga), - outline="") - c.create_text(FCX, FCY, text=SYSTEM_NAME, - fill=self._ac(0, 212, 255, min(255, int(self.halo_a * 2))), - font=("Courier", 14, "bold")) - - # ── Header ──────────────────────────────────────────────────────────── - HDR = 62 - c.create_rectangle(0, 0, W, HDR, fill="#00080d", outline="") - c.create_line(0, HDR, W, HDR, fill=C_MID, width=1) - c.create_text(W // 2, 22, text=SYSTEM_NAME, - fill=C_PRI, font=("Courier", 18, "bold")) - c.create_text(W // 2, 44, text="Just A Rather Very Intelligent System", - fill=C_MID, font=("Courier", 9)) - c.create_text(16, 31, text=MODEL_BADGE, - fill=C_DIM, font=("Courier", 9), anchor="w") - c.create_text(W - 16, 31, text=time.strftime("%H:%M:%S"), - fill=C_PRI, font=("Courier", 14, "bold"), anchor="e") - - # ── Durum göstergesi ────────────────────────────────────────────────── - sy = FCY + FW // 2 + 45 - - if self.muted: - stat = "⊘ MUTED" - sc = C_MUTED - elif self.speaking: - stat = "● SPEAKING" - sc = C_ACC - elif self._jarvis_state == "THINKING": - sym = "◈" if self.status_blink else "◇" - stat = f"{sym} THINKING" - sc = C_ACC2 - elif self._jarvis_state == "PROCESSING": - sym = "▷" if self.status_blink else "▶" - stat = f"{sym} PROCESSING" - sc = C_ACC2 - elif self._jarvis_state == "LISTENING": - sym = "●" if self.status_blink else "○" - stat = f"{sym} LISTENING" - sc = C_GREEN - else: - sym = "●" if self.status_blink else "○" - stat = f"{sym} {self.status_text}" - sc = C_PRI - - c.create_text(W // 2, sy, text=stat, - fill=sc, font=("Courier", 11, "bold")) - - # ── Ses dalgası ─────────────────────────────────────────────────────── - wy = sy + 22 - N = 32 - BH = 18 - bw = 8 - total_w = N * bw - wx0 = (W - total_w) // 2 - for i in range(N): - if self.muted: - hb = 2 - col = C_MUTED - elif self.speaking: - hb = random.randint(3, BH) - col = C_PRI if hb > BH * 0.6 else C_MID - else: - hb = int(3 + 2 * math.sin(t * 0.08 + i * 0.55)) - col = C_DIM - bx = wx0 + i * bw - c.create_rectangle(bx, wy + BH - hb, bx + bw - 1, wy + BH, - fill=col, outline="") - - # ── Footer ──────────────────────────────────────────────────────────── - c.create_rectangle(0, H - 28, W, H, fill="#00080d", outline="") - c.create_line(0, H - 28, W, H - 28, fill=C_DIM, width=1) - - # F4 ipucu sağ tarafta - c.create_text(W - 16, H - 14, fill=C_DIM, font=("Courier", 8), - text="[F4] MUTE", anchor="e") - c.create_text(W // 2, H - 14, fill=C_DIM, font=("Courier", 8), - text="FatihMakes Industries · CLASSIFIED · MARK XXXV") - - # ── Log ─────────────────────────────────────────────────────────────────── + self._win._state_sig.emit(state) def write_log(self, text: str): - self.typing_queue.append(text) - tl = text.lower() - if tl.startswith("you:"): - self.set_state("PROCESSING") - elif tl.startswith("jarvis:") or tl.startswith("ai:"): - self.set_state("SPEAKING") - if not self.is_typing: - self._start_typing() + self._win._log_sig.emit(text) - def _start_typing(self): - if not self.typing_queue: - self.is_typing = False - if not self.speaking and not self.muted: - self.set_state("LISTENING") - return - self.is_typing = True - text = self.typing_queue.popleft() - tl = text.lower() - if tl.startswith("you:"): - tag = "you" - elif tl.startswith("jarvis:") or tl.startswith("ai:"): - tag = "ai" - elif tl.startswith("err:") or "error" in tl or "failed" in tl: - tag = "err" - else: - tag = "sys" - self.log_text.configure(state="normal") - self._type_char(text, 0, tag) - - def _type_char(self, text, i, tag): - if i < len(text): - self.log_text.insert(tk.END, text[i], tag) - self.log_text.see(tk.END) - self.root.after(8, self._type_char, text, i + 1, tag) - else: - self.log_text.insert(tk.END, "\n") - self.log_text.configure(state="disabled") - self.root.after(25, self._start_typing) - - # ── Eski compat metotlar (main.py hâlâ bunları çağırabilir) ────────────── + def wait_for_api_key(self): + while not self._win._ready: + time.sleep(0.1) def start_speaking(self): self.set_state("SPEAKING") def stop_speaking(self): if not self.muted: - self.set_state("LISTENING") - - # ── API key ─────────────────────────────────────────────────────────────── - - def _api_keys_exist(self): - return API_FILE.exists() - - def wait_for_api_key(self): - while not self._api_key_ready: - time.sleep(0.1) - - def _show_setup_ui(self): - self.setup_frame = tk.Frame( - self.root, bg="#00080d", - highlightbackground=C_PRI, highlightthickness=1 - ) - self.setup_frame.place(relx=0.5, rely=0.5, anchor="center") - - tk.Label(self.setup_frame, text="◈ INITIALISATION REQUIRED", - fg=C_PRI, bg="#00080d", font=("Courier", 13, "bold")).pack(pady=(18, 4)) - tk.Label(self.setup_frame, - text="Enter your Gemini API key to boot J.A.R.V.I.S.", - fg=C_MID, bg="#00080d", font=("Courier", 9)).pack(pady=(0, 10)) - - tk.Label(self.setup_frame, text="GEMINI API KEY", - fg=C_DIM, bg="#00080d", font=("Courier", 9)).pack(pady=(8, 2)) - self.gemini_entry = tk.Entry( - self.setup_frame, width=52, fg=C_TEXT, bg="#000d12", - insertbackground=C_TEXT, borderwidth=0, font=("Courier", 10), show="*" - ) - self.gemini_entry.pack(pady=(0, 4)) - - tk.Button( - self.setup_frame, text="▸ INITIALISE SYSTEMS", - command=self._save_api_keys, bg=C_BG, fg=C_PRI, - activebackground="#003344", font=("Courier", 10), - borderwidth=0, pady=8 - ).pack(pady=14) - - def _save_api_keys(self): - gemini = self.gemini_entry.get().strip() - if not gemini: - return - os.makedirs(CONFIG_DIR, exist_ok=True) - with open(API_FILE, "w", encoding="utf-8") as f: - json.dump({"gemini_api_key": gemini}, f, indent=4) - self.setup_frame.destroy() - self._api_key_ready = True - self.set_state("LISTENING") - self.write_log("SYS: Systems initialised. JARVIS online.") + self.set_state("LISTENING") \ No newline at end of file diff --git a/weather_report.py b/weather_report.py new file mode 100644 index 0000000..1fd9bf6 --- /dev/null +++ b/weather_report.py @@ -0,0 +1,215 @@ +# actions/send_message.py +# Universal messaging — WhatsApp & Instagram +# Uses visual element detection (pyautogui + screen search) instead of +# hardcoded tab/click sequences — works on any screen resolution. + +import time +import pyautogui +from pathlib import Path + +pyautogui.FAILSAFE = True +pyautogui.PAUSE = 0.08 + +def _open_app(app_name: str) -> bool: + """Opens an app via Windows search.""" + try: + pyautogui.press("win") + time.sleep(0.4) + pyautogui.write(app_name, interval=0.04) + time.sleep(0.5) + pyautogui.press("enter") + time.sleep(2.0) + return True + except Exception as e: + print(f"[SendMessage] Could not open {app_name}: {e}") + return False + + +def _search_contact(contact: str, platform: str): + """ + Searches for a contact inside the messaging app. + Uses Ctrl+F (universal search shortcut) then types contact name. + """ + time.sleep(0.5) + pyautogui.hotkey("ctrl", "f") + time.sleep(0.4) + pyautogui.hotkey("ctrl", "a") + pyautogui.write(contact, interval=0.04) + time.sleep(0.8) + pyautogui.press("enter") + time.sleep(0.6) + + +def _type_and_send(message: str): + """Types message and sends it.""" + pyautogui.press("tab") + time.sleep(0.2) + pyautogui.hotkey("ctrl", "a") + pyautogui.write(message, interval=0.03) + time.sleep(0.2) + pyautogui.press("enter") + time.sleep(0.3) + + +def _send_whatsapp(receiver: str, message: str) -> str: + """ + Sends a WhatsApp message via the Windows desktop app. + Steps: Open WhatsApp → Search contact → Click → Type → Send + """ + try: + if not _open_app("WhatsApp"): + return "Could not open WhatsApp." + + time.sleep(1.5) + + pyautogui.hotkey("ctrl", "f") + time.sleep(0.4) + pyautogui.hotkey("ctrl", "a") + pyautogui.write(receiver, interval=0.04) + time.sleep(1.0) + + pyautogui.press("enter") + time.sleep(0.8) + + pyautogui.write(message, interval=0.03) + time.sleep(0.2) + pyautogui.press("enter") + + return f"Message sent to {receiver} via WhatsApp." + + except Exception as e: + return f"WhatsApp error: {e}" + + +def _send_instagram(receiver: str, message: str) -> str: + """ + Sends an Instagram DM via browser (instagram.com). + Steps: Open Chrome → Go to instagram.com/direct → Search contact → Send + """ + try: + import webbrowser + + webbrowser.open("https://www.instagram.com/direct/new/") + time.sleep(3.5) + + pyautogui.write(receiver, interval=0.05) + time.sleep(1.5) + + pyautogui.press("down") + time.sleep(0.3) + pyautogui.press("enter") + time.sleep(0.5) + + for _ in range(3): + pyautogui.press("tab") + time.sleep(0.1) + pyautogui.press("enter") + time.sleep(1.5) + + pyautogui.write(message, interval=0.04) + time.sleep(0.2) + pyautogui.press("enter") + + return f"Message sent to {receiver} via Instagram." + + except Exception as e: + return f"Instagram error: {e}" + +def _send_telegram(receiver: str, message: str) -> str: + """Sends a Telegram message via Windows desktop app.""" + try: + if not _open_app("Telegram"): + return "Could not open Telegram." + + time.sleep(1.5) + + pyautogui.hotkey("ctrl", "f") + time.sleep(0.4) + pyautogui.write(receiver, interval=0.04) + time.sleep(1.0) + pyautogui.press("enter") + time.sleep(0.8) + + pyautogui.write(message, interval=0.03) + time.sleep(0.2) + pyautogui.press("enter") + + return f"Message sent to {receiver} via Telegram." + + except Exception as e: + return f"Telegram error: {e}" + + + +def _send_generic(platform: str, receiver: str, message: str) -> str: + """ + For any other platform not explicitly supported. + Opens the app, searches for contact, types and sends. + Works for: Messenger, Discord, Signal, etc. + """ + try: + if not _open_app(platform): + return f"Could not open {platform}." + + time.sleep(1.5) + pyautogui.hotkey("ctrl", "f") + time.sleep(0.4) + pyautogui.write(receiver, interval=0.04) + time.sleep(1.0) + pyautogui.press("enter") + time.sleep(0.8) + pyautogui.write(message, interval=0.03) + time.sleep(0.2) + pyautogui.press("enter") + + return f"Message sent to {receiver} via {platform}." + + except Exception as e: + return f"{platform} error: {e}" + +def send_message( + parameters: dict, + response=None, + player=None, + session_memory=None +) -> str: + """ + Called from main.py. + + parameters: + receiver : Contact name to send to + message_text : The message content + platform : whatsapp | instagram | telegram | + Default: whatsapp + """ + params = parameters or {} + receiver = params.get("receiver", "").strip() + message_text = params.get("message_text", "").strip() + platform = params.get("platform", "whatsapp").strip().lower() + + if not receiver: + return "Please specify who to send the message to, sir." + if not message_text: + return "Please specify what message to send, sir." + + print(f"[SendMessage] 📨 {platform} → {receiver}: {message_text[:40]}") + if player: + player.write_log(f"[msg] Sending to {receiver} via {platform}...") + + if "whatsapp" in platform or "wp" in platform or "wapp" in platform: + result = _send_whatsapp(receiver, message_text) + + elif "instagram" in platform or "ig" in platform or "insta" in platform: + result = _send_instagram(receiver, message_text) + + elif "telegram" in platform or "tg" in platform: + result = _send_telegram(receiver, message_text) + + else: + result = _send_generic(platform, receiver, message_text) + + print(f"[SendMessage] ✅ {result}") + if player: + player.write_log(f"[msg] {result}") + + return result \ No newline at end of file diff --git a/web_search.py b/web_search.py new file mode 100644 index 0000000..2ac70a2 --- /dev/null +++ b/web_search.py @@ -0,0 +1,62 @@ +# actions/weather_report.py + +import webbrowser +from urllib.parse import quote_plus + + +def weather_action( + parameters: dict, + player=None, + session_memory=None +): + """ + Weather report action. + Opens a Google weather search and gives a short spoken confirmation. + """ + + city = parameters.get("city") + time = parameters.get("time") + if not city or not isinstance(city, str): + msg = "Sir, the city is missing for the weather report." + _speak_and_log(msg, player) + return msg + + city = city.strip() + + if not time or not isinstance(time, str): + time = "today" + else: + time = time.strip() + + search_query = f"weather in {city} {time}" + encoded_query = quote_plus(search_query) + url = f"https://www.google.com/search?q={encoded_query}" + + try: + webbrowser.open(url) + except Exception: + msg = f"Sir, I couldn't open the browser for the weather report." + _speak_and_log(msg, player) + return msg + + msg = f"Showing the weather for {city}, {time}, sir." + _speak_and_log(msg, player) + + if session_memory: + try: + session_memory.set_last_search( + query=search_query, + response=msg + ) + except Exception: + pass + + return msg + + +def _speak_and_log(message: str, player=None): + if player: + try: + player.write_log(f"JARVIS: {message}") + except Exception: + pass \ No newline at end of file diff --git a/youtube_video.py b/youtube_video.py new file mode 100644 index 0000000..56ebe1c --- /dev/null +++ b/youtube_video.py @@ -0,0 +1,137 @@ +#web_search.py +import json +import sys +from pathlib import Path + +def _get_base_dir() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).resolve().parent.parent + + +BASE_DIR = _get_base_dir() +API_CONFIG_PATH = BASE_DIR / "config" / "api_keys.json" + + +def _get_api_key() -> str: + with open(API_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f)["gemini_api_key"] + + +def _gemini_search(query: str) -> str: + from google import genai + + client = genai.Client(api_key=_get_api_key()) + response = client.models.generate_content( + model="gemini-2.5-flash", + contents=query, + config={"tools": [{"google_search": {}}]}, + ) + + text = "" + for part in response.candidates[0].content.parts: + if hasattr(part, "text") and part.text: + text += part.text + + text = text.strip() + if not text: + raise ValueError("Gemini returned an empty response.") + return text + + +def _ddg_search(query: str, max_results: int = 6) -> list[dict]: + try: + from ddgs import DDGS + except ImportError: + from duckduckgo_search import DDGS + + results = [] + with DDGS() as ddgs: + for r in ddgs.text(query, max_results=max_results): + results.append({ + "title": r.get("title", ""), + "snippet": r.get("body", ""), + "url": r.get("href", ""), + }) + return results + + +def _format_ddg(query: str, results: list[dict]) -> str: + if not results: + return f"No results found for: {query}" + + lines = [f"Search results for: {query}\n"] + for i, r in enumerate(results, 1): + if r.get("title"): lines.append(f"{i}. {r['title']}") + if r.get("snippet"): lines.append(f" {r['snippet']}") + if r.get("url"): lines.append(f" {r['url']}") + lines.append("") + return "\n".join(lines).strip() + +def _compare(items: list[str], aspect: str) -> str: + query = ( + f"Compare {', '.join(items)} in terms of {aspect}. " + "Give specific facts and data." + ) + try: + return _gemini_search(query) + except Exception as e: + print(f"[WebSearch] ⚠️ Gemini compare failed: {e} — falling back to DDG") + + # DDG fallback: fetch results per item and merge + all_results: dict[str, list] = {} + for item in items: + try: + all_results[item] = _ddg_search(f"{item} {aspect}", max_results=3) + except Exception: + all_results[item] = [] + + lines = [f"Comparison — {aspect.upper()}", "─" * 40] + for item in items: + lines.append(f"\n▸ {item}") + for r in all_results.get(item, [])[:2]: + if r.get("snippet"): + lines.append(f" • {r['snippet']}") + return "\n".join(lines) + +def web_search( + parameters: dict, + response=None, + player=None, + session_memory=None, +) -> str: + params = parameters or {} + query = params.get("query", "").strip() + mode = params.get("mode", "search").lower().strip() + items = params.get("items", []) + aspect = params.get("aspect", "general").strip() or "general" + + if not query and not items: + return "Please provide a search query, sir." + + if items and mode != "compare": + mode = "compare" + + if player: + player.write_log(f"[Search] {query or ', '.join(items)}") + + print(f"[WebSearch] 🔍 Query: {query!r} Mode: {mode}") +# replace: result = _gemini_search(query) block with: + try: + from or_client import client + result = client.chat( + query, + system="You are a web search assistant. Answer factually and concisely." + ) + print("[WebSearch] ✅ OpenRouter OK.") + return result + except Exception as e: + print(f"[WebSearch] ⚠️ OpenRouter failed ({e}) — trying DDG...") + results = _ddg_search(query) + result = _format_ddg(query, results) + print(f"[WebSearch] ✅ DDG: {len(results)} result(s).") + return result + + except Exception as e: + print(f"[WebSearch] ❌ All backends failed: {e}") + return f"Search failed, sir: {e}" \ No newline at end of file