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:
1 parent
61292ea2e8
commit
3e9ecd487d
14 files changed
+1186
-484
No files matched your search
@@ -8,6 +8,7 @@ whatsapp_bridge/node_modules/
|
|||||||
data/
|
data/
|
||||||
config/api_keys.json
|
config/api_keys.json
|
||||||
config/settings.json
|
config/settings.json
|
||||||
|
config/mcp.json
|
||||||
memory/long_term.json
|
memory/long_term.json
|
||||||
*.session
|
*.session
|
||||||
*.session-journal
|
*.session-journal
|
||||||
@@ -20,4 +21,5 @@ avatar3d/models_archivio/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.venv-chatterbox/
|
.venv-chatterbox/
|
||||||
|
.venv-mflux/
|
||||||
tts_chatterbox/test_*.py
|
tts_chatterbox/test_*.py
|
||||||
@@ -67,3 +67,63 @@ class History:
|
|||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self.messages, self.meta = [], {}
|
self.messages, self.meta = [], {}
|
||||||
self.save()
|
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 ""
|
||||||
@@ -112,10 +112,33 @@ class ClaudeCodeEngine:
|
|||||||
flags["tools"] = flags["tools"] + ",Read"
|
flags["tools"] = flags["tools"] + ",Read"
|
||||||
flags["allowed"] = [*flags["allowed"], f"Read(//{attach_path.lstrip('/')})"]
|
flags["allowed"] = [*flags["allowed"], f"Read(//{attach_path.lstrip('/')})"]
|
||||||
mcp = {"mcpServers": {"avatar": {"command": sys.executable, "args": [str(MCP_SCRIPT)]}}}
|
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",
|
args = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
|
||||||
"--model", self.model, "--effort", self.effort,
|
"--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",
|
"--mcp-config", json.dumps(mcp), "--strict-mcp-config",
|
||||||
|
*(["--disallowedTools", ",".join(disallowed_mcp)] if disallowed_mcp else []),
|
||||||
"--system-prompt-snapshot", "off",
|
"--system-prompt-snapshot", "off",
|
||||||
"--resume" if resume else "--session-id", session_id]
|
"--resume" if resume else "--session-id", session_id]
|
||||||
if flags["mode"]:
|
if flags["mode"]:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from pathlib import Path
|
|||||||
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
|
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
|
||||||
from avatar.plugins import registry
|
from avatar.plugins import registry
|
||||||
from avatar.websearch import brave_search
|
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
|
from .openai_compat import SEARCH_TOOL, Aborted
|
||||||
|
|
||||||
MAX_ROUNDS = 8
|
MAX_ROUNDS = 8
|
||||||
@@ -323,7 +323,7 @@ class MLXEngine:
|
|||||||
"- Chiama gli strumenti solo nel formato previsto dal tuo template e mai descrivendoli a parole.")
|
"- 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
|
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.")
|
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:
|
def _recent(self) -> list:
|
||||||
msgs = self.history.messages
|
msgs = self.history.messages
|
||||||
@@ -443,6 +443,9 @@ class MLXEngine:
|
|||||||
full = full.strip() or "(nessuna risposta)"
|
full = full.strip() or "(nessuna risposta)"
|
||||||
self.history.save()
|
self.history.save()
|
||||||
emit({"type": "done", "text": full, "sources": sources})
|
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:
|
except Aborted:
|
||||||
self._rollback()
|
self._rollback()
|
||||||
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
|
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
|
||||||
@@ -450,6 +453,22 @@ class MLXEngine:
|
|||||||
self._rollback()
|
self._rollback()
|
||||||
emit({"type": "error", "message": f"Modello interno: {err}"})
|
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:
|
def _rollback(self) -> None:
|
||||||
msgs = self.history.messages
|
msgs = self.history.messages
|
||||||
if msgs and msgs[-1].get("role") == "user":
|
if msgs and msgs[-1].get("role") == "user":
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import openai
|
|||||||
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
|
from avatar.memory_tools import memory_prompt, openai_tools, parse_args, run_tool
|
||||||
from avatar.plugins import registry
|
from avatar.plugins import registry
|
||||||
from avatar.websearch import brave_search
|
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_ROUNDS = 8
|
||||||
MAX_HISTORY = 30
|
MAX_HISTORY = 30
|
||||||
@@ -98,7 +98,7 @@ class OpenAICompatEngine:
|
|||||||
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
|
else "\n- Non hai accesso al web: se ti chiedono informazioni aggiornate, dillo chiaramente.")
|
||||||
else:
|
else:
|
||||||
note = "\n\nNota: in questa modalità non hai strumenti (niente memoria automatica né ricerca web)."
|
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:
|
def _recent(self) -> list:
|
||||||
msgs = self.history.messages
|
msgs = self.history.messages
|
||||||
@@ -210,6 +210,7 @@ class OpenAICompatEngine:
|
|||||||
full = full.strip() or "(nessuna risposta)"
|
full = full.strip() or "(nessuna risposta)"
|
||||||
self.history.save()
|
self.history.save()
|
||||||
emit({"type": "done", "text": full, "sources": sources})
|
emit({"type": "done", "text": full, "sources": sources})
|
||||||
|
compact_history(self.history, self.summarize)
|
||||||
except Aborted:
|
except Aborted:
|
||||||
self._rollback()
|
self._rollback()
|
||||||
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
|
emit({"type": "error", "message": "Risposta interrotta.", "aborted": True})
|
||||||
@@ -217,6 +218,13 @@ class OpenAICompatEngine:
|
|||||||
self._rollback()
|
self._rollback()
|
||||||
emit({"type": "error", "message": friendly_error(err, self.base_url)})
|
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:
|
def _rollback(self) -> None:
|
||||||
msgs = self.history.messages
|
msgs = self.history.messages
|
||||||
if msgs and msgs[-1].get("role") == "user":
|
if msgs and msgs[-1].get("role") == "user":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
||||||
@@ -91,6 +91,24 @@ class PluginRegistry:
|
|||||||
self.modules[mod_name] = info
|
self.modules[mod_name] = info
|
||||||
return self
|
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:
|
def _enabled(self, module: str) -> bool:
|
||||||
try:
|
try:
|
||||||
from memory.config_manager import get_plugin_enabled
|
from memory.config_manager import get_plugin_enabled
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
|
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
|
||||||
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
|
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
|
||||||
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
|
"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")
|
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale."""
|
"""Finestra impostazioni: motore, chiavi, voce e riconoscimento vocale."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -65,7 +66,7 @@ class SettingsDialog(QDialog):
|
|||||||
self.tabs.addTab(sc, title)
|
self.tabs.addTab(sc, title)
|
||||||
lay.addStretch(1)
|
lay.addStretch(1)
|
||||||
return lay
|
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
|
def add(lay, item): # inserisce prima dello stretch finale
|
||||||
if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item)
|
if isinstance(item, QWidget): lay.insertWidget(lay.count() - 1, item)
|
||||||
else: lay.insertLayout(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)
|
self.ha_hint = QLabel(""); self.ha_hint.setWordWrap(True); form_ha.addRow("", self.ha_hint)
|
||||||
add(pg_casa, form_ha)
|
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()
|
btns = QHBoxLayout(); btns.addStretch()
|
||||||
cancel = QPushButton("Annulla"); cancel.clicked.connect(self.reject); btns.addWidget(cancel)
|
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)
|
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])
|
self._async.emit("el_error", str(err)[:120])
|
||||||
threading.Thread(target=work, daemon=True).start()
|
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:
|
def _ha_check(self) -> None:
|
||||||
vals = {"homeassistant_url": self.ha_url.text().strip().rstrip("/")}
|
vals = {"homeassistant_url": self.ha_url.text().strip().rstrip("/")}
|
||||||
if self.ha_token.text().strip():
|
if self.ha_token.text().strip():
|
||||||
@@ -427,6 +477,8 @@ class SettingsDialog(QDialog):
|
|||||||
def _on_async(self, kind: str, payload) -> None:
|
def _on_async(self, kind: str, payload) -> None:
|
||||||
if kind == "ha":
|
if kind == "ha":
|
||||||
self.ha_hint.setText(str(payload)); return
|
self.ha_hint.setText(str(payload)); return
|
||||||
|
if kind == "mcp":
|
||||||
|
self.mcp_hint.setText(str(payload)); return
|
||||||
if kind == "vb":
|
if kind == "vb":
|
||||||
self.vb_profile.clear()
|
self.vb_profile.clear()
|
||||||
for pid, label in payload:
|
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_dir": self.srv_dir.text().strip() or "/opt/aiserverassistance",
|
||||||
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
|
"server_assist_bot": self.srv_bot.text().strip().lstrip("@") or "luzaserver_bot",
|
||||||
"homeassistant_url": self.ha_url.text().strip().rstrip("/"),
|
"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():
|
if self.ha_token.text().strip():
|
||||||
values["homeassistant_token"] = self.ha_token.text().strip()
|
values["homeassistant_token"] = self.ha_token.text().strip()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ def main() -> None:
|
|||||||
confirm.bind(ui.show_confirm, ui.hide_confirm, ui.write_log)
|
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}
|
registry.ctx = {"say": assistant.say, "log": ui.write_log, "confirm": confirm.request, "player": ui}
|
||||||
ui.get_plugins = registry.list_for_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.get_plugin_settings = lambda: []
|
||||||
ui.request_say = assistant.say
|
ui.request_say = assistant.say
|
||||||
# Questi due sono attributi della finestra, non della facciata JarvisUI.
|
# Questi due sono attributi della finestra, non della facciata JarvisUI.
|
||||||
|
|||||||
+512
-478
@@ -1,479 +1,513 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
def get_base_dir() -> Path:
|
def get_base_dir() -> Path:
|
||||||
if getattr(sys, "frozen", False):
|
if getattr(sys, "frozen", False):
|
||||||
return Path(sys.executable).parent
|
return Path(sys.executable).parent
|
||||||
return Path(__file__).resolve().parent.parent
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = get_base_dir()
|
BASE_DIR = get_base_dir()
|
||||||
MEMORY_PATH = BASE_DIR / "memory" / "long_term.json"
|
MEMORY_PATH = BASE_DIR / "memory" / "long_term.json"
|
||||||
_lock = Lock()
|
_lock = Lock()
|
||||||
MAX_VALUE_LENGTH = 380
|
MAX_VALUE_LENGTH = 380
|
||||||
|
|
||||||
# ── Why there are two very different numbers here ────────────────────────────
|
# ── Why there are two very different numbers here ────────────────────────────
|
||||||
#
|
#
|
||||||
# There used to be one: MEMORY_MAX_CHARS = 2200, applied to the whole store. It
|
# There used to be one: MEMORY_MAX_CHARS = 2200, applied to the whole store. It
|
||||||
# was a *storage* limit, and it existed only because the entire memory was
|
# was a *storage* limit, and it existed only because the entire memory was
|
||||||
# pasted into the system prompt on every connect — so growing the memory grew
|
# pasted into the system prompt on every connect — so growing the memory grew
|
||||||
# every single request. When it filled, _trim_to_limit() deleted the oldest
|
# every single request. When it filled, _trim_to_limit() deleted the oldest
|
||||||
# entries and printed one line to a console nobody reads. A memory described as
|
# entries and printed one line to a console nobody reads. A memory described as
|
||||||
# "deeply remembers projects, preferences and personal context" was in practice
|
# "deeply remembers projects, preferences and personal context" was in practice
|
||||||
# two pages long, and quietly forgot your sister's name after a few weeks.
|
# two pages long, and quietly forgot your sister's name after a few weeks.
|
||||||
#
|
#
|
||||||
# Storage and prompt budget are now separate concerns:
|
# Storage and prompt budget are now separate concerns:
|
||||||
#
|
#
|
||||||
# MEMORY_MAX_CHARS — a runaway guard, not a feature limit. Nothing normal
|
# MEMORY_MAX_CHARS — a runaway guard, not a feature limit. Nothing normal
|
||||||
# reaches it; a bug writing in a loop does.
|
# reaches it; a bug writing in a loop does.
|
||||||
# PROMPT_CORE_CHARS — what actually rides in the system prompt every session.
|
# PROMPT_CORE_CHARS — what actually rides in the system prompt every session.
|
||||||
# Smaller than the old whole-memory dump, so sessions
|
# Smaller than the old whole-memory dump, so sessions
|
||||||
# start *faster* than before, not slower.
|
# start *faster* than before, not slower.
|
||||||
#
|
#
|
||||||
# Everything above the core stays on disk and is fetched on demand by the
|
# Everything above the core stays on disk and is fetched on demand by the
|
||||||
# recall_memory tool — see search_memory() and format_memory_for_prompt().
|
# recall_memory tool — see search_memory() and format_memory_for_prompt().
|
||||||
MEMORY_MAX_CHARS = 200_000
|
MEMORY_MAX_CHARS = 200_000
|
||||||
PROMPT_CORE_CHARS = 900
|
PROMPT_CORE_CHARS = 900
|
||||||
PROMPT_INDEX_CHARS = 420
|
PROMPT_INDEX_CHARS = 420
|
||||||
# Most entries any one category may contribute to the core block, so a person
|
# Most entries any one category may contribute to the core block, so a person
|
||||||
# with forty stored preferences still gets their sister into the prompt.
|
# with forty stored preferences still gets their sister into the prompt.
|
||||||
PROMPT_MAX_PER_CATEGORY = 6
|
PROMPT_MAX_PER_CATEGORY = 6
|
||||||
|
|
||||||
def _empty_memory() -> dict:
|
def _empty_memory() -> dict:
|
||||||
return {
|
return {
|
||||||
"identity": {},
|
"identity": {},
|
||||||
"preferences": {},
|
"preferences": {},
|
||||||
"projects": {},
|
"projects": {},
|
||||||
"relationships": {},
|
"relationships": {},
|
||||||
"wishes": {},
|
"wishes": {},
|
||||||
"notes": {},
|
"notes": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
def load_memory() -> dict:
|
def load_memory() -> dict:
|
||||||
if not MEMORY_PATH.exists():
|
if not MEMORY_PATH.exists():
|
||||||
return _empty_memory()
|
return _empty_memory()
|
||||||
with _lock:
|
with _lock:
|
||||||
try:
|
try:
|
||||||
data = json.loads(MEMORY_PATH.read_text(encoding="utf-8"))
|
data = json.loads(MEMORY_PATH.read_text(encoding="utf-8"))
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
base = _empty_memory()
|
base = _empty_memory()
|
||||||
for key in base:
|
for key in base:
|
||||||
if key not in data:
|
if key not in data:
|
||||||
data[key] = {}
|
data[key] = {}
|
||||||
return data
|
return data
|
||||||
return _empty_memory()
|
return _empty_memory()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Memory] ⚠️ Load error: {e}")
|
print(f"[Memory] ⚠️ Load error: {e}")
|
||||||
return _empty_memory()
|
return _empty_memory()
|
||||||
|
|
||||||
def _all_entries(memory: dict) -> list[tuple]:
|
def _all_entries(memory: dict) -> list[tuple]:
|
||||||
entries = []
|
entries = []
|
||||||
for cat, items in memory.items():
|
for cat, items in memory.items():
|
||||||
if not isinstance(items, dict):
|
if not isinstance(items, dict):
|
||||||
continue
|
continue
|
||||||
for key, entry in items.items():
|
for key, entry in items.items():
|
||||||
if isinstance(entry, dict) and "value" in entry:
|
if isinstance(entry, dict) and "value" in entry:
|
||||||
entries.append((cat, key, entry))
|
entries.append((cat, key, entry))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
# Set by main.py so a trim can reach the activity log. Deleting something a
|
# Set by main.py so a trim can reach the activity log. Deleting something a
|
||||||
# person told you and mentioning it only on stdout is how a memory loses trust.
|
# person told you and mentioning it only on stdout is how a memory loses trust.
|
||||||
_trim_notifier = None
|
_trim_notifier = None
|
||||||
|
|
||||||
|
|
||||||
def set_trim_notifier(fn) -> None:
|
def set_trim_notifier(fn) -> None:
|
||||||
"""Register a callable(str) that surfaces trims to the user."""
|
"""Register a callable(str) that surfaces trims to the user."""
|
||||||
global _trim_notifier
|
global _trim_notifier
|
||||||
_trim_notifier = fn
|
_trim_notifier = fn
|
||||||
|
|
||||||
|
|
||||||
def _trim_to_limit(memory: dict) -> dict:
|
def _trim_to_limit(memory: dict) -> dict:
|
||||||
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
||||||
return memory
|
return memory
|
||||||
entries = _all_entries(memory)
|
entries = _all_entries(memory)
|
||||||
entries.sort(key=lambda t: t[2].get("updated", "0000-00-00"))
|
entries.sort(key=lambda t: t[2].get("updated", "0000-00-00"))
|
||||||
dropped = []
|
dropped = []
|
||||||
for cat, key, _ in entries:
|
for cat, key, _ in entries:
|
||||||
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
if len(json.dumps(memory, ensure_ascii=False)) <= MEMORY_MAX_CHARS:
|
||||||
break
|
break
|
||||||
del memory[cat][key]
|
del memory[cat][key]
|
||||||
dropped.append(f"{cat}/{key}")
|
dropped.append(f"{cat}/{key}")
|
||||||
print(f"[Memory] 🗑️ Trimmed {cat}/{key}")
|
print(f"[Memory] 🗑️ Trimmed {cat}/{key}")
|
||||||
if dropped and _trim_notifier:
|
if dropped and _trim_notifier:
|
||||||
try:
|
try:
|
||||||
_trim_notifier(
|
_trim_notifier(
|
||||||
f"SYS: Memory full — forgot {len(dropped)} oldest entries "
|
f"SYS: Memory full — forgot {len(dropped)} oldest entries "
|
||||||
f"({', '.join(dropped[:3])}{'…' if len(dropped) > 3 else ''})"
|
f"({', '.join(dropped[:3])}{'…' if len(dropped) > 3 else ''})"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return memory
|
return memory
|
||||||
|
|
||||||
def save_memory(memory: dict) -> None:
|
def save_memory(memory: dict) -> None:
|
||||||
if not isinstance(memory, dict):
|
if not isinstance(memory, dict):
|
||||||
return
|
return
|
||||||
memory = _trim_to_limit(memory)
|
memory = _trim_to_limit(memory)
|
||||||
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with _lock:
|
with _lock:
|
||||||
MEMORY_PATH.write_text(
|
MEMORY_PATH.write_text(
|
||||||
json.dumps(memory, indent=2, ensure_ascii=False),
|
json.dumps(memory, indent=2, ensure_ascii=False),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
export_markdown(memory)
|
||||||
def _truncate_value(val: str) -> str:
|
except Exception as err:
|
||||||
if isinstance(val, str) and len(val) > MAX_VALUE_LENGTH:
|
print(f"[Memory] esportazione Markdown fallita: {err}")
|
||||||
return val[:MAX_VALUE_LENGTH].rstrip() + "…"
|
|
||||||
return val
|
|
||||||
|
EXPORT_DIR = get_base_dir() / "data" / "memoria"
|
||||||
|
_EXPORT_LABELS = {"identity": "Identità", "preferences": "Preferenze", "projects": "Progetti", "relationships": "Persone",
|
||||||
def _recursive_update(target: dict, updates: dict) -> bool:
|
"wishes": "Desideri e obiettivi", "notes": "Note"}
|
||||||
changed = False
|
|
||||||
for key, value in updates.items():
|
|
||||||
if value is None:
|
def export_markdown(memory: dict, folder: Path | None = None) -> Path:
|
||||||
continue
|
"""Specchia la memoria in una cartella di file Markdown (apribile come vault Obsidian): un file per categoria più un indice."""
|
||||||
if isinstance(value, str) and not value.strip():
|
folder = folder or EXPORT_DIR
|
||||||
continue
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
if isinstance(value, dict) and "value" not in value:
|
index = ["# Memoria di LuZa", "", f"Aggiornata: {datetime.now():%Y-%m-%d %H:%M}", ""]
|
||||||
if key not in target or not isinstance(target[key], dict):
|
for cat, label in _EXPORT_LABELS.items():
|
||||||
target[key] = {}
|
entries = memory.get(cat) or {}
|
||||||
changed = True
|
if not isinstance(entries, dict):
|
||||||
if _recursive_update(target[key], value):
|
continue
|
||||||
changed = True
|
lines = ["---", f"categoria: {cat}", f"voci: {len(entries)}", "---", "", f"# {label}", ""]
|
||||||
else:
|
for key, entry in sorted(entries.items(), key=lambda kv: (kv[1].get("updated", "") if isinstance(kv[1], dict) else ""), reverse=True):
|
||||||
new_val = _truncate_value(str(value["value"] if isinstance(value, dict) else value))
|
val = _entry_value(entry)
|
||||||
entry = {"value": new_val, "updated": datetime.now().strftime("%Y-%m-%d")}
|
upd = entry.get("updated", "") if isinstance(entry, dict) else ""
|
||||||
existing = target.get(key, {})
|
lines.append(f"- **{_pretty(key)}**: {val}" + (f" _(aggiornato {upd})_" if upd else ""))
|
||||||
if not isinstance(existing, dict) or existing.get("value") != new_val:
|
(folder / f"{label}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
target[key] = entry
|
index.append(f"- [[{label}]] — {len(entries)} voci")
|
||||||
changed = True
|
sessions = memory.get("sessions") or []
|
||||||
return changed
|
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")
|
||||||
def update_memory(memory_update: dict) -> dict:
|
index.append("- [[Sessioni recenti]]")
|
||||||
if not isinstance(memory_update, dict) or not memory_update:
|
(folder / "Memoria.md").write_text("\n".join(index) + "\n", encoding="utf-8")
|
||||||
return load_memory()
|
return folder
|
||||||
memory = load_memory()
|
|
||||||
if _recursive_update(memory, memory_update):
|
|
||||||
save_memory(memory)
|
def _truncate_value(val: str) -> str:
|
||||||
print(f"[Memory] 💾 Saved: {list(memory_update.keys())}")
|
if isinstance(val, str) and len(val) > MAX_VALUE_LENGTH:
|
||||||
return memory
|
return val[:MAX_VALUE_LENGTH].rstrip() + "…"
|
||||||
|
return val
|
||||||
def _entry_value(entry) -> str:
|
|
||||||
"""Accept both the {'value': ..., 'updated': ...} shape and a bare string,
|
|
||||||
because early versions of the store wrote plain strings."""
|
def _recursive_update(target: dict, updates: dict) -> bool:
|
||||||
if isinstance(entry, dict):
|
changed = False
|
||||||
return str(entry.get("value", "") or "").strip()
|
for key, value in updates.items():
|
||||||
return str(entry or "").strip()
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
def _pretty(key: str) -> str:
|
continue
|
||||||
return key.replace("_", " ").strip()
|
if isinstance(value, dict) and "value" not in value:
|
||||||
|
if key not in target or not isinstance(target[key], dict):
|
||||||
|
target[key] = {}
|
||||||
# Identity is always in the prompt; these categories compete for the remaining
|
changed = True
|
||||||
# budget by recency.
|
if _recursive_update(target[key], value):
|
||||||
_CATEGORY_LABELS = {
|
changed = True
|
||||||
"preferences": "Preferences",
|
else:
|
||||||
"projects": "Active projects / goals",
|
new_val = _truncate_value(str(value["value"] if isinstance(value, dict) else value))
|
||||||
"relationships": "People in their life",
|
entry = {"value": new_val, "updated": datetime.now().strftime("%Y-%m-%d")}
|
||||||
"wishes": "Wishes / plans",
|
existing = target.get(key, {})
|
||||||
"notes": "Notes",
|
if not isinstance(existing, dict) or existing.get("value") != new_val:
|
||||||
}
|
target[key] = entry
|
||||||
|
changed = True
|
||||||
_IDENTITY_FIELDS = ["name", "age", "birthday", "city", "job",
|
return changed
|
||||||
"language", "school", "nationality"]
|
|
||||||
|
|
||||||
|
def update_memory(memory_update: dict) -> dict:
|
||||||
def format_memory_for_prompt(memory: dict | None) -> str:
|
if not isinstance(memory_update, dict) or not memory_update:
|
||||||
"""Build the memory block that goes into the system prompt.
|
return load_memory()
|
||||||
|
memory = load_memory()
|
||||||
This used to dump everything. It now sends three things:
|
if _recursive_update(memory, memory_update):
|
||||||
|
save_memory(memory)
|
||||||
1. IDENTITY - always, in full. It is small, and it is wrong for the
|
print(f"[Memory] 💾 Saved: {list(memory_update.keys())}")
|
||||||
assistant to have to look up your name.
|
return memory
|
||||||
2. RECENT - the most recently updated entries from every other
|
|
||||||
category, up to PROMPT_CORE_CHARS. Recency is the cheapest useful
|
def _entry_value(entry) -> str:
|
||||||
relevance signal available without embeddings.
|
"""Accept both the {'value': ..., 'updated': ...} shape and a bare string,
|
||||||
3. AN INDEX - the *keys* of everything else, values omitted.
|
because early versions of the store wrote plain strings."""
|
||||||
|
if isinstance(entry, dict):
|
||||||
Point 3 is what makes recall work at all. A model cannot decide to look
|
return str(entry.get("value", "") or "").strip()
|
||||||
something up if it does not know the thing exists: with only points 1 and 2,
|
return str(entry or "").strip()
|
||||||
"who is Ayse?" would get "I don't know" while ayse_sister sat on disk
|
|
||||||
unread. The index costs a few hundred characters and turns recall from a
|
|
||||||
gamble into a lookup.
|
def _pretty(key: str) -> str:
|
||||||
|
return key.replace("_", " ").strip()
|
||||||
Net effect on latency: this block is SMALLER than the old full dump, so
|
|
||||||
every session connects with fewer tokens. Occasionally the model spends one
|
|
||||||
extra round trip on recall_memory - covered by the acknowledgment it
|
# Identity is always in the prompt; these categories compete for the remaining
|
||||||
already speaks before any slow step."""
|
# budget by recency.
|
||||||
if not memory:
|
_CATEGORY_LABELS = {
|
||||||
return ""
|
"preferences": "Preferences",
|
||||||
|
"projects": "Active projects / goals",
|
||||||
core_lines: list[str] = []
|
"relationships": "People in their life",
|
||||||
|
"wishes": "Wishes / plans",
|
||||||
# 1. Identity - always, in full
|
"notes": "Notes",
|
||||||
identity = memory.get("identity", {}) or {}
|
}
|
||||||
for field in _IDENTITY_FIELDS:
|
|
||||||
val = _entry_value(identity.get(field))
|
_IDENTITY_FIELDS = ["name", "age", "birthday", "city", "job",
|
||||||
if not val:
|
"language", "school", "nationality"]
|
||||||
continue
|
|
||||||
if field == "language":
|
|
||||||
# Labelled as an observation, not a setting. A bare "Language:
|
def format_memory_for_prompt(memory: dict | None) -> str:
|
||||||
# English" line written months ago reads like a standing order and
|
"""Build the memory block that goes into the system prompt.
|
||||||
# was one of the reasons a Turkish question came back in English.
|
|
||||||
core_lines.append(
|
This used to dump everything. It now sends three things:
|
||||||
f"Has spoken to you in: {val} (an observation about the past — "
|
|
||||||
f"always answer in the language of their CURRENT message)")
|
1. IDENTITY - always, in full. It is small, and it is wrong for the
|
||||||
else:
|
assistant to have to look up your name.
|
||||||
core_lines.append(f"{field.title()}: {val}")
|
2. RECENT - the most recently updated entries from every other
|
||||||
for key, entry in identity.items():
|
category, up to PROMPT_CORE_CHARS. Recency is the cheapest useful
|
||||||
if key in _IDENTITY_FIELDS:
|
relevance signal available without embeddings.
|
||||||
continue
|
3. AN INDEX - the *keys* of everything else, values omitted.
|
||||||
val = _entry_value(entry)
|
|
||||||
if val:
|
Point 3 is what makes recall work at all. A model cannot decide to look
|
||||||
core_lines.append(f"{_pretty(key).title()}: {val}")
|
something up if it does not know the thing exists: with only points 1 and 2,
|
||||||
|
"who is Ayse?" would get "I don't know" while ayse_sister sat on disk
|
||||||
# 2. Everything else, most recently updated first
|
unread. The index costs a few hundred characters and turns recall from a
|
||||||
rest: list[tuple[str, str, str, str]] = [] # (updated, cat, key, value)
|
gamble into a lookup.
|
||||||
for cat in _CATEGORY_LABELS:
|
|
||||||
for key, entry in (memory.get(cat, {}) or {}).items():
|
Net effect on latency: this block is SMALLER than the old full dump, so
|
||||||
val = _entry_value(entry)
|
every session connects with fewer tokens. Occasionally the model spends one
|
||||||
if not val:
|
extra round trip on recall_memory - covered by the acknowledgment it
|
||||||
continue
|
already speaks before any slow step."""
|
||||||
updated = (entry.get("updated", "") if isinstance(entry, dict) else "") or "0000-00-00"
|
if not memory:
|
||||||
rest.append((updated, cat, key, val))
|
return ""
|
||||||
rest.sort(key=lambda t: t[0], reverse=True)
|
|
||||||
|
core_lines: list[str] = []
|
||||||
used = sum(len(l) + 1 for l in core_lines)
|
|
||||||
shown: dict[str, list[str]] = {}
|
# 1. Identity - always, in full
|
||||||
overflow: dict[str, list[str]] = {}
|
identity = memory.get("identity", {}) or {}
|
||||||
|
for field in _IDENTITY_FIELDS:
|
||||||
# Recency decides order, but no single category may take the whole budget.
|
val = _entry_value(identity.get(field))
|
||||||
# Without the cap, someone with forty stored preferences gets a prompt that
|
if not val:
|
||||||
# is forty preferences and not one person's name — the categories that
|
continue
|
||||||
# matter most in conversation are also the ones that change least often, so
|
if field == "language":
|
||||||
# pure recency systematically buries them.
|
# Labelled as an observation, not a setting. A bare "Language:
|
||||||
per_cat_used: dict[str, int] = {}
|
# English" line written months ago reads like a standing order and
|
||||||
for _updated, cat, key, val in rest:
|
# was one of the reasons a Turkish question came back in English.
|
||||||
line = f" - {_pretty(key).title()}: {val}"
|
core_lines.append(
|
||||||
if (per_cat_used.get(cat, 0) < PROMPT_MAX_PER_CATEGORY
|
f"Has spoken to you in: {val} (an observation about the past — "
|
||||||
and used + len(line) + 1 <= PROMPT_CORE_CHARS):
|
f"always answer in the language of their CURRENT message)")
|
||||||
shown.setdefault(cat, []).append(line)
|
else:
|
||||||
per_cat_used[cat] = per_cat_used.get(cat, 0) + 1
|
core_lines.append(f"{field.title()}: {val}")
|
||||||
used += len(line) + 1
|
for key, entry in identity.items():
|
||||||
else:
|
if key in _IDENTITY_FIELDS:
|
||||||
overflow.setdefault(cat, []).append(_pretty(key))
|
continue
|
||||||
|
val = _entry_value(entry)
|
||||||
# The index is a table of contents, so it is interleaved across categories
|
if val:
|
||||||
# rather than continuing in recency order. Sorted by recency it would list
|
core_lines.append(f"{_pretty(key).title()}: {val}")
|
||||||
# twenty-four preferences before the first relationship, and the one entry
|
|
||||||
# the index exists for — the old fact the model has no other way to know
|
# 2. Everything else, most recently updated first
|
||||||
# about — would fall off the end.
|
rest: list[tuple[str, str, str, str]] = [] # (updated, cat, key, value)
|
||||||
indexed: list[str] = []
|
for cat in _CATEGORY_LABELS:
|
||||||
if overflow:
|
for key, entry in (memory.get(cat, {}) or {}).items():
|
||||||
cats = [c for c in _CATEGORY_LABELS if overflow.get(c)]
|
val = _entry_value(entry)
|
||||||
cursor = {c: 0 for c in cats}
|
if not val:
|
||||||
while cats:
|
continue
|
||||||
for cat in list(cats):
|
updated = (entry.get("updated", "") if isinstance(entry, dict) else "") or "0000-00-00"
|
||||||
i = cursor[cat]
|
rest.append((updated, cat, key, val))
|
||||||
if i >= len(overflow[cat]):
|
rest.sort(key=lambda t: t[0], reverse=True)
|
||||||
cats.remove(cat)
|
|
||||||
continue
|
used = sum(len(l) + 1 for l in core_lines)
|
||||||
indexed.append(overflow[cat][i])
|
shown: dict[str, list[str]] = {}
|
||||||
cursor[cat] = i + 1
|
overflow: dict[str, list[str]] = {}
|
||||||
|
|
||||||
for cat, label in _CATEGORY_LABELS.items():
|
# Recency decides order, but no single category may take the whole budget.
|
||||||
if shown.get(cat):
|
# Without the cap, someone with forty stored preferences gets a prompt that
|
||||||
core_lines.append("")
|
# is forty preferences and not one person's name — the categories that
|
||||||
core_lines.append(f"{label}:")
|
# matter most in conversation are also the ones that change least often, so
|
||||||
core_lines.extend(shown[cat])
|
# pure recency systematically buries them.
|
||||||
|
per_cat_used: dict[str, int] = {}
|
||||||
if not core_lines and not indexed:
|
for _updated, cat, key, val in rest:
|
||||||
return ""
|
line = f" - {_pretty(key).title()}: {val}"
|
||||||
|
if (per_cat_used.get(cat, 0) < PROMPT_MAX_PER_CATEGORY
|
||||||
out = [
|
and used + len(line) + 1 <= PROMPT_CORE_CHARS):
|
||||||
"[WHAT YOU KNOW ABOUT THIS PERSON — use naturally, never recite like a list]",
|
shown.setdefault(cat, []).append(line)
|
||||||
*core_lines,
|
per_cat_used[cat] = per_cat_used.get(cat, 0) + 1
|
||||||
]
|
used += len(line) + 1
|
||||||
|
else:
|
||||||
# 3. The index of what is on disk but not in this prompt
|
overflow.setdefault(cat, []).append(_pretty(key))
|
||||||
if indexed:
|
|
||||||
budget, names = PROMPT_INDEX_CHARS, []
|
# The index is a table of contents, so it is interleaved across categories
|
||||||
for n in indexed:
|
# rather than continuing in recency order. Sorted by recency it would list
|
||||||
if budget - len(n) - 2 < 0:
|
# twenty-four preferences before the first relationship, and the one entry
|
||||||
break
|
# the index exists for — the old fact the model has no other way to know
|
||||||
names.append(n)
|
# about — would fall off the end.
|
||||||
budget -= len(n) + 2
|
indexed: list[str] = []
|
||||||
if names:
|
if overflow:
|
||||||
out.append("")
|
cats = [c for c in _CATEGORY_LABELS if overflow.get(c)]
|
||||||
out.append(
|
cursor = {c: 0 for c in cats}
|
||||||
"[ALSO REMEMBERED — values not shown here. Call recall_memory "
|
while cats:
|
||||||
"with a keyword to read any of these before saying you do not know]"
|
for cat in list(cats):
|
||||||
)
|
i = cursor[cat]
|
||||||
out.append(", ".join(names)
|
if i >= len(overflow[cat]):
|
||||||
+ (f" (+{len(indexed) - len(names)} more)"
|
cats.remove(cat)
|
||||||
if len(indexed) > len(names) else ""))
|
continue
|
||||||
|
indexed.append(overflow[cat][i])
|
||||||
return "\n".join(out) + "\n"
|
cursor[cat] = i + 1
|
||||||
|
|
||||||
|
for cat, label in _CATEGORY_LABELS.items():
|
||||||
# ── Recall ────────────────────────────────────────────────────────────────────
|
if shown.get(cat):
|
||||||
|
core_lines.append("")
|
||||||
def _score(query_words: list[str], cat: str, key: str, value: str) -> int:
|
core_lines.append(f"{label}:")
|
||||||
"""Cheap lexical relevance. No embeddings, no network, no model call - this
|
core_lines.extend(shown[cat])
|
||||||
runs in well under a millisecond, which is the entire point: recall must
|
|
||||||
cost one model round trip, never two."""
|
if not core_lines and not indexed:
|
||||||
hay_key = _pretty(key).lower()
|
return ""
|
||||||
hay_val = value.lower()
|
|
||||||
score = 0
|
out = [
|
||||||
for w in query_words:
|
"[WHAT YOU KNOW ABOUT THIS PERSON — use naturally, never recite like a list]",
|
||||||
if not w:
|
*core_lines,
|
||||||
continue
|
]
|
||||||
if w == hay_key:
|
|
||||||
score += 10
|
# 3. The index of what is on disk but not in this prompt
|
||||||
elif w in hay_key:
|
if indexed:
|
||||||
score += 6
|
budget, names = PROMPT_INDEX_CHARS, []
|
||||||
if w in hay_val:
|
for n in indexed:
|
||||||
score += 3
|
if budget - len(n) - 2 < 0:
|
||||||
if w in cat:
|
break
|
||||||
score += 1
|
names.append(n)
|
||||||
return score
|
budget -= len(n) + 2
|
||||||
|
if names:
|
||||||
|
out.append("")
|
||||||
def search_memory(query: str, limit: int = 8) -> str:
|
out.append(
|
||||||
"""Find stored facts matching `query`. Backs the recall_memory tool.
|
"[ALSO REMEMBERED — values not shown here. Call recall_memory "
|
||||||
|
"with a keyword to read any of these before saying you do not know]"
|
||||||
An empty query is treated as "show me everything you know", capped - the
|
)
|
||||||
model asks that when the user says "what do you remember about me?"."""
|
out.append(", ".join(names)
|
||||||
memory = load_memory()
|
+ (f" (+{len(indexed) - len(names)} more)"
|
||||||
words = [w for w in re.split(r"[^\w]+", (query or "").lower()) if len(w) > 1]
|
if len(indexed) > len(names) else ""))
|
||||||
|
|
||||||
rows: list[tuple[int, str, str, str]] = []
|
return "\n".join(out) + "\n"
|
||||||
for cat, items in memory.items():
|
|
||||||
if not isinstance(items, dict):
|
|
||||||
continue # skip 'sessions', which is a list
|
# ── Recall ────────────────────────────────────────────────────────────────────
|
||||||
for key, entry in items.items():
|
|
||||||
val = _entry_value(entry)
|
def _score(query_words: list[str], cat: str, key: str, value: str) -> int:
|
||||||
if not val:
|
"""Cheap lexical relevance. No embeddings, no network, no model call - this
|
||||||
continue
|
runs in well under a millisecond, which is the entire point: recall must
|
||||||
s = _score(words, cat, key, val) if words else 1
|
cost one model round trip, never two."""
|
||||||
if s > 0:
|
hay_key = _pretty(key).lower()
|
||||||
rows.append((s, cat, key, val))
|
hay_val = value.lower()
|
||||||
|
score = 0
|
||||||
if not rows:
|
for w in query_words:
|
||||||
return (f"Nothing stored about '{query}'." if query
|
if not w:
|
||||||
else "I have not stored anything about this person yet.")
|
continue
|
||||||
|
if w == hay_key:
|
||||||
rows.sort(key=lambda r: (-r[0], r[2]))
|
score += 10
|
||||||
lines = [f"{cat}/{_pretty(key)}: {val}" for _s, cat, key, val in rows[:max(1, limit)]]
|
elif w in hay_key:
|
||||||
head = (f"Stored facts matching '{query}':" if query
|
score += 6
|
||||||
else "Everything currently stored:")
|
if w in hay_val:
|
||||||
more = (f"\n(+{len(rows) - len(lines)} more — search with a narrower keyword)"
|
score += 3
|
||||||
if len(rows) > len(lines) else "")
|
if w in cat:
|
||||||
return head + "\n" + "\n".join(lines) + more
|
score += 1
|
||||||
|
return score
|
||||||
|
|
||||||
def all_entries_for_ui() -> list[dict]:
|
|
||||||
"""Flat list for the memory panel: what JARVIS knows, and when it learned it.
|
def search_memory(query: str, limit: int = 8) -> str:
|
||||||
Sorted newest first so the panel opens on what changed most recently."""
|
"""Find stored facts matching `query`. Backs the recall_memory tool.
|
||||||
memory = load_memory()
|
|
||||||
rows = []
|
An empty query is treated as "show me everything you know", capped - the
|
||||||
for cat, items in memory.items():
|
model asks that when the user says "what do you remember about me?"."""
|
||||||
if not isinstance(items, dict):
|
memory = load_memory()
|
||||||
continue
|
words = [w for w in re.split(r"[^\w]+", (query or "").lower()) if len(w) > 1]
|
||||||
for key, entry in items.items():
|
|
||||||
val = _entry_value(entry)
|
rows: list[tuple[int, str, str, str]] = []
|
||||||
if not val:
|
for cat, items in memory.items():
|
||||||
continue
|
if not isinstance(items, dict):
|
||||||
rows.append({
|
continue # skip 'sessions', which is a list
|
||||||
"category": cat,
|
for key, entry in items.items():
|
||||||
"key": key,
|
val = _entry_value(entry)
|
||||||
"value": val,
|
if not val:
|
||||||
"updated": (entry.get("updated", "") if isinstance(entry, dict) else ""),
|
continue
|
||||||
})
|
s = _score(words, cat, key, val) if words else 1
|
||||||
rows.sort(key=lambda r: (r["updated"] or "0000-00-00"), reverse=True)
|
if s > 0:
|
||||||
return rows
|
rows.append((s, cat, key, val))
|
||||||
|
|
||||||
def remember(key: str, value: str, category: str = "notes") -> str:
|
if not rows:
|
||||||
valid = {"identity", "preferences", "projects", "relationships", "wishes", "notes"}
|
return (f"Nothing stored about '{query}'." if query
|
||||||
if category not in valid:
|
else "I have not stored anything about this person yet.")
|
||||||
category = "notes"
|
|
||||||
update_memory({category: {key: {"value": value}}})
|
rows.sort(key=lambda r: (-r[0], r[2]))
|
||||||
return f"Remembered: {category}/{key} = {value}"
|
lines = [f"{cat}/{_pretty(key)}: {val}" for _s, cat, key, val in rows[:max(1, limit)]]
|
||||||
|
head = (f"Stored facts matching '{query}':" if query
|
||||||
|
else "Everything currently stored:")
|
||||||
def forget(key: str, category: str = "notes") -> str:
|
more = (f"\n(+{len(rows) - len(lines)} more — search with a narrower keyword)"
|
||||||
memory = load_memory()
|
if len(rows) > len(lines) else "")
|
||||||
cat = memory.get(category, {})
|
return head + "\n" + "\n".join(lines) + more
|
||||||
if key in cat:
|
|
||||||
del cat[key]
|
|
||||||
memory[category] = cat
|
def all_entries_for_ui() -> list[dict]:
|
||||||
save_memory(memory)
|
"""Flat list for the memory panel: what JARVIS knows, and when it learned it.
|
||||||
return f"Forgotten: {category}/{key}"
|
Sorted newest first so the panel opens on what changed most recently."""
|
||||||
return f"Not found: {category}/{key}"
|
memory = load_memory()
|
||||||
|
rows = []
|
||||||
|
for cat, items in memory.items():
|
||||||
forget_memory = forget
|
if not isinstance(items, dict):
|
||||||
|
continue
|
||||||
|
for key, entry in items.items():
|
||||||
# ── Session memory ─────────────────────────────────────────────────────────────
|
val = _entry_value(entry)
|
||||||
|
if not val:
|
||||||
_SESSION_MAX = 3 # safety cap — in practice 0-1 entries after pop
|
continue
|
||||||
|
rows.append({
|
||||||
|
"category": cat,
|
||||||
def save_session_summary(summary: str, language: str = "") -> None:
|
"key": key,
|
||||||
"""Append a 1-2 sentence session summary to long_term.json['sessions']."""
|
"value": val,
|
||||||
summary = (summary or "").strip()
|
"updated": (entry.get("updated", "") if isinstance(entry, dict) else ""),
|
||||||
if not summary:
|
})
|
||||||
return
|
rows.sort(key=lambda r: (r["updated"] or "0000-00-00"), reverse=True)
|
||||||
memory = load_memory()
|
return rows
|
||||||
sessions = memory.get("sessions", [])
|
|
||||||
if not isinstance(sessions, list):
|
def remember(key: str, value: str, category: str = "notes") -> str:
|
||||||
sessions = []
|
valid = {"identity", "preferences", "projects", "relationships", "wishes", "notes"}
|
||||||
entry: dict = {
|
if category not in valid:
|
||||||
"date": datetime.now().strftime("%Y-%m-%d"),
|
category = "notes"
|
||||||
"summary": summary[:280],
|
update_memory({category: {key: {"value": value}}})
|
||||||
}
|
return f"Remembered: {category}/{key} = {value}"
|
||||||
if language:
|
|
||||||
entry["language"] = language
|
|
||||||
sessions.append(entry)
|
def forget(key: str, category: str = "notes") -> str:
|
||||||
memory["sessions"] = sessions[-_SESSION_MAX:]
|
memory = load_memory()
|
||||||
with _lock:
|
cat = memory.get(category, {})
|
||||||
MEMORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
if key in cat:
|
||||||
MEMORY_PATH.write_text(
|
del cat[key]
|
||||||
json.dumps(memory, indent=2, ensure_ascii=False),
|
memory[category] = cat
|
||||||
encoding="utf-8",
|
save_memory(memory)
|
||||||
)
|
return f"Forgotten: {category}/{key}"
|
||||||
print(f"[Memory] 📝 Session saved ({entry['date']}): {summary[:60]}…")
|
return f"Not found: {category}/{key}"
|
||||||
|
|
||||||
|
|
||||||
def pop_last_session() -> dict | None:
|
forget_memory = forget
|
||||||
"""
|
|
||||||
Return AND remove the most recent session entry.
|
|
||||||
Calling this consumes the entry so it is never repeated in future briefings.
|
# ── Session memory ─────────────────────────────────────────────────────────────
|
||||||
"""
|
|
||||||
with _lock:
|
_SESSION_MAX = 3 # safety cap — in practice 0-1 entries after pop
|
||||||
if not MEMORY_PATH.exists():
|
|
||||||
return None
|
|
||||||
try:
|
def save_session_summary(summary: str, language: str = "") -> None:
|
||||||
memory = json.loads(MEMORY_PATH.read_text(encoding="utf-8"))
|
"""Append a 1-2 sentence session summary to long_term.json['sessions']."""
|
||||||
sessions = memory.get("sessions", [])
|
summary = (summary or "").strip()
|
||||||
if not isinstance(sessions, list) or not sessions:
|
if not summary:
|
||||||
return None
|
return
|
||||||
entry = sessions.pop() # remove the last entry
|
memory = load_memory()
|
||||||
memory["sessions"] = sessions
|
sessions = memory.get("sessions", [])
|
||||||
MEMORY_PATH.write_text(
|
if not isinstance(sessions, list):
|
||||||
json.dumps(memory, indent=2, ensure_ascii=False),
|
sessions = []
|
||||||
encoding="utf-8",
|
entry: dict = {
|
||||||
)
|
"date": datetime.now().strftime("%Y-%m-%d"),
|
||||||
return entry
|
"summary": summary[:280],
|
||||||
except Exception as e:
|
}
|
||||||
print(f"[Memory] ⚠️ pop_last_session error: {e}")
|
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
|
return None
|
||||||
@@ -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},
|
||||||
|
]
|
||||||
Reference in new issue
Block a user