#!/usr/bin/env python3 """piSynapse Installer — offline-first personal AI assistant. Platforms: Linux, macOS, Windows (experimental). Run with: python3 install.py Flow: 1. Python version check 2. System dependencies (ffmpeg, curl) 3. Virtual environment + pip packages 4. LLM backend (LiteRT-LM or Ollama) + model + server start 5. Piper TTS voices (optional) 6. Environment configuration (.env auto-generated) 7. systemd service (Linux only, optional) """ import getpass import glob import json import os import re import secrets import shutil import subprocess import sys import time import urllib.request from pathlib import Path # ── Defaults ────────────────────────────────────────────────────────────────── VENV_DIR = "venv" LITERT_PORT = 9379 IS_WIN = sys.platform == "win32" # Shared state passed between steps so no question is asked twice. STATE: dict = {} # ── CLI flags ──────────────────────────────────────────────────────────────── BATCH_MODE = False # --yes : accept all defaults, no prompts SKIP_LLM = False # --skip-llm : skip LLM backend setup SKIP_TTS = False # --skip-tts : skip TTS voice download SKIP_SYSTEMD = False # --skip-systemd : skip systemd service creation def _parse_args() -> None: global BATCH_MODE, SKIP_LLM, SKIP_TTS, SKIP_SYSTEMD args = sys.argv[1:] if "--yes" in args or "-y" in args: BATCH_MODE = True if "--skip-llm" in args: SKIP_LLM = True if "--skip-tts" in args: SKIP_TTS = True if "--skip-systemd" in args: SKIP_SYSTEMD = True if "--help" in args or "-h" in args: print(""" piSynapse Installer Usage: python3 install.py [options] Options: -y, --yes Non-interactive mode (accept all defaults) --skip-llm Skip LLM backend setup (model download, server start) --skip-tts Skip TTS voice download --skip-systemd Skip systemd service creation -h, --help Show this help """) sys.exit(0) # ── Distro / package manager detection ─────────────────────────────────────── class _Distro: """Detected Linux distribution info.""" id: str = "" # ubuntu, fedora, arch, alpine, ... pm: str = "" # apt, dnf, pacman, zypper, apk, brew, unknown pm_install: list[str] = [] # command prefix for installing packages pm_sudo: bool = True # needs sudo? @property def is_debian(self) -> bool: return self.pm == "apt" @property def is_redhat(self) -> bool: return self.pm in ("dnf", "yum") @property def is_arch(self) -> bool: return self.pm == "pacman" DISTRO = _Distro() def _detect_distro() -> None: """Detect the Linux distribution and set DISTRO.pm (package manager).""" if IS_WIN or sys.platform == "darwin": DISTRO.pm = "brew" if sys.platform == "darwin" else "unknown" return # 1. Check /etc/os-release (standard on all modern distros) os_release: dict[str, str] = {} try: for line in Path("/etc/os-release").read_text().splitlines(): if "=" in line: k, v = line.split("=", 1) os_release[k.strip()] = v.strip().strip('"') except Exception: pass DISTRO.id = os_release.get("ID", "").lower() # 2. Detect package manager by checking what's on PATH if shutil.which("apt") or shutil.which("apt-get"): DISTRO.pm = "apt" elif shutil.which("dnf"): DISTRO.pm = "dnf" elif shutil.which("pacman"): DISTRO.pm = "pacman" elif shutil.which("zypper"): DISTRO.pm = "zypper" elif shutil.which("apk"): DISTRO.pm = "apk" elif shutil.which("brew"): DISTRO.pm = "brew" else: DISTRO.pm = "unknown" def _sudo_prefix() -> list[str]: """`sudo` prefix for package installs. In batch/non-interactive mode use `sudo -n`: a plain `sudo` would block forever on a password prompt that nobody can answer (CI/automation) — `-n` fails fast instead and the caller reports the missing privilege. """ return ["sudo", "-n"] if BATCH_MODE else ["sudo"] def _check_sudo_batch() -> None: """Batch mode: verify passwordless sudo works BEFORE we first need it. Otherwise the first `sudo -n` fails mid-install with a confusing error; a clear up-front warning lets the operator fix it (run as root, configure NOPASSWD, or use the interactive installer). """ if not BATCH_MODE: return try: r = subprocess.run(["sudo", "-n", "true"], capture_output=True) except Exception: r = None if r is None or r.returncode != 0: warn("Batch mode: passwordless sudo unavailable — package steps will be skipped. " "Run as root, configure NOPASSWD, or use the interactive installer.") def _pkg_install(*packages: str, extra_args: list[str] | None = None) -> bool: """Install packages using the detected package manager. Returns True on success.""" args = extra_args or [] sudo = _sudo_prefix() if DISTRO.pm == "apt": cmd = sudo + ["apt-get", "install", "-y"] + args + list(packages) elif DISTRO.pm == "dnf": cmd = sudo + ["dnf", "install", "-y"] + args + list(packages) elif DISTRO.pm == "yum": cmd = sudo + ["yum", "install", "-y"] + args + list(packages) elif DISTRO.pm == "pacman": cmd = sudo + ["pacman", "-S", "--noconfirm"] + args + list(packages) elif DISTRO.pm == "zypper": cmd = sudo + ["zypper", "--non-interactive", "install"] + args + list(packages) elif DISTRO.pm == "apk": cmd = sudo + ["apk", "add"] + args + list(packages) elif DISTRO.pm == "brew": cmd = ["brew", "install"] + list(packages) else: warn(f"Unknown package manager — install manually: {' '.join(packages)}") return False r = subprocess.run(cmd) return r.returncode == 0 def _pkg_build_essential() -> str | None: """Return the build-essential equivalent package name for this distro, or None.""" if DISTRO.pm == "apt": return "build-essential" elif DISTRO.pm in ("dnf", "yum"): return "gcc" # groupinstall "Development Tools" or just gcc elif DISTRO.pm == "pacman": return "base-devel" elif DISTRO.pm == "zypper": return "patterns-devel-base-devel_basis" elif DISTRO.pm == "apk": return "build-base" return None def _pkg_python_venv() -> str | None: """Return the python3-venv package name for this distro, or None if not needed.""" major, minor = sys.version_info[:2] ver = f"{major}.{minor}" if DISTRO.pm == "apt": return f"python{ver}-venv" elif DISTRO.pm in ("dnf", "yum"): # On Fedora/RHEL, venv module is included in the main python3 package. # But if ensurepip is missing, we need python3-pip or python3-virtualenv. # Check if ensurepip module exists first. try: r = subprocess.run( [sys.executable, "-c", "import ensurepip"], capture_output=True, timeout=5, ) if r.returncode == 0: return None # ensurepip works, no extra package needed except Exception: pass return "python3-pip" # provides ensurepip on Fedora/RHEL elif DISTRO.pm == "pacman": return None # Arch python includes venv by default elif DISTRO.pm == "zypper": return f"python{ver}-venv" elif DISTRO.pm == "apk": return None # Alpine python3 includes venv return None # ── Terminal helpers ────────────────────────────────────────────────────────── def _c(c: str, t: str) -> str: return t if IS_WIN else f"{c}{t}\033[0m" def green(t: str) -> str: return _c("\033[0;32m", t) def blue(t: str) -> str: return _c("\033[0;34m", t) def yellow(t: str) -> str: return _c("\033[1;33m", t) def red(t: str) -> str: return _c("\033[0;31m", t) LINE = "\u2550" INFO_SYM = "\u2139" OK_SYM = "\u2705" WARN_SYM = "\u26a0" ERR_SYM = "\u274c" def info(m: str) -> None: print(blue(f" {INFO_SYM} {m}")) def ok(m: str) -> None: print(green(f" {OK_SYM} {m}")) def warn(m: str) -> None: print(yellow(f" {WARN_SYM} {m}")) def error(m: str) -> None: print(red(f" {ERR_SYM} {m}")) def header(m: str) -> None: print(f"\n{blue(LINE * 56)}\n {m}\n{blue(LINE * 56)}") def ask(prompt: str, default: str = "") -> str: if BATCH_MODE: return default suffix = f" [{default}]" if default else "" val = input(f" {prompt}{suffix}: ").strip() return val or default def ask_secret(prompt: str) -> str: if BATCH_MODE: return "" try: return getpass.getpass(f" {prompt}: ").strip() except Exception: return input(f" {prompt} (visible): ").strip() def ask_yesno(prompt: str, default: bool = True) -> bool: if BATCH_MODE: return default hint = "Y/n" if default else "y/N" val = input(f" {prompt} [{hint}]: ").strip().lower() if not val: return default return val.startswith("y") def menu(title: str, options: list[tuple[str, str]], default: int = 1) -> int: """Numbered choice menu. options = [(label, description)]. Returns 1-based index.""" if BATCH_MODE: return default print(f"\n {title}") for i, (label, desc) in enumerate(options, 1): line = f" {i}) {label}" if desc: line += f" — {desc}" print(line) val = input(f" Choose [1-{len(options)}] (default {default}): ").strip() if not val: return default try: idx = int(val) except ValueError: idx = 0 if not (1 <= idx <= len(options)): warn(f"Invalid choice '{val}' — using option {default}.") return default return idx def venv_bin(name: str) -> str: return os.path.join(VENV_DIR, "Scripts" if IS_WIN else "bin", f"{name}.exe" if IS_WIN else name) def detect_shell() -> str: if IS_WIN: return "powershell" if os.environ.get("PSModulePath") else "cmd" n = os.path.basename(os.environ.get("SHELL", "")).lower() for s in ("fish", "zsh", "bash"): if s in n: return s return "unknown" def activation_cmd(shell: str) -> str: m = { "fish": f"source {VENV_DIR}/bin/activate.fish", "bash": f"source {VENV_DIR}/bin/activate", "zsh": f"source {VENV_DIR}/bin/activate", "unknown": f"source {VENV_DIR}/bin/activate", "powershell": rf"{VENV_DIR}\Scripts\Activate.ps1", "cmd": rf"{VENV_DIR}\Scripts\activate.bat", } return m.get(shell, f"source {VENV_DIR}/bin/activate") # ── Step 1: Python ──────────────────────────────────────────────────────────── def step_python() -> None: header("1 / 7 Python version") major, minor = sys.version_info[:2] info(f"Python {major}.{minor}") if (major, minor) < (3, 10): error("piSynapse requires Python 3.10 or newer.") sys.exit(1) ok("OK") # ── Step 2: System dependencies ─────────────────────────────────────────────── def step_system_deps() -> None: header("2 / 7 System dependencies") _check_sudo_batch() if DISTRO.pm != "unknown": info(f"Detected: {DISTRO.id or DISTRO.pm} ({DISTRO.pm})") missing: list[str] = [] # ffmpeg if shutil.which("ffmpeg"): ok("ffmpeg found") else: warn("ffmpeg not found (required for audio/voice)") if IS_WIN: info("Download from: https://ffmpeg.org/download.html") elif sys.platform == "darwin": if ask_yesno("Install ffmpeg now? (brew install ffmpeg)"): if _pkg_install("ffmpeg"): ok("ffmpeg installed") else: missing.append("ffmpeg") else: if ask_yesno(f"Install ffmpeg now? ({DISTRO.pm} install ffmpeg)"): if _pkg_install("ffmpeg"): ok("ffmpeg installed") else: warn("ffmpeg install failed — install manually later") missing.append("ffmpeg") else: missing.append("ffmpeg") # curl if not shutil.which("curl"): warn("curl not found (needed for model downloads and health checks)") if not IS_WIN: _pkg_install("curl") if shutil.which("curl"): ok("curl installed") else: missing.append("curl") else: missing.append("curl") # python3-venv (needed before venv creation) if not IS_WIN and sys.platform != "darwin": venv_pkg = _pkg_python_venv() if venv_pkg: # Quick check: can we actually create a venv? if not _venv_works(): warn(f"python3-venv not functional — installing {venv_pkg}") if _pkg_install(venv_pkg): ok(f"{venv_pkg} installed") else: warn(f"Could not install {venv_pkg} — venv creation may fail") missing.append(venv_pkg) # build tools if not IS_WIN and sys.platform != "darwin": if not _build_tools_installed(): be_pkg = _pkg_build_essential() if be_pkg and ask_yesno(f"Install build tools ({be_pkg}, recommended for pip packages)?"): _pkg_install(be_pkg) if not _build_tools_installed(): missing.append(be_pkg or "build-essential") _report_missing_deps(missing) def _report_missing_deps(missing: list[str]) -> None: """Summarize unmet system dependencies so the user knows what to fix (D1). Previously a non-empty `missing` produced NOTHING — the user was left wondering which deps failed. Now: a one-line summary, the exact install command, what breaks without them, and a continue prompt (interactive). """ if not missing: ok("All system dependencies satisfied") return warn(f"{len(missing)} system dependency(ies) still missing: {', '.join(missing)}") if DISTRO.pm not in ("unknown", "brew"): info(f"Install them with: sudo {DISTRO.pm} install {' '.join(missing)}") info("piSynapse starts without them, but the affected features won't work: " "audio/voice needs ffmpeg, model downloads need curl, pip builds need build tools.") if not BATCH_MODE and not ask_yesno("Continue the install anyway?", default=True): error("Aborted — install the missing dependencies and re-run.") sys.exit(1) def _venv_works() -> bool: """Check if venv creation with ensurepip actually works.""" test_dir = Path(".venv_test") try: r = subprocess.run( [sys.executable, "-m", "venv", "--without-pip", str(test_dir)], capture_output=True, timeout=10, ) # Even --without-pip should work; if it fails, venv is broken if r.returncode != 0: return False # Now test ensurepip pip_path = test_dir / "bin" / "pip" if not pip_path.exists(): # Try bootstrapping r2 = subprocess.run( [str(test_dir / "bin" / "python3"), "-m", "ensurepip", "--upgrade"], capture_output=True, timeout=30, ) return r2.returncode == 0 return True except Exception: return False finally: shutil.rmtree(test_dir, ignore_errors=True) def _build_tools_installed() -> bool: """Return True when a C toolchain is already present.""" # Check for compilers on PATH (universal) if shutil.which("gcc") and shutil.which("make"): return True # Debian/Ubuntu: query build-essential via dpkg if shutil.which("dpkg"): try: r = subprocess.run( ["dpkg", "-s", "build-essential"], capture_output=True, timeout=15, ) if r.returncode == 0: return True except Exception: pass # Fedora/RHEL: check for gcc via rpm if shutil.which("rpm"): try: r = subprocess.run( ["rpm", "-q", "gcc"], capture_output=True, timeout=15, ) if r.returncode == 0: return True except Exception: pass # Arch: base-devel group if shutil.which("pacman"): try: r = subprocess.run( ["pacman", "-Qi", "base-devel"], capture_output=True, timeout=15, ) if r.returncode == 0: return True except Exception: pass return False # ── Step 3: Virtual env + Python packages ──────────────────────────────────── def step_venv() -> None: header("3 / 7 Virtual environment & dependencies") pip = venv_bin("pip") # Check if existing venv is functional if os.path.exists(VENV_DIR): if os.path.isfile(pip): ok(f"{VENV_DIR}/ already exists") else: # venv exists but pip is missing — broken venv (e.g. ensurepip was unavailable) warn(f"{VENV_DIR}/ exists but is broken (no pip) — recreating") shutil.rmtree(VENV_DIR, ignore_errors=True) if not os.path.exists(VENV_DIR): info(f"Creating {VENV_DIR}/...") r = subprocess.run([sys.executable, "-m", "venv", VENV_DIR]) if r.returncode != 0: # ensurepip might have failed; try without-pip and bootstrap warn("Standard venv creation failed — trying without pip") r2 = subprocess.run([sys.executable, "-m", "venv", "--without-pip", VENV_DIR]) if r2.returncode != 0: error("Failed to create virtual environment.") error("Install the venv package for your distro:") error(f" Debian/Ubuntu: sudo apt install python{sys.version_info[0]}.{sys.version_info[1]}-venv") error(f" Fedora/RHEL: sudo dnf install python{sys.version_info[0]}.{sys.version_info[1]}-devel") error(" Arch: sudo pacman -S python (includes venv)") error(f" openSUSE: sudo zypper install python{sys.version_info[0]}.{sys.version_info[1]}-venv") sys.exit(1) # Bootstrap pip manually via get-pip.py info("Bootstrapping pip via get-pip.py...") bootstrap_ok = False for cmd in [ [sys.executable, "-m", "ensurepip", "--upgrade"], ]: r3 = subprocess.run(cmd, cwd=VENV_DIR, capture_output=True, text=True) if r3.returncode == 0: bootstrap_ok = True break if not bootstrap_ok: # Last resort: download get-pip.py info("Downloading get-pip.py...") try: get_pip = os.path.join(VENV_DIR, "get-pip.py") urllib.request.urlretrieve("https://bootstrap.pypa.io/get-pip.py", get_pip) venv_python = venv_bin("python3") r4 = subprocess.run([venv_python, get_pip], capture_output=True, text=True) os.remove(get_pip) bootstrap_ok = r4.returncode == 0 except Exception as e: warn(f"get-pip.py download failed: {e}") if not bootstrap_ok: error("Could not install pip into the virtual environment.") sys.exit(1) ok(f"{VENV_DIR}/ created (pip bootstrapped)") else: ok(f"{VENV_DIR}/ created") # Verify pip exists now if not os.path.isfile(pip): # Try to find pip3 as fallback pip3 = venv_bin("pip3") if os.path.isfile(pip3): pip = pip3 else: error(f"pip not found at {pip}") error("The virtual environment may be broken. Delete it and re-run:") error(f" rm -rf {VENV_DIR}") sys.exit(1) info("Upgrading pip...") subprocess.run([pip, "install", "--quiet", "--upgrade", "pip"]) if os.path.exists("requirements.txt"): info("Installing requirements.txt...") r = subprocess.run([pip, "install", "-r", "requirements.txt"]) if r.returncode != 0: warn("pip install had issues — check output above") else: ok("Dependencies installed") else: warn("requirements.txt not found — skipping pip install") # ── Step 4: LLM backend ────────────────────────────────────────────────────── # piSynapse model name → (HuggingFace repo, filename, size hint). # NOTE: litert-lm imports with the model ID the app will request. The app sends # LLM_MODEL with ':' replaced by '-', so the import ID MUST use dashes. _LITERT_MODEL_REGISTRY: dict[str, tuple[str, str, str]] = { "gemma4:e2b": ("litert-community/gemma-4-E2B-it-litert-lm", "gemma-4-E2B-it.litertlm", "~2.4 GB"), "gemma4:e4b": ("litert-community/gemma-4-E4B-it-litert-lm", "gemma-4-E4B-it.litertlm", "~3.4 GB"), } _OLLAMA_MODELS = ["gemma4:e2b", "gemma4:e4b"] _STEP_TIMEOUT = 900 # 15 min: model downloads are large but must not hang forever def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: """Run a subprocess with live output (downloads/progress bars). A hard timeout is enforced: on a dead/hung link these used to block forever (no message, install frozen). subprocess.run kills the child on timeout; callers turn the TimeoutExpired into a clear warning. """ kwargs.setdefault("timeout", _STEP_TIMEOUT) return subprocess.run(cmd, **kwargs) def _ensure_uv() -> str | None: """Return uv binary path, installing it if needed.""" exe = shutil.which("uv") if exe: return exe info("uv not found — installing...") # Try pip install first (into venv) pip = venv_bin("pip") if os.path.isfile(pip): r = subprocess.run( [pip, "install", "--quiet", "uv"], capture_output=True, text=True, ) elif shutil.which("pipx"): r = subprocess.run( ["pipx", "install", "uv"], capture_output=True, text=True, ) else: # Fallback: try pip install --user r = subprocess.run( [sys.executable, "-m", "pip", "install", "--user", "--quiet", "uv"], capture_output=True, text=True, ) exe = shutil.which("uv") if r.returncode == 0 and exe: ok("uv installed") return exe # uv tool may not be on PATH for this session (e.g. ~/.local/bin not in PATH). for candidate in ( os.path.expanduser("~/.local/bin/uv"), os.path.expanduser("~/Library/Python/*/bin/uv"), ): # glob.glob expands '*' in ANY path component; the old # Path(dirname).glob(basename) only globbed the filename, so the # "~/Library/Python/*/bin" pattern never matched (dead code). hits = glob.glob(candidate) if "*" in candidate else [candidate] for hit in hits: if os.path.isfile(hit) and os.access(hit, os.X_OK): ok(f"uv found at {hit}") return hit warn("uv not found — will install litert-lm with pip instead") return None def _find_litert_bin() -> str | None: """Locate the litert-lm executable across platforms.""" exe = shutil.which("litert-lm") if exe: return exe candidates = [ os.path.expanduser("~/.local/bin/litert-lm"), os.path.expanduser("~/Library/Python/3.*/bin/litert-lm"), os.path.abspath(os.path.join(VENV_DIR, "Scripts" if IS_WIN else "bin", "litert-lm")), ] for pattern in candidates: # Same fix as _ensure_uv: expand '*' in any component (glob.glob), # not just the filename. hits = glob.glob(pattern) if "*" in pattern else [pattern] for hit in hits: if os.path.isfile(hit) and os.access(hit, os.X_OK): return hit return None def _install_litertlm(uv_bin: str | None) -> str | None: """Install litert-lm and return its binary path, or None on failure.""" existing = _find_litert_bin() if existing: ok(f"litert-lm found at {existing}") return existing info("Installing litert-lm (official LiteRT-LM CLI, ~64 kB)...") if uv_bin: r = subprocess.run([uv_bin, "tool", "install", "litert-lm"], capture_output=True, text=True) if r.returncode == 0: exe = _find_litert_bin() if exe: ok("litert-lm installed (uv)") return exe warn(f"uv tool install failed: {r.stderr.strip()[-300:]}") info("Falling back to pip install litert-lm...") pip = venv_bin("pip") r = subprocess.run([pip, "install", "--quiet", "litert-lm"], capture_output=True, text=True) if r.returncode == 0: exe = _find_litert_bin() if exe: ok("litert-lm installed (pip)") return exe # No console entrypoint found — create a shim inside the venv. shim_path = os.path.abspath(os.path.join(VENV_DIR, "bin", "litert-lm")) venv_python = venv_bin("python3") with open(shim_path, "w") as f: f.write(f'#!{venv_python}\nfrom litert_lm.cli import main; main()\n') os.chmod(shim_path, 0o750) ok(f"Created shim at {shim_path}") return shim_path error(f"Could not install litert-lm: {r.stderr.strip()[-300:]}") error("Install it manually with: uv tool install litert-lm (or: pip install litert-lm)") return None def _litert_model_imported(import_id: str, litert_bin: str = "litert-lm") -> bool: """Check whether a model is already in litert-lm's local registry. A hung/hissing CLI must not crash the installer — treat timeouts and any subprocess failure as "not imported" (the caller then offers to import). """ try: r = subprocess.run([litert_bin, "list"], capture_output=True, text=True, timeout=15) except (subprocess.TimeoutExpired, OSError): return False if r.returncode != 0: return False return any(line.strip().startswith(import_id) for line in r.stdout.splitlines()) def _litert_is_running() -> bool: try: r = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", f"http://localhost:{LITERT_PORT}/v1/models"], capture_output=True, text=True, timeout=5, ) return r.stdout.strip() == "200" except Exception: return False def _litert_model_served(model_id: str) -> bool: """Check whether a specific model id appears in the running server's registry.""" try: r = subprocess.run( ["curl", "-s", "--max-time", "5", f"http://localhost:{LITERT_PORT}/v1/models"], capture_output=True, text=True, timeout=8, ) return r.returncode == 0 and model_id in r.stdout except Exception: return False def _litert_python() -> str: """Return a python interpreter that can import litert_lm. litert-lm is installed one of two ways: - `uv tool install litert-lm` → dedicated tool venv (~/.local/share/uv/tools/litert-lm/bin/python3) - `pip install litert-lm` into the app venv → .venv python The piServe server (litert_serve/server.py) must run under the interpreter that has the litert_lm package — the CLI binary alone is not enough. """ uv_python = os.path.expanduser("~/.local/share/uv/tools/litert-lm/bin/python3") if os.path.isfile(uv_python): return uv_python return venv_bin("python3") def _model_dash_id() -> str: """Model id as the app requests it ('gemma4:e2b' → 'gemma4-e2b').""" return STATE.get("model", "gemma4:e2b").replace(":", "-") def _piserve_config_path() -> Path: return Path(__file__).resolve().parent / "litert_serve" / "config.json" def _write_piserve_config() -> bool: """Regenerate litert_serve/config.json for the installed model. server.py reads this file (Engine needs an exact model_path, and `litert-lm serve` ignores max_num_tokens — this is the whole reason piServe exists). Atomic write so a crash can't leave a half-written config behind. """ model_id = _model_dash_id() cfg = { "model_id": model_id, "model_path": os.path.expanduser(f"~/.litert-lm/models/{model_id}/model.litertlm"), # Must match config.DEFAULT_LLM_NUM_CTX (single source of truth). "max_num_tokens": 8192, "speculative_decoding": True, "use_ringbuffers_local_attention": False, "enable_ynnpack": False, "host": "127.0.0.1", "port": LITERT_PORT, } cfg_path = _piserve_config_path() try: tmp = cfg_path.with_name(cfg_path.name + ".tmp") tmp.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8") os.replace(tmp, cfg_path) return True except OSError as e: warn(f"Could not write {cfg_path}: {e}") return False def _start_litert_server(litert_bin: str) -> bool: """Start the piServe backend and wait until it accepts requests. piServe (litert_serve/server.py) wraps the litert Engine so the context window is configurable (max_num_tokens); the stock `litert-lm serve` hard-caps at 4096 and is bound to 127.0.0.1. """ if _litert_is_running(): ok(f"LiteRT server already running on :{LITERT_PORT}") return True server_py = Path(__file__).resolve().parent / "litert_serve" / "server.py" if not server_py.is_file(): error(f"Missing server file: {server_py}") error("Re-run install from the piSynapse checkout (litert_serve/ must sit next to install.py).") return False if not _write_piserve_config(): return False # Linux with systemd → proper service (auto-start on boot). If the user # declines the service, still ASK before falling back to a background # process instead of silently starting one. if shutil.which("systemctl"): if ask_yesno("Create & start piServe systemd service (auto-start on boot)?"): if _create_piserve_service(server_py): return _wait_litert_ready() return False if not ask_yesno(f"Start piServe server now on port {LITERT_PORT}?"): info("Skipping server start. Start it later with:") info(f" {_litert_python()} {server_py}") return False info(f"Starting piServe on 127.0.0.1:{LITERT_PORT}...") log_path = os.path.abspath("litert-server.log") try: # Single-generation rotation: the spawned daemon inherits this FD # across reboots, so an unbounded append would grow forever. if os.path.exists(log_path) and os.path.getsize(log_path) > 50 * 1024 * 1024: os.replace(log_path, log_path + ".1") except OSError as e: warn(f"Could not rotate {log_path}: {e}") # Models/errors land in this log: create it 0600 (owner-only) instead of # the umask default 0644 — the non-systemd path has no UMask= to lean on. _logfd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) logf = os.fdopen(_logfd, "ab") try: subprocess.Popen( [_litert_python(), str(server_py)], stdout=logf, stderr=logf, stdin=subprocess.DEVNULL, start_new_session=True, ) except Exception as e: error(f"Could not start piServe: {e}") logf.close() return False ok(f"Server starting — logs: {log_path}") return _wait_litert_ready() def _wait_litert_ready(timeout_s: int = 120) -> bool: """Poll the LiteRT health endpoint until it responds or times out.""" info(f"Waiting for LiteRT server on :{LITERT_PORT}...") for _ in range(timeout_s // 2): if _litert_is_running(): ok("LiteRT server is ready") return True time.sleep(2) warn(f"LiteRT server did not respond after {timeout_s}s.") warn("Check the log: cat litert-server.log") warn("Check the model: litert-lm list") return False def _render_piserve_unit(user: str, user_home: str, python: str, server_py: Path) -> str: """Render the piserve.service unit text (pure; tested). The unit always points at the repo's litert_serve/server.py — a copy elsewhere (e.g. an orphan ~/litert_serve) must never be served. EnvironmentFile is dash-prefixed (optional): missing .env must not fail the unit; when present it carries PISERVE_ADMIN_TOKEN through. """ repo_dir = server_py.parent.parent return f"""[Unit] Description=piServe (LiteRT-LM backend for piSynapse) After=network-online.target Wants=network-online.target [Service] Type=simple User={user} Environment=HOME={user_home} WorkingDirectory={server_py.parent} UMask=0077 EnvironmentFile=-{repo_dir}/.env ExecStart={python} {server_py} Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target """ def _create_piserve_service(server_py: Path) -> bool: """Create a systemd unit that runs litert_serve/server.py (piServe).""" if not shutil.which("systemctl"): warn("systemctl not found — skipping systemd service") return False user = os.environ.get("SUDO_USER") or os.environ.get("USER") or "pi" python = os.path.abspath(_litert_python()) user_home = os.path.expanduser(f"~{user}") unit = _render_piserve_unit(user, user_home, python, server_py) try: # Pipe the unit straight to root via `sudo tee` — no predictable # /tmp file (an unprivileged user could pre-create it as a symlink or # race to swap its content before the privileged cp; CWE-377). subprocess.run( ["sudo", "tee", "/etc/systemd/system/piserve.service"], input=unit.encode("utf-8"), check=True, capture_output=True, ) subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True) subprocess.run(["sudo", "systemctl", "enable", "piserve"], capture_output=True) subprocess.run(["sudo", "systemctl", "start", "piserve"]) # Retire the legacy litert.service — it binds the same port with a # fixed 4096-token cap, so leaving both up would fight for :9379. if os.path.exists("/etc/systemd/system/litert.service"): subprocess.run(["sudo", "systemctl", "stop", "litert"], capture_output=True) subprocess.run(["sudo", "systemctl", "disable", "litert"], capture_output=True) info("Stopped & disabled legacy litert.service (superseded by piserve.service)") ok("piserve.service created and started") return True except Exception as e: warn(f"Could not create piserve systemd service: {e}") return False def _check_resources(model_id: str) -> None: """Warn (never block) on insufficient disk/RAM before GB-sized downloads.""" need_gb = 4.5 if "e4b" in model_id else 3.5 # model + embedding/whisper/voice headroom try: free_gb = shutil.disk_usage(".").free / (1024 ** 3) if free_gb < need_gb + 2: warn(f"Low disk space: {free_gb:.1f} GB free, downloads need ~{need_gb:.0f} GB + headroom — expect failures.") except Exception: pass try: mem_gb = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / (1024 ** 3) except Exception: mem_gb = 0 if mem_gb and "e4b" in model_id and mem_gb < 12: warn(f"Only {mem_gb:.1f} GB RAM — e4b wants 16 GB+; expect slowness or OOM.") elif mem_gb and mem_gb < 6: warn(f"Only {mem_gb:.1f} GB RAM — 8 GB recommended even for e2b.") def _predownload_one(label: str, code: str) -> bool: """Run one pre-download step with 2 attempts; surface WHY it failed. The old code made a single silent attempt (capture_output swallowed the cause) — a stalled download raises nothing for minutes and the user saw only "failed", with no way to retry. Now the stderr tail is shown, one retry follows a short pause, and the exact command to redo it later is printed. A failed step never blocks the install (lazy download covers first use). """ py = venv_bin("python3") for attempt in range(1, 3): try: r = subprocess.run([py, "-c", code], capture_output=True, text=True, timeout=1200) except subprocess.TimeoutExpired: warn(f"{label} pre-download timed out after 20 min (attempt {attempt}/2) — likely a stalled download.") r = None if r is not None and r.returncode == 0: ok(f"{label} ready") return True if r is not None: tail = r.stderr.strip().splitlines()[-1][:200] if (r.stderr and r.stderr.strip()) else f"exit {r.returncode}" warn(f"{label} pre-download failed (attempt {attempt}/2): {tail}") if attempt < 2: time.sleep(3) info(f"Not blocking the install — it downloads lazily on first use. " f"Retry later with: {py} -c \"{code}\"") return False def _predownload_ml_models() -> None: """Pre-download embedding (~470MB) + Whisper tiny (~75MB) into the venv. Optional (ask, default yes); any failure only warns — first use lazily downloads anyway, and offline first chats degrade gracefully. """ if not ask_yesno("Pre-download embedding + speech models now (~550 MB)?", True): info("Skipping — they download lazily on first use.") return _predownload_one( "Embedding", "from embedding import embed; print('embedding dim:', len(embed('warmup')) // 4)", ) _predownload_one( "Whisper", "from routers.media import _get_whisper; print('whisper backend:', _get_whisper()[1])", ) def step_llm_backend() -> None: header("4 / 7 LLM backend") backend_idx = menu("Choose LLM backend:", [ ("LiteRT-LM", "Recommended — faster and uses less RAM on ARM/Apple Silicon"), ("Ollama", "x86 desktop favorite, needs more RAM"), ]) backend = "litert" if backend_idx == 1 else "ollama" STATE["backend"] = backend # ── Model selection ─────────────────────────────────────────────────── if backend == "litert": model_idx = menu("Choose model:", [ ("gemma4:e2b (~2.4 GB)", "Recommended — fits in 8 GB RAM"), ("gemma4:e4b (~3.4 GB)", "Higher quality — needs 16 GB+ RAM"), ]) model_id = "gemma4:e2b" if model_idx == 1 else "gemma4:e4b" else: model_idx = menu("Choose model:", [ ("gemma4:e2b (~2.4 GB)", "Recommended — fits in 8 GB RAM"), ("gemma4:e4b (~3.4 GB)", "Higher quality — needs 16 GB+ RAM"), ]) model_id = _OLLAMA_MODELS[model_idx - 1] STATE["model"] = model_id ok(f"Selected {backend} / {model_id}") _check_resources(model_id) # ── LiteRT path ─────────────────────────────────────────────────────── if backend == "litert": # Install litert-lm CLI (system-wide via uv, independent of the venv). uv_bin = _ensure_uv() litert_bin = _install_litertlm(uv_bin) if litert_bin is None: error("Aborting — litert-lm must be installed to continue.") sys.exit(1) # Import the model into the local registry if not present. import_id = model_id.replace(":", "-") # app requests gemma4-e2b if _litert_model_imported(import_id, litert_bin): ok(f"Model '{import_id}' already imported") else: hf_repo, hf_file, size_hint = _LITERT_MODEL_REGISTRY[model_id] info(f"Downloading {model_id} from HuggingFace ({size_hint})...") info(f" repo: {hf_repo}") print() imported = False for attempt in (1, 2): try: r = _run([ litert_bin, "import", f"--from-huggingface-repo={hf_repo}", hf_file, import_id, ]) except subprocess.TimeoutExpired: warn(f"Import attempt {attempt}/2 timed out after {_STEP_TIMEOUT // 60} min — dead/flaky link?") continue if r.returncode == 0 and _litert_model_imported(import_id, litert_bin): imported = True break warn(f"Import attempt {attempt}/2 failed — retrying...") if not imported: warn("Import failed — retry later with:") warn(f" {litert_bin} import --from-huggingface-repo={hf_repo} {hf_file} {import_id}") elif not _litert_model_imported(import_id, litert_bin): warn(f"Import reported success but '{import_id}' not found in registry — check: {litert_bin} list") else: ok(f"Model imported as '{import_id}'") # Verify the registry, then start the server. ok("Imported models:") subprocess.run([litert_bin, "list"]) _start_litert_server(litert_bin) if not _litert_model_served(import_id): warn(f"Model '{import_id}' is not served yet — start the server and check: {litert_bin} list") # ── Ollama path ─────────────────────────────────────────────────────── else: if not shutil.which("ollama"): info("Ollama not found. Installing...") if IS_WIN: info("Download from: https://ollama.com/download") info("Then run this installer again.") sys.exit(1) # Never pipe a remote script to sh unseen (K7): save it first so # the operator can inspect it, then run the saved file on approval. script_path = os.path.abspath("ollama-install.sh") r = subprocess.run(["curl", "-fsSL", "-o", script_path, "https://ollama.com/install.sh"], capture_output=True, text=True) if r.returncode != 0: warn("Automatic install failed — install manually: https://ollama.com") elif BATCH_MODE: warn(f"Saved installer to {script_path} — review it, then run: sh {script_path} (batch mode never executes remote scripts unseen)") elif ask_yesno(f"Saved installer to {script_path} — review it, then run it now?", default=True): subprocess.run(["sh", script_path]) ok("Ollama installed") else: info(f"Skipped — run later with: sh {script_path}") else: ok("Ollama already installed") info(f"Pulling {model_id}...") pulled = False for attempt in (1, 2): try: r = _run(["ollama", "pull", model_id]) except subprocess.TimeoutExpired: warn(f"Pull attempt {attempt}/2 timed out after {_STEP_TIMEOUT // 60} min — dead/flaky link?") continue if r.returncode == 0: pulled = True break warn(f"Pull attempt {attempt}/2 failed — retrying...") if pulled: ok(f"{model_id} ready") else: warn(f"Pull failed twice — try: ollama pull {model_id}") # Make sure Ollama server is running. if not _ollama_is_running(): if ask_yesno("Ollama server isn't running — start it now?"): info("Starting Ollama server...") subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) else: info("Skipping — start it later with: ollama serve") for _ in range(15): if _ollama_is_running(): ok("Ollama server is ready") break time.sleep(2) _predownload_ml_models() def _ollama_is_running() -> bool: try: r = subprocess.run( ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://localhost:11434"], capture_output=True, text=True, timeout=5, ) return r.stdout.strip() == "200" except Exception: return False # ── Step 5: Piper TTS voices ───────────────────────────────────────────────── PIPER_VOICES = { "en_US-lessac-medium": "rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium", "tr_TR-dfki-medium": "rhasspy/piper-voices/resolve/main/tr/tr_TR/dfki/medium/tr_TR-dfki-medium", "en_US-amy-medium": "rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium", } def _download_piper_voice(name: str, hf_path: str) -> bool: """Download one Piper voice (.onnx + config). Returns True on success.""" base_url = "https://huggingface.co" piper_dir = os.path.join("models", "piper") os.makedirs(piper_dir, exist_ok=True) ok_flag = True for ext, label in [(".onnx", "model"), (".onnx.json", "config")]: dest = os.path.join(piper_dir, f"{name}{ext}") if os.path.exists(dest) and os.path.getsize(dest) > 0: continue tmp = dest + ".tmp" url = f"{base_url}/{hf_path}{ext}" info(f" Downloading {name} {label}...") r = subprocess.run(["curl", "-fSL", "-o", tmp, url], capture_output=True) # A partial/interrupted download must never pass as complete: only an # atomically replaced, non-empty file counts (A-K2). if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 0: os.replace(tmp, dest) else: try: if os.path.exists(tmp): os.remove(tmp) except OSError: pass warn(f" Failed: {name}{ext}") ok_flag = False return ok_flag def step_tts_voices() -> None: header("5 / 7 Piper TTS voices (optional)") if not shutil.which("ffmpeg"): warn("ffmpeg not installed — TTS output may not play in browser") names = list(PIPER_VOICES) voice_idx = menu("Which TTS voice do you want?", [ ("en_US-lessac-medium (~60 MB)", "English male"), ("tr_TR-dfki-medium (~60 MB)", "Turkish male"), ("en_US-amy-medium (~60 MB)", "English female"), ("None — skip TTS", "You can download later"), ]) if voice_idx == 4: STATE["tts_voice"] = "en_US-lessac-medium" STATE["tts_enabled"] = False info("Skipping TTS voice download") return chosen = names[voice_idx - 1] STATE["tts_voice"] = chosen STATE["tts_enabled"] = True if _download_piper_voice(chosen, PIPER_VOICES[chosen]): ok(f"Voice ready: {chosen}") else: warn("Some voice files failed to download — TTS may not work") # ── Step 6: Environment configuration ──────────────────────────────────────── _ENV_TEMPLATE = """# ============================================================ # piSynapse — Environment Configuration # Auto-generated by install.py # ============================================================ # --- LLM Backend --- # "litert" — LiteRT-LM (OpenAI-compatible, recommended) # "ollama" — Ollama server LLM_BACKEND={LLM_BACKEND} # --- LLM URLs --- OLLAMA_BASE_URL=http://localhost:11434 LITERT_BASE_URL=http://localhost:{LITERT_PORT} # --- Model --- # Keep these in sync with config.DEFAULT_LLM_NUM_CTX / DEFAULT_LLM_MAX_OUTPUT_TOKENS. LLM_MODEL={LLM_MODEL} LLM_NUM_CTX=8192 LLM_NUM_BATCH=256 LLM_TEMPERATURE=0.6 LLM_TOP_P=0.85 LLM_TOP_K=40 LLM_KEEP_ALIVE=4h LLM_TIMEOUT=600 INTENT_LLM_TIMEOUT=10 SSE_READ_IDLE_TIMEOUT=300.0 LLM_MAX_TOOL_ITERATIONS=5 LLM_REASONING_EFFORT=medium LLM_MAX_OUTPUT_TOKENS=4096 # --- Embedding --- EMBED_MODEL=sentence-transformers/paraphrase-multilingual-mpnet-base-v2 # --- Memory --- MEMORY_SIMILARITY_THRESHOLD=0.68 HISTORY_LIMIT=12 MEMORY_LIMIT=10 SUMMARY_BATCH_SIZE=5 SUMMARY_EARLY_TRIGGER=6 LLM_TITLE_ENRICHMENT=on # Retention (days; 0 = keep forever) CONVERSATION_RETENTION_DAYS=0 MEMORY_RETENTION_DAYS=0 # --- Database --- DB_PATH=assistant.db # --- Weather --- DEFAULT_CITY={DEFAULT_CITY} WEATHER_TIMEOUT=10 # --- Nextcloud (optional) --- NEXTCLOUD_URL={NEXTCLOUD_URL} NEXTCLOUD_USER={NEXTCLOUD_USER} NEXTCLOUD_PASSWORD={NEXTCLOUD_PASSWORD} NEXTCLOUD_TIMEOUT=10 # --- Email Provider --- # "gmail" — Gmail with App Password # "proton" — ProtonMail via ProtonBridge (bridge must be running) # Leave empty to disable email MAIL_PROVIDER={MAIL_PROVIDER} # --- Gmail (only if MAIL_PROVIDER=gmail) --- GMAIL_USER={GMAIL_USER} GMAIL_APP_PASSWORD={GMAIL_APP_PASSWORD} IMAP_HOST=imap.gmail.com IMAP_PORT=993 SMTP_HOST=smtp.gmail.com SMTP_PORT=465 # --- ProtonMail / ProtonBridge (only if MAIL_PROVIDER=proton) --- PROTON_USER={PROTON_USER} PROTON_PASSWORD={PROTON_PASSWORD} PROTON_IMAP_HOST=localhost PROTON_IMAP_PORT=1143 PROTON_SMTP_HOST=localhost PROTON_SMTP_PORT=1025 # --- Timeouts --- IMAP_TIMEOUT=20 SMTP_TIMEOUT=20 # --- Piper TTS --- TTS_VOICE={TTS_VOICE} TTS_ENGINE={TTS_ENGINE} # --- Security --- ENV_PATH=.env API_KEY={API_KEY} # Per-user credential encryption key (Fernet). Losing it orphans saved # mail/Nextcloud passwords (they become unreadable, never leaked). MAIL_CREDS_KEY={MAIL_CREDS_KEY} # Session cookie Secure flag: enable ONLY behind HTTPS (1/true/yes/on). # Plain-HTTP LAN installs must leave it off or browsers get locked out. SESSION_COOKIE_SECURE=off # Open user registration ("on"/"off"); admin toggles it post-install. REGISTRATION_OPEN=on CORS_ORIGINS= # Comma-separated allowed Host header values (e.g. 192.168.1.X,myhost.local). # Empty = auto-allow this machine's local hostnames/IPs (safe default). TRUSTED_HOSTS={TRUSTED_HOSTS} # Trust X-Forwarded-For for client IPs when behind a reverse proxy (1/true/yes/on) TRUST_X_FORWARDED_FOR= MEDIA_MAX_MB=100 # Rate limits, requests/min (raise on bigger hardware) RATE_LIMIT_RPM=30 RATE_LIMIT_SESSION_RPM=20 RATE_LIMIT_PUBLIC_RPM=120 # Admin token for the LiteRT server (piserve) admin calls (optional) PISERVE_ADMIN_TOKEN= # Audit CSV export directory (default: /audit_exports) AUDIT_EXPORT_DIR= # --- STT --- STT_ENGINE={STT_ENGINE} # --- Voice Input Behavior --- AUTO_SEND_ON_VOICE={AUTO_SEND_ON_VOICE} AUTO_TTS_ON_VOICE={AUTO_TTS_ON_VOICE} # --- Intent --- INTENT_LLM_FALLBACK=off # Cosine threshold for the corpus-feeder conflict check (see config.py) CONFLICT_COSINE=0.50 # --- Assistant language for backend user-facing strings (tr/en) --- UI_LANGUAGE=en # --- Personalization --- ASSISTANT_USER={ASSISTANT_USER} """ def _read_current_env() -> dict[str, str]: """Read existing .env into a dict, preserving only 'key=value' lines.""" env_path = Path(".env") if not env_path.exists(): return {} result: dict[str, str] = {} for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) result[k.strip()] = v.strip() return result def _fill_template(template: str, values: dict) -> str: """Substitute {PLACEHOLDER} keys without re-interpreting user-supplied values (unlike str.format, which chokes on literal braces in values). """ def _repl(m: re.Match) -> str: key = m.group(1) return values.get(key, m.group(0)) return re.sub(r"\{([A-Z0-9_]+)\}", _repl, template) def _ensure_data_dirs() -> None: """Create runtime data dirs with owner-only mode (no lazy permission surprises).""" for d in ("models", "corpus_data", "audit_exports", "backups"): try: os.makedirs(d, mode=0o700, exist_ok=True) os.chmod(d, 0o700) except OSError as e: warn(f"Could not prepare directory {d}: {e}") def _merge_env_extras(content: str, current: dict[str, str]) -> tuple[str, dict[str, str]]: """Append .env keys the template does NOT manage (hand-added by the operator) so a re-run never drops them silently (D4). Returns (new_content, extras). """ template_keys = set(re.findall(r"^([A-Za-z0-9_]+)=", content, re.MULTILINE)) extras = {k: v for k, v in current.items() if k not in template_keys} if extras: content = content.rstrip() + "\n\n# --- Preserved from a previous .env (not managed by the installer) ---\n" content += "".join(f"{k}={v}\n" for k, v in extras.items()) return content, extras def _generate_creds_key() -> str: """A Fernet-COMPATIBLE credential-encryption key (urlsafe-base64 of 32 bytes). When `cryptography` isn't importable we still must emit a valid Fernet key (base64url of 32 random bytes) — the old secrets.token_urlsafe(32) fallback was the wrong shape and made credstore raise "Incorrect padding", silently breaking every credential write. """ try: from cryptography.fernet import Fernet return Fernet.generate_key().decode("utf-8") except Exception: import base64 return base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8") def step_env() -> None: header("6 / 7 Environment configuration") env_path = Path(".env") current = _read_current_env() info("You will now be asked a few questions. Press Enter to accept the default.\n") # Auto-generate API key (reuse existing if present). api_key = current.get("API_KEY", "") if not api_key: api_key = secrets.token_urlsafe(32) info("Generated API key (saved to .env)") # Per-user credential encryption key (reuse existing if present). creds_key = current.get("MAIL_CREDS_KEY", "") if not creds_key: creds_key = _generate_creds_key() info("Generated credential-encryption key (saved to .env)") # Personal setup (name, city, mail, Nextcloud) moved to the browser # onboarding — the installer no longer asks for or stores user data. info("Note: name, city, mail and Nextcloud are set up per-user in the browser after first launch.") # Security: allowed Host header values (empty = auto-allow local names/IPs). trusted_hosts = ask( "Allowed Host headers, comma-separated (empty = auto-allow local host/IP)", current.get("TRUSTED_HOSTS", ""), ) # ── Email / Nextcloud: per-user since multi-user (browser onboarding # collects them into the encrypted store). The installer preserves any # pre-existing shared values but never asks for them anymore. mail_provider = current.get("MAIL_PROVIDER", "") gmail_user = current.get("GMAIL_USER", "") gmail_pass = current.get("GMAIL_APP_PASSWORD", "") proton_user = current.get("PROTON_USER", "") proton_pass = current.get("PROTON_PASSWORD", "") nc_url = current.get("NEXTCLOUD_URL", "") nc_user = current.get("NEXTCLOUD_USER", "") nc_pass = current.get("NEXTCLOUD_PASSWORD", "") # ── Voice ──────────────────────────────────────────────────────────── stt_idx = menu("Speech-to-text engine:", [ ("whisper", "Fast, accurate, lightweight (~75 MB)"), ("gemma4", "Uses the LLM directly (slower but captures tone)"), ("browser", "Web Speech API in the browser"), ]) stt = "whisper" if stt_idx == 1 else ("gemma4" if stt_idx == 2 else "browser") tts_idx = menu("Text-to-speech engine:", [ ("piper", "Local, fully offline"), ("browser", "Web Speech API (more voices, needs internet)"), ]) tts_engine = "piper" if tts_idx == 1 else "browser" tts_voice = STATE.get("tts_voice", current.get("TTS_VOICE", "en_US-lessac-medium")) if tts_engine == "browser": # Browser speech needs no local model file — a lingering voice name # would fake readiness while the first TTS call 503s. if tts_voice: info("TTS engine is 'browser': ignoring local Piper voice setting.") tts_voice = "" elif not STATE.get("tts_enabled", True): voice_path = os.path.join("models", "piper", f"{tts_voice}.onnx") if tts_voice and not os.path.exists(voice_path): warn(f"Piper voice '{tts_voice}' was never downloaded — the first TTS call will fail. Re-run the installer or download it manually.") auto_send = ask("Auto-send after voice transcription? (on/off)", current.get("AUTO_SEND_ON_VOICE", "off")) auto_tts = ask("Auto-speak response when input was voice? (on/off)", current.get("AUTO_TTS_ON_VOICE", "off")) # Backend sanity: with --skip-llm the litert binary may be missing while # litert stays selected — that fails only later at server startup. if STATE.get("backend", "litert") == "litert" and not (shutil.which("litert-lm") or _find_litert_bin()): warn("LiteRT backend selected but litert-lm not found — the server will fail at startup. " "Install it (uv tool install litert-lm) or re-run without --skip-llm.") _ensure_data_dirs() values = { # --skip-llm ile gelindiyse STATE boş olur: makul defaults kullan. "LLM_BACKEND": STATE.get("backend", "litert"), # LiteRT model IDs use dashes (gemma4-e2b); Ollama uses colons (gemma4:e2b). "LLM_MODEL": STATE.get("model", "gemma4:e2b").replace(":", "-") if STATE.get("backend", "litert") == "litert" else STATE.get("model", "gemma4:e2b"), "LITERT_PORT": str(LITERT_PORT), "DEFAULT_CITY": current.get("DEFAULT_CITY", ""), "ASSISTANT_USER": current.get("ASSISTANT_USER", ""), "TRUSTED_HOSTS": trusted_hosts, "API_KEY": api_key, "MAIL_CREDS_KEY": creds_key, "MAIL_PROVIDER": mail_provider, "GMAIL_USER": gmail_user, "GMAIL_APP_PASSWORD": gmail_pass, "PROTON_USER": proton_user, "PROTON_PASSWORD": proton_pass, "NEXTCLOUD_URL": nc_url, "NEXTCLOUD_USER": nc_user, "NEXTCLOUD_PASSWORD": nc_pass, "STT_ENGINE": stt, "TTS_ENGINE": tts_engine, "TTS_VOICE": tts_voice, "AUTO_SEND_ON_VOICE": auto_send if auto_send in ("on", "off") else "off", "AUTO_TTS_ON_VOICE": auto_tts if auto_tts in ("on", "off") else "off", } # Preserve any existing values not re-asked above. preserved_keys = { "OLLAMA_BASE_URL", "LITERT_BASE_URL", "LLM_NUM_CTX", "LLM_NUM_BATCH", "LLM_TEMPERATURE", "LLM_TOP_P", "LLM_TOP_K", "LLM_KEEP_ALIVE", "LLM_TIMEOUT", "INTENT_LLM_TIMEOUT", "SSE_READ_IDLE_TIMEOUT", "LLM_TITLE_ENRICHMENT", "UI_LANGUAGE", "LLM_MAX_TOOL_ITERATIONS", "LLM_REASONING_EFFORT", "LLM_MAX_OUTPUT_TOKENS", "ENV_PATH", "TRUST_X_FORWARDED_FOR", "EMBED_MODEL", "MEMORY_SIMILARITY_THRESHOLD", "HISTORY_LIMIT", "MEMORY_LIMIT", "SUMMARY_BATCH_SIZE", "SUMMARY_EARLY_TRIGGER", "CONVERSATION_RETENTION_DAYS", "MEMORY_RETENTION_DAYS", "DB_PATH", "WEATHER_TIMEOUT", "NEXTCLOUD_TIMEOUT", "IMAP_HOST", "IMAP_PORT", "SMTP_HOST", "SMTP_PORT", "PROTON_IMAP_HOST", "PROTON_IMAP_PORT", "PROTON_SMTP_HOST", "PROTON_SMTP_PORT", "IMAP_TIMEOUT", "SMTP_TIMEOUT", "CORS_ORIGINS", "MEDIA_MAX_MB", "INTENT_LLM_FALLBACK", "CONFLICT_COSINE", "PISERVE_ADMIN_TOKEN", "AUDIT_EXPORT_DIR", "REGISTRATION_OPEN", "MAIL_CREDS_KEY", "SESSION_COOKIE_SECURE", "RATE_LIMIT_RPM", "RATE_LIMIT_SESSION_RPM", "RATE_LIMIT_PUBLIC_RPM", # NOTE: TRUSTED_HOSTS is deliberately NOT preserved — step_env always # asks it, so the given answer (even an intentional emptying) wins. } for k in preserved_keys: v = current.get(k) if v: values[k] = v content = _fill_template(_ENV_TEMPLATE, values) for k, v in values.items(): pattern = rf"^{re.escape(k)}=.*$" if re.search(pattern, content, re.MULTILINE): content = re.sub(pattern, lambda _m: f"{k}={v}", content, flags=re.MULTILINE) content, extras = _merge_env_extras(content, current) if extras: info(f"Preserved {len(extras)} custom .env key(s): {', '.join(sorted(extras))}") # Create the file at 0600 ATOMICALLY: write_text() would birth it # world-readable (umask 0644) and only the later chmod would tighten it — # a window where secrets (API_KEY, encryption key) sit readable. umask # can only clear bits, so an explicit 0o600 open is an upper bound. fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(content.strip() + "\n") try: os.chmod(env_path, 0o600) # a pre-existing .env may have looser perms except OSError: pass ok(".env created / updated") # Lock down any pre-existing SQLite files (db / -wal / -shm / -journal). # The DB itself is created on first app start; db.py re-enforces this # on every startup, this is the install-time safety net. for p in sorted(Path(".").glob("*.db*")): try: if p.is_file(): os.chmod(p, 0o600) except OSError: pass # ── Step 7: systemd service (Linux only) ───────────────────────────────────── def _render_pisynapse_unit(user: str, project_dir: str, uvicorn_path: str, wants_litert: bool) -> str: """Render the pisynapse.service unit text (pure; tested). Depends on piserve.service (never the legacy litert.service name) when the LiteRT backend is in use. """ after = "network-online.target" + (" piserve.service" if wants_litert else "") return f"""[Unit] Description=piSynapse AI Assistant After={after} Wants=network-online.target{" piserve.service" if wants_litert else ""} [Service] Type=simple User={user} WorkingDirectory={project_dir} UMask=0077 Environment=PATH={os.path.join(project_dir, VENV_DIR, "bin")}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ExecStart={uvicorn_path} main:app --host 0.0.0.0 --port 8765 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target """ def _users_table_empty(db_path: str) -> bool: """True when no users table/rows exist (stdlib sqlite3 — installer rule).""" import sqlite3 try: if not os.path.isfile(db_path): return True conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) try: cur = conn.execute( "SELECT COUNT(*) FROM users" ) return (cur.fetchone() or [0])[0] == 0 except Exception: return True # no users table yet = fresh finally: conn.close() except Exception: return True def step_owner() -> None: """Create the owner admin account (first install only). Deterministic ownership: the human drove the installer, so they become admin here — no phantom bootstrap row, no first-visitor race. Later accounts register in the browser. Batch mode skips (first browser registration becomes admin instead). """ header("Owner account (first admin)") project_dir = Path(__file__).resolve().parent env_now = _read_current_env() db_path = str(project_dir / (env_now.get("DB_PATH", "") or "assistant.db")) if not _users_table_empty(db_path): info("Users already exist — skipping owner setup.") return if BATCH_MODE: info("Non-interactive: skipping owner setup — first browser registration becomes admin.") return if not ask_yesno("Create the owner admin account now?", default=True): info("Skipped — first browser registration becomes admin.") return name = "" while not name: name = ask("Owner display name") if not name: warn("Name must not be empty.") password = "" while len(password) < 8: password = ask_secret("Owner password (min 8 chars)") if len(password) < 8: warn("Password must be at least 8 characters.") city = ask("Default city for weather (empty = skip)", "") mail: dict | None = None midx = menu("Owner email (assistant reads/sends as you):", [ ("Skip", "Add later in the browser"), ("Gmail", "Address + App Password"), ("ProtonMail", "Address + Bridge password (Bridge must hold the account)"), ], default=1) if midx == 2: mail = {"provider": "gmail", "account": ask("Gmail address", ""), "secret": ask_secret("Gmail App Password (16 chars, no spaces)").replace(" ", "").replace("-", "")} elif midx == 3: mail = {"provider": "proton", "account": ask("ProtonMail address", ""), "secret": ask_secret("ProtonBridge password")} nc: dict | None = None if ask_yesno("Add owner Nextcloud (notes/tasks/calendar)?", default=False): nc = {"url": ask("Nextcloud URL (e.g. https://cloud.example.com)"), "account": ask("Nextcloud username"), "secret": ask_secret("Nextcloud app password")} import json as _json import subprocess as _sp payload = {"name": name, "password": password, "city": city, "mail": mail, "nc": nc} try: proc = _sp.run( [venv_bin("python3"), str(project_dir / "provision_owner.py")], input=_json.dumps(payload), capture_output=True, text=True, timeout=300, cwd=str(project_dir), ) result = _json.loads(proc.stdout or "{}") except Exception as e: warn(f"Owner setup failed ({e}) — register in the browser instead.") return if not result.get("ok"): warn(f"Owner setup failed ({result.get('error', 'unknown')}) — register in the browser instead.") return for w in result.get("warnings", []): warn(w) STATE["owner"] = name print(f"\n{green(LINE * 56)}") print(green(" OWNER API KEY — copy it now, it is shown exactly once:")) print(f"\n {result['api_key']}\n") print(green(LINE * 56)) info("Browser login uses name + password (no key needed there).") info("Subsequent accounts register in the browser.") def step_systemd() -> None: if IS_WIN or sys.platform == "darwin": info("systemd is Linux-only — to auto-start on boot on this OS, see README.md") return header("7 / 7 Systemd service (optional)") if not shutil.which("systemctl"): info("systemctl not found — skipping systemd setup") return if ask_yesno("Create a systemd service to start piSynapse on boot?"): user = os.environ.get("SUDO_USER") or os.environ.get("USER") or "pi" project_dir = os.path.abspath(".") uvicorn_path = os.path.join(project_dir, VENV_DIR, "bin", "uvicorn") wants_litert = STATE.get("backend") == "litert" and os.path.exists("/etc/systemd/system/piserve.service") unit = _render_pisynapse_unit(user, project_dir, uvicorn_path, wants_litert) service_path = Path("/etc/systemd/system/pisynapse.service") try: # `sudo tee` instead of a predictable /tmp file + sudo cp (CWE-377 # insecure temp: pre-created symlink / swap race → root-owned unit). subprocess.run( ["sudo", "tee", str(service_path)], input=unit.encode("utf-8"), check=True, capture_output=True, ) subprocess.run(["sudo", "systemctl", "daemon-reload"], capture_output=True) ok("systemd service created: pisynapse.service") if ask_yesno("Enable and start the service now?"): subprocess.run(["sudo", "systemctl", "enable", "pisynapse"], capture_output=True) subprocess.run(["sudo", "systemctl", "start", "pisynapse"]) ok("piSynapse started — check status: systemctl status pisynapse") except Exception as e: warn(f"Could not create service: {e}") info("Manual setup: see README.md for systemd instructions") else: info("Skipping systemd setup") # ── Summary ─────────────────────────────────────────────────────────────────── def print_summary() -> None: shell = detect_shell() activate = activation_cmd(shell) run_cmd = f"{venv_bin('uvicorn')} main:app --host 0.0.0.0 --port 8765" api_key = "" try: for line in Path(".env").read_text(encoding="utf-8").splitlines(): if line.strip().startswith("API_KEY=") and "=" in line: api_key = line.split("=", 1)[1].strip() break except Exception: pass backend = STATE.get("backend", "litert") model = STATE.get("model", "gemma4:e2b") print(f"\n{green(LINE * 56)}") print(green(f" {OK_SYM} Installation complete!")) print(green(LINE * 56)) print(f"\n {'Backend':12s}: {backend}") print(f" {'Model':12s}: {model}") if backend == "litert": print(f" {'LiteRT':12s}: {'systemd (active)' if os.path.exists('/etc/systemd/system/piserve.service') else f'background process on :{LITERT_PORT}'}") if os.path.exists("/etc/systemd/system/pisynapse.service"): print(f" {'piSynapse':12s}: systemd (active)") if api_key: print(f" {'API Key':12s}: saved to .env (mode 0600, never logged)") print(" Keep .env private; re-run the installer to rotate the key.") if STATE.get("owner"): print(f" {'Owner':12s}: {STATE['owner']} (admin — browser login with name + password)") print("\n Start piSynapse:") print(f" {activate}") print(f" {run_cmd}") if backend == "litert" and not _litert_is_running(): print("\n piServe is not running — start it first in another terminal:") print(f" {_litert_python()} {Path(__file__).resolve().parent / 'litert_serve' / 'server.py'}") if backend == "ollama" and not _ollama_is_running(): print("\n Ollama is not running — start it first:") print(" ollama serve") print("\n Open http://localhost:8765 in your browser.") if api_key: print(" Enter your API key when prompted.\n") if os.path.exists("/etc/systemd/system/piserve.service") or os.path.exists("/etc/systemd/system/pisynapse.service"): print(f" {'Manage':12s}: systemctl status piserve pisynapse\n") # ── Entry ───────────────────────────────────────────────────────────────────── def main() -> None: _parse_args() print(blue("\n piSynapse Installer\n")) if BATCH_MODE: info("Non-interactive mode (--yes)") if not os.path.isfile("main.py"): error("main.py not found — run this script from the piSynapse project directory.") sys.exit(1) _detect_distro() step_python() step_system_deps() step_venv() if not SKIP_LLM: step_llm_backend() else: header("4 / 7 LLM backend — skipped") if not SKIP_TTS: step_tts_voices() else: header("5 / 7 TTS voices — skipped") step_env() step_owner() if not SKIP_SYSTEMD: step_systemd() else: header("7 / 7 Systemd — skipped") print_summary() if __name__ == "__main__": main()