diff --git a/install.ps1 b/install.ps1 index 318d7b2..2210abc 100644 --- a/install.ps1 +++ b/install.ps1 @@ -293,7 +293,17 @@ function Install-Standalone { # Ask before the long pip phase, write after it (needs the venv's python). $Chosen = Select-EmbedModel $PyExe = Ensure-Python # installs it from python.org if absent - $Home_ = if ($env:NEURON_HOME) { $env:NEURON_HOME } else { Join-Path $env:LOCALAPPDATA "neuron" } + # Anche lo standalone vive nella radice UNICA della suite: se domani si + # aggiunge Gray Matter, i dati sono gia' dove la suite li cerca e non serve + # traslocare niente. Un'install esistente nella posizione piatta pre-suite + # continua a essere usata (un venv non e' spostabile). + $NBase = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $env:USERPROFILE "AppData\Local" } + $Home_ = if ($env:NEURON_HOME) { $env:NEURON_HOME } + else { Join-Path (Join-Path $NBase "GrayMatterEnvironment") "neuron" } + $NLegacy = Join-Path $NBase "neuron" + if ((Test-Path (Join-Path $NLegacy ".venv")) -and -not (Test-Path (Join-Path $Home_ ".venv"))) { + $Home_ = $NLegacy + } $Venv = Join-Path $Home_ ".venv" # INSTALLER-UX §5.3 — kill what runs from this venv BEFORE pip writes to it. # A loaded .pyd cannot be replaced on Windows: pip dies with diff --git a/install.sh b/install.sh index c822ada..9d0579b 100644 --- a/install.sh +++ b/install.sh @@ -201,7 +201,13 @@ standalone_install() { # `exit 1` inside $( ) only kills the subshell — propagate it explicitly # rather than relying on set -e to notice the assignment failed. PY=$(ensure_python) || exit 1 - VENV="${NEURON_HOME:-$HOME/.local/share/neuron}/.venv" + # Radice UNICA della suite anche in standalone — vedi la nota in install.ps1. + _nbase="${XDG_DATA_HOME:-$HOME/.local/share}" + NEURON_DIR_HOME="${NEURON_HOME:-$_nbase/GrayMatterEnvironment/neuron}" + if [ -d "$_nbase/neuron/.venv" ] && [ ! -d "$NEURON_DIR_HOME/.venv" ]; then + NEURON_DIR_HOME="$_nbase/neuron" + fi + VENV="$NEURON_DIR_HOME/.venv" # INSTALLER-UX §5.3 — stop what runs from this venv before pip writes to it. # POSIX unlinks mapped files happily, so this is not the Windows lock, but a # stale server writing to the same store during an upgrade is its own hazard. diff --git a/src/neuron/__main__.py b/src/neuron/__main__.py index fa089da..7bcf9ec 100644 --- a/src/neuron/__main__.py +++ b/src/neuron/__main__.py @@ -153,19 +153,25 @@ def _bootstrap_gray_matter() -> bool: candidates.append(("cartella sorella", argv)) except Exception: # noqa: BLE001 — path non registrato pass - # Wheel d'emergenza vendorato NEL package (viaggia nel wheel di Neuron): GM - # ha solo `mcp` come dep, già presente qui → install completamente OFFLINE, - # nessuna dipendenza da rete/PyPI/GitHub. - vendor = Path(__file__).resolve().parent / "_gm_vendor" - if vendor.is_dir() and any(vendor.glob("gray_matter-*.whl")): - candidates.append(("wheel vendorato (offline)", - [py, "-m", "pip", "install", "--find-links", str(vendor), - "gray-matter"])) candidates.append(("indice pip", [py, "-m", "pip", "install", "gray-matter>=1.0"])) import shutil if shutil.which("git"): candidates.append(("GitHub", [py, "-m", "pip", "install", "git+https://github.com/recla93/gray-matter"])) + # Wheel d'emergenza vendorata NEL package (viaggia nel wheel di Neuron): GM ha + # solo `mcp` come dep, già presente qui → install completamente OFFLINE. + # + # ULTIMA, non seconda. È un artefatto CONGELATO al momento della release di + # Neuron — il pyproject stesso ammette "va ricostruito a ogni release di GM" — + # e provandola prima di PyPI e GitHub una macchina con rete perfettamente + # funzionante si ritrovava installata una Gray Matter vecchia. Da ultima + # continua a fare il suo mestiere (l'unico caso in cui serve è quando la rete + # NON c'è) senza poter più scavalcare una versione aggiornata. + vendor = Path(__file__).resolve().parent / "_gm_vendor" + if vendor.is_dir() and any(vendor.glob("gray_matter-*.whl")): + candidates.append(("wheel vendorata (offline, ultima risorsa)", + [py, "-m", "pip", "install", "--no-index", + "--find-links", str(vendor), "gray-matter"])) for label, argv in candidates: print(f"[gui] Gray Matter is not installed: installing it ({label})...") try: diff --git a/src/neuron/chatgpt.py b/src/neuron/chatgpt.py new file mode 100644 index 0000000..3725e47 --- /dev/null +++ b/src/neuron/chatgpt.py @@ -0,0 +1,116 @@ +"""ChatGPT come client: bridge + tunnel, non un file di configurazione. + +Gli altri sei client girano SULLA macchina e si registrano scrivendo un JSON con +il path di un interprete locale. ChatGPT no: gira altrove e raggiunge la suite +solo via HTTP pubblico. Quindi "registrarlo" vuol dire tre cose diverse — +accendere il bridge (`neuron.bridge`, Streamable HTTP su :8000), esporlo +con un tunnel, e dare all'utente l'URL da incollare nelle sue impostazioni. + +Nessuna di queste puo' fallire in silenzio, e nessuna deve bloccare l'install. + +Il quick tunnel (`*.trycloudflare.com`) NON e' un'alternativa: e' una demo. Non +richiede credenziali, ma il tier gratuito senza account gli mette sopra un +timer — il tunnel SCADE da solo dopo un po', e la connessione di ChatGPT muore +senza che nessuno abbia toccato niente. Per un collegamento che dura serve un +account registrato e un named tunnel. Quindi qui il quick si offre per provare, +e si dice a chiare lettere che va registrato, con link e comandi esatti. +""" +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +# --- gli unici tre valori che cambiano fra le copie tool-local --------------- +# Copia keep-in-sync con gray_matter/chatgpt.py e neurag/chatgpt.py: anche +# in standalone i peer devono offrire gli stessi client del gateway, o +# "standalone" vuol dire "senza ChatGPT". Ognuno espone il PROPRIO bridge. +TOOL_LABEL = "Neuron" +BRIDGE_MODULE = "neuron.bridge" +BRIDGE_PORT = 8000 # Neuron=8000, NeuRAG=8001, suite intera=8002 + +SIGNUP_URL = "https://dash.cloudflare.com/sign-up" +CONNECTOR_DOC = "https://platform.openai.com/docs/mcp" + + +def _cloudflared_cert() -> Path: + """Dove cloudflared tiene le credenziali dei named tunnel.""" + if os.name == "nt": + return Path(os.environ.get("USERPROFILE", "~")) / ".cloudflared" / "cert.pem" + return Path.home() / ".cloudflared" / "cert.pem" + + +def state() -> dict: + """Cosa c'e' e cosa manca. Puro: non accende niente.""" + cf = shutil.which("cloudflared") + cert = _cloudflared_cert() + has_creds = bool(cf) and cert.exists() + return { + "cloudflared": cf, + "cert_path": str(cert), + "has_credentials": has_creds, + # Senza credenziali si puo' comunque partire, ma solo per provare: il + # quick tunnel ha un timer e si spegne da solo. + "can_start": bool(cf), + "mode": "named" if has_creds else ("quick" if cf else "none"), + # Vero solo con un account registrato: e' l'unica configurazione in cui + # il collegamento a ChatGPT resta su. + "persistent": has_creds, + "bridge_port": BRIDGE_PORT, + } + + +def instructions(st: dict | None = None) -> list[str]: + """Le righe da mostrare all'utente. Vuote quando non c'e' niente da dire.""" + s = st or state() + if s["mode"] == "named": + return [] + out: list[str] = [] + if not s["cloudflared"]: + out += [ + "cloudflared non e' installato: senza, ChatGPT non puo' raggiungere la suite.", + " Windows: winget install --id Cloudflare.cloudflared", + " macOS: brew install cloudflared", + " Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/", + ] + return out + out += [ + "Nessuna credenziale Cloudflare: si parte con un quick tunnel, che serve", + "SOLO per provare — senza account ha un timer e SCADE da solo, portandosi", + "dietro la connessione di ChatGPT senza nessun errore visibile.", + "Per un collegamento che dura serve registrarsi (gratis) e creare un tunnel:", + f" 1. crea l'account: {SIGNUP_URL}", + " 2. autenticati: cloudflared tunnel login", + " 3. crea il tunnel: cloudflared tunnel create gray-matter", + f" (le credenziali finiscono in {s['cert_path']})", + f"Poi incolla l'URL del tunnel nelle impostazioni connettori: {CONNECTOR_DOC}", + ] + return out + + +def register() -> dict: + """Risultato nella stessa forma degli altri client, cosi' l'installer e la + GUI lo stampano senza sapere che questo e' diverso.""" + s = state() + detail = f"bridge :{BRIDGE_PORT} + tunnel {s['mode']}" + if s["persistent"]: + return {"client": "ChatGPT", "ok": True, "action": "bridge+tunnel", + "detail": f"{TOOL_LABEL}: {detail}", "state": s} + # `ok` FALSE anche quando si potrebbe partire: un tunnel che scade da solo + # non e' un'installazione riuscita, e segnarla verde vorrebbe dire lasciare + # l'utente a scoprire da solo perche' ChatGPT ha smesso di rispondere. + return { + "client": "ChatGPT", "ok": False, + "action": "manual", + "detail": (detail + " — temporaneo, scade" if s["can_start"] + else "cloudflared assente"), + "state": s, + "snippet": "\n".join(instructions(s)), + } + + +def start_command() -> list[str]: + """Il comando che accende bridge e tunnel insieme.""" + import sys + return [sys.executable, "-m", BRIDGE_MODULE, "--tunnel", + "--port", str(BRIDGE_PORT)] diff --git a/src/neuron/clients.py b/src/neuron/clients.py index fe73be5..6f0095f 100644 --- a/src/neuron/clients.py +++ b/src/neuron/clients.py @@ -318,6 +318,20 @@ def vscode_keys_for(path: str) -> list[str]: "format": "toml", "create_if_missing": False, }, + # ChatGPT non gira su questa macchina: non ha un config da scrivere, ci + # arriva via HTTP pubblico. Anche in STANDALONE deve esserci — offrire meno + # client del gateway vuol dire che standalone non serve a niente. Qui espone + # il bridge di Neuron (:8000); `remote` dice a chi registra di non + # cercargli un file e di non contarlo come "client non trovato". + "chatgpt": { + "label": "ChatGPT", + "candidates": lambda: [], + "keys": [], + "entry": lambda py: {}, + "format": "remote", + "remote": True, + "create_if_missing": False, + }, } diff --git a/src/neuron/config.py b/src/neuron/config.py index 19b7321..3ced167 100644 --- a/src/neuron/config.py +++ b/src/neuron/config.py @@ -52,19 +52,42 @@ def default_graphs_dir() -> str: return os.path.join(user_data_dir(), "graphs") +SUITE_DIR = "GrayMatterEnvironment" + + +def _os_base() -> str: + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_DATA_HOME") or os.path.join( + os.path.expanduser("~"), ".local", "share") + # Vuoto (servizio, scheduled task, env ripulito) darebbe un path RELATIVO, + # cioe' un graph store nella cwd del processo di turno. + return base or os.path.expanduser("~") + + def user_data_dir() -> str: """The per-user Neuron home — the parent of ``graphs``. + Sta sotto la radice UNICA della suite: ``/GrayMatterEnvironment/``. + Prima i tre tool scrivevano in tre radici scollegate e nulla diceva che + fossero lo stesso prodotto. + + Uno store ESISTENTE nella vecchia posizione vince sempre: cambiare la regola + non deve poter far sparire una memoria. Il trasloco e' esplicito + (``gray_matter.paths.migrate_to_suite_root``), non un effetto collaterale + di un aggiornamento. + Deliberately NOT keyed on ``NEURON_HOME``: that variable only picks the *venv* location in install.ps1/install.sh, and honouring it here would silently relocate an existing graph store.""" slug = resolve_slug() - if os.name == "nt": - base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - else: - base = os.environ.get("XDG_DATA_HOME") or os.path.join( - os.path.expanduser("~"), ".local", "share") - return os.path.join(base, slug) + base = _os_base() + new = os.path.join(base, SUITE_DIR, slug) + legacy = os.path.join(base, slug) + if not os.path.isdir(new) and os.path.isdir(legacy): + return legacy + return new def user_env_file() -> str: diff --git a/tests/test_gm_vendor_wheel.py b/tests/test_gm_vendor_wheel.py new file mode 100644 index 0000000..e2335d6 --- /dev/null +++ b/tests/test_gm_vendor_wheel.py @@ -0,0 +1,82 @@ +"""La wheel di Gray Matter vendorata dentro il package non deve invecchiare. + +`_gm_vendor/gray_matter-*.whl` esiste per un solo motivo: far funzionare +`neuron gui` su una macchina SENZA rete. È un artefatto congelato al momento +della release di Neuron, e il pyproject stesso lo dice ("va ricostruito a ogni +release di GM"). Finché veniva provata PRIMA di PyPI e GitHub, una macchina con +rete perfettamente funzionante finiva con una Gray Matter vecchia installata — +il "prende wheel vecchie se presenti nel PC" segnalato sul campo. + +Due regole, quindi: resta l'ULTIMA risorsa, e non può essere più vecchia della +GM che le sta accanto nel checkout. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] # .../neuron +VENDOR = ROOT / "src" / "neuron" / "_gm_vendor" +MAIN = ROOT / "src" / "neuron" / "__main__.py" + + +def _wheel_version() -> str | None: + whls = sorted(VENDOR.glob("gray_matter-*.whl")) + if not whls: + return None + m = re.match(r"gray_matter-([0-9][^-]*)-", whls[-1].name) + return m.group(1) if m else None + + +def _sibling_gm_version() -> str | None: + """Versione della GM accanto a noi nel checkout (in CI c'è sempre).""" + for cand in (ROOT.parent / "gray_matter", ROOT.parent / "gray-matter"): + toml = cand / "pyproject.toml" + if not toml.exists(): + continue + for line in toml.read_text(encoding="utf-8").splitlines(): + m = re.match(r'^\s*version\s*=\s*"(.+?)"', line) + if m: + return m.group(1) + return None + + +def _tuple(v: str) -> tuple: + return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) + + +def test_vendored_wheel_is_the_last_candidate(): + """Se torna davanti a PyPI/GitHub, il bug di regressione è tornato.""" + src = MAIN.read_text(encoding="utf-8") + i_vendor = src.find('"wheel vendorata') + i_pypi = src.find('"indice pip"') + if i_vendor < 0: + pytest.skip("nessuna wheel vendorata in questo checkout") + assert i_pypi >= 0, "il candidato PyPI è sparito" + assert i_vendor > i_pypi, ( + "la wheel vendorata viene provata PRIMA dell'indice pip: una macchina " + "con rete si ritrova installata una Gray Matter congelata") + + +def test_vendored_wheel_is_offline_only(): + """--no-index, o non è un fallback offline: è una scorciatoia che può + risolvere da PyPI di nascosto e mascherare il candidato precedente.""" + src = MAIN.read_text(encoding="utf-8") + if '"wheel vendorata' not in src: + pytest.skip("nessuna wheel vendorata in questo checkout") + tail = src[src.find('"wheel vendorata'):] + assert "--no-index" in tail[:400] + + +def test_vendored_wheel_is_not_older_than_the_sibling_gm(): + wheel = _wheel_version() + sibling = _sibling_gm_version() + if wheel is None: + pytest.skip("nessuna wheel vendorata in questo checkout") + if sibling is None: + pytest.skip("nessuna Gray Matter accanto: niente con cui confrontare") + assert _tuple(wheel) >= _tuple(sibling), ( + f"la wheel vendorata è {wheel} ma Gray Matter è {sibling}: ricostruiscila " + f"(vedi RELEASE-CHECKLIST) o un install offline parte già vecchio")