diff --git a/README.md b/README.md index 0156c67..8fc575c 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Altri plugin inclusi, tutti locali: | `buongiorno` | riepilogo: impegni, promemoria, email, meteo | "buongiorno", "come si presenta la giornata" | | `whatsapp_archivio` | chat WhatsApp esportate: importazione, ricerca full-text, lettura per periodo, statistiche | "cosa mi ha detto Marco sulla riunione?", "riassumi la chat con Anna dell'ultima settimana", "quando abbiamo parlato del dentista?" | | `whatsapp_live` | WhatsApp in tempo reale come dispositivo collegato (Baileys): novità, invio con conferma, annunci vocali | "ci sono novità su WhatsApp?", "chi mi ha scritto?", "scrivi a Marco su WhatsApp che arrivo" | +| `visione` | webcam, schermo intero o finestra in primo piano: immagine per i motori con visione (Claude API, Claude Code) più testo riconosciuto per tutti | "cosa vedi?", "cosa c'è sullo schermo?", "leggi questo errore", "riassumi questa finestra" | | `telegram` | Telegram con il tuo account (Telethon): non letti, lettura, ricerca, invio con conferma | "ho messaggi su Telegram?", "leggimi la chat con Anna", "scrivi a Luca su Telegram che sono in ritardo" | **WhatsApp in tempo reale** usa un client non ufficiale (Baileys, in `whatsapp_bridge/`, Node.js) collegato come "dispositivo collegato". È contro i termini di WhatsApp e Meta può bloccare il numero: l'app non invia mai senza conferma e non fa azioni di massa, ma il rischio resta a carico di chi lo attiva. Per collegarlo: "Motore e Voce" › WhatsApp › "Collega (QR)", poi sul telefono WhatsApp › Impostazioni › Dispositivi collegati › Collega un dispositivo. Da quel momento i messaggi in arrivo (e quelli che invii da altri dispositivi) finiscono nell'archivio, in tempo reale; con "Annuncia a voce" l'assistente li legge appena arrivano. Il ponte parte da solo all'avvio dell'app una volta collegato. Richiede Node.js (`npm install` in `whatsapp_bridge/` è già fatto). @@ -88,7 +89,7 @@ Per scriverne uno nuovo copia la struttura di `plugins/calendario.py`: una lista - macOS su Apple Silicon, Python 3.12, [uv](https://docs.astral.sh/uv/). - `brew install espeak-ng portaudio` (fonetica italiana per Kokoro e audio). -- Permessi macOS richiesti al primo uso: Microfono, Calendario, Promemoria, Contatti, Automazione; per leggere SMS e iMessage serve «Accesso completo al disco» all'app (Impostazioni di Sistema › Privacy e sicurezza). +- Permessi macOS richiesti al primo uso: Microfono, Fotocamera, Registrazione schermo, Calendario, Promemoria, Contatti, Automazione; per leggere SMS e iMessage serve «Accesso completo al disco» all'app (Impostazioni di Sistema › Privacy e sicurezza). - Per il motore Claude Code: Claude Code installato e collegato. ## Avvio diff --git a/avatar/engines/anthropic_engine.py b/avatar/engines/anthropic_engine.py index 8ef4e1e..90df6ad 100644 --- a/avatar/engines/anthropic_engine.py +++ b/avatar/engines/anthropic_engine.py @@ -23,6 +23,22 @@ class Aborted(Exception): pass +def _tool_content(res: str): + """Se il risultato cita un'immagine (riga «IMMAGINE: percorso»), la allega come blocco image.""" + import base64 + import re as _re + from pathlib import Path as _P + m = _re.search(r"^IMMAGINE: (.+)$", res, flags=_re.M) + if not m or not _P(m.group(1).strip()).exists(): + return res + try: + from avatar.attachments import image_payload + media, data = image_payload(_P(m.group(1).strip())) + return [{"type": "text", "text": res}, {"type": "image", "source": {"type": "base64", "media_type": media, "data": base64.b64encode(data).decode()}}] + except Exception: + return res + + def friendly_error(err: Exception) -> str: if isinstance(err, anthropic.AuthenticationError): return "La chiave API Anthropic non è valida. Controllala nelle impostazioni." @@ -147,7 +163,7 @@ class AnthropicEngine: res, ev = run_tool(b.name, parse_args(b.input)) if ev: emit(ev) - results.append({"type": "tool_result", "tool_use_id": b.id, "content": res}) + results.append({"type": "tool_result", "tool_use_id": b.id, "content": _tool_content(res)}) self.history.messages.append({"role": "user", "content": results}) emit({"type": "status", "status": "thinking"}) diff --git a/avatar/engines/claude_code.py b/avatar/engines/claude_code.py index 9ff1fca..c7f25f4 100644 --- a/avatar/engines/claude_code.py +++ b/avatar/engines/claude_code.py @@ -93,11 +93,18 @@ class ClaudeCodeEngine: f"Adesso è {today_label()}.", "Strumenti di memoria (obbligatori): per ricordare un fatto sull'utente chiama `mcp__avatar__salva_memoria`; per cancellarne uno `mcp__avatar__dimentica_memoria`; per cercare `mcp__avatar__cerca_memoria`. Non dire mai di aver salvato qualcosa senza averlo chiamato davvero. Gli altri strumenti `mcp__avatar__*` (per esempio il calendario) agiscono sul Mac dell'utente: usali quando servono, e per le azioni irreversibili chiedi conferma a voce prima di richiamarli con confermato=true.", access, + "Quando uno strumento risponde con una riga «IMMAGINE: percorso», leggi quel file con Read per vedere l'immagine prima di rispondere.", "Stai parlando dentro un'app vocale: rispondi in modo conversazionale, senza intestazioni Markdown né tabelle, e non citare nomi di file o strumenti interni a meno che non serva.", ]) def _args(self, session_id: str, resume: bool, attach_path: str = "") -> list[str]: flags = dict(ACCESS[self.access]) + # Le catture di webcam/schermo del plugin visione sono sempre leggibili. + from avatar.settings import DATA_DIR + captures = str(DATA_DIR / "captures") + if "Read" not in flags["tools"] and flags["tools"] != "default": + flags["tools"] = flags["tools"] + ",Read" + flags["allowed"] = [*flags["allowed"], f"Read(//{captures.lstrip('/')}/**)"] if attach_path: # Permesso di lettura limitato al file allegato (immagini incluse: Claude Code le vede). if "Read" not in flags["tools"] and flags["tools"] != "default": diff --git a/avatar/vision.py b/avatar/vision.py new file mode 100644 index 0000000..04fcd19 --- /dev/null +++ b/avatar/vision.py @@ -0,0 +1,97 @@ +"""Cattura di webcam, schermo e finestra in primo piano, con OCR e anteprima per i motori.""" +from __future__ import annotations + +import subprocess +import time +from pathlib import Path + +from avatar.settings import DATA_DIR + +CAPTURES = DATA_DIR / "captures" +KEEP = 30 + + +def _prune() -> None: + files = sorted(CAPTURES.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True) + for p in files[KEEP:]: + p.unlink(missing_ok=True) + + +def _shrink(src: Path, max_side: int = 1600, quality: int = 85) -> Path: + """Versione JPEG ridotta, adatta ai modelli con visione.""" + from PIL import Image + im = Image.open(src).convert("RGB") + im.thumbnail((max_side, max_side)) + out = src.with_suffix(".jpg") if src.suffix.lower() != ".jpg" else src + im.save(out, format="JPEG", quality=quality) + if out != src: + src.unlink(missing_ok=True) + return out + + +def capture_webcam(warmup_frames: int = 8) -> Path: + import cv2 + CAPTURES.mkdir(parents=True, exist_ok=True) + cap = cv2.VideoCapture(0) + if not cap.isOpened(): + raise RuntimeError("Webcam non disponibile: controlla il permesso Fotocamera in Impostazioni di Sistema > Privacy e sicurezza.") + try: + frame = None + for _ in range(warmup_frames): # qualche fotogramma per l'esposizione automatica + ok, frame = cap.read() + if not ok: + break + time.sleep(0.05) + if frame is None: + raise RuntimeError("La webcam non ha restituito immagini.") + out = CAPTURES / f"webcam-{time.strftime('%Y%m%d-%H%M%S')}.jpg" + cv2.imwrite(str(out), frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) + finally: + cap.release() + _prune() + return _shrink(out) + + +def capture_screen(all_displays: bool = False) -> Path: + CAPTURES.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + out = CAPTURES / f"schermo-{stamp}.png" + cmd = ["screencapture", "-x", str(out)] if not all_displays else ["screencapture", "-x", str(out), str(CAPTURES / f"schermo2-{stamp}.png")] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + if res.returncode != 0 or not out.exists(): + raise RuntimeError("Cattura schermo non riuscita: concedi «Registrazione schermo» all'app in Impostazioni di Sistema > Privacy e sicurezza.") + _prune() + return _shrink(out, max_side=2000) + + +def _front_window_id() -> tuple[int | None, str]: + import Quartz + from AppKit import NSWorkspace + app = NSWorkspace.sharedWorkspace().frontmostApplication() + name = app.localizedName() if app else "" + pid = app.processIdentifier() if app else None + for w in Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID): + if w.get("kCGWindowOwnerPID") == pid and w.get("kCGWindowLayer", 0) == 0 and (w.get("kCGWindowBounds") or {}).get("Height", 0) > 50: + return w["kCGWindowNumber"], name + return None, name + + +def capture_front_window() -> tuple[Path, str]: + CAPTURES.mkdir(parents=True, exist_ok=True) + wid, name = _front_window_id() + if wid is None: + return capture_screen(), name or "schermo" + out = CAPTURES / f"finestra-{time.strftime('%Y%m%d-%H%M%S')}.png" + res = subprocess.run(["screencapture", "-x", "-l", str(wid), str(out)], capture_output=True, text=True, timeout=20) + if res.returncode != 0 or not out.exists(): + raise RuntimeError("Cattura finestra non riuscita: serve il permesso «Registrazione schermo».") + _prune() + return _shrink(out, max_side=2000), name + + +def ocr_text(path: Path) -> str: + from avatar.attachments import ocr + try: + return ocr(path).strip() + except Exception: + return "" diff --git a/plugins/visione.py b/plugins/visione.py new file mode 100644 index 0000000..1077b28 --- /dev/null +++ b/plugins/visione.py @@ -0,0 +1,47 @@ +"""Visione: guarda dalla webcam, lo schermo o la finestra in primo piano (immagine + testo riconosciuto).""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from avatar import vision # noqa: E402 + +NOTE = ("Descrivi ciò che vedi in modo naturale. Se il motore non può vedere le immagini, basati sul testo riconosciuto.") + + +def _result(path: Path, label: str, ctx: dict) -> str: + text = vision.ocr_text(path) + try: + ui = ctx.get("player") + if ui is not None and hasattr(ui, "show_camera_frame"): + ui.show_camera_frame(path.read_bytes()) + except Exception: + pass + return (f"{label}\nIMMAGINE: {path}\n" + f"Testo riconosciuto nell'immagine:\n{text or '(nessun testo)'}\n{NOTE}") + + +def webcam(params: dict, ctx: dict) -> str: + path = vision.capture_webcam() + return _result(path, "Fotogramma dalla webcam (l'utente e il suo ambiente).", ctx) + + +def schermo(params: dict, ctx: dict) -> str: + path = vision.capture_screen(bool(params.get("tutti_gli_schermi"))) + return _result(path, "Cattura dello schermo del Mac (il computer dell'utente, non una foto dell'utente).", ctx) + + +def finestra(params: dict, ctx: dict) -> str: + path, name = vision.capture_front_window() + return _result(path, f"Cattura della finestra in primo piano: {name or 'sconosciuta'}.", ctx) + + +TOOLS = [ + {"name": "guarda_webcam", "description": "Scatta un fotogramma dalla webcam per vedere l'utente e l'ambiente attorno a lui. Usalo per 'cosa vedi?', 'guarda questo', 'come sto?'.", + "parameters": {"type": "object", "properties": {}}, "run": webcam}, + {"name": "guarda_schermo", "description": "Cattura lo schermo del Mac per vedere cosa c'è aperto: 'cosa c'è sullo schermo?', 'leggi questo errore', 'riassumi questa pagina'.", + "parameters": {"type": "object", "properties": {"tutti_gli_schermi": {"type": "boolean"}}}, "run": schermo}, + {"name": "guarda_finestra", "description": "Cattura solo la finestra dell'app in primo piano (più leggibile dello schermo intero).", + "parameters": {"type": "object", "properties": {}}, "run": finestra}, +] diff --git a/pyproject.toml b/pyproject.toml index 0f54c28..be95490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "mlx-whisper>=0.4.3", "numpy>=2.5.3", "openai>=3.17.0", + "opencv-python>=5.0.0.93", "psutil>=7.2.2", "pyobjc-framework-contacts>=12.2.2", "pyobjc-framework-eventkit>=12.2.2", diff --git a/scripts/crea_app.py b/scripts/crea_app.py index 5ef3edf..8ef4160 100644 --- a/scripts/crea_app.py +++ b/scripts/crea_app.py @@ -132,6 +132,7 @@ exec "{python}" main.py >> "$HOME/Library/Logs/{APP_NAME}.log" 2>&1 "CFBundleExecutable": APP_NAME, "CFBundleIconFile": f"{APP_NAME}.icns", "LSMinimumSystemVersion": "13.0", "NSHighResolutionCapable": True, "NSMicrophoneUsageDescription": "Per ascoltare la tua voce.", + "NSCameraUsageDescription": "Per guardare l'ambiente dalla webcam quando lo chiedi.", "NSCalendarsFullAccessUsageDescription": "Per leggere e creare eventi nel Calendario.", "NSCalendarsUsageDescription": "Per leggere e creare eventi nel Calendario.", "NSRemindersFullAccessUsageDescription": "Per gestire i promemoria.", diff --git a/uv.lock b/uv.lock index b782d0d..1d9bc57 100644 --- a/uv.lock +++ b/uv.lock @@ -108,6 +108,7 @@ dependencies = [ { name = "mlx-whisper" }, { name = "numpy" }, { name = "openai" }, + { name = "opencv-python" }, { name = "psutil" }, { name = "pyobjc-framework-contacts" }, { name = "pyobjc-framework-eventkit" }, @@ -134,6 +135,7 @@ requires-dist = [ { name = "mlx-whisper", specifier = ">=0.4.3" }, { name = "numpy", specifier = ">=2.5.3" }, { name = "openai", specifier = ">=3.17.0" }, + { name = "opencv-python", specifier = ">=5.0.0.93" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "pyobjc-framework-contacts", specifier = ">=12.2.2" }, { name = "pyobjc-framework-eventkit", specifier = ">=12.2.2" }, @@ -1925,6 +1927,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bc/5b80a0ca4069a6b5ba6c7d48ae7a4f8176b2c0bde0bebccdb867472ec6f6/openai-3.17.0-py3-none-any.whl", hash = "sha256:712bc2982a6da1af10f6948a1d2d9ff5ce3ee3604ded88002a53765a77cf5ae5", size = 2068574, upload-time = "2026-09-22T02:37:15.373Z" }, ] +[[package]] +name = "opencv-python" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/75/76f6ade78f6102c61034f828e2a22616708df2c9504bc8d6af9dd8f73dc5/opencv_python-5.0.0.93-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:198a75138241810206a17c829dbcc40a7cb1841cda538ca86cbbfc6c7d95f898", size = 48322443, upload-time = "2026-07-02T05:50:25.466Z" }, + { url = "https://files.pythonhosted.org/packages/15/8c/bc1bda6aae69a32e9d84fc34153ba104cd25226861eb4aea33b2cea4860d/opencv_python-5.0.0.93-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:6bbc32f59e1b1a7db7b39c81f63d00625f041d333037fd8702f6da52cc39108b", size = 34782755, upload-time = "2026-07-02T05:51:30.556Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8a/b04776ec45d2dea08a1b176f1829201db3515d4ed16c35f8fcc9fa7beb16/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2b4272e736836f66c2d176e43ab8101f3a00d45654916399f52e150c58981ac", size = 50614064, upload-time = "2026-07-02T06:53:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/95/54/eb47866b94f2b5b42dde17644b78055ef1ee05aae59962c7290e55270803/opencv_python-5.0.0.93-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8b6d0a212253dd26ad338c812f1f23ca118fdf05a9c8c6b9444f161aa8c5881", size = 71064711, upload-time = "2026-07-02T06:54:13.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/da/962579f1e703cbf8c5422fd1f576467dcb3b5b0b0b81c1471c979764353a/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:08d5d91d967b58d6db86073b2ad3eaef88ca4ebdfd45c9059bf59f5ded0c7ad2", size = 49798576, upload-time = "2026-07-02T06:54:33.781Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c8de2dec111122a02e8beb28e16c31904992dfd6186560b142a92c71403c1039", size = 73783032, upload-time = "2026-07-02T06:55:03.415Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4b/edaf83b996ca5a1a3d8ccad485706b9c6d4742b13b9c4586bf1c1e7d9423/opencv_python-5.0.0.93-cp37-abi3-win32.whl", hash = "sha256:4b4b1a34c79bf8d3738e3cfe9a9e67b51a79663f6b692cbdad8c31f570da4157", size = 35564734, upload-time = "2026-07-02T05:49:57.704Z" }, + { url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" }, +] + [[package]] name = "packaging" version = "26.3"