Add files via upload
This commit is contained in:
1 parent
215ff44d3d
commit
b4d6ae9bc7
7 files changed
+581
-154
No files matched your search
@@ -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
|
||||
+84
-32
@@ -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
|
||||
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).",
|
||||
])
|
||||
@@ -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:
|
||||
await asyncio.to_thread(stream.write, chunk)
|
||||
batch.extend(self.audio_in_queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
try:
|
||||
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,7 +1252,13 @@ class JarvisLive:
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
alert = await asyncio.to_thread(self._sys_monitor.check)
|
||||
if alert and self.session:
|
||||
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}]},
|
||||
@@ -1143,6 +1267,39 @@ class JarvisLive:
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _run_proactive_mode(self) -> None:
|
||||
@@ -1169,7 +1326,13 @@ class JarvisLive:
|
||||
|
||||
try:
|
||||
memory = await asyncio.to_thread(load_memory)
|
||||
prompt = self._proactive.build_prompt(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")
|
||||
|
||||
@@ -213,3 +213,59 @@ def forget(key: str, category: str = "notes") -> str:
|
||||
|
||||
|
||||
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
|
||||
@@ -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,13 +120,15 @@ python main.py
|
||||
## 🗂️ Project Structure
|
||||
|
||||
```
|
||||
Mark XLIX/
|
||||
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
|
||||
│ ├── 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
|
||||
@@ -124,9 +144,10 @@ Mark XLIX/
|
||||
│ ├── 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
|
||||
│ └── 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
|
||||
└── config/
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user