Client MCP (scheda Strumenti, filtri per server, Cua Driver), riepilogo compresso della cronologia, memoria in Markdown/Obsidian, plugin immagini con mflux

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-27 19:48:55 +02:00
1 parent 61292ea2e8
commit 3e9ecd487d
14 files changed
+708 -6

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ whatsapp_bridge/node_modules/
data/
config/api_keys.json
config/settings.json
config/mcp.json
memory/long_term.json
*.session
*.session-journal
@@ -20,4 +21,5 @@ avatar3d/models_archivio/
*.log
.DS_Store
.venv-chatterbox/
.venv-mflux/
tts_chatterbox/test_*.py
+60
View File
@@ -67,3 +67,63 @@ class History:
def clear(self) -> None:
self.messages, self.meta = [], {}
self.save()
# ── Compattazione della cronologia (motori locali) ───────────────────────────
COMPACT_AFTER = 30 # oltre questo numero di messaggi i più vecchi vengono riassunti
COMPACT_KEEP = 12 # messaggi recenti lasciati per esteso
def render_messages(msgs: list, per_msg: int = 600) -> str:
out = []
for m in msgs:
role = m.get("role")
text = str(m.get("content") or "").strip()
if role == "user":
out.append("Utente: " + text[:per_msg])
elif role == "assistant":
calls = m.get("tool_calls") or []
if calls:
names = ", ".join(str((c.get("function") or {}).get("name", "?")) for c in calls)
text = (text + f" [usa strumenti: {names}]").strip()
if text:
out.append("Assistente: " + text[:per_msg])
elif role == "tool":
out.append(f"Risultato di {m.get('name', 'strumento')}: " + text[:200])
return "\n".join(out)
def summary_prompt(previous: str, new_text: str) -> str:
return ("Riassumi in italiano, in modo compatto (al massimo 150 parole), ciò che serve per continuare la conversazione: "
"richieste dell'utente, cose fatte o decise, fatti e preferenze emersi, questioni aperte. Niente saluti, niente frasi generiche.\n\n"
+ (f"Riassunto precedente:\n{previous}\n\n" if previous else "")
+ f"Nuovi messaggi:\n{new_text}\n\nRiassunto aggiornato:")
def compact_history(history: "History", summarize: Callable[[str], str]) -> bool:
"""Se la cronologia è lunga, riassume i messaggi più vecchi in history.meta['summary'] e li rimuove."""
msgs = history.messages
if len(msgs) <= COMPACT_AFTER:
return False
cut = len(msgs) - COMPACT_KEEP
while cut < len(msgs) and msgs[cut].get("role") != "user":
cut += 1
if cut <= 0 or cut >= len(msgs):
return False
text = render_messages(msgs[:cut])
try:
summary = (summarize(summary_prompt(str(history.meta.get("summary") or ""), text)) or "").strip()
except Exception as err:
print(f"[cronologia] riassunto fallito: {err}")
return False
if not summary:
return False
history.meta["summary"] = summary[:2000]
history.messages = msgs[cut:]
history.save()
return True
def summary_block(history: "History") -> str:
s = str(history.meta.get("summary") or "").strip()
return f"\n\nRiassunto della conversazione precedente (i messaggi più vecchi sono stati compattati):\n{s}" if s else ""
+24 -1
View File
@@ -112,10 +112,33 @@ class ClaudeCodeEngine:
flags["tools"] = flags["tools"] + ",Read"
flags["allowed"] = [*flags["allowed"], f"Read(//{attach_path.lstrip('/')})"]
mcp = {"mcpServers": {"avatar": {"command": sys.executable, "args": [str(MCP_SCRIPT)]}}}
allowed_mcp = list(MEMORY_TOOLS)
disallowed_mcp: list[str] = []
try:
from avatar.mcp_client import load_config
for name, c in load_config().items():
if c.get("disabled") or name == "avatar":
continue
mcp["mcpServers"][name] = {k: v for k, v in c.items() if k in ("command", "args", "env", "cwd", "url", "headers", "type")}
if c.get("url") and "type" not in c:
mcp["mcpServers"][name]["type"] = "http"
if c.get("tools") or c.get("exclude"):
# Filtri: consenti solo gli strumenti scelti e nega gli altri (l'elenco completo arriva dal client in-app, se collegato).
from avatar.mcp_client import manager, selected_tools
srv = manager.servers.get(name)
if srv is not None:
chosen = {t["name"] for t in selected_tools(c, srv.tools)}
allowed_mcp += [f"mcp__{name}__{t}" for t in sorted(chosen)]
disallowed_mcp += [f"mcp__{name}__{t['name']}" for t in srv.tools if t["name"] not in chosen]
continue
allowed_mcp.append(f"mcp__{name}")
except Exception as err:
print(f"[claude code] server MCP esterni ignorati: {err}")
args = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
"--model", self.model, "--effort", self.effort,
"--tools", flags["tools"], "--allowedTools", ",".join(flags["allowed"] + MEMORY_TOOLS),
"--tools", flags["tools"], "--allowedTools", ",".join(flags["allowed"] + allowed_mcp),
"--mcp-config", json.dumps(mcp), "--strict-mcp-config",
*(["--disallowedTools", ",".join(disallowed_mcp)] if disallowed_mcp else []),
"--system-prompt-snapshot", "off",
"--resume" if resume else "--session-id", session_id]
if flags["mode"]:
+21 -2
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
from avatar.plugins import registry
from avatar.websearch import brave_search
from .base import Emit, History, persona_text, today_label, user_block
from .base import Emit, History, compact_history, persona_text, summary_block, today_label, user_block
from .openai_compat import SEARCH_TOOL, Aborted
MAX_ROUNDS = 8
@@ -323,7 +323,7 @@ class MLXEngine:
"- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.")
note += ("\n- Per informazioni aggiornate chiama cerca_web e rispondi in base ai risultati." if self.search_api_key
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
return f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{note}"
return f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{summary_block(self.history)}{note}"
def _recent(self) -> list:
msgs = self.history.messages
@@ -443,6 +443,9 @@ class MLXEngine:
full = full.strip() or "(nessuna risposta)"
self.history.save()
emit({"type": "done", "text": full, "sources": sources})
with _lock:
if compact_history(self.history, self.summarize):
_loaded["snap"] = None # il prompt cambia: la fotografia non vale più
except Aborted:
self._rollback()
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
@@ -450,6 +453,22 @@ class MLXEngine:
self._rollback()
emit({"type": "error", "message": f"Modello interno: {err}"})
def summarize(self, prompt: str) -> str:
"""Genera un testo breve col modello caricato, senza strumenti né cache condivisa (usato per compattare la cronologia)."""
from mlx_lm import stream_generate
from mlx_lm.sample_utils import make_sampler
from mlx_lm.models.cache import make_prompt_cache
msgs = [{"role": "system", "content": "Sei un assistente che riassume conversazioni in italiano, in modo fedele e compatto."},
{"role": "user", "content": prompt}]
text = self.tokenizer.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False)
f = FORMATS[self.fmt]
think = TagFilter(*f["think"], keep=False)
think.inside = text.rstrip().endswith(f["think"][0])
out = ""
for r in stream_generate(self.model, self.tokenizer, text, max_tokens=400, sampler=make_sampler(temp=0.3, top_p=0.9), prompt_cache=make_prompt_cache(self.model)):
out += think.push(r.text)
return (out + think.flush()).strip()
def _rollback(self) -> None:
msgs = self.history.messages
if msgs and msgs[-1].get("role") == "user":
+10 -2
View File
@@ -9,7 +9,7 @@ import openai
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
from avatar.plugins import registry
from avatar.websearch import brave_search
from .base import Emit, History, persona_text, today_label, user_block
from .base import Emit, History, compact_history, persona_text, summary_block, today_label, user_block
MAX_ROUNDS = 8
MAX_HISTORY = 30
@@ -98,7 +98,7 @@ class OpenAICompatEngine:
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
else:
note = "\n\nNota: in questa modalità non hai strumenti (niente memoria automatica né ricerca web)."
return {"role": "system", "content": f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{note}"}
return {"role": "system", "content": f"{persona_text(self.assistant_name)}\n\n{user_block(self.user_name, memory_prompt())}{summary_block(self.history)}{note}"}
def _recent(self) -> list:
msgs = self.history.messages
@@ -210,6 +210,7 @@ class OpenAICompatEngine:
full = full.strip() or "(nessuna risposta)"
self.history.save()
emit({"type": "done", "text": full, "sources": sources})
compact_history(self.history, self.summarize)
except Aborted:
self._rollback()
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
@@ -217,6 +218,13 @@ class OpenAICompatEngine:
self._rollback()
emit({"type": "error", "message": friendly_error(err, self.base_url)})
def summarize(self, prompt: str) -> str:
r = self.client.chat.completions.create(model=self.model, max_tokens=400, temperature=0.3, messages=[
{"role": "system", "content": "Sei un assistente che riassume conversazioni in italiano, in modo fedele e compatto."},
{"role": "user", "content": prompt}])
flt = ThinkFilter()
return (flt.push(r.choices[0].message.content or "") + flt.carry).strip()
def _rollback(self) -> None:
msgs = self.history.messages
if msgs and msgs[-1].get("role") == "user":
+318
View File
@@ -0,0 +1,318 @@
"""Client MCP: collega server esterni (stdio o HTTP) e ne espone gli strumenti ai motori dell'app.
Configurazione in config/mcp.json, stesso formato di Claude Desktop:
{"mcpServers": {"nome": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/cartella"], "env": {}},
"remoto": {"url": "https://esempio.it/mcp", "headers": {"Authorization": "Bearer …"}}}}
Gli strumenti compaiono come <nome>_<strumento>; con il motore Claude Code i server vengono passati direttamente a `claude`.
Opzioni per server: "tools": ["click", "browser_*"] (solo questi), "exclude": [...], "description_limit": 700 (caratteri
di descrizione per strumento: server come Cua Driver ne espongono decine con descrizioni lunghissime), "disabled": true.
"""
from __future__ import annotations
import base64
import fnmatch
import json
import os
import re
import subprocess
import threading
import time
import traceback
import urllib.request
from pathlib import Path
from typing import Any
from avatar.settings import CONFIG_DIR, DATA_DIR
CONFIG_FILE = CONFIG_DIR / "mcp.json"
PROTOCOL = "2025-06-18"
CALL_TIMEOUT = 120
EXTRA_PATH = ("/opt/homebrew/bin", "/opt/homebrew/opt/node@22/bin", "/usr/local/bin", str(Path.home() / ".local/bin"))
def load_config() -> dict[str, dict]:
try:
d = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return {k: v for k, v in (d.get("mcpServers") or {}).items() if isinstance(v, dict)}
except FileNotFoundError:
return {}
except Exception as err:
raise RuntimeError(f"config/mcp.json non valido: {err}")
def save_config(text: str) -> dict[str, dict]:
d = json.loads(text)
if not isinstance(d, dict) or not isinstance(d.get("mcpServers", {}), dict):
raise ValueError("serve un oggetto con la chiave mcpServers")
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(d, indent=2, ensure_ascii=False), encoding="utf-8")
return load_config()
def _env_with_path(extra: dict | None) -> dict:
env = dict(os.environ)
env["PATH"] = ":".join([*EXTRA_PATH, env.get("PATH", "")])
env.pop("CLAUDECODE", None)
for k, v in (extra or {}).items():
env[str(k)] = str(v)
return env
class MCPServer:
def __init__(self, name: str, cfg: dict) -> None:
self.name, self.cfg = name, cfg
self.tools: list[dict] = []
self.error = ""
self._id = 0
self._lock = threading.Lock()
self._pending: dict[int, dict] = {}
self._proc: subprocess.Popen | None = None
self._session = ""
self.stdio = bool(cfg.get("command"))
# ── trasporto ──────────────────────────────────────────────────────────
def start(self) -> None:
if self.stdio:
cmd = [str(self.cfg["command"]), *[str(a) for a in self.cfg.get("args", [])]]
self._proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, env=_env_with_path(self.cfg.get("env")), cwd=self.cfg.get("cwd") or None)
threading.Thread(target=self._reader, daemon=True).start()
threading.Thread(target=self._drain_stderr, daemon=True).start()
res = self._request("initialize", {"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "LuZa", "version": "1.0"}}, timeout=60)
self._notify("notifications/initialized")
self.server_info = res.get("serverInfo", {})
self.tools = list(self._request("tools/list", {}, timeout=60).get("tools", []))
def stop(self) -> None:
if self._proc:
try:
self._proc.terminate()
self._proc.wait(timeout=5)
except Exception:
try:
self._proc.kill()
except Exception:
pass
self._proc = None
def _reader(self) -> None:
proc = self._proc
try:
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except Exception:
continue
mid = msg.get("id")
if mid in self._pending:
self._pending[mid]["msg"] = msg
self._pending[mid]["ev"].set()
except Exception:
pass
for p in list(self._pending.values()):
p["msg"] = {"error": {"message": "server MCP terminato"}}
p["ev"].set()
def _drain_stderr(self) -> None:
try:
log = (DATA_DIR / "mcp").joinpath(f"{self.name}.log")
log.parent.mkdir(parents=True, exist_ok=True)
with open(log, "a", encoding="utf-8") as f:
for line in self._proc.stderr:
f.write(line)
except Exception:
pass
def _next_id(self) -> int:
with self._lock:
self._id += 1
return self._id
def _notify(self, method: str, params: dict | None = None) -> None:
msg = {"jsonrpc": "2.0", "method": method, "params": params or {}}
if self.stdio:
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
else:
try:
self._http(msg, timeout=15)
except Exception:
pass
def _request(self, method: str, params: dict, timeout: float = CALL_TIMEOUT) -> dict:
mid = self._next_id()
msg = {"jsonrpc": "2.0", "id": mid, "method": method, "params": params}
if self.stdio:
if not self._proc or self._proc.poll() is not None:
raise RuntimeError(f"server MCP '{self.name}' non in esecuzione")
slot = {"ev": threading.Event(), "msg": None}
self._pending[mid] = slot
self._proc.stdin.write(json.dumps(msg) + "\n")
self._proc.stdin.flush()
if not slot["ev"].wait(timeout):
self._pending.pop(mid, None)
raise RuntimeError(f"server MCP '{self.name}': nessuna risposta entro {int(timeout)} s")
self._pending.pop(mid, None)
resp = slot["msg"]
else:
resp = self._http(msg, timeout=timeout, want_id=mid)
if resp is None:
raise RuntimeError("risposta vuota")
if "error" in resp:
e = resp["error"]
raise RuntimeError(e.get("message") if isinstance(e, dict) else str(e))
return resp.get("result") or {}
def _http(self, msg: dict, timeout: float, want_id: int | None = None) -> dict | None:
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(self.cfg.get("headers") or {})}
if self._session:
headers["Mcp-Session-Id"] = self._session
req = urllib.request.Request(str(self.cfg["url"]), data=json.dumps(msg).encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as r:
sid = r.headers.get("Mcp-Session-Id")
if sid:
self._session = sid
ctype = r.headers.get("Content-Type", "")
body = r.read().decode("utf-8", errors="replace")
if want_id is None:
return None
if "text/event-stream" in ctype:
for line in body.splitlines():
if line.startswith("data:"):
try:
m = json.loads(line[5:].strip())
except Exception:
continue
if m.get("id") == want_id:
return m
raise RuntimeError("nessuna risposta nello stream SSE")
return json.loads(body) if body.strip() else None
# ── strumenti ──────────────────────────────────────────────────────────
def call(self, tool: str, args: dict) -> str:
res = self._request("tools/call", {"name": tool, "arguments": args or {}})
parts = []
for c in res.get("content") or []:
t = c.get("type")
if t == "text":
parts.append(str(c.get("text", "")))
elif t == "image" and c.get("data"):
cap = DATA_DIR / "captures"
cap.mkdir(parents=True, exist_ok=True)
ext = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}.get(c.get("mimeType", ""), "png")
p = cap / f"mcp-{self.name}-{int(time.time())}.{ext}"
p.write_bytes(base64.b64decode(c["data"]))
parts.append(f"IMMAGINE: {p}")
elif t == "resource":
r = c.get("resource") or {}
parts.append(str(r.get("text") or r.get("uri") or ""))
out = "\n".join(p for p in parts if p).strip()
if res.get("isError"):
return "Errore dello strumento: " + (out or "senza dettagli")
if "structuredContent" in res and not out:
out = json.dumps(res["structuredContent"], ensure_ascii=False)
return out[:20000] or "Fatto."
def selected_tools(cfg: dict, tools: list[dict]) -> list[dict]:
"""Applica i filtri tools/exclude della configurazione all'elenco degli strumenti di un server."""
inc, exc = cfg.get("tools") or [], cfg.get("exclude") or []
out = []
for t in tools:
n = t["name"]
if inc and not any(fnmatch.fnmatchcase(n, pat) for pat in inc):
continue
if exc and any(fnmatch.fnmatchcase(n, pat) for pat in exc):
continue
out.append(t)
return out
def _safe(name: str) -> str:
return re.sub(r"[^a-zA-Z0-9_]", "_", name)[:64].strip("_") or "strumento"
class MCPManager:
"""Avvia i server configurati e registra i loro strumenti nel registro dei plugin."""
def __init__(self) -> None:
self.servers: dict[str, MCPServer] = {}
self.errors: dict[str, str] = {}
self.registry = None
self.log = print
self._lock = threading.Lock()
def start(self, registry, log=None) -> None:
self.registry = registry
if log:
self.log = log
threading.Thread(target=self.reload, daemon=True).start()
def reload(self) -> None:
with self._lock:
self._stop_all()
try:
cfg = load_config()
except Exception as err:
self.errors["config"] = str(err)
self.log(f"ERR: MCP — {err}")
return
self.errors.pop("config", None)
for name, c in cfg.items():
if c.get("disabled"):
continue
srv = MCPServer(name, c)
try:
srv.start()
except Exception as err:
srv.stop()
self.errors[name] = str(err)
self.log(f"ERR: MCP «{name}» — {err}")
traceback.print_exc()
continue
self.errors.pop(name, None)
self.servers[name] = srv
self._register(srv)
self.log(f"SYS: MCP «{name}»: {len(srv.tools)} strumenti.")
def _register(self, srv: MCPServer) -> None:
if self.registry is None:
return
tools = []
limit = int(srv.cfg.get("description_limit") or 700)
for t in selected_tools(srv.cfg, srv.tools):
tname = _safe(f"{srv.name}_{t['name']}")
desc = " ".join((t.get("description") or t["name"]).split())
if len(desc) > limit:
desc = desc[:limit].rsplit(" ", 1)[0] + " …"
tools.append({"name": tname, "description": f"[{srv.name}] {desc}",
"parameters": t.get("inputSchema") or {"type": "object", "properties": {}},
"run": (lambda params, ctx, _s=srv, _n=t["name"]: _s.call(_n, params))})
info = getattr(srv, "server_info", {}) or {}
self.registry.add_external(f"mcp:{srv.name}", f"Server MCP {info.get('name') or srv.name}: {len(tools)} strumenti", tools)
def _stop_all(self) -> None:
for name, srv in list(self.servers.items()):
if self.registry is not None:
self.registry.remove_module(f"mcp:{name}")
srv.stop()
self.servers.clear()
def stop(self) -> None:
with self._lock:
self._stop_all()
def status(self) -> str:
lines = []
for name, srv in self.servers.items():
sel = selected_tools(srv.cfg, srv.tools)
lines.append(f"• {name}: {len(sel)} strumenti attivi su {len(srv.tools)} — " + ", ".join(t["name"] for t in sel[:8]) + (" …" if len(sel) > 8 else ""))
for name, err in self.errors.items():
lines.append(f"✗ {name}: {err}")
return "\n".join(lines) or "Nessun server MCP configurato."
manager = MCPManager()
+12
View File
@@ -42,3 +42,15 @@ Sei **Ava**, l'assistente personale di chi ti parla. Vivi in un'app sul suo Mac
- Con gli strumenti "casa_…" controlli la casa tramite Home Assistant: luci, prese, clima, tapparelle, media, scene e sensori. Se l'utente nomina un dispositivo che non trovi, cerca con casa_dispositivi prima di dire che non esiste; per comandi su più stanze usa casa_chiedi.
- Conferma a voce cosa hai fatto in poche parole ("Luce cucina accesa"). Serrature e allarme solo dopo la conferma esplicita dell'utente.
## Computer
- Se sono disponibili gli strumenti "cua_driver_…" puoi ispezionare e comandare le app del Mac (aprire finestre, leggere e premere elementi, scrivere nei campi) senza togliere il focus all'utente. Usali quando l'utente chiede un'azione dentro un'app e non esiste un plugin dedicato: per mail, calendario, promemoria, note, musica, file e casa preferisci sempre i plugin.
- Prima di agire osserva: leggi la finestra o l'elemento, poi esegui un passo alla volta e verifica il risultato. Niente sequenze lunghe alla cieca.
- Chiedi conferma prima di azioni difficili da annullare: inviare messaggi o mail, pagare, cancellare, chiudere documenti non salvati, modificare impostazioni. Se un passaggio non riesce due volte, fermati e spiega cosa vedi invece di insistere.
- Non inserire mai password o codici, e non agire in finestre di banche o pagamenti se non su richiesta esplicita e puntuale dell'utente.
- Metodo del driver: prima list_apps o list_windows per trovare pid e finestra, poi get_window_state per leggere gli elementi, poi agisci con click, type_text o set_value indicando l'elemento (element_token o element_index + window_id) invece delle coordinate, infine rileggi lo stato o usa verify_state per controllare. Lancia le app con launch_app (in background), e porta in primo piano con bring_to_front solo se l'utente lo chiede.
## Immagini
- Puoi creare immagini con immagine_genera: traduci la richiesta in una descrizione inglese ricca (soggetto, scena, luce, stile, inquadratura) e avvisa che ci vuole qualche decina di secondi. Quando è pronta, dilla in poche parole senza descrivere i dettagli tecnici.
+18
View File
@@ -91,6 +91,24 @@ class PluginRegistry:
self.modules[mod_name] = info
return self
def add_external(self, module: str, description: str, tools: list[dict]) -> None:
"""Registra strumenti forniti a runtime (server MCP), sostituendo quelli dello stesso modulo."""
self.load()
self.remove_module(module)
info = {"name": module, "description": description, "valid": True, "error": "", "tools": []}
for t in tools:
name = str(t["name"])
if name in self.tools: # nome già usato da un plugin: prefissa
name = f"x_{name}"[:64]
self.tools[name] = Tool(module, name, str(t.get("description", "")), _normalize_schema(t.get("parameters", {})), t["run"])
info["tools"].append(name)
self.modules[module] = info
def remove_module(self, module: str) -> None:
for name in [n for n, t in self.tools.items() if t.module == module]:
self.tools.pop(name, None)
self.modules.pop(module, None)
def _enabled(self, module: str) -> bool:
try:
from memory.config_manager import get_plugin_enabled
+2
View File
@@ -52,6 +52,8 @@ DEFAULTS: dict[str, Any] = {
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
"immagini_famiglia": "z-image-turbo", # z-image-turbo | schnell | dev | qwen (comando mflux e passi predefiniti)
"immagini_modello": "", # repo Hugging Face o cartella; vuoto = predefinito della famiglia
}
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token")
+54 -1
View File
@@ -1,6 +1,7 @@
"""Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale."""
from __future__ import annotations
import json
import sys
import threading
from pathlib import Path
@@ -65,7 +66,7 @@ class SettingsDialog(QDialog):
self.tabs.addTab(sc, title)
lay.addStretch(1)
return lay
pg_motore, pg_voce, pg_msg, pg_mon, pg_casa = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor"), page("Casa")
pg_motore, pg_voce, pg_msg, pg_mon, pg_casa, pg_tool = page("Motore"), page("Voce e avatar"), page("Messaggistica"), page("Monitor"), page("Casa"), page("Strumenti")
def add(lay, item): # inserisce prima dello stretch finale
if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item)
else: lay.insertLayout(lay.count() - 1, item)
@@ -260,6 +261,36 @@ class SettingsDialog(QDialog):
self.ha_hint = QLabel(""); self.ha_hint.setWordWrap(True); form_ha.addRow("", self.ha_hint)
add(pg_casa, form_ha)
# ── Strumenti (server MCP esterni) ────────────────────────────────
from PyQt6.QtWidgets import QPlainTextEdit
from .mcp_client import CONFIG_FILE as MCP_FILE, manager as mcp_manager
form_mcp = QFormLayout()
form_mcp.addRow(QLabel("Server MCP esterni: stesso formato di Claude Desktop (mcpServers con command/args/env, oppure url/headers). "
"Gli strumenti diventano disponibili a tutti i motori; con Claude Code i server vengono passati direttamente a claude."))
self.mcp_text = QPlainTextEdit()
try:
self.mcp_text.setPlainText(MCP_FILE.read_text(encoding="utf-8"))
except Exception:
self.mcp_text.setPlainText('{\n "mcpServers": {\n }\n}\n')
self.mcp_text.setMinimumHeight(220)
form_mcp.addRow(self.mcp_text)
row = QHBoxLayout()
b = QPushButton("Applica e ricollega"); b.clicked.connect(self._mcp_apply); row.addWidget(b)
b = QPushButton("Esempio"); b.clicked.connect(self._mcp_example); row.addWidget(b)
row.addStretch()
form_mcp.addRow(row)
self.mcp_hint = QLabel(mcp_manager.status()); self.mcp_hint.setWordWrap(True); form_mcp.addRow(self.mcp_hint)
add(pg_tool, form_mcp)
# ── Immagini (mflux) ──────────────────────────────────────────────
form_img = QFormLayout()
form_img.addRow(QLabel("Generazione immagini in locale (mflux, MLX). Il primo uso scarica il modello da Hugging Face."))
self.img_fam = _combo([("z-image-turbo", "Z-Image Turbo (veloce, 8 passi, consigliato)"), ("schnell", "FLUX.1 schnell (4 passi)"), ("dev", "FLUX.1 dev (lento, licenza non commerciale)"), ("qwen", "Qwen-Image (pesante, ottimo col testo)")], s.get("immagini_famiglia") or "z-image-turbo")
form_img.addRow("Famiglia", self.img_fam)
self.img_model = QLineEdit(str(s.get("immagini_modello") or "")); self.img_model.setPlaceholderText("vuoto = predefinito (es. mflux-community/z-image-turbo-mflux-q4)")
form_img.addRow("Modello (repo Hugging Face)", self.img_model)
add(pg_tool, form_img)
btns = QHBoxLayout(); btns.addStretch()
cancel = QPushButton("Annulla"); cancel.clicked.connect(self.reject); btns.addWidget(cancel)
save = QPushButton("Salva"); save.setObjectName("primary"); save.clicked.connect(self._save); btns.addWidget(save)
@@ -407,6 +438,25 @@ class SettingsDialog(QDialog):
self._async.emit("el_error", str(err)[:120])
threading.Thread(target=work, daemon=True).start()
def _mcp_example(self) -> None:
self.mcp_text.setPlainText(json.dumps({"mcpServers": {
"filesystem": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", str(Path.home() / "Documents")]},
"remoto": {"url": "https://esempio.it/mcp", "headers": {"Authorization": "Bearer TOKEN"}, "disabled": True},
}}, indent=2, ensure_ascii=False))
def _mcp_apply(self) -> None:
from .mcp_client import save_config, manager as mcp_manager
try:
save_config(self.mcp_text.toPlainText())
except Exception as err:
self.mcp_hint.setText(f"JSON non valido: {err}"); return
self.mcp_hint.setText("Collego i server…")
def go():
mcp_manager.reload()
self._async.emit("mcp", mcp_manager.status())
threading.Thread(target=go, daemon=True).start()
def _ha_check(self) -> None:
vals = {"homeassistant_url": self.ha_url.text().strip().rstrip("/")}
if self.ha_token.text().strip():
@@ -427,6 +477,8 @@ class SettingsDialog(QDialog):
def _on_async(self, kind: str, payload) -> None:
if kind == "ha":
self.ha_hint.setText(str(payload)); return
if kind == "mcp":
self.mcp_hint.setText(str(payload)); return
if kind == "vb":
self.vb_profile.clear()
for pid, label in payload:
@@ -517,6 +569,7 @@ class SettingsDialog(QDialog):
"server_assist_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance",
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
"homeassistant_url": self.ha_url.text().strip().rstrip("/"),
"immagini_famiglia": self.img_fam.currentData() or "z-image-turbo", "immagini_modello": self.img_model.text().strip(),
}
if self.ha_token.text().strip():
values["homeassistant_token"] = self.ha_token.text().strip()
+52
View File
@@ -0,0 +1,52 @@
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/tuonome/Documents"
]
},
"remoto": {
"url": "https://esempio.it/mcp",
"headers": {
"Authorization": "Bearer TOKEN"
},
"disabled": true
},
"cua-driver": {
"command": "/Users/tuonome/.local/bin/cua-driver",
"args": [
"mcp"
],
"tools": [
"list_apps",
"list_windows",
"get_window_state",
"verify_state",
"launch_app",
"bring_to_front",
"invoke_menu",
"click",
"double_click",
"right_click",
"type_text",
"press_key",
"hotkey",
"set_value",
"scroll",
"get_desktop_state",
"zoom",
"clipboard_read",
"clipboard_write",
"get_browser_state",
"browser_navigate",
"browser_click",
"browser_type"
],
"description_limit": 800,
"disabled": true
}
}
}
+3
View File
@@ -62,6 +62,9 @@ def main() -> None:
confirm.bind(ui.show_confirm, ui.hide_confirm, ui.write_log)
registry.ctx = {"say": assistant.say, "log": ui.write_log, "confirm": confirm.request, "player": ui}
ui.get_plugins = registry.list_for_ui
from avatar.mcp_client import manager as mcp_manager
mcp_manager.start(registry, ui.write_log)
ui._app.aboutToQuit.connect(mcp_manager.stop)
ui.get_plugin_settings = lambda: []
ui.request_say = assistant.say
# Questi due sono attributi della finestra, non della facciata JarvisUI.
+34
View File
@@ -125,6 +125,40 @@ def save_memory(memory: dict) -> None:
json.dumps(memory, indent=2, ensure_ascii=False),
encoding="utf-8",
)
try:
export_markdown(memory)
except Exception as err:
print(f"[Memory] esportazione Markdown fallita: {err}")
EXPORT_DIR = get_base_dir() / "data" / "memoria"
_EXPORT_LABELS = {"identity": "Identità", "preferences": "Preferenze", "projects": "Progetti", "relationships": "Persone",
"wishes": "Desideri e obiettivi", "notes": "Note"}
def export_markdown(memory: dict, folder: Path | None = None) -> Path:
"""Specchia la memoria in una cartella di file Markdown (apribile come vault Obsidian): un file per categoria più un indice."""
folder = folder or EXPORT_DIR
folder.mkdir(parents=True, exist_ok=True)
index = ["# Memoria di LuZa", "", f"Aggiornata: {datetime.now():%Y-%m-%d %H:%M}", ""]
for cat, label in _EXPORT_LABELS.items():
entries = memory.get(cat) or {}
if not isinstance(entries, dict):
continue
lines = ["---", f"categoria: {cat}", f"voci: {len(entries)}", "---", "", f"# {label}", ""]
for key, entry in sorted(entries.items(), key=lambda kv: (kv[1].get("updated", "") if isinstance(kv[1], dict) else ""), reverse=True):
val = _entry_value(entry)
upd = entry.get("updated", "") if isinstance(entry, dict) else ""
lines.append(f"- **{_pretty(key)}**: {val}" + (f" _(aggiornato {upd})_" if upd else ""))
(folder / f"{label}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
index.append(f"- [[{label}]] — {len(entries)} voci")
sessions = memory.get("sessions") or []
if isinstance(sessions, list) and sessions:
lines = ["# Sessioni recenti", ""] + [f"- {e.get('date', '')}: {e.get('summary', '')}" for e in sessions]
(folder / "Sessioni recenti.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
index.append("- [[Sessioni recenti]]")
(folder / "Memoria.md").write_text("\n".join(index) + "\n", encoding="utf-8")
return folder
def _truncate_value(val: str) -> str:
+98
View File
@@ -0,0 +1,98 @@
"""Generazione di immagini in locale con mflux (MLX su Apple Silicon): Z-Image Turbo, FLUX e altri.
Gira in un ambiente separato (.venv-mflux) come processo esterno, così la memoria viene liberata a fine generazione.
Il modello si sceglie nelle impostazioni (scheda Strumenti); il primo uso scarica i pesi da Hugging Face.
"""
from __future__ import annotations
import re
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from avatar.settings import BASE_DIR, DATA_DIR, Settings # noqa: E402
VENV = BASE_DIR / ".venv-mflux" / "bin"
OUT_DIR = DATA_DIR / "immagini"
# comando mflux, passi e guida consigliati per ciascuna famiglia
MODELLI = {
"z-image-turbo": {"cmd": "mflux-generate-z-image-turbo", "steps": 8, "guidance": None, "default_repo": "mflux-community/z-image-turbo-mflux-q4"},
"schnell": {"cmd": "mflux-generate", "steps": 4, "guidance": None, "default_repo": "dhairyashil/FLUX.1-schnell-mflux-4bit", "base": "schnell"},
"dev": {"cmd": "mflux-generate", "steps": 20, "guidance": 3.5, "default_repo": "", "base": "dev"},
"qwen": {"cmd": "mflux-generate-qwen", "steps": 20, "guidance": 4.0, "default_repo": ""},
}
FORMATI = {"quadrata": (1024, 1024), "orizzontale": (1280, 768), "verticale": (768, 1280), "schermo": (1536, 864), "piccola": (768, 768)}
_last: dict = {"path": ""}
def _cfg() -> tuple[str, str]:
s = Settings()
fam = str(s.get("immagini_famiglia") or "z-image-turbo")
repo = str(s.get("immagini_modello") or "").strip() or MODELLI.get(fam, MODELLI["z-image-turbo"])["default_repo"]
return fam, repo
def genera(params: dict, ctx: dict) -> str:
prompt = str(params.get("descrizione", "")).strip()
if not prompt:
return "Errore: serve la descrizione dell'immagine (in inglese, dettagliata)."
if not (VENV / "mflux-generate").exists():
return "Generazione immagini non disponibile: manca l'ambiente .venv-mflux (uv venv .venv-mflux && uv pip install mflux)."
fam, repo = _cfg()
m = MODELLI.get(fam, MODELLI["z-image-turbo"])
w, h = FORMATI.get(str(params.get("formato", "quadrata")).lower(), FORMATI["quadrata"])
steps = int(params.get("passi") or m["steps"])
OUT_DIR.mkdir(parents=True, exist_ok=True)
stem = re.sub(r"[^a-z0-9]+", "-", prompt.lower())[:40].strip("-") or "immagine"
out = OUT_DIR / f"{time.strftime('%Y%m%d-%H%M%S')}-{stem}.png"
cmd = [str(VENV / m["cmd"]), "--prompt", prompt, "--steps", str(steps), "--width", str(w), "--height", str(h), "--output", str(out)]
if repo:
cmd += ["--model", repo]
if m.get("base") and "/" in repo:
cmd += ["--base-model", m["base"]]
if m["guidance"] is not None:
cmd += ["--guidance", str(params.get("guida") or m["guidance"])]
if params.get("seme") is not None:
cmd += ["--seed", str(int(params["seme"]))]
if params.get("immagine_base"):
cmd += ["--image-path", str(params["immagine_base"]), "--image-strength", str(params.get("forza") or 0.5)]
if ctx.get("log"):
ctx["log"](f"[immagini] {fam} {w}x{h} {steps} passi: {prompt[:80]}")
t = time.time()
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
except subprocess.TimeoutExpired:
return "Generazione interrotta: troppo lenta (oltre 30 minuti)."
if proc.returncode != 0 or not out.exists():
err = (proc.stderr or proc.stdout).strip().splitlines()
tail = " | ".join(err[-3:]) if err else f"codice {proc.returncode}"
if "not found" in tail.lower() or "401" in tail or "403" in tail:
tail += " (controlla il nome del modello nelle impostazioni, o accetta la licenza su Hugging Face)"
return f"Generazione fallita: {tail[:400]}"
_last["path"] = str(out)
secs = time.time() - t
if params.get("mostra", True) not in (False, "false", "no"):
subprocess.Popen(["open", str(out)])
return f"Immagine generata in {secs:.0f} s e salvata in {out} ({w}x{h}, {fam}).\nIMMAGINE: {out}"
def ultima(params: dict, ctx: dict) -> str:
files = sorted(OUT_DIR.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True) if OUT_DIR.exists() else []
if not files:
return "Nessuna immagine generata finora."
n = max(1, min(int(params.get("numero") or 5), 20))
if str(params.get("apri", "")).lower() in ("true", "1", "sì", "si"):
subprocess.Popen(["open", str(files[0])])
return "Ultime immagini:\n" + "\n".join(f"- {p.name}" for p in files[:n]) + f"\nIMMAGINE: {files[0]}"
TOOLS = [
{"name": "immagine_genera", "description": "Genera un'immagine in locale (MLX) da una descrizione. Scrivi la descrizione in INGLESE, dettagliata (soggetto, ambiente, luce, stile, inquadratura), anche se l'utente parla italiano. formato: quadrata (default), orizzontale, verticale, schermo, piccola. L'immagine viene salvata e aperta in Anteprima; richiede 10-60 secondi, il primo uso scarica il modello. Con immagine_base (percorso) e forza (0-1) parte da un'immagine esistente.",
"parameters": {"type": "object", "properties": {"descrizione": {"type": "string"}, "formato": {"type": "string", "enum": ["quadrata", "orizzontale", "verticale", "schermo", "piccola"]},
"passi": {"type": "integer"}, "seme": {"type": "integer"}, "immagine_base": {"type": "string"}, "forza": {"type": "number"}, "mostra": {"type": "boolean"}},
"required": ["descrizione"]}, "run": genera},
{"name": "immagine_ultime", "description": "Elenca le ultime immagini generate (cartella data/immagini); con apri=true apre la più recente.",
"parameters": {"type": "object", "properties": {"numero": {"type": "integer"}, "apri": {"type": "boolean"}}}, "run": ultima},
]