Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 14 additions & 8 deletions src/neuron/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
116 changes: 116 additions & 0 deletions src/neuron/chatgpt.py
Original file line number Diff line number Diff line change
@@ -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)]
14 changes: 14 additions & 0 deletions src/neuron/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


Expand Down
35 changes: 29 additions & 6 deletions src/neuron/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ``<base>/GrayMatterEnvironment/<slug>``.
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:
Expand Down
82 changes: 82 additions & 0 deletions tests/test_gm_vendor_wheel.py
Original file line number Diff line number Diff line change
@@ -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")
Loading