Plugin visione: webcam, schermo e finestra con OCR e immagine per i motori

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-24 15:29:03 +02:00
1 parent 64f3474d92
commit 8687c895a1
8 files changed
+193 -2

No files matched your search

+2 -1
View File
@@ -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
+17 -1
View File
@@ -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"})
+7
View File
@@ -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":
+97
View File
@@ -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 ""
+47
View File
@@ -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},
]
+1
View File
@@ -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",
+1
View File
@@ -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.",
Generated
+21
View File
@@ -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"