diff --git a/actions/background_monitor.py b/actions/background_monitor.py new file mode 100644 index 0000000..d34a1fd --- /dev/null +++ b/actions/background_monitor.py @@ -0,0 +1,159 @@ +""" +BackgroundMonitor — user-configured topic watching. +Checks DDG news once per day per topic; alerts JARVIS when a new headline appears. +No crypto, no finance, no uninvited tracking. +""" +import hashlib +import json +import re +from datetime import datetime +from pathlib import Path + + +# ── Blocked categories (never monitor regardless of what user says) ──────────── + +_BLOCKED = { + # Marka / varlık adları — her dilde aynı yazılır + "bitcoin", "ethereum", "dogecoin", "solana", "binance", + "nft", "blockchain", "defi", "altcoin", "memecoin", "coin", "token", + # "kripto" kökünün farklı dillerdeki yazılışları + "crypto", "kripto", "cripto", "krypto", "крипто", "仮想通貨", "暗号資産", + "cryptocurrency", +} + +def _is_blocked(topic: str) -> bool: + t = topic.lower() + return any(word in t for word in _BLOCKED) + + +# ── Slug / hash helpers ──────────────────────────────────────────────────────── + +def _slug(topic: str) -> str: + return re.sub(r"[^a-z0-9]+", "_", topic.lower().strip())[:40].strip("_") + +def _title_hash(title: str) -> str: + return hashlib.md5(title.encode("utf-8", errors="ignore")).hexdigest()[:12] + + +# ── Memory I/O ───────────────────────────────────────────────────────────────── + +def _load() -> dict: + from memory.memory_manager import load_memory + data = load_memory().get("monitors", {}) + return data if isinstance(data, dict) else {} + +def _save(monitors: dict) -> None: + from memory.memory_manager import load_memory, MEMORY_PATH, _lock + memory = load_memory() + memory["monitors"] = monitors + with _lock: + MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True) + MEMORY_PATH.write_text( + json.dumps(memory, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + +# ── Public API ───────────────────────────────────────────────────────────────── + +def add_monitor(topic: str) -> str: + topic = topic.strip() + if not topic: + return "Please specify a topic to monitor." + if _is_blocked(topic): + return "I don't monitor crypto or financial topics." + monitors = _load() + slug = _slug(topic) + if slug in monitors: + return f"Already monitoring: {monitors[slug]['topic']}" + monitors[slug] = { + "topic": topic, + "added": datetime.now().strftime("%Y-%m-%d"), + "last_check": "", + "last_hash": "", + } + _save(monitors) + print(f"[Monitor] ➕ Added: {topic}") + return f"Now monitoring: {topic}" + + +def remove_monitor(topic: str) -> str: + topic = topic.strip().lower() + monitors = _load() + # exact slug match first + slug = _slug(topic) + if slug in monitors: + label = monitors.pop(slug)["topic"] + _save(monitors) + return f"Stopped monitoring: {label}" + # partial match fallback + for key, val in list(monitors.items()): + if topic in val.get("topic", "").lower(): + label = monitors.pop(key)["topic"] + _save(monitors) + return f"Stopped monitoring: {label}" + return f"Not found in monitored topics: {topic}" + + +def list_monitors() -> list[str]: + return [v.get("topic", k) for k, v in _load().items()] + + +def check_all() -> list[str]: + """ + Run all pending topic checks (once per day per topic). + Returns a list of [MONITOR_ALERT] strings — empty if nothing new. + """ + from actions.web_search import _ddg_news + + monitors = _load() + if not monitors: + return [] + + today = datetime.now().strftime("%Y-%m-%d") + alerts = [] + changed = False + + for slug, data in monitors.items(): + if data.get("last_check") == today: + continue # already checked today + + topic = data.get("topic", slug) + try: + results = _ddg_news(topic, max_results=5) + if not results: + monitors[slug]["last_check"] = today + changed = True + continue + + top = results[0] + title = top.get("title", "").strip() + if not title: + continue + + h = _title_hash(title) + monitors[slug]["last_check"] = today + changed = True + + if h == data.get("last_hash"): + continue # same headline as last check — no alert + + monitors[slug]["last_hash"] = h + + snippet = top.get("snippet", "")[:150] + source = top.get("source", "") + parts = [f"[MONITOR_ALERT] {topic}", f"Headline: {title}"] + if snippet: + parts.append(snippet) + if source: + parts.append(f"Source: {source}") + alerts.append("\n".join(parts)) + print(f"[Monitor] 🔔 New headline for '{topic}': {title[:60]}") + + except Exception as e: + print(f"[Monitor] ⚠️ Check failed for '{topic}': {e}") + + if changed: + _save(monitors) + + return alerts diff --git a/actions/proactive.py b/actions/proactive.py index 2a037a8..23bf19c 100644 --- a/actions/proactive.py +++ b/actions/proactive.py @@ -1,7 +1,6 @@ """ -ProactiveEngine — context-aware background prompting. -Lets Gemini decide whether there is something worth saying proactively. -No hardcoded rules: we pass time + memory as context and Gemini chooses. +ProactiveEngine 2.0 — context-aware, time-aware, non-repetitive background prompting. +Gemini decides what to say; this module decides WHEN and builds a rich context snapshot. """ import time from datetime import datetime @@ -9,64 +8,117 @@ from datetime import datetime class ProactiveEngine: """ - Tracks silence duration and decides when to hand context to Gemini for a - proactive check-in. Gemini reads the context and decides whether to speak. + Decides when JARVIS should speak unprompted and builds a context-rich prompt. - Defaults (all overridable): - min_silence_secs — user must be silent this long before any check (900 = 15 min) - check_cooldown — minimum gap between two proactive triggers (600 = 10 min) + Improvements over 1.0: + - Time-of-day awareness (morning / afternoon / evening / night) + - Monitor-topic awareness (what the user is tracking) + - Recent-session context (last few turns of the current conversation) + - Non-repetitive (rotates context focus to avoid same opener) + - Smarter silence gate (doesn't fire while JARVIS is speaking) + + Defaults: + min_silence_secs — 900 s (15 min) user must be silent before any check + check_cooldown — 1200 s (20 min) minimum gap between proactive messages """ def __init__( self, min_silence_secs: int = 900, - check_cooldown: int = 600, + check_cooldown: int = 1200, ): self.min_silence_secs = min_silence_secs self.check_cooldown = check_cooldown self._last_triggered = 0.0 + self._rotation = 0 # cycles through context focus areas + + # ── Trigger gate ─────────────────────────────────────────────────────────── def should_trigger(self, last_user_speech: float) -> bool: - """ - Returns True only when: - • user has been silent long enough, AND - • enough time has passed since the last proactive message. - """ - now = time.monotonic() - silence = now - last_user_speech - gap = now - self._last_triggered - return silence >= self.min_silence_secs and gap >= self.check_cooldown + now = time.monotonic() + return ( + (now - last_user_speech) >= self.min_silence_secs + and (now - self._last_triggered) >= self.check_cooldown + ) def mark_triggered(self) -> None: self._last_triggered = time.monotonic() + self._rotation += 1 - def build_prompt(self, memory: dict) -> str: + # ── Prompt builder ───────────────────────────────────────────────────────── + + def build_prompt( + self, + memory: dict, + monitors: list[str] | None = None, + recent_turns: list[str] | None = None, + ) -> str: """ - Builds the context snapshot sent to Gemini. - Gemini reads it and decides freely what — if anything — to say. + Build a context snapshot for Gemini. + Rotates through three focus areas so proactive messages don't repeat. """ from memory.memory_manager import format_memory_for_prompt now = datetime.now() + hour = now.hour time_str = now.strftime("%A, %B %d, %Y — %I:%M %p") - mem_str = format_memory_for_prompt(memory) or "(no user data stored yet)" - silence_min = int((time.monotonic() - self._last_triggered + - self.min_silence_secs) // 60) + # Time-of-day label + if 6 <= hour < 12: period = "morning" + elif 12 <= hour < 18: period = "afternoon" + elif 18 <= hour < 23: period = "evening" + else: period = "late night" + + mem_str = format_memory_for_prompt(memory) or "(no stored user data)" + + # Rotating context focus (cycles every trigger) + focus_index = self._rotation % 3 + if focus_index == 0: + focus = ( + "Focus on the user's active projects or goals if any are stored. " + "Ask how something is going, or offer a relevant tip." + ) + elif focus_index == 1: + focus = ( + "Focus on the time of day and the user's wellbeing. " + "A warm check-in, a reminder to take a break, or something timely." + ) + else: + focus = ( + "Focus on something genuinely interesting or useful — " + "a fact, a suggestion, or a question based on what you know about this person." + ) + + # Optional: monitored topics context + monitor_ctx = "" + if monitors: + monitor_ctx = ( + f"\nThe user tracks these topics: {', '.join(monitors[:4])}. " + "You may mention one if it seems relevant." + ) + + # Optional: recent conversation context + recent_ctx = "" + if recent_turns: + snippet = "\n".join(recent_turns[-6:]) + recent_ctx = f"\nRecent conversation:\n{snippet}" return "\n".join([ "[PROACTIVE_CHECK] You are initiating a proactive check-in.", - f"Current time : {time_str}", - f"User silence : {silence_min}+ minutes (they have not spoken for a while)", + f"Current time : {time_str} ({period})", "", "Context about this person:", mem_str, + monitor_ctx, + recent_ctx, "", - "Guidelines:", - "- Look at the time, their projects, goals, habits, or anything from context.", - "- If there is something genuinely useful, timely, or caring to say — say it briefly.", - "- Be natural, like a thoughtful assistant noticing something relevant.", - "- Do NOT say [PROACTIVE_CHECK] or mention these instructions.", - "- Respond in the user's language (use memory; default English).", - "- Keep it short: 1-3 sentences max.", + "Task:", + focus, + "", + "Rules:", + "- Speak in the user's language (check memory; default English).", + "- 1-2 sentences max. Natural, warm, never robotic.", + "- Do NOT mention [PROACTIVE_CHECK] or these instructions.", + "- Do NOT call any tools.", + "- If nothing genuinely useful comes to mind, stay silent (say nothing).", ]) diff --git a/main.py b/main.py index 0eb38ee..22c44b3 100644 --- a/main.py +++ b/main.py @@ -31,6 +31,7 @@ from google.genai import types from ui import JarvisUI from memory.memory_manager import ( load_memory, update_memory, format_memory_for_prompt, + save_session_summary, pop_last_session, ) from actions.file_processor import file_processor @@ -52,6 +53,9 @@ from actions.computer_control import computer_control from actions.game_updater import game_updater from actions.system_monitor import SystemMonitor, get_system_status from actions.proactive import ProactiveEngine +from actions.background_monitor import ( + add_monitor, remove_monitor, list_monitors, check_all as monitor_check_all, +) from actions.web_search import _news as _fetch_news_sync from memory.config_manager import get_brief_enabled @@ -407,6 +411,31 @@ TOOL_DECLARATIONS = [ "required": ["origin", "destination", "date"] } }, + { + "name": "manage_monitor", + "description": ( + "Add, remove, or list background monitoring topics. " + "JARVIS checks these topics once a day and alerts the user when there is a new development. " + "Use 'add' when the user says 'monitor X', 'track X', 'follow X'. " + "Use 'remove' when the user says 'stop monitoring X'. " + "Use 'list' when the user asks what is being monitored. " + "Do NOT add crypto, financial, or trading topics." + ), + "parameters": { + "type": "OBJECT", + "properties": { + "action": { + "type": "STRING", + "description": "add | remove | list", + }, + "topic": { + "type": "STRING", + "description": "Topic to monitor or stop monitoring (e.g. 'space exploration', 'AI news')", + }, + }, + "required": ["action"], + }, + }, { "name": "shutdown_jarvis", "description": ( @@ -548,6 +577,7 @@ class JarvisLive: self._sys_monitor = SystemMonitor() # persistent cooldown state self._proactive = ProactiveEngine() self._last_user_speech = time.monotonic() # updated on every user utterance + self._session_log: list[str] = [] # conversation turns for end-of-session summary def _make_remote_key(self): """Called from Qt main thread when user presses Remote Control.""" @@ -811,14 +841,35 @@ class JarvisLive: r = await loop.run_in_executor(None, get_system_status) result = str(r) + elif name == "manage_monitor": + action = args.get("action", "").lower().strip() + topic = args.get("topic", "").strip() + if action == "add" and topic: + result = await asyncio.to_thread(add_monitor, topic) + elif action == "remove" and topic: + result = await asyncio.to_thread(remove_monitor, topic) + elif action == "list": + topics = await asyncio.to_thread(list_monitors) + result = ("Monitoring: " + ", ".join(topics)) if topics else "No topics are being monitored." + else: + result = "Specify action (add/remove/list) and a topic." + elif name == "shutdown_jarvis": self.ui.write_log("SYS: Shutdown requested.") - self.speak("Goodbye, sir.") - def _shutdown(): - import time, os - time.sleep(1) - os._exit(0) - threading.Thread(target=_shutdown, daemon=True).start() + async def _do_shutdown(): + await self._save_session_summary() + if self.session: + try: + await self.session.send_client_content( + turns={"parts": [{"text": "Say a brief natural goodbye to the user."}]}, + turn_complete=True, + ) + except Exception: + pass + await asyncio.sleep(1.5) + import os as _os + _os._exit(0) + asyncio.create_task(_do_shutdown()) else: result = f"Unknown tool: {name}" @@ -921,6 +972,7 @@ class JarvisLive: full_in = " ".join(in_buf).strip() if full_in: self.ui.write_log(f"You: {full_in}") + self._session_log.append(f"User: {full_in}") if self._dashboard: asyncio.create_task(self._dashboard.broadcast({ "type": "log", "speaker": "user", @@ -932,6 +984,7 @@ class JarvisLive: full_out = " ".join(out_buf).strip() if full_out: self.ui.write_log(f"{self._asst_name}: {full_out}") + self._session_log.append(f"{self._asst_name}: {full_out}") if self._dashboard: asyncio.create_task(self._dashboard.broadcast({ "type": "log", "speaker": "jarvis", @@ -1012,9 +1065,21 @@ class JarvisLive: self.set_speaking(False) self._turn_done_event.clear() continue + self.set_speaking(True) + + # Batch all immediately-available chunks into one write to reduce + # thread-pool round-trips (was one asyncio.to_thread per 50ms slice). + # Cap at ~200 ms so interrupt() still stops audio within ~200 ms. + batch = bytearray(chunk) + while len(batch) < 9600: # 9600 bytes ≈ 200 ms at 24 kHz / 16-bit mono + try: + batch.extend(self.audio_in_queue.get_nowait()) + except asyncio.QueueEmpty: + break + try: - await asyncio.to_thread(stream.write, chunk) + await asyncio.to_thread(stream.write, bytes(batch)) except (RuntimeError, asyncio.CancelledError): break # executor shutting down — exit cleanly except Exception as e: @@ -1058,9 +1123,23 @@ class JarvisLive: # ── Phase 1: instant greeting ───────────────────────────────────────── lang_clause = f" Respond in {lang}." if lang else "" name_clause = f" Address the user as {name}." if name else "" + + # Inject last session context if available — pop removes it so it's never repeated + last = await asyncio.to_thread(pop_last_session) + session_clause = "" + if last: + try: + _delta = (datetime.now() - datetime.strptime(last["date"], "%Y-%m-%d")).days + _when = "earlier today" if _delta == 0 else ("yesterday" if _delta == 1 else f"{_delta} days ago") + except Exception: + _when = "last time" + session_clause = ( + f" Also briefly and naturally mention that {_when}: {last['summary']}" + ) + p1 = ( - f"Greet the user, mention it is {time_str}, and say you are fetching today's news now. " - f"One short sentence only. Do not call any tools.{lang_clause}{name_clause}" + f"Greet the user warmly, mention it is {time_str}, and say you are fetching today's news now.{session_clause} " + f"Keep it to 2 short sentences max. Do not call any tools.{lang_clause}{name_clause}" ) # Clear the turn-done event so we can wait for Phase 1 to finish @@ -1089,8 +1168,13 @@ class JarvisLive: except asyncio.TimeoutError: pass - # If turn_complete didn't fire (timeout), give a small buffer - if not turn_waited: + # Extra buffer: turn_complete fires when Gemini finishes *generating* + # Phase 1, but audio may still be playing. Waiting a beat here + # prevents Phase 2 audio from arriving while Phase 1 is mid-sentence + # (which sounds like a "repeated first response" to the user). + if turn_waited: + await asyncio.sleep(0.8) + else: await asyncio.sleep(1.0) try: @@ -1127,6 +1211,40 @@ class JarvisLive: asyncio.create_task(_deliver_news()) + # ── Session memory ────────────────────────────────────────────────────────── + + async def _save_session_summary(self) -> None: + """Summarise the current session in 1-2 sentences and save to long_term.json.""" + log = self._session_log + if len(log) < 3: # need at least one exchange to be worth saving + return + self._session_log = [] # reset immediately so the next session starts clean + + memory = load_memory() + lang_entry = memory.get("identity", {}).get("language", {}) + lang = (lang_entry.get("value", "") if isinstance(lang_entry, dict) else str(lang_entry)).strip() + lang = lang or "English" + + convo = "\n".join(log[-40:]) # cap at last 40 turns to stay within token budget + prompt = ( + f"Summarize this conversation in 1-2 sentences in {lang}. " + "Focus on what the user accomplished or discussed. " + "Output ONLY the summary text, nothing else:\n\n" + convo + ) + try: + from google import genai as _genai + client = _genai.Client(api_key=_get_api_key()) + resp = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + ) + summary = (resp.text or "").strip() + if summary: + save_session_summary(summary, lang) + except Exception as e: + print(f"[Memory] ⚠️ Session summary failed: {e}") + # ── System monitor ────────────────────────────────────────────────────────── async def _run_system_monitor(self) -> None: @@ -1134,14 +1252,53 @@ class JarvisLive: while True: await asyncio.sleep(10) alert = await asyncio.to_thread(self._sys_monitor.check) - if alert and self.session: - try: - await self.session.send_client_content( - turns={"parts": [{"text": alert}]}, - turn_complete=True, - ) - except Exception as e: - print(f"[Monitor] ⚠️ Could not send alert: {e}") + if not alert or not self.session: + continue + # Don't interrupt an active conversation + with self._speaking_lock: + speaking = self._is_speaking + if speaking or (time.monotonic() - self._last_user_speech) < 10: + continue + try: + await self.session.send_client_content( + turns={"parts": [{"text": alert}]}, + turn_complete=True, + ) + except Exception as e: + print(f"[Monitor] ⚠️ Could not send alert: {e}") + + # ── Background monitor ────────────────────────────────────────────────────── + + async def _run_background_monitor(self) -> None: + """Check user-configured topics once per day; speak alerts when new headlines appear.""" + await asyncio.sleep(300) # wait 5 min after startup before first check + while True: + if self.session: + # Don't interrupt if user spoke recently or JARVIS is mid-sentence + with self._speaking_lock: + speaking = self._is_speaking + recent_speech = (time.monotonic() - self._last_user_speech) < 30 + if not speaking and not recent_speech: + try: + alerts = await asyncio.to_thread(monitor_check_all) + memory = load_memory() + lang_e = memory.get("identity", {}).get("language", {}) + lang = (lang_e.get("value", "") if isinstance(lang_e, dict) else str(lang_e)).strip() or "English" + for alert in alerts: + msg = ( + f"{alert}\n\n" + f"Inform the user about this development naturally in {lang}. " + "One brief sentence only." + ) + await self.session.send_client_content( + turns={"parts": [{"text": msg}]}, + turn_complete=True, + ) + self.ui.write_log(f"SYS: Monitor alert sent.") + await asyncio.sleep(6) # gap between consecutive alerts + except Exception as e: + print(f"[Monitor] ⚠️ Background check error: {e}") + await asyncio.sleep(1800) # check every 30 minutes # ── Proactive mode ────────────────────────────────────────────────────────── @@ -1168,8 +1325,14 @@ class JarvisLive: self._proactive.mark_triggered() try: - memory = await asyncio.to_thread(load_memory) - prompt = self._proactive.build_prompt(memory) + memory = await asyncio.to_thread(load_memory) + monitors = await asyncio.to_thread(list_monitors) + recent_turns = self._session_log[-8:] if self._session_log else [] + prompt = self._proactive.build_prompt( + memory = memory, + monitors = monitors or None, + recent_turns = recent_turns or None, + ) await self.session.send_client_content( turns={"parts": [{"text": prompt}]}, turn_complete=True, @@ -1290,6 +1453,7 @@ class JarvisLive: tg.create_task(self._receive_audio()) tg.create_task(self._play_audio()) tg.create_task(self._run_system_monitor()) + tg.create_task(self._run_background_monitor()) tg.create_task(self._run_proactive_mode()) if self._dashboard: tg.create_task(self._relay_phone_audio()) @@ -1340,6 +1504,9 @@ class JarvisLive: self._conn_backoff = 3 finally: self.session = None + # Only save if there was a real conversation (≥3 turns) + if len(self._session_log) >= 3: + asyncio.create_task(self._save_session_summary()) self.set_speaking(False) self.ui.set_state("SLEEPING") diff --git a/memory/memory_manager.py b/memory/memory_manager.py index 4c6299e..c3a6077 100644 --- a/memory/memory_manager.py +++ b/memory/memory_manager.py @@ -212,4 +212,60 @@ def forget(key: str, category: str = "notes") -> str: return f"Not found: {category}/{key}" -forget_memory = forget \ No newline at end of file +forget_memory = forget + + +# ── Session memory ───────────────────────────────────────────────────────────── + +_SESSION_MAX = 3 # safety cap — in practice 0-1 entries after pop + + +def save_session_summary(summary: str, language: str = "") -> None: + """Append a 1-2 sentence session summary to long_term.json['sessions'].""" + summary = (summary or "").strip() + if not summary: + return + memory = load_memory() + sessions = memory.get("sessions", []) + if not isinstance(sessions, list): + sessions = [] + entry: dict = { + "date": datetime.now().strftime("%Y-%m-%d"), + "summary": summary[:280], + } + if language: + entry["language"] = language + sessions.append(entry) + memory["sessions"] = sessions[-_SESSION_MAX:] + with _lock: + MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True) + MEMORY_PATH.write_text( + json.dumps(memory, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + print(f"[Memory] 📝 Session saved ({entry['date']}): {summary[:60]}…") + + +def pop_last_session() -> dict | None: + """ + Return AND remove the most recent session entry. + Calling this consumes the entry so it is never repeated in future briefings. + """ + with _lock: + if not MEMORY_PATH.exists(): + return None + try: + memory = json.loads(MEMORY_PATH.read_text(encoding="utf-8")) + sessions = memory.get("sessions", []) + if not isinstance(sessions, list) or not sessions: + return None + entry = sessions.pop() # remove the last entry + memory["sessions"] = sessions + MEMORY_PATH.write_text( + json.dumps(memory, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return entry + except Exception as e: + print(f"[Memory] ⚠️ pop_last_session error: {e}") + return None \ No newline at end of file diff --git a/readme.md b/readme.md index c99e4ea..1fd0116 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ -# ⚙️ MARK XLIX +# ⚙️ MARK L (50) ### The Ultimate Cross-Platform Personal AI Assistant — By FatihMakes -> 📺 **[Watch the full setup video on YouTube](https://youtu.be/CiGdcIlnXb8))** +> 📺 **[Watch the full setup video on YouTube](https://www.youtube.com/@FatihMakes)** A real-time voice AI that can hear, see, understand, and control your computer — on any OS. Supports Windows, macOS, and Linux. Built on the Gemini Live API for native audio streaming, delivering zero subscriptions and total digital autonomy. @@ -9,7 +9,9 @@ A real-time voice AI that can hear, see, understand, and control your computer ## ✨ Overview -MARK XLIX deepens the personal assistant foundation. Rather than adding more tools, this build focused on making the assistant truly *yours*: it starts with your computer, learns your name, and pays attention to what you're doing. The goal before the plugin era begins is a core that feels alive — not just reactive. +MARK L is where the assistant stops being a tool and starts being a presence. It remembers yesterday's conversation, watches the topics you care about, and speaks first when it has something worth saying. The goal of this build was continuity — JARVIS should feel like it never fully left, even after you close it. + +It's not just an assistant — it's an extension of your digital life. --- @@ -24,8 +26,10 @@ MARK XLIX deepens the personal assistant foundation. Rather than adding more too | 👁️ Visual Awareness | Real-time screen capture and webcam vision piped into your main Gemini session | | 🧠 Persistent Memory | Deeply remembers projects, preferences, and personal context across sessions | | ⌨️ Hybrid Input | Seamlessly switch between keyboard typing and voice commands | -| 🌅 Morning Briefing | On first boot: greets you, reads the time, fetches live news headlines, and checks weather | -| 🔔 Proactive Check-ins | After 15 minutes of silence, checks context and offers something genuinely useful | +| 🌅 Morning Briefing | On first boot: greets you, reads the time, recaps yesterday, and fetches live news | +| 🔔 Proactive 2.0 | Time-aware, context-aware check-ins — knows the time of day, your projects, and what you've been discussing | +| 🗓️ Session Memory | Summarises each conversation and mentions it naturally next morning — consumed after use, never repeats | +| 👁️‍🗨️ Background Monitoring | User-configured topic watching — checks for new headlines once a day and alerts naturally | | 📊 Hardware Monitoring | Continuous CPU, RAM, GPU and temperature telemetry with localized voice alerts | | 🌤️ Weather Report | Live weather data for your city, personalized from memory | | 🗺️ Dynamic Content Panel | Scrollable display layer beneath the HUD that renders web results, news, and search data | @@ -39,28 +43,42 @@ MARK XLIX deepens the personal assistant foundation. Rather than adding more too | 📨 Send Message | Compose and send messages through WhatsApp, Telegram, and more | | 🎬 YouTube Control | Search, play, and control YouTube playback by voice | | 🖱️ Desktop Control | Taskbar, window management, and desktop-level operations | -| 🧑‍💻 Silent Language Memory | Detects spoken language on first use and saves it — all future sessions adapt automatically | +| 🧑‍💻 Silent Language Memory | Detects spoken language on first use — all future sessions adapt automatically | | 📱 Remote Dashboard | Control the assistant from your phone via QR code pairing | +| ⚡ Auto-Start on Boot | Registers with the OS startup system (registry / LaunchAgent / .desktop) | +| 📋 Clipboard Intelligence | Copy any text → floating panel with Translate / Summarise / Explain / Fix | +| 🎨 Assistant Customization | Change the assistant name and your name from the UI — takes effect immediately | --- -## 🆕 What's New in XLIX +## 🆕 What's New in Mark L -### ⚡ Auto-Start on Boot -The assistant now registers itself with the operating system's startup system. One click in the UI toggles it on or off. On Windows, it writes to the registry using `pythonw.exe` so no console window ever appears. On macOS it installs a LaunchAgent plist; on Linux a `.desktop` autostart entry. The button reflects the current state every time the app launches. +### 🗓️ Session Memory — JARVIS Remembers Yesterday +At the end of every session, JARVIS generates a 1-2 sentence summary of what was discussed and saves it to memory. The next morning, it's mentioned naturally in the briefing: +> *"Good morning, sir — it's 09:15. Yesterday you were working on the Mark L background monitoring feature. Fetching today's headlines now."* -### 🎨 Assistant Customization -The assistant is no longer locked to the name "JARVIS". Click `⚙ CUSTOMISE ASSISTANT` in the right panel to change: -- **Assistant name** — displayed everywhere in the UI (title bar, header, HUD, log, footer) and injected into the Gemini system prompt so the AI knows its own name -- **Your name** — how the assistant addresses you. Leave blank for the default language-aware addressing (`sir` / `efendim`), or set your actual name for a more personal feel +The summary is consumed immediately after use — it never repeats in future briefings and adds zero long-term bloat to memory. -Changes take effect immediately without restarting. +### 👁️‍🗨️ Background Monitoring — JARVIS Watches While You're Away +Tell JARVIS to monitor any topic and it checks for new developments once a day using DuckDuckGo news. When a headline changes, it reports back naturally in your language: +> *"Efendim, takip ettiğiniz yapay zeka haberlerinde bir gelişme var: Google yeni bir model duyurdu."* -### 📋 Clipboard Intelligence -Copy any text of 10 or more characters and a floating panel appears at the bottom of the window. Four quick actions — **TRANSLATE**, **SUMMARISE**, **EXPLAIN**, **FIX** — send the copied content directly to the assistant with one click. The panel auto-dismisses after 8 seconds. This turns the clipboard into a silent command channel for anything on your screen. +Fully opt-in — JARVIS monitors nothing without being explicitly asked. Crypto, financial, and trading topics are blocked at the code level regardless of what is requested. Same headline never triggers twice. -### ☀ Morning Brief Toggle + Speed Optimization -The morning briefing can now be turned on or off with one click from the settings drawer (`⚙` → `☀ MORNING BRIEF: ON/OFF`). Users who don't want a startup briefing can disable it permanently; the setting survives restarts. The briefing itself was also re-engineered: news is now pre-fetched in a background thread the moment the session starts, running in parallel while the greeting plays. By the time the greeting finishes, the results are already ready — no extra Gemini tool-call round-trip needed. Briefing delivery is noticeably faster as a result. +### 🔔 Proactive System 2.0 — Context-Aware, Time-Aware, Non-Repetitive +The proactive engine was rebuilt from the ground up. Instead of a generic check-in after 15 minutes of silence, JARVIS now: +- Knows the **time of day** — morning tone differs from evening tone +- Knows your **active projects** from memory and can ask how something is going +- Knows your **monitored topics** and can bring one up naturally +- Knows **what you were just talking about** (last 8 conversation turns) +- **Rotates** between three focus areas so it never opens with the same line twice +- Has a 20-minute cooldown (up from 10) — less intrusive, more meaningful + +### 👁️ Instant Vision Acknowledgment — No More Silent Waiting +When you ask JARVIS to look at your screen or camera, it no longer goes silent while processing. It immediately says something natural ("Looking at your screen now, sir" / "Ekrana bakıyorum efendim") while the capture runs. The actual analysis follows as the next response. + +### 📰 Parallel News Search — First Result Wins +News queries now run Gemini Grounded Search and DuckDuckGo news simultaneously in two threads. Whichever delivers a valid result first is used; the other is silently discarded. A Gemini 503 error no longer delays results — the DDG fallback is already running in parallel. --- @@ -70,16 +88,16 @@ The morning briefing can now be turned on or off with one click from the setting |---|---| | **XLVIII** | Instant interrupt · parallel news · two-phase briefing · exponential backoff · vision cooldown | | **XLIX** | Auto-start · clipboard intelligence · assistant customization | -| **L** | Wake word · proactive system 2.0 · session memory / daily continuity | -| **LI+** | Plugin system · email · quiz mode · calorie counter · and more | +| **L** | Session memory · background monitoring · proactive 2.0 · instant vision · parallel news search | +| **LI+** | Plugin system · email · quiz mode · calorie counter · calendar | --- ## ⚡ Quick Start ```bash -git clone https://github.com/FatihMakes/Mark-XLIX.git -cd Mark-XLIX +git clone https://github.com/FatihMakes/Mark-L.git +cd Mark-L pip install -r requirements.txt python main.py ``` @@ -102,35 +120,38 @@ python main.py ## 🗂️ Project Structure ``` -Mark XLIX/ -├── main.py # Core loop — Gemini Live session, audio I/O, tool dispatch -├── ui.py # PyQt6 HUD — waveform, log panel, interrupt button, camera feed -├── setup.py # First-run configuration wizard +Mark L/ +├── main.py # Core loop — Gemini Live session, audio I/O, tool dispatch +├── ui.py # PyQt6 HUD — waveform, log panel, interrupt button, camera feed +├── setup.py # First-run configuration wizard ├── actions/ -│ ├── web_search.py # Gemini + DDG parallel search (news, research, price, compare) -│ ├── screen_processor.py # Screen capture & webcam vision via Gemini Live -│ ├── reminder.py # OS-native scheduled notifications -│ ├── system_monitor.py # CPU / RAM / GPU / temperature telemetry -│ ├── computer_settings.py # Volume, brightness, WiFi, power -│ ├── computer_control.py # Keyboard shortcuts, mouse, window management -│ ├── open_app.py # Application launcher -│ ├── browser_control.py # Web browser control -│ ├── file_controller.py # File system operations -│ ├── file_processor.py # Document reading and summarization -│ ├── send_message.py # Messaging integration -│ ├── weather_report.py # Live weather data -│ ├── flight_finder.py # Flight search -│ ├── youtube_video.py # YouTube playback control -│ ├── game_updater.py # Game update management (Steam / Epic) -│ ├── code_helper.py # Code review and generation -│ ├── dev_agent.py # Developer task agent -│ ├── desktop.py # Desktop and taskbar control -│ └── proactive.py # Proactive silence-break suggestions -├── memory/ # Persistent key-value memory store +│ ├── web_search.py # Gemini + DDG parallel search (news, research, price, compare) +│ ├── screen_processor.py # Screen capture & webcam vision via Gemini Live +│ ├── background_monitor.py # User-configured topic watching — daily DDG check, no crypto +│ ├── proactive.py # Proactive 2.0 — time/context/rotation-aware check-ins +│ ├── reminder.py # OS-native scheduled notifications +│ ├── system_monitor.py # CPU / RAM / GPU / temperature telemetry +│ ├── computer_settings.py # Volume, brightness, WiFi, power +│ ├── computer_control.py # Keyboard shortcuts, mouse, window management +│ ├── open_app.py # Application launcher +│ ├── browser_control.py # Web browser control +│ ├── file_controller.py # File system operations +│ ├── file_processor.py # Document reading and summarization +│ ├── send_message.py # Messaging integration +│ ├── weather_report.py # Live weather data +│ ├── flight_finder.py # Flight search +│ ├── youtube_video.py # YouTube playback control +│ ├── game_updater.py # Game update management (Steam / Epic) +│ ├── code_helper.py # Code review and generation +│ ├── dev_agent.py # Developer task agent +│ └── desktop.py # Desktop and taskbar control +├── memory/ +│ ├── memory_manager.py # Load/save long_term.json — sessions, monitors, identity +│ └── long_term.json # Persistent store: identity, preferences, projects, sessions, monitors ├── core/ -│ └── prompt.txt # Assistant personality and tool-routing rules +│ └── prompt.txt # Assistant personality and tool-routing rules └── config/ - └── api_keys.json # API key, OS setting, assistant name, user name + └── api_keys.json # API key, OS setting, assistant name, user name ``` --- diff --git a/setup.py b/setup.py index 6287892..efcbb37 100644 --- a/setup.py +++ b/setup.py @@ -22,5 +22,5 @@ if platform.system() == "Windows": f' "{sys.executable}" "{postinstall}" -install\n' ) -print("\n✅ Setup complete! Run 'python main.py' to start MARK XXV.") +print("\n✅ Setup complete! Run 'python main.py' to start MARK L.") diff --git a/ui.py b/ui.py index 882de6a..4230cfc 100644 --- a/ui.py +++ b/ui.py @@ -2076,6 +2076,57 @@ class MainWindow(QMainWindow): print(f"[Shortcut] ⚠️ Icon generation failed: {e}") return False + @staticmethod + def _create_lnk_windows(lnk: str, target: str, args: str, + work_dir: str, icon_loc: str) -> None: + """ + Create a Windows .lnk shortcut WITHOUT launching PowerShell or cmd. + Tries win32com (pywin32) first; falls back to wscript.exe + VBScript. + wscript.exe is a GUI-mode host — it never opens a console window. + """ + # ── Option 1: pywin32 (pure Python COM, zero subprocess) ────────── + try: + from win32com.client import Dispatch # type: ignore + sh = Dispatch("WScript.Shell") + sc = sh.CreateShortCut(lnk) + sc.TargetPath = target + sc.Arguments = f'"{args}"' + sc.WorkingDirectory = work_dir + sc.Description = "J.A.R.V.I.S AI Assistant" + sc.IconLocation = icon_loc + sc.save() + return + except ImportError: + pass + + # ── Option 2: wscript.exe + VBScript (always available on Windows, + # GUI-mode executable — never opens a console window) ──────────── + vbs = "\n".join([ + 'Set ws = CreateObject("WScript.Shell")', + f'Set sc = ws.CreateShortcut("{lnk}")', + f'sc.TargetPath = "{target}"', + f'sc.Arguments = Chr(34) & "{args}" & Chr(34)', + f'sc.WorkingDirectory = "{work_dir}"', + 'sc.Description = "J.A.R.V.I.S AI Assistant"', + f'sc.IconLocation = "{icon_loc}"', + 'sc.Save', + ]) + import tempfile + fd, tmp = tempfile.mkstemp(suffix=".vbs") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(vbs) + proc = subprocess.Popen( + ["wscript.exe", "/nologo", tmp], + creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, + ) + proc.wait(timeout=10) + finally: + try: + os.unlink(tmp) + except Exception: + pass + @staticmethod def _get_desktop_dir() -> Path: """ @@ -2157,72 +2208,6 @@ class MainWindow(QMainWindow): # display-only). Everything else lands here as a last resort. return home / "Desktop" - @staticmethod - def _create_lnk_windows(lnk: str, target: str, args: str, - work_dir: str, icon_loc: str) -> None: - """ - Create a Windows .lnk shortcut WITHOUT launching PowerShell or cmd. - Tries win32com (pywin32) first; falls back to wscript.exe + VBScript. - wscript.exe is a GUI-mode host — it never opens a console window. - Raises on failure so the caller can log a useful error. - """ - # ── Option 1: pywin32 (pure Python COM, zero subprocess) ────────── - com_err: Exception | None = None - try: - from win32com.client import Dispatch # type: ignore - sh = Dispatch("WScript.Shell") - sc = sh.CreateShortCut(lnk) - sc.TargetPath = target - sc.Arguments = f'"{args}"' - sc.WorkingDirectory = work_dir - sc.Description = "J.A.R.V.I.S AI Assistant" - sc.IconLocation = icon_loc - sc.save() - return - except ImportError: - pass - except Exception as e: # COM error — still try VBScript - com_err = e - - # ── Option 2: wscript.exe + VBScript (always available on Windows, - # GUI-mode executable — never opens a console window) ──────────── - def q(s: str) -> str: # escape for a VBScript string literal - return s.replace('"', '""') - - vbs = "\n".join([ - 'On Error Resume Next', - 'Set ws = CreateObject("WScript.Shell")', - f'Set sc = ws.CreateShortcut("{q(lnk)}")', - f'sc.TargetPath = "{q(target)}"', - f'sc.Arguments = Chr(34) & "{q(args)}" & Chr(34)', - f'sc.WorkingDirectory = "{q(work_dir)}"', - 'sc.Description = "J.A.R.V.I.S AI Assistant"', - f'sc.IconLocation = "{q(icon_loc)}"', - 'sc.Save', - 'If Err.Number <> 0 Then WScript.Quit 1', - ]) - import tempfile - fd, tmp = tempfile.mkstemp(suffix=".vbs") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(vbs) - proc = subprocess.Popen( - ["wscript.exe", "/nologo", tmp], - creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, - ) - proc.wait(timeout=10) - finally: - try: - os.unlink(tmp) - except Exception: - pass - - if not Path(lnk).exists(): - raise RuntimeError( - f"could not create '{lnk}'" - + (f" ({com_err})" if com_err else "") - ) - def _create_desktop_shortcut(self): """ Create a desktop shortcut on Windows / macOS / Linux. @@ -2240,7 +2225,6 @@ class MainWindow(QMainWindow): try: _os = platform.system() - desktop.mkdir(parents=True, exist_ok=True) # ── Windows ─────────────────────────────────────────────────────── if _os == "Windows": @@ -2321,7 +2305,7 @@ class MainWindow(QMainWindow): desk.write_text( "[Desktop Entry]\n" "Name=J.A.R.V.I.S\n" - f'Exec="{python}" "{script}"\n' + f"Exec={python} {script}\n" f"Path={script.parent}\n" "Type=Application\n" "Terminal=false\n" @@ -2329,22 +2313,10 @@ class MainWindow(QMainWindow): + icon_line ) desk.chmod(desk.stat().st_mode | 0o755) - # GNOME refuses to launch desktop files until they are - # marked trusted ("Allow Launching") — do it automatically. - try: - subprocess.run( - ["gio", "set", str(desk), - "metadata::trusted", "true"], - capture_output=True, timeout=5, - ) - except Exception: - pass # non-GNOME desktops don't need (or have) gio - self._log.append_log(f"SYS: Desktop shortcut created in '{desktop}'.") + self._log.append_log("SYS: Desktop shortcut created.") except Exception as e: - self._log.append_log( - f"ERR: Shortcut failed — {e} (desktop dir: '{desktop}')" - ) + self._log.append_log(f"ERR: Shortcut failed — {e}") def _toggle_fullscreen(self): if self.isFullScreen():