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")