From aa4d6a6109237d7dabf82d7522076d0fecf8c702 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 10:55:20 +0200 Subject: [PATCH 01/14] fix(stack): save a setting without rewriting other lines in stack.toml When a hook or command saved a setting (`ctx.cfg(key, value)`), the writer replaced every quoted `key = "..."` in the file. Sections were ignored and comments were not skipped, so saving `[ai] default` also rewrote the commented-out model alternatives and any `default` key in another section. A key whose value was not a quoted string was appended again instead of replaced, which left a file TOML refuses to parse. The writer now changes the one assignment inside the named section, keeps any trailing comment on that line, and escapes the value. --- lib/stack/stack.py | 36 +++++--- tests/unit/framework/test_config_writer.py | 96 ++++++++++++++++++++++ 2 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 tests/unit/framework/test_config_writer.py diff --git a/lib/stack/stack.py b/lib/stack/stack.py index b3df3f42..b97d3486 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -158,26 +158,38 @@ def _cfg(self, section: str, key: str, default: str = "") -> str: def _set_cfg(self, section: str, key: str, value: str): """Write a config value to stack.toml. - Updates an existing key or appends to the section. Creates the - section if it doesn't exist. + Updates the key inside `[section]` or adds it there, creating the + section if it doesn't exist. Only the one assignment line changes: + the file is the admin's, so comments, commented-out alternatives + and same-named keys in other sections stay as they are. """ + import json import re toml_path = self.instance_dir / "stack.toml" if not toml_path.exists(): return - content = toml_path.read_text() + lines = toml_path.read_text().splitlines() - pattern = rf'{re.escape(key)}\s*=\s*"[^"]*"' - replacement = f'{key} = "{value}"' + # A JSON string is a valid TOML basic string, escapes included. + quoted = json.dumps(str(value)) + assignment = re.compile(rf'^(\s*){re.escape(key)}\s*=\s*("(?:[^"\\]|\\.)*"|[^#\s]*)(.*)$') + header = re.compile(r"^\s*\[") - if re.search(pattern, content): - content = re.sub(pattern, replacement, content) - elif f"[{section}]" in content: - content = content.replace(f"[{section}]", f"[{section}]\n{replacement}") + start = next((i for i, line in enumerate(lines) + if line.strip() == f"[{section}]"), None) + if start is None: + lines += ["", f"[{section}]", f"{key} = {quoted}"] else: - content += f"\n[{section}]\n{replacement}\n" - - toml_path.write_text(content) + end = next((i for i in range(start + 1, len(lines)) + if header.match(lines[i])), len(lines)) + for i in range(start + 1, end): + if m := assignment.match(lines[i]): + lines[i] = f"{m.group(1)}{key} = {quoted}{m.group(3)}" + break + else: + lines.insert(start + 1, f"{key} = {quoted}") + + toml_path.write_text("\n".join(lines) + "\n") # ── Discovery ───────────────────────────────────────────────────── diff --git a/tests/unit/framework/test_config_writer.py b/tests/unit/framework/test_config_writer.py new file mode 100644 index 00000000..7dbc26e8 --- /dev/null +++ b/tests/unit/framework/test_config_writer.py @@ -0,0 +1,96 @@ +"""Writing one value into stack.toml without touching anything else. + +Hooks and CLI commands record choices in stack.toml through +`ctx.cfg(key, value)`. The file is the admin's: it carries their +comments, their commented-out alternatives, and sections that reuse the +same key names. A write that changes one value must change exactly that +value, in exactly that section, and leave a file that still parses. +""" + +import tomllib + + +GENERATED = '''\ +[core] +language = "en" + +[ai] +provider = "" +openai_url = "" +# Models by RAM tier, uncomment one to switch: +default = "mlx-community/Qwen3.5-9B-MLX-8bit" # 32 GB (selected) +# default = "mlx-community/Qwen2.5-14B-Instruct-4bit" # 64 GB + +[backup] +default = "daily" +enabled = true +''' + + +def _stack(make_stack): + stck = make_stack() + (stck.instance_dir / "stack.toml").write_text(GENERATED) + return stck + + +def _text(stck): + return (stck.instance_dir / "stack.toml").read_text() + + +class TestOneValueInOneSection: + + def test_a_key_shared_by_two_sections_changes_only_in_the_one_named(self, make_stack): + stck = _stack(make_stack) + stck._set_cfg("ai", "default", "llama3.1:8b") + + assert stck.config["ai"]["default"] == "llama3.1:8b" + assert stck.config["backup"]["default"] == "daily" + + def test_commented_out_alternatives_stay_as_the_admin_left_them(self, make_stack): + """The generated file lists the other model tiers as comments so + switching is an uncomment. Rewriting them would destroy the menu.""" + stck = _stack(make_stack) + stck._set_cfg("ai", "default", "llama3.1:8b") + + assert '# default = "mlx-community/Qwen2.5-14B-Instruct-4bit" # 64 GB' in _text(stck) + + def test_a_trailing_comment_on_the_line_survives(self, make_stack): + stck = _stack(make_stack) + stck._set_cfg("ai", "default", "llama3.1:8b") + + assert 'default = "llama3.1:8b" # 32 GB (selected)' in _text(stck) + + def test_a_value_that_is_not_a_string_is_replaced_not_duplicated(self, make_stack): + """A second `enabled =` line in the section is a TOML error, and + the whole stack stops reading its config.""" + stck = _stack(make_stack) + stck._set_cfg("backup", "enabled", "false") + + assert stck.config["backup"]["enabled"] == "false" + assert _text(stck).count("enabled =") == 1 + + +class TestKeysAndSectionsThatDoNotExistYet: + + def test_a_new_key_lands_in_its_section(self, make_stack): + stck = _stack(make_stack) + stck._set_cfg("ai", "whisper_url", "http://192.168.1.20:42062/v1") + + assert stck.config["ai"]["whisper_url"] == "http://192.168.1.20:42062/v1" + assert "whisper_url" not in stck.config["backup"] + + def test_a_new_section_is_appended(self, make_stack): + stck = _stack(make_stack) + stck._set_cfg("agent", "name", "Marge") + + assert stck.config["agent"]["name"] == "Marge" + + +class TestValuesThatNeedEscaping: + + def test_quotes_and_backslashes_round_trip(self, make_stack): + """API keys are opaque strings; the file must still parse.""" + stck = _stack(make_stack) + stck._set_cfg("ai", "openai_key", 'sk-"odd"\\key') + + assert tomllib.loads(_text(stck))["ai"]["openai_key"] == 'sk-"odd"\\key' From 3fa5d1b6fad486f30b8047abf724de778ba70faa Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 10:56:10 +0200 Subject: [PATCH 02/14] feat(agent): run the agent against an AI server on another machine The agent refused to start unless the local AI stacklet was set up, although it only ever talks to `[ai] openai_url`. A household running its models on another machine on the network, or with a hosted OpenAI-compatible provider, could not use the agent at all. The agent now requires only `messages`. Before it starts it checks the AI server instead: - no `[ai] openai_url` stops the start and names both ways to get one, `./stack up ai` or `./stack ai connect ` - a server that does not answer is a warning; the agent starts and replies once the server is back - a configured model the server does not list is named in a warning --- stacklets/agent/hooks/on_start.py | 49 +++++++++++++ stacklets/agent/stacklet.toml | 5 +- tests/unit/stacklets/test_agent_ai_server.py | 77 ++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 stacklets/agent/hooks/on_start.py create mode 100644 tests/unit/stacklets/test_agent_ai_server.py diff --git a/stacklets/agent/hooks/on_start.py b/stacklets/agent/hooks/on_start.py new file mode 100644 index 00000000..b7244def --- /dev/null +++ b/stacklets/agent/hooks/on_start.py @@ -0,0 +1,49 @@ +"""Check the agent has an AI server before it starts. + +The agent talks to whatever `[ai] openai_url` points at: the local AI +stacklet, another machine on the network, or a hosted provider. So the +local stacklet is not a requirement, but an address is. Without one the +container would start and fail every message. + +An address that does not answer is only a warning. The server may still +be booting, or be a machine that sleeps; neither should keep the agent +down once it comes back. +""" + +from stack.ai.probe import probe +from stack.prompt import warn + + +def _host_url(docker_url: str) -> str: + """The env holds the address as containers see it. This hook runs on + the host, where the framework's localhost rewrite has to be undone.""" + return docker_url.replace("://host.docker.internal", "://localhost") + + +def _has_model(model: str, available: list[str]) -> bool: + """Servers list ids with or without the org prefix, so compare the + short name either way, as `stack ai models` does.""" + short = model.rsplit("/", 1)[-1] + return any(short in m or m in short for m in available) + + +def run(ctx): + url = ctx.env.get("AGENT_OPENAI_URL", "") + if not url: + raise RuntimeError( + "No AI server configured in stack.toml [ai] openai_url. " + "Install the local engine with './stack up ai', or use one " + "elsewhere with './stack ai connect '." + ) + + url = _host_url(url) + result = probe(url, ctx.env.get("AGENT_OPENAI_KEY", "")) + if not result.reachable: + warn(f"AI server at {url} is not answering. " + "The agent starts anyway and replies once it is back.") + return + + model = ctx.env.get("AGENT_MODEL", "") + if model and result.models and not _has_model(model, result.models): + warn(f"The AI server at {url} does not list the model {model}. " + f"Pick one it has with './stack ai connect {url} --model '.") diff --git a/stacklets/agent/stacklet.toml b/stacklets/agent/stacklet.toml index ad60abf3..c291874e 100644 --- a/stacklets/agent/stacklet.toml +++ b/stacklets/agent/stacklet.toml @@ -4,7 +4,8 @@ # notes and wiki and can act through the stack CLI. It runs the nanobot harness # in a container, connects to Matrix through nanobot's own channel as the agent # account that the core bot-runner provisions from bot/bot.toml, and talks to the -# local LLM configured in stack.toml [ai]. +# AI server configured in stack.toml [ai]: the local ai stacklet, another +# machine on the network, or a hosted provider (`stack ai connect `). # # The stacklet is "agent"; the persona (name + voice) is configurable. It ships # as "Stacky" via AGENT_NAME and a family can rename it (see the note below). @@ -14,7 +15,7 @@ name = "Agent" description = "The family agent — ask it in chat, it answers from your own notes" version = "0.1.0" category = "ai" -requires = ["ai", "messages"] +requires = ["messages"] # Image is built locally from the Dockerfile here (nanobot from PyPI). There is # no upstream image to pull, so build on every `stack up`. diff --git a/tests/unit/stacklets/test_agent_ai_server.py b/tests/unit/stacklets/test_agent_ai_server.py new file mode 100644 index 00000000..435dee0c --- /dev/null +++ b/tests/unit/stacklets/test_agent_ai_server.py @@ -0,0 +1,77 @@ +"""Starting the agent needs an AI server, not the local AI stacklet. + +The agent talks to whatever `[ai] openai_url` points at: the local +engine, a machine elsewhere on the network, or a hosted provider. It +used to refuse to start unless the local AI stacklet was set up, which +locked out every household running its models on another machine. + +What it does need is an address. Without one the agent could only +start and then fail every message, so `stack up agent` stops and says +how to configure one. An address that does not answer right now is a +warning, not a stop: the server may be booting, and a machine that is +switched off at night should not keep the agent from coming back up. +""" + +from __future__ import annotations + +import importlib.util +import tomllib +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_AGENT = _REPO_ROOT / "stacklets" / "agent" + +_spec = importlib.util.spec_from_file_location( + "agent_hooks_on_start", _AGENT / "hooks" / "on_start.py") +on_start = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(on_start) + + +class FakeCtx: + """The agent's rendered env, as the framework hands it to the hook.""" + + def __init__(self, url, model="llama3.1:8b"): + self.env = {"AGENT_OPENAI_URL": url, "AGENT_OPENAI_KEY": "k", + "AGENT_MODEL": model} + + +def test_the_local_ai_stacklet_is_not_a_requirement(): + manifest = tomllib.loads((_AGENT / "stacklet.toml").read_text()) + assert "ai" not in manifest["requires"] + assert "messages" in manifest["requires"] + + +def test_no_ai_server_stops_the_start_and_names_both_ways_to_get_one(): + with pytest.raises(RuntimeError) as e: + on_start.run(FakeCtx(url="")) + assert "stack up ai" in str(e.value) + assert "stack ai connect" in str(e.value) + + +def test_a_server_that_answers_starts_quietly(httpserver, capsys): + httpserver.expect_request("/v1/models").respond_with_json( + {"data": [{"id": "llama3.1:8b"}]}) + + on_start.run(FakeCtx(url=httpserver.url_for("/v1"))) + + assert "⚠" not in capsys.readouterr().out + + +def test_a_server_that_does_not_answer_warns_and_starts_anyway(capsys): + on_start.run(FakeCtx(url="http://127.0.0.1:9/v1")) + + assert "127.0.0.1:9" in capsys.readouterr().out + + +def test_a_model_the_server_does_not_have_is_named(httpserver, capsys): + """The installer picks an MLX model id. A server running something + else answers every request with an error the family never sees.""" + httpserver.expect_request("/v1/models").respond_with_json( + {"data": [{"id": "llama3.1:8b"}]}) + + on_start.run(FakeCtx(url=httpserver.url_for("/v1"), + model="mlx-community/Qwen3.5-9B-MLX-8bit")) + + assert "Qwen3.5-9B-MLX-8bit" in capsys.readouterr().out From 761e88f5fe3b6cf40814e6789f54a9a670e3fc74 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:22:27 +0200 Subject: [PATCH 03/14] feat(ai): transcribe voice messages on the AI server by default Voice messages always went to `[ai] whisper_url`, which defaulted to the local Whisper on port 42062. On an instance without the ai stacklet that address serves nothing, and no key was ever passed on, so a hosted provider refused every voice message. Transcription is the OpenAI `/audio/transcriptions` call, which the hosted providers answer too, and oMLX with a speech model loaded. So: - with `[ai] whisper_url` unset, voice messages go to `[ai] openai_url` with `[ai] openai_key`: one server, one key - with it set, they go to that dedicated speech server with `[ai] whisper_key`, never the AI key - `[ai] whisper_model` names the speech model to ask for, `whisper-1` by default; other servers name theirs and refuse any other - `stack up ai` sets the local Whisper as the speech server when it builds it, and the installer no longer writes one for everyone Core, memory and chatai now receive the key and the model. Existing instances keep the `whisper_url` their installer wrote and see no change. --- docs/stack-reference.md | 4 +- lib/stack/ai/client.py | 11 +++- lib/stack/ai/probe.py | 19 ++++++ lib/stack/installer_v2.py | 1 - lib/stack/stack.py | 12 +++- stacklets/ai/hooks/on_install.py | 3 + stacklets/chatai/stacklet.toml | 10 +-- stacklets/core/stacklet.toml | 2 + stacklets/memory/stacklet.toml | 2 + tests/unit/framework/test_ai_client.py | 28 ++++++++ tests/unit/framework/test_ai_probe.py | 23 +++++++ tests/unit/stacklets/test_voice_routing.py | 76 ++++++++++++++++++++++ 12 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 tests/unit/framework/test_ai_probe.py create mode 100644 tests/unit/stacklets/test_voice_routing.py diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 909c46ea..3ca85923 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -196,7 +196,9 @@ Available template variables: | `{ai_openai_url}` | Derived from `stack.toml` → `[ai].openai_url` | | `{ai_openai_url_docker}` | Same, rewritten for container access via `host.docker.internal` | | `{ai_openai_key}` | `stack.toml` → `[ai].openai_key` | -| `{ai_whisper_url_docker}` | Derived from `stack.toml` → `[ai].whisper_url` | +| `{ai_whisper_url_docker}` | Derived from `stack.toml` → `[ai].whisper_url`, or the AI server when unset | +| `{ai_whisper_key}` | `stack.toml` → `[ai].whisper_key` with a dedicated speech server, otherwise the AI key | +| `{ai_whisper_model}` | `stack.toml` → `[ai].whisper_model`, default `whisper-1` | | `{ai_default_model}` | `stack.toml` → `[ai].default` | | `{ai_tts_voice}` | Derived from `[ai].language` | | `{messages_server_name}` | `stack.toml` → `[messages].server_name` | diff --git a/lib/stack/ai/client.py b/lib/stack/ai/client.py index 85c71462..24e3a69d 100644 --- a/lib/stack/ai/client.py +++ b/lib/stack/ai/client.py @@ -449,9 +449,13 @@ class Transcriber: archivist vs scribe) without touching call sites later. """ - def __init__(self, client: AsyncOpenAI, *, namespace: str | None = None): + def __init__(self, client: AsyncOpenAI, *, namespace: str | None = None, + model: str = _DEFAULT_WHISPER_MODEL): self._client = client self.namespace = namespace + # The speech model to ask for. OpenAI's is `whisper-1`; other + # servers name theirs and refuse any other name. + self.model = model @classmethod def from_env(cls, *, namespace: str | None = None, @@ -480,7 +484,8 @@ def from_env(cls, *, namespace: str | None = None, # on *some* key — same trick as LLM.from_env. key = os.environ.get("WHISPER_KEY", "") or "not-needed" client = AsyncOpenAI(base_url=url, api_key=key, max_retries=max_retries) - return cls(client, namespace=namespace) + return cls(client, namespace=namespace, + model=os.environ.get("WHISPER_MODEL", "") or _DEFAULT_WHISPER_MODEL) async def transcribe(self, audio: bytes, *, filename: str = "voice.ogg", model: str | None = None, @@ -616,7 +621,7 @@ async def _stt(self, audio: bytes, *, filename: str, """ try: return await self._client.audio.transcriptions.create( - model=model or _DEFAULT_WHISPER_MODEL, + model=model or self.model, file=(filename, audio), **params, **({"prompt": vocabulary} if vocabulary.strip() else {}), diff --git a/lib/stack/ai/probe.py b/lib/stack/ai/probe.py index c389730b..2cf1ebbb 100644 --- a/lib/stack/ai/probe.py +++ b/lib/stack/ai/probe.py @@ -49,3 +49,22 @@ def probe(url: str, key: str = "", *, timeout: float = 3.0) -> ProbeResult: return ProbeResult(reachable=False) except Exception: return ProbeResult(reachable=False) + + +def transcribes(url: str, key: str = "", *, timeout: float = 3.0) -> bool: + """Whether ``{url}/audio/transcriptions`` exists. + + Asked with an empty POST, so nothing is uploaded and nothing is + transcribed. A server with the endpoint rejects the request for the + missing file (400, 415 or 422); one without it answers 404 or 405. + """ + req = urllib.request.Request( + f"{url.rstrip('/')}/audio/transcriptions", data=b"", method="POST", + headers={"Authorization": f"Bearer {key}"} if key else {}) + try: + with urllib.request.urlopen(req, timeout=timeout, context=_SSL): + return True + except urllib.error.HTTPError as e: + return e.code in (400, 415, 422) + except Exception: + return False diff --git a/lib/stack/installer_v2.py b/lib/stack/installer_v2.py index 3f9aec57..d70a051f 100644 --- a/lib/stack/installer_v2.py +++ b/lib/stack/installer_v2.py @@ -282,7 +282,6 @@ def write_stack_toml(family_name, server_name, timezone, language="en"): provider = "" openai_url = "" openai_key = "" -whisper_url = "http://localhost:42062/v1" language = "en" {model_block} diff --git a/lib/stack/stack.py b/lib/stack/stack.py index b97d3486..687d70e6 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -333,9 +333,15 @@ def _build_template_vars(self) -> dict: - Admin user from users.toml """ ai_openai_url = self._cfg("ai", "openai_url", "http://localhost:8000/v1") - ai_whisper_url = self._cfg("ai", "whisper_url", "http://localhost:42062/v1") ai_openai_key = (self.secrets.get("", "AI_API_KEY") or self._cfg("ai", "openai_key")) + # Voice messages go to the AI server, which answers transcription + # too on oMLX and the hosted providers, unless [ai] whisper_url + # names a dedicated speech server. The key follows the server. + dedicated_whisper = self._cfg("ai", "whisper_url") + ai_whisper_url = dedicated_whisper or ai_openai_url + ai_whisper_key = ((self._cfg("ai", "whisper_key") or "local") + if dedicated_whisper else ai_openai_key) template_vars = { # Core config @@ -360,6 +366,10 @@ def _build_template_vars(self) -> dict: "ai_openai_key": ai_openai_key, "ai_whisper_url": ai_whisper_url, "ai_whisper_url_docker": ai_whisper_url.replace("://localhost", "://host.docker.internal"), + "ai_whisper_key": ai_whisper_key, + # OpenAI's name. Other servers name their speech models and + # refuse any other; whisper.cpp ignores it. + "ai_whisper_model": self._cfg("ai", "whisper_model") or "whisper-1", "ai_language": self._cfg("ai", "language", "en"), "ai_tts_voice": "onyx" if self._cfg("ai", "language", "en").startswith("de") else "alloy", "ai_default_model": self._cfg("ai", "default"), diff --git a/stacklets/ai/hooks/on_install.py b/stacklets/ai/hooks/on_install.py index 77e2ee93..593cdd24 100644 --- a/stacklets/ai/hooks/on_install.py +++ b/stacklets/ai/hooks/on_install.py @@ -60,6 +60,9 @@ def run(ctx): dim("STACK_AI_NO_VOICE=1 — skipping Whisper install") else: _install_whisper(ctx, data_dir, state_dir) + # A dedicated speech server. Without it voice messages go to the + # AI server, which may not transcribe. + ctx.cfg("whisper_url", "http://localhost:42062/v1") section("Setup complete") diff --git a/stacklets/chatai/stacklet.toml b/stacklets/chatai/stacklet.toml index 5733ffbd..5ffdadc8 100644 --- a/stacklets/chatai/stacklet.toml +++ b/stacklets/chatai/stacklet.toml @@ -36,12 +36,14 @@ WEBUI_AUTH = "true" WEBUI_ADMIN_NAME = "{admin_username}" WEBUI_ADMIN_EMAIL = "{admin_email}" WEBUI_ADMIN_PASSWORD = "{admin_password}" -# Speech-to-text — whisper.cpp running natively on the host (Metal GPU). -# Open WebUI has a built-in faster-whisper, but it runs CPU-only inside -# Docker and is dramatically slower than native Metal inference. +# Speech-to-text — whisper.cpp running natively on the host (Metal GPU), +# or whatever [ai] whisper_url points at. Open WebUI has a built-in +# faster-whisper, but it runs CPU-only inside Docker and is dramatically +# slower than native Metal inference. AUDIO_STT_ENGINE = "openai" AUDIO_STT_OPENAI_API_BASE_URL = "{ai_whisper_url_docker}" -AUDIO_STT_OPENAI_API_KEY = "unused" +AUDIO_STT_OPENAI_API_KEY = "{ai_whisper_key}" +AUDIO_STT_MODEL = "{ai_whisper_model}" # Text-to-speech — Piper via openedai-speech in the ai stacklet. # Container-to-container on the stack Docker network. AUDIO_TTS_ENGINE = "openai" diff --git a/stacklets/core/stacklet.toml b/stacklets/core/stacklet.toml index 433fbc6e..dd21ef8e 100644 --- a/stacklets/core/stacklet.toml +++ b/stacklets/core/stacklet.toml @@ -51,6 +51,8 @@ STACK_ADMIN_USER_IDS = "{admin_user_ids}" OPENAI_URL = "{ai_openai_url_docker}" OPENAI_KEY = "{ai_openai_key}" WHISPER_URL = "{ai_whisper_url_docker}/audio/transcriptions" +WHISPER_KEY = "{ai_whisper_key}" +WHISPER_MODEL = "{ai_whisper_model}" # Transcripts are a cache over the audio, not a record: the mxc on the # timeline stays the anchor. Kept on disk because whisper costs minutes # of GPU per memo and a backfill over the memories room must never pay diff --git a/stacklets/memory/stacklet.toml b/stacklets/memory/stacklet.toml index 833b5ed5..91f6171f 100644 --- a/stacklets/memory/stacklet.toml +++ b/stacklets/memory/stacklet.toml @@ -113,6 +113,8 @@ LANGUAGE = "{language}" MATRIX_HOMESERVER = "http://stack-messages-synapse:8008" MATRIX_SERVER_NAME = "{messages_server_name}" WHISPER_URL = "{ai_whisper_url_docker}/audio/transcriptions" +WHISPER_KEY = "{ai_whisper_key}" +WHISPER_MODEL = "{ai_whisper_model}" TIMEZONE = "{timezone}" MATRIX_ADMIN_USER = "{admin_username}" diff --git a/tests/unit/framework/test_ai_client.py b/tests/unit/framework/test_ai_client.py index 546132db..295cd7f4 100644 --- a/tests/unit/framework/test_ai_client.py +++ b/tests/unit/framework/test_ai_client.py @@ -524,6 +524,34 @@ def test_empty_url_raises_unavailable(self, monkeypatch): with pytest.raises(LLMUnavailableError): Transcriber.from_env() + async def test_the_speech_model_comes_from_the_environment( + self, httpserver: HTTPServer, monkeypatch): + """A server that is not OpenAI names its speech models itself and + refuses `whisper-1` ("Model 'whisper-1' not found"), so the model + the stack configured is the one that goes on the wire.""" + captured: dict = {} + + def handler(request): + captured["body"] = request.get_data() + from werkzeug import Response + return Response('{"text": "ok"}', content_type="application/json") + + httpserver.expect_request( + "/v1/audio/transcriptions", method="POST").respond_with_handler(handler) + monkeypatch.setenv("WHISPER_URL", httpserver.url_for("/v1")) + monkeypatch.setenv("WHISPER_MODEL", "whisper-large-v3-turbo") + + tr = Transcriber.from_env() + await tr.transcribe(b"fake-audio") + + assert b"whisper-large-v3-turbo" in captured["body"] + await tr.aclose() + + def test_without_a_configured_model_it_asks_for_openais_name(self, monkeypatch): + monkeypatch.setenv("WHISPER_URL", "http://whisper.local/v1") + monkeypatch.delenv("WHISPER_MODEL", raising=False) + assert Transcriber.from_env().model == "whisper-1" + def test_empty_key_falls_back(self, monkeypatch): """Native whisper-server is unauthenticated; the SDK insists on *some* key so the constructor substitutes a placeholder.""" diff --git a/tests/unit/framework/test_ai_probe.py b/tests/unit/framework/test_ai_probe.py new file mode 100644 index 00000000..88d22673 --- /dev/null +++ b/tests/unit/framework/test_ai_probe.py @@ -0,0 +1,23 @@ +"""What the stack can learn about an AI server before using it. + +A dedicated speech server has no model list, so whether it transcribes +is asked of the route itself, before the first voice message fails. +""" + +from stack.ai.probe import transcribes + + +class TestTranscription: + + def test_a_server_that_wants_the_missing_file_transcribes(self, httpserver): + httpserver.expect_request("/v1/audio/transcriptions", method="POST") \ + .respond_with_data("file is required", status=422) + + assert transcribes(httpserver.url_for("/v1")) + + def test_a_server_without_the_route_does_not(self, httpserver): + assert not transcribes(httpserver.url_for("/v1")) + + def test_nothing_listening_does_not(self): + assert not transcribes("http://127.0.0.1:9/v1") + diff --git a/tests/unit/stacklets/test_voice_routing.py b/tests/unit/stacklets/test_voice_routing.py new file mode 100644 index 00000000..8a20f767 --- /dev/null +++ b/tests/unit/stacklets/test_voice_routing.py @@ -0,0 +1,76 @@ +"""Where voice messages go, as the stacklets that transcribe them see it. + +Transcription is the OpenAI `/audio/transcriptions` call, which oMLX and +the hosted providers answer as well as a Whisper server does. So voice +messages go to the AI server, with its key, unless `[ai] whisper_url` +names a dedicated speech server; then that server gets them, with its +own key and never the AI key. `stack up ai` sets the local Whisper as +that dedicated server when it builds it. + +The rendered environment is what the containers actually use, so that +is what these read. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from stack import Stack + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.fixture +def instance(tmp_path): + (tmp_path / "stack.toml").write_text( + f'[core]\ndata_dir = "{tmp_path / "data"}"\n\n[ai]\nlanguage = "en"\n' + '\n[messages]\nserver_name = "simpson"\n') + (tmp_path / ".stack").mkdir() + return Stack(REPO_ROOT, tmp_path / "data", instance_dir=tmp_path) + + +class TestWhatTheStackletsGet: + + @pytest.mark.parametrize("stacklet", ["core", "memory", "chatai"]) + def test_without_a_speech_server_voice_goes_to_the_ai_server_with_its_key( + self, instance, stacklet): + instance._set_cfg("ai", "openai_url", "https://ai.example.test/v1") + instance._set_cfg("ai", "openai_key", "sk-test") + + env = instance.env(stacklet) + whisper = {k: v for k, v in env.items() if "STT" in k or "WHISPER" in k} + + assert any(v.startswith("https://ai.example.test/v1") for v in whisper.values()) + assert "sk-test" in whisper.values() + + @pytest.mark.parametrize("stacklet", ["core", "memory", "chatai"]) + def test_a_speech_server_gets_its_own_key_and_never_the_ai_key( + self, instance, stacklet): + instance._set_cfg("ai", "openai_key", "sk-test") + instance._set_cfg("ai", "whisper_url", "https://stt.example.test/v1") + instance._set_cfg("ai", "whisper_key", "stt-key") + + env = instance.env(stacklet) + whisper = {k: v for k, v in env.items() if "STT" in k or "WHISPER" in k} + + assert "stt-key" in whisper.values() + assert "sk-test" not in whisper.values() + + @pytest.mark.parametrize("stacklet", ["core", "memory", "chatai"]) + def test_the_speech_model_reaches_every_stacklet_that_transcribes( + self, instance, stacklet): + instance._set_cfg("ai", "whisper_model", "whisper-large-v3-turbo") + + env = instance.env(stacklet) + + assert "whisper-large-v3-turbo" in env.values() + + def test_an_emptied_speech_key_is_not_sent_as_an_empty_key(self, instance): + """`stack ai connect --whisper ai` clears the key; the clients + refuse an empty one, so it renders as the placeholder.""" + instance._set_cfg("ai", "whisper_url", "http://localhost:42062/v1") + instance._set_cfg("ai", "whisper_key", "") + + assert instance.env("core")["WHISPER_KEY"] == "local" From e20b0e9de874f3fe40b90a56d4e5a7c68176d17d Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:22:34 +0200 Subject: [PATCH 04/14] feat(ai): choose the AI server with one command `./stack ai connect ` points the stack at an OpenAI-compatible server on another machine on the network, or a hosted provider, without installing anything on this Mac. `./stack ai connect local` switches back to the engine `./stack up ai` installed. Before, the only way was the "external endpoint" answer inside `./stack up ai`, which still installed Whisper and TTS locally and was labelled unsupported. The command checks the server answers, keeps the configured model if the server has it, takes the only chat model it lists otherwise, and asks for `--model ` when there are several. Nothing is written when the server does not answer or wants a key it was not given (`--key`). Voice messages: - `--whisper ` sets a dedicated speech server, which then gets voice messages whatever the AI server offers; the command checks it answers the transcription call - without one, the AI server gets them, and can only transcribe with a speech-to-text model loaded; the command looks for one in its model list, records it as `[ai] whisper_model`, and warns when there is none - `--whisper ai` removes the speech server, and is refused when the AI server lists no speech model A server outside the home network gets a warning naming what is sent there: document text, notes, chat questions and voice messages. Loopback, private and Tailscale addresses count as home. Nothing restarts; the command names the running stacklets to restart. CLI plugins can now save settings through `config["set_cfg"]`, the plugin-side twin of a hook's `ctx.cfg(key, value)`. --- docs/stack-reference.md | 6 + lib/stack/ai/probe.py | 29 +++ lib/stack/installer_v2.py | 1 + lib/stack/stack.py | 3 + stacklets/ai/backend.py | 15 ++ stacklets/ai/cli/connect.py | 248 +++++++++++++++++++++ stacklets/ai/hooks/on_configure.py | 12 +- tests/unit/framework/test_ai_probe.py | 32 ++- tests/unit/stacklets/test_ai_connect.py | 273 ++++++++++++++++++++++++ 9 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 stacklets/ai/cli/connect.py create mode 100644 tests/unit/stacklets/test_ai_connect.py diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 3ca85923..b439af80 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -727,6 +727,12 @@ The convention: Files starting with `_` are private helpers (e.g., `_matrix.py`), not exposed as commands. +A command that records a choice in `stack.toml` calls +`config["set_cfg"](section, key, value)`, the plugin-side twin of a +hook's `ctx.cfg(key, value)`. It changes that one line in that section +and leaves the admin's comments and other sections as they are. +`stack ai connect` uses it to write `[ai]`. + --- ## Docker Compose Conventions diff --git a/lib/stack/ai/probe.py b/lib/stack/ai/probe.py index 2cf1ebbb..7f2a64b3 100644 --- a/lib/stack/ai/probe.py +++ b/lib/stack/ai/probe.py @@ -8,9 +8,12 @@ from __future__ import annotations import dataclasses +import ipaddress import json +import socket import ssl import urllib.error +import urllib.parse import urllib.request # LAN endpoints often serve self-signed TLS; the AI endpoint is trusted by @@ -68,3 +71,29 @@ def transcribes(url: str, key: str = "", *, timeout: float = 3.0) -> bool: return e.code in (400, 415, 422) except Exception: return False + + +# Tailscale hands out addresses from the carrier-grade NAT range, which +# Python does not count as private. +_TAILNET = ipaddress.ip_network("100.64.0.0/10") + + +def stays_home(url: str) -> bool: + """Whether every address the host of ``url`` resolves to is on the + home network: loopback, private, link-local or a Tailscale tailnet. + + Anything else is a server run by someone else, and what the stack + sends it (document text, notes, voice) leaves the house. A name + that does not resolve is not assumed to be at home. + """ + host = urllib.parse.urlsplit(url).hostname or "" + try: + infos = socket.getaddrinfo(host, None) + except OSError: + return False + for info in infos: + ip = ipaddress.ip_address(info[4][0].split("%")[0]) + if not (ip.is_loopback or ip.is_private or ip.is_link_local + or ip in _TAILNET): + return False + return bool(infos) diff --git a/lib/stack/installer_v2.py b/lib/stack/installer_v2.py index d70a051f..233740fc 100644 --- a/lib/stack/installer_v2.py +++ b/lib/stack/installer_v2.py @@ -549,6 +549,7 @@ def wizard(): out(f" {TEAL}stack up photos{RESET} Private photo library") out(f" {TEAL}stack up docs{RESET} Document archive with OCR") out(f" {TEAL}stack up ai{RESET} Local AI engine") + out(f" {TEAL}stack ai connect{RESET} AI on another machine or a hosted provider") out(f" {TEAL}stack up code{RESET} Private git server") out(f" {TEAL}stack up memory{RESET} Family wiki and curated knowledge") nl() diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 687d70e6..c90f524d 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -1188,6 +1188,9 @@ def run_cli_command(self, stacklet_id: str, command: str, args: list | None = No # responding. Zero-arg closure so we don't pay the HTTP # round-trip on every plugin invocation. "is_healthy": lambda: self.is_healthy(stacklet_id), + # Save a setting to stack.toml, the plugin-side twin of + # a hook's ctx.cfg(key, value), for any section. + "set_cfg": self._set_cfg, } return mod.run(args or [], stacklet, config) except Exception as e: diff --git a/stacklets/ai/backend.py b/stacklets/ai/backend.py index ec2935d8..fe6265df 100644 --- a/stacklets/ai/backend.py +++ b/stacklets/ai/backend.py @@ -16,6 +16,21 @@ from stack.ai.probe import probe as _probe +def normalize_url(url: str) -> str: + """What people type, as the OpenAI clients expect it. + + `192.168.1.20:11434` and `https://api.example.com` are both how an + address gets written down; the clients need a scheme and the `/v1` + base path. + """ + url = url.strip().rstrip("/") + if not url.startswith("http"): + url = f"http://{url}" + if not url.endswith("/v1"): + url = f"{url}/v1" + return url + + def _load_ai_config(repo_root: Path) -> dict: """Read [ai] section from stack.toml.""" toml_path = repo_root / "stack.toml" diff --git a/stacklets/ai/cli/connect.py b/stacklets/ai/cli/connect.py new file mode 100644 index 00000000..32c65366 --- /dev/null +++ b/stacklets/ai/cli/connect.py @@ -0,0 +1,248 @@ +"""stack ai connect — choose the AI server the stack uses. + +The stack talks to one OpenAI-compatible address, `[ai] openai_url`, +whoever serves it: the local engine `stack up ai` installs, another +machine on the network, or a hosted provider. This command points the +stack at one without installing anything here. It checks the server +answers, picks a model it actually has, and writes `[ai]` in stack.toml. +`local` switches back to the engine on this Mac. + +Voice messages go to `[ai] whisper_url` when a speech server is set, +whatever the AI server offers. Without one they go to the AI server, +which can transcribe only if it lists a speech-to-text model: the +command looks for one and records it as `[ai] whisper_model`, and says +when there is none. `--whisper ` sets a speech server, `--whisper +ai` removes it. + +Nothing restarts. The command names the running stacklets that read the +address, and `stack restart` with those ids applies it. + + stack ai connect 192.168.1.20:11434 + stack ai connect https://api.example.com --key sk-... --model gpt-4.1-mini + stack ai connect 192.168.1.20:8000 --whisper 192.168.1.20:42062 + stack ai connect local +""" + +HELP = "Choose the AI server: another machine, a hosted provider, or local" + +import argparse +import sys +import tomllib +from pathlib import Path + +# Any value works for a server without auth, and the OpenAI clients +# refuse an empty key. Same placeholder the local engine uses. +_NO_KEY = "local" + +# The engine and speech server `stack up ai` installs on this Mac. +_LOCAL_URL = "http://localhost:42060/v1" +_LOCAL_WHISPER = "http://localhost:42062/v1" + +# `--whisper ai`: no dedicated speech server, the AI server transcribes. +_VIA_AI = "ai" + + +def _parse(args: list[str]) -> argparse.Namespace: + p = argparse.ArgumentParser(prog="stack ai connect", description=HELP) + p.add_argument("url", help="address of an OpenAI-compatible server, " + "or 'local' for the engine on this Mac") + p.add_argument("--key", default="", help="API key, if the server needs one") + p.add_argument("--model", default="", help="model id the stack uses by default") + p.add_argument("--whisper", default="", + help="a dedicated speech-to-text server for voice messages, " + "or 'ai' to send them to the AI server") + p.add_argument("--whisper-key", default="", + help="API key for the speech-to-text server, if it needs one") + return p.parse_args(args) + + +def _matches(model: str, available: list[str]) -> str | None: + """The listed id for a model, compared by short name either way. + Servers list ids with or without the org prefix.""" + short = model.rsplit("/", 1)[-1] + return next((m for m in available if short in m or m in short), None) + + +def _choose_model(asked: str, current: str, available: list[str]) -> dict: + if not available: + # Some providers do not list models. Nothing to check against. + return {"model": asked or current} + if asked: + if found := _matches(asked, available): + return {"model": found} + return {"error": f"The server does not list {asked}.", + "models": available} + if current and (found := _matches(current, available)): + return {"model": found} + if len(available) == 1: + return {"model": available[0]} + return {"error": "The server lists several models. " + "Choose one with --model .", + "models": available} + + +def _speech_models(models: list[str]) -> list[str]: + """Speech-to-text models in a server's list. A server that answers + the transcription route without one loaded fails every voice message + with "model not found", so the route alone proves nothing.""" + return [m for m in models if "whisper" in m.lower() or "transcribe" in m.lower()] + + +def _speech_server(opts, local: bool, current: str) -> str: + """The dedicated speech server after this command, "" for none. + + An explicit `--whisper` decides. Otherwise `local` brings the local + Whisper back with the local engine, and any other switch keeps the + speech server the admin already chose: keeping voice on this Mac + while text goes elsewhere is a reasonable choice to have made. + """ + if opts.whisper == _VIA_AI: + return "" + if opts.whisper: + from backend import normalize_url + return normalize_url(opts.whisper) + if local: + return _LOCAL_WHISPER + return current + + +# The template variables this command changes the value of. +_CHANGED_VARS = ("{ai_openai_", "{ai_whisper_", "{ai_default_model") + + +def _ai_consumers(repo_root: Path) -> set[str]: + """Stacklets whose environment is rendered from a variable this + command changes. They pick up the new value only when their env is + rendered again.""" + ids = set() + for manifest in repo_root.glob("stacklets/*/stacklet.toml"): + env = tomllib.loads(manifest.read_text()).get("env", {}).get("defaults", {}) + if any(var in str(v) for v in env.values() for var in _CHANGED_VARS): + ids.add(manifest.parent.name) + return ids + + +def _running(ids: set[str]) -> list[str]: + from stack.docker import project_states + states = project_states() + return sorted(i for i in ids if states.get(i) in ("running", "starting", "failing")) + + +def run(args, stacklet, config): + sys.path.insert(0, str(Path(__file__).parent.parent)) + from backend import normalize_url, _probe + from stack.ai.probe import stays_home, transcribes + + opts = _parse(args) + ai = config.get("stack", {}).get("ai", {}) + ai_installed = (Path(config["instance_dir"]) / ".stack" / "ai.setup-done").exists() + + local = opts.url == "local" + if local and not ai_installed: + return {"error": "The local AI engine is not installed. " + "Install it with './stack up ai'."} + url = _LOCAL_URL if local else normalize_url(opts.url) + key = _NO_KEY if local else opts.key + + probe = _probe(url, key) + if probe.needs_auth: + return {"error": f"The server at {url} wants an API key. " + "Pass it with --key ."} + if not probe.reachable: + if local: + return {"error": f"The local AI engine is not answering at {url}. " + "Start it with './stack up ai'."} + return {"error": f"Nothing answers at {url}. Check the address " + "and that the server is running."} + + speech = _speech_models(probe.models) + chat = [m for m in probe.models if m not in speech] + chosen = _choose_model(opts.model, ai.get("default", ""), chat) + if "error" in chosen: + return chosen + + whisper = _speech_server(opts, local, ai.get("whisper_url", "")) + if not whisper and not speech and opts.whisper == _VIA_AI: + return {"error": f"The AI server at {url} lists no speech-to-text " + "model, so it cannot transcribe voice messages.", + "models": probe.models} + + notes, warnings = [], [] + speech_model = "" + if whisper: + whisper_key = opts.whisper_key or ai.get("whisper_key") or _NO_KEY + if not transcribes(whisper, whisper_key): + warnings.append( + f"The speech server at {whisper} does not answer the " + "transcription call, so voice messages will fail.") + if speech: + notes.append( + f"The AI server also lists {speech[0]}. " + "'--whisper ai' sends voice messages there instead.") + elif speech: + current = ai.get("whisper_model", "") + speech_model = current if current in speech else speech[0] + else: + warnings.append( + f"The AI server at {url} lists no speech-to-text model, so " + "voice messages will fail. Pass --whisper for a " + "speech server.") + + set_cfg = config["set_cfg"] + set_cfg("ai", "provider", "managed" if local else "external") + set_cfg("ai", "openai_url", url) + set_cfg("ai", "openai_key", key or _NO_KEY) + if chosen["model"]: + set_cfg("ai", "default", chosen["model"]) + set_cfg("ai", "whisper_url", whisper) + if opts.whisper_key or not whisper: + set_cfg("ai", "whisper_key", opts.whisper_key) + if speech_model: + set_cfg("ai", "whisper_model", speech_model) + + voice_url = whisper or url + leaving = sorted({u for u in (url, voice_url) if not stays_home(u)}) + if leaving: + warnings.append( + f"{' and '.join(leaving)} is outside your home network. Document " + "text, notes, chat questions and voice messages are sent there " + "and processed by whoever runs that server.") + + result = { + "openai_url": url, + "model": chosen["model"], + "whisper_url": voice_url, + "whisper_model": speech_model, + "restart": _running(_ai_consumers(Path(config["repo_root"]))), + } + if warnings: + result["warnings"] = warnings + if notes: + result["notes"] = notes + + if sys.stderr.isatty(): + _report(result, dedicated=bool(whisper)) + return result + + +def _report(result: dict, *, dedicated: bool) -> None: + from stack.prompt import nl, done, out, warn, dim, TEAL, RESET + nl() + done(f"AI server: {result['openai_url']}") + done(f"Model: {result['model'] or '(unchanged)'}") + if dedicated: + out(f" Voice messages: {result['whisper_url']} (speech server)") + elif result["whisper_model"]: + out(f" Voice messages: the AI server, {result['whisper_model']}") + for n in result.get("notes", []): + dim(f" {n}") + for w in result.get("warnings", []): + nl() + warn(w) + nl() + if result["restart"]: + out("Apply it to what is running:") + out(f" {TEAL}./stack restart {' '.join(result['restart'])}{RESET}") + else: + dim("Nothing that uses AI is running. It applies on the next 'stack up'.") + nl() diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index 3383add7..69a6d4c6 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -36,11 +36,9 @@ def _ask_external_endpoint(ctx) -> bool: url = ask("Endpoint URL") if not url: return False - url = url.strip().rstrip("/") - if not url.startswith("http"): - url = f"http://{url}" - if not url.endswith("/v1"): - url = f"{url}/v1" + sys.path.insert(0, str(Path(__file__).parent.parent)) + from backend import normalize_url + url = normalize_url(url) key = ask("API key (leave empty if none)") key = key.strip() if key else "" @@ -119,8 +117,8 @@ def run(ctx): # ── External endpoint (advanced) ──────────────────────────────── nl() - warn("Bring your own endpoint is for advanced users.") - dim("This is unsupported. Your mileage may vary.") + out("Whisper and TTS still get installed on this Mac.") + dim("To install nothing here, cancel and run './stack ai connect '.") nl() if _ask_external_endpoint(ctx): diff --git a/tests/unit/framework/test_ai_probe.py b/tests/unit/framework/test_ai_probe.py index 88d22673..f166291d 100644 --- a/tests/unit/framework/test_ai_probe.py +++ b/tests/unit/framework/test_ai_probe.py @@ -1,10 +1,14 @@ """What the stack can learn about an AI server before using it. -A dedicated speech server has no model list, so whether it transcribes -is asked of the route itself, before the first voice message fails. +`stack ai connect` asks two things beyond "does it answer": whether the +server transcribes, so voice messages do not fail later, and whether it +is on the home network, so nobody sends the family's documents to a +provider without being told. """ -from stack.ai.probe import transcribes +import pytest + +from stack.ai.probe import stays_home, transcribes class TestTranscription: @@ -21,3 +25,25 @@ def test_a_server_without_the_route_does_not(self, httpserver): def test_nothing_listening_does_not(self): assert not transcribes("http://127.0.0.1:9/v1") + +class TestHomeNetwork: + + @pytest.mark.parametrize("url", [ + "http://localhost:42060/v1", + "http://127.0.0.1:8000/v1", + "http://192.168.1.20:11434/v1", + "http://10.0.0.5/v1", + "http://100.101.102.103:8000/v1", # a Tailscale address + ]) + def test_addresses_in_the_house(self, url): + assert stays_home(url) + + @pytest.mark.parametrize("url", [ + "https://8.8.8.8/v1", + "https://1.1.1.1/v1", + ]) + def test_addresses_outside_it(self, url): + assert not stays_home(url) + + def test_a_name_that_does_not_resolve_is_not_assumed_home(self): + assert not stays_home("https://nowhere.invalid/v1") diff --git a/tests/unit/stacklets/test_ai_connect.py b/tests/unit/stacklets/test_ai_connect.py new file mode 100644 index 00000000..d6e19a60 --- /dev/null +++ b/tests/unit/stacklets/test_ai_connect.py @@ -0,0 +1,273 @@ +"""`stack ai connect `: choose the AI server the stack uses. + +A household may run its models on another machine on the network, or +with a hosted provider. Pointing the stack there is one command, and it +must not need the local AI stacklet installed: that is the whole point. +`stack ai connect local` goes back to the engine on this Mac. + +The command runs through the same path as `./stack ai connect`, against +the real stacklets and a throwaway instance, with a real HTTP server +standing in for the AI machine. Docker is the one thing it does not ask: +which stacklets are running is replaced, so the answer does not depend +on what happens to be up on the Mac running the tests. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest + +from stack import Stack +import stack.docker + +REPO_ROOT = Path(__file__).resolve().parents[3] + +# What the installer writes: no speech server of its own until +# `stack up ai` builds one. +GENERATED = '''\ +[core] +data_dir = "{data}" + +[ai] +provider = "" +openai_url = "" +openai_key = "" +language = "en" +# Models by RAM tier, uncomment one to switch: +default = "mlx-community/Qwen3.5-9B-MLX-8bit" # 32 GB (selected) +# default = "mlx-community/Qwen2.5-14B-Instruct-4bit" # 64 GB + +[messages] +server_name = "simpson" +''' + + +@pytest.fixture +def instance(tmp_path, monkeypatch): + """A fresh install that never ran `stack up ai`, with core, docs, + memory and messages running, and the local speech container of the ai + stacklet.""" + (tmp_path / "stack.toml").write_text(GENERATED.format(data=tmp_path / "data")) + (tmp_path / ".stack").mkdir() + monkeypatch.setattr(stack.docker, "project_states", lambda: { + "ai": "running", "core": "running", "docs": "running", + "memory": "running", "messages": "running"}) + return Stack(REPO_ROOT, tmp_path / "data", instance_dir=tmp_path) + + +def _serve(httpserver, models, prefix="/v1"): + """An OpenAI-compatible server listing `models`. Like oMLX, it has + the transcription route whether or not a speech model is loaded.""" + httpserver.expect_request(f"{prefix}/models").respond_with_json( + {"data": [{"id": m} for m in models]}) + httpserver.expect_request( + f"{prefix}/audio/transcriptions", method="POST", + ).respond_with_data("file is required", status=422) + return httpserver + + +def _speech_server(httpserver, prefix="/stt/v1"): + """whisper.cpp: the transcription route, no model list.""" + httpserver.expect_request( + f"{prefix}/audio/transcriptions", method="POST", + ).respond_with_data("file is required", status=422) + return httpserver.url_for(prefix) + + +@pytest.fixture +def ai_server(httpserver): + """A machine on the network with one chat model and a speech model.""" + return _serve(httpserver, ["llama3.1:8b", "whisper-large-v3-turbo"]) + + +def _connect(instance, *args): + return instance.run_cli_command("ai", "connect", list(args)) + + +def _ai(instance): + return instance.config["ai"] + + +class TestPointingTheStackElsewhere: + + def test_the_address_is_written_the_way_the_clients_need_it(self, instance, ai_server): + """People write `host:port`; the clients need a scheme and `/v1`.""" + address = ai_server.url_for("/").removeprefix("http://").rstrip("/") + + result = _connect(instance, address) + + assert "error" not in result, result + assert _ai(instance)["openai_url"] == ai_server.url_for("/v1") + assert _ai(instance)["provider"] == "external" + + def test_the_only_chat_model_the_server_has_becomes_the_default(self, instance, ai_server): + """The installer picked an MLX model this server has never heard + of. Keeping it would fail every request, silently for the family. + The speech model is not a candidate: it cannot answer a question.""" + _connect(instance, ai_server.url_for("/v1")) + + assert _ai(instance)["default"] == "llama3.1:8b" + + def test_a_server_without_auth_still_gets_a_key(self, instance, ai_server): + """The OpenAI clients refuse an empty key.""" + _connect(instance, ai_server.url_for("/v1")) + + assert _ai(instance)["openai_key"] + + def test_the_admins_commented_alternatives_survive(self, instance, ai_server): + _connect(instance, ai_server.url_for("/v1")) + + text = (instance.instance_dir / "stack.toml").read_text() + assert '# default = "mlx-community/Qwen2.5-14B-Instruct-4bit" # 64 GB' in text + tomllib.loads(text) + + def test_it_names_the_running_stacklets_to_restart(self, instance, ai_server): + """Nothing restarts on its own. Only stacklets that read the AI + address and are running need it. The archivist runs in core, so + docs reads no AI setting of its own, messages none at all, and the ai + stacklet only its speech voice, which this command leaves alone.""" + result = _connect(instance, ai_server.url_for("/v1")) + + assert result["restart"] == ["core", "memory"] + + def test_a_server_at_home_raises_no_privacy_warning(self, instance, ai_server): + result = _connect(instance, ai_server.url_for("/v1")) + + assert "warnings" not in result + + +class TestChoosingAModel: + + @pytest.fixture + def many_models(self, httpserver): + return _serve(httpserver, ["llama3.1:8b", "qwen3:14b"]) + + def test_several_models_and_no_choice_asks_for_one_and_writes_nothing( + self, instance, many_models): + result = _connect(instance, many_models.url_for("/v1")) + + assert "--model" in result["error"] + assert result["models"] == ["llama3.1:8b", "qwen3:14b"] + assert _ai(instance)["openai_url"] == "" + + def test_a_chosen_model_the_server_has(self, instance, many_models): + _connect(instance, many_models.url_for("/v1"), "--model", "qwen3:14b") + + assert _ai(instance)["default"] == "qwen3:14b" + + def test_a_chosen_model_the_server_lacks_is_refused(self, instance, many_models): + result = _connect(instance, many_models.url_for("/v1"), "--model", "gpt-4.1") + + assert "gpt-4.1" in result["error"] + assert _ai(instance)["openai_url"] == "" + + +class TestServersThatDoNotAnswer: + + def test_nothing_listening_writes_nothing(self, instance): + result = _connect(instance, "http://127.0.0.1:9") + + assert "Nothing answers" in result["error"] + assert _ai(instance)["openai_url"] == "" + + def test_a_server_that_wants_a_key_says_so(self, instance, httpserver): + httpserver.expect_request("/v1/models").respond_with_data("", status=401) + + result = _connect(instance, httpserver.url_for("/v1")) + + assert "--key" in result["error"] + + def test_the_key_is_sent_and_stored(self, instance, httpserver): + httpserver.expect_request( + "/v1/models", headers={"Authorization": "Bearer sk-test"}, + ).respond_with_json({"data": [{"id": "gpt-4.1-mini"}]}) + + _connect(instance, httpserver.url_for("/v1"), "--key", "sk-test") + + assert _ai(instance)["openai_key"] == "sk-test" + + +class TestBackToTheLocalEngine: + + def test_local_without_the_engine_installed_says_how_to_get_it(self, instance): + result = _connect(instance, "local") + + assert "./stack up ai" in result["error"] + assert _ai(instance)["openai_url"] == "" + + +class TestVoiceMessages: + """A speech server, once set, gets the voice messages whatever the AI + server offers. Without one the AI server gets them, and can only + transcribe with a speech-to-text model loaded. Having the route is + not enough: oMLX answers it with none and fails every voice message + with "model not found".""" + + def test_without_a_speech_server_the_ai_servers_speech_model_is_used( + self, instance, ai_server): + result = _connect(instance, ai_server.url_for("/v1")) + + assert result["whisper_url"] == ai_server.url_for("/v1") + assert _ai(instance)["whisper_url"] == "" + assert _ai(instance)["whisper_model"] == "whisper-large-v3-turbo" + assert "warnings" not in result + + def test_an_ai_server_without_a_speech_model_is_named(self, instance, httpserver): + """The oMLX case: the route answers, nothing can transcribe.""" + _serve(httpserver, ["llama3.1:8b"]) + + result = _connect(instance, httpserver.url_for("/v1")) + + assert any("no speech-to-text model" in w for w in result["warnings"]) + + def test_whisper_ai_without_a_speech_model_is_refused_and_writes_nothing( + self, instance, httpserver): + _serve(httpserver, ["llama3.1:8b"]) + instance._set_cfg("ai", "whisper_url", "http://localhost:42062/v1") + + result = _connect(instance, httpserver.url_for("/v1"), "--whisper", "ai") + + assert "no speech-to-text model" in result["error"] + assert _ai(instance)["whisper_url"] == "http://localhost:42062/v1" + assert _ai(instance)["openai_url"] == "" + + def test_a_dedicated_speech_server(self, instance, httpserver): + _serve(httpserver, ["llama3.1:8b"]) + stt = _speech_server(httpserver) + + result = _connect(instance, httpserver.url_for("/v1"), "--whisper", stt) + + assert _ai(instance)["whisper_url"] == stt + assert "warnings" not in result + + def test_a_speech_server_that_does_not_answer_is_named(self, instance, ai_server): + result = _connect(instance, ai_server.url_for("/v1"), + "--whisper", "http://127.0.0.1:9") + + assert any("127.0.0.1:9" in w for w in result["warnings"]) + + def test_a_speech_server_already_set_wins_over_the_ai_servers_model( + self, instance, ai_server): + """Voice on this Mac, text elsewhere, is a choice worth keeping. + The AI server's speech model is mentioned, not switched to.""" + stt = _speech_server(ai_server) + instance._set_cfg("ai", "whisper_url", stt) + + result = _connect(instance, ai_server.url_for("/v1")) + + assert _ai(instance)["whisper_url"] == stt + assert "whisper_model" not in _ai(instance) + assert any("--whisper ai" in n for n in result["notes"]) + + def test_whisper_ai_sends_voice_to_the_ai_server_again(self, instance, ai_server): + instance._set_cfg("ai", "whisper_url", "http://localhost:42062/v1") + instance._set_cfg("ai", "whisper_key", "old") + + result = _connect(instance, ai_server.url_for("/v1"), "--whisper", "ai") + + assert _ai(instance)["whisper_url"] == "" + assert _ai(instance)["whisper_key"] == "" + assert _ai(instance)["whisper_model"] == "whisper-large-v3-turbo" + assert result["whisper_url"] == ai_server.url_for("/v1") From 11e1e0f7bfbf7efe721dc57f14579846c361432f Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:24:17 +0200 Subject: [PATCH 05/14] fix(ai): start the local AI engine on stack up ai oMLX runs as a Homebrew service that the first `./stack up ai` starts. Nothing started it again: once it stopped, by hand, a brew upgrade or a crash, `./stack up ai` left it stopped, and every AI request failed until it was started by hand or at the next login. `./stack up ai` now starts the service when the local engine is the provider and it does not answer. A remote provider starts nothing on this Mac. --- stacklets/ai/hooks/on_start.py | 21 +++++++++ tests/unit/framework/test_ai_on_start.py | 4 +- tests/unit/stacklets/test_ai_start_guard.py | 49 ++++++++++++++++++++- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index bf321213..522cbfc9 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -61,9 +61,30 @@ def run(ctx): nl() raise RuntimeError("Missing openai_url for external provider") + if provider == "managed": + _start_local_engine(ctx) + _reconcile_whisper(ctx) +def _start_local_engine(ctx): + """Start oMLX when it is not answering. + + It runs as a Homebrew service that on_install starts once. After + that nothing started it again, so a service stopped by hand, by a + brew upgrade or by a crash stayed down while `stack up ai` reported + the AI as running. `brew services start` is idempotent, and the LLM + health check that follows waits for the engine to come up. + """ + from stack.ai.probe import probe + + url = ctx.cfg("openai_url", default="") or "http://localhost:42060/v1" + if probe(url, ctx.cfg("openai_key", default="")).reachable: + return + ctx.step("Starting oMLX") + ctx.shell("brew services start omlx") + + def _reconcile_whisper(ctx): """Bring the whisper LaunchAgent in line with the current code. diff --git a/tests/unit/framework/test_ai_on_start.py b/tests/unit/framework/test_ai_on_start.py index 6785d815..5a097846 100644 --- a/tests/unit/framework/test_ai_on_start.py +++ b/tests/unit/framework/test_ai_on_start.py @@ -31,7 +31,9 @@ def _ctx(make_stack, env): from stack.hooks import StackContext stck = make_stack() - stck._set_cfg("ai", "provider", "managed") # so on_start doesn't bail early + # External, so on_start neither bails early nor starts the local + # engine: a managed provider runs `brew services start omlx` for real. + stck._set_cfg("ai", "provider", "external") return StackContext(stck, "ai", env) diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index db74db5d..5c6a10e6 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -31,13 +31,26 @@ class FakeCtx: + """The hook context: config in, shell commands recorded rather than + run, since they would start and stop services on this Mac.""" + def __init__(self, **cfg): self._cfg = cfg self.env: dict = {} + self.shell_calls: list[str] = [] - def cfg(self, key, default=None): + def cfg(self, key, value=None, default=None): + if value is not None: + self._cfg[key] = value + return value return self._cfg.get(key, default) + def step(self, msg): + pass + + def shell(self, cmd): + self.shell_calls.append(cmd) + class TestManagedProviderNeedsItsEngine: @@ -71,3 +84,37 @@ def test_external_without_a_url_still_stops(self, monkeypatch): lambda _cmd: "/opt/homebrew/bin/omlx") with pytest.raises(RuntimeError, match="Missing openai_url"): on_start.run(FakeCtx(provider="external")) + + +class TestTheLocalEngineIsStarted: + """oMLX runs as a Homebrew service. Once it stopped, nothing started + it again: `stack up ai` checked the configured address, found the + service down or found another server there, and still reported the + AI as running.""" + + @pytest.fixture(autouse=True) + def installed(self, monkeypatch): + monkeypatch.setattr(on_start.shutil, "which", + lambda _cmd: "/opt/homebrew/bin/omlx") + + def test_a_stopped_engine_is_started(self): + ctx = FakeCtx(provider="managed", openai_url="http://127.0.0.1:9/v1") + + on_start.run(ctx) + + assert ctx.shell_calls == ["brew services start omlx"] + + def test_a_running_engine_is_left_alone(self, httpserver): + httpserver.expect_request("/v1/models").respond_with_json({"data": []}) + ctx = FakeCtx(provider="managed", openai_url=httpserver.url_for("/v1")) + + on_start.run(ctx) + + assert ctx.shell_calls == [] + + def test_a_remote_endpoint_starts_nothing_here(self): + ctx = FakeCtx(provider="external", openai_url="http://127.0.0.1:9/v1") + + on_start.run(ctx) + + assert ctx.shell_calls == [] From f6260c4c29f9d6b8b29c4703dd35958a9af59f08 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:28:43 +0200 Subject: [PATCH 06/14] feat(ai): ask before stack up ai replaces a remote AI server After `./stack ai connect `, bringing up the ai stacklet means the AI engine and speech-to-text on this Mac take over from that server. That is a different setup, not a restart, so `./stack up ai` now says which endpoint is configured and asks first. No is the default and leaves the configuration as it was; without a terminal it stops instead of asking. Yes switches chat and voice to this Mac together and names the default model, which a hosted setup may have set to one this Mac does not have. The first `./stack up ai` no longer offers an external endpoint of its own. That path recorded a remote server and still installed Whisper and TTS here. `./stack up ai` is the local engine; a server elsewhere is `./stack ai connect `, which the question now points to. --- stacklets/ai/hooks/on_configure.py | 97 +++++---------------- stacklets/ai/hooks/on_start.py | 7 ++ stacklets/ai/local_mode.py | 52 +++++++++++ tests/unit/framework/test_ai_on_start.py | 24 +++-- tests/unit/stacklets/test_ai_start_guard.py | 81 ++++++++++++++--- 5 files changed, 163 insertions(+), 98 deletions(-) create mode 100644 stacklets/ai/local_mode.py diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index 69a6d4c6..e19a063c 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -1,11 +1,9 @@ -"""AI stacklet first-run configuration — choose LLM provider. +"""AI stacklet first-run configuration — confirm the local AI engine. -Two paths: - 1. External endpoint — user provides an OpenAI-compatible URL + API key. - We store it and skip oMLX installation entirely. - 2. Managed oMLX — we install and manage oMLX via Homebrew. - -Whisper and TTS are always installed regardless. +`stack up ai` installs and manages oMLX, Whisper and TTS on this Mac. +An AI server elsewhere is `stack ai connect `, which installs +nothing here; after that, bringing up this stacklet asks before it +switches the stack back to local mode. Runs before on_install. Only fires on first 'stack up ai'. Skipped when STACK_SETUP_CONFIRMED=1 (installer already confirmed). @@ -15,49 +13,7 @@ import sys from pathlib import Path -from stack.prompt import section, out, nl, dim, confirm, ask, done, warn - - -def _probe_endpoint(url: str, key: str = "") -> bool: - """Quick check if an OpenAI-compatible endpoint is reachable.""" - sys.path.insert(0, str(Path(__file__).parent.parent)) - from backend import _probe - result = _probe(url, key) - return result.reachable - - -def _ask_external_endpoint(ctx) -> bool: - """Prompt for an external endpoint. Returns True if configured.""" - nl() - out("Enter the URL of your OpenAI-compatible endpoint.") - dim("Examples: https://api.openai.com/v1, http://192.168.1.50:11434/v1") - nl() - - url = ask("Endpoint URL") - if not url: - return False - sys.path.insert(0, str(Path(__file__).parent.parent)) - from backend import normalize_url - url = normalize_url(url) - - key = ask("API key (leave empty if none)") - key = key.strip() if key else "" - - if not _probe_endpoint(url, key): - warn(f"Cannot reach {url}") - out("Check the URL, make sure the server is running, and try again.") - nl() - if confirm("Try again?"): - return _ask_external_endpoint(ctx) - return False - - done(f"Connected to {url}") - ctx.cfg("provider", "external") - ctx.cfg("openai_url", url) - ctx.cfg("openai_key", key) - if key: - ctx.secret("AI_API_KEY", key) - return True +from stack.prompt import section, out, nl, dim, confirm, warn def _check_brew_available(): @@ -91,8 +47,16 @@ def run(ctx): provider = ctx.cfg("provider", default="") + # A remote endpoint set with `stack ai connect`: installing the ai + # stacklet replaces it with the local engine, so that needs a yes. + if provider == "external": + sys.path.insert(0, str(Path(__file__).parent.parent)) + from local_mode import switch_to_local + switch_to_local(ctx) + return + # Already configured — nothing to do - if provider in ("managed", "external"): + if provider == "managed": return section("AI Engine", "Local AI on your Mac's GPU") @@ -103,33 +67,14 @@ def run(ctx): dim("After that, everything starts in seconds.") nl() - # ── Provider choice ───────────────────────────────────────────── - # Y = managed oMLX (recommended, just works) - # N = bring your own OpenAI-compatible endpoint (advanced, unsupported) - if confirm("Set up oMLX?", default=True): - if not _check_brew_available(): - raise RuntimeError("Homebrew not found") - - ctx.cfg("provider", "managed") - ctx.cfg("openai_url", "http://localhost:42060/v1") - ctx.cfg("openai_key", "local") - return - - # ── External endpoint (advanced) ──────────────────────────────── - nl() - out("Whisper and TTS still get installed on this Mac.") - dim("To install nothing here, cancel and run './stack ai connect '.") - nl() - - if _ask_external_endpoint(ctx): + # `stack up ai` is the local engine. A server elsewhere is + # `stack ai connect`, which installs nothing on this Mac. + if not confirm("Set up oMLX?", default=True): nl() - dim("oMLX will be skipped. Whisper and TTS still get set up.") + out("To use an AI server on another machine or a hosted provider,") + out("run './stack ai connect ' instead. Nothing was installed.") nl() - return - - nl() - out("No worries, setting up oMLX instead.") - nl() + raise RuntimeError("Cancelled: the local AI engine was not set up") if not _check_brew_available(): raise RuntimeError("Homebrew not found") diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index 522cbfc9..522ad643 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -7,6 +7,8 @@ import os import shutil +import sys +from pathlib import Path from stack.prompt import out, nl, warn, dim, TEAL, RESET @@ -20,6 +22,11 @@ def run(ctx): ctx.env["COMPOSE_PROFILES"] = "" dim("STACK_AI_NO_VOICE=1 — speech container disabled") + # A remote endpoint is only replaced by the local engine with a yes. + sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + from local_mode import switch_to_local + switch_to_local(ctx) + provider = ctx.cfg("provider", default="") if not provider: diff --git a/stacklets/ai/local_mode.py b/stacklets/ai/local_mode.py new file mode 100644 index 00000000..fc2d3d69 --- /dev/null +++ b/stacklets/ai/local_mode.py @@ -0,0 +1,52 @@ +"""Ask before `stack up ai` moves a remote AI setup onto this Mac. + +`stack ai connect ` points the stack at a server elsewhere. Bringing +up the ai stacklet after that means the engine and speech-to-text on this +Mac take over, which is a different setup, not a restart. So it is asked, +not assumed, and a "no" leaves the remote endpoint exactly as it was. +""" + +import sys + +from stack.prompt import confirm, dim, nl, out, warn + +LOCAL_URL = "http://localhost:42060/v1" +LOCAL_WHISPER = "http://localhost:42062/v1" + + +def switch_to_local(ctx) -> None: + """Switch `[ai]` to the local engine, once the admin has said yes. + + Does nothing unless the provider is a remote endpoint. Raises when + the answer is no, or when there is no terminal to ask in, so the + `stack up` stops before anything is installed or started. + """ + if ctx.cfg("provider", default="") != "external": + return + url = ctx.cfg("openai_url", default="") + + nl() + warn(f"You are connected to a remote AI endpoint ({url}).") + out("Bringing up the ai stacklet switches the stack to local mode: the") + out("AI engine and speech-to-text on this Mac take over from it.") + nl() + + if not sys.stdin.isatty(): + raise RuntimeError( + f"The stack uses the remote AI endpoint {url}. Bringing up the " + "ai stacklet switches it to local mode, which needs a yes: run " + "'./stack up ai' in a terminal.") + if not confirm("Are you sure?", default=False): + raise RuntimeError(f"Cancelled. The stack still uses {url}.") + + ctx.cfg("provider", "managed") + ctx.cfg("openai_url", LOCAL_URL) + ctx.cfg("openai_key", "local") + ctx.cfg("whisper_url", LOCAL_WHISPER) + ctx.cfg("whisper_key", "") + + model = ctx.cfg("default", default="") + if model: + dim(f"Default model: {model}. If this Mac does not have it, choose " + "one with './stack ai connect local --model '.") + nl() diff --git a/tests/unit/framework/test_ai_on_start.py b/tests/unit/framework/test_ai_on_start.py index 5a097846..609574ea 100644 --- a/tests/unit/framework/test_ai_on_start.py +++ b/tests/unit/framework/test_ai_on_start.py @@ -27,16 +27,24 @@ def _load_on_start(): return mod -def _ctx(make_stack, env): +def _ctx(make_stack, env, engine_url): from stack.hooks import StackContext stck = make_stack() - # External, so on_start neither bails early nor starts the local - # engine: a managed provider runs `brew services start omlx` for real. - stck._set_cfg("ai", "provider", "external") + # Managed with an engine that answers, so on_start neither bails + # early, nor asks about leaving a remote endpoint, nor runs + # `brew services start omlx` for real. + stck._set_cfg("ai", "provider", "managed") + stck._set_cfg("ai", "openai_url", engine_url) return StackContext(stck, "ai", env) +@pytest.fixture +def engine_url(httpserver): + httpserver.expect_request("/v1/models").respond_with_json({"data": []}) + return httpserver.url_for("/v1") + + @pytest.fixture(autouse=True) def _engine_installed(monkeypatch): """Pretend oMLX is on the machine, because that is not what these cover. @@ -61,15 +69,15 @@ def _restore_no_voice(): os.environ["STACK_AI_NO_VOICE"] = old -def test_no_voice_clears_compose_profile(make_stack): +def test_no_voice_clears_compose_profile(make_stack, engine_url): os.environ["STACK_AI_NO_VOICE"] = "1" env = {"COMPOSE_PROFILES": "voice"} - _load_on_start().run(_ctx(make_stack, env)) + _load_on_start().run(_ctx(make_stack, env, engine_url)) assert env["COMPOSE_PROFILES"] == "" -def test_voice_profile_kept_by_default(make_stack): +def test_voice_profile_kept_by_default(make_stack, engine_url): os.environ.pop("STACK_AI_NO_VOICE", None) env = {"COMPOSE_PROFILES": "voice"} - _load_on_start().run(_ctx(make_stack, env)) + _load_on_start().run(_ctx(make_stack, env, engine_url)) assert env["COMPOSE_PROFILES"] == "voice" diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index 5c6a10e6..90769327 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -64,12 +64,6 @@ def test_an_installed_engine_starts_normally(self, monkeypatch): lambda _cmd: "/opt/homebrew/bin/omlx") on_start.run(FakeCtx(provider="managed")) # must not raise - def test_an_external_endpoint_does_not_need_a_local_engine(self, monkeypatch): - """Someone pointing at their own server has no business being told - to install oMLX.""" - monkeypatch.setattr(on_start.shutil, "which", lambda _cmd: None) - on_start.run(FakeCtx(provider="external", - openai_url="https://ai.example.test/v1")) class TestExistingGuardsStillHold: @@ -79,11 +73,6 @@ def test_no_provider_configured_still_stops(self, monkeypatch): with pytest.raises(RuntimeError, match="provider not configured"): on_start.run(FakeCtx()) - def test_external_without_a_url_still_stops(self, monkeypatch): - monkeypatch.setattr(on_start.shutil, "which", - lambda _cmd: "/opt/homebrew/bin/omlx") - with pytest.raises(RuntimeError, match="Missing openai_url"): - on_start.run(FakeCtx(provider="external")) class TestTheLocalEngineIsStarted: @@ -112,9 +101,73 @@ def test_a_running_engine_is_left_alone(self, httpserver): assert ctx.shell_calls == [] - def test_a_remote_endpoint_starts_nothing_here(self): - ctx = FakeCtx(provider="external", openai_url="http://127.0.0.1:9/v1") - on_start.run(ctx) +class TestLeavingARemoteEndpoint: + """After `stack ai connect `, bringing up the ai stacklet means + the engine and speech-to-text on this Mac take over. That is a + different setup, not a restart, so it needs a yes, and a no leaves + the remote endpoint exactly as it was.""" + + REMOTE = dict(provider="external", openai_url="https://ai.example.test/v1", + openai_key="sk-test", whisper_url="https://stt.example.test/v1", + whisper_key="stt-key", default="gpt-4.1-mini") + + @pytest.fixture(autouse=True) + def installed(self, monkeypatch): + monkeypatch.setattr(on_start.shutil, "which", + lambda _cmd: "/opt/homebrew/bin/omlx") + + def _answer(self, monkeypatch, answer): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _prompt: answer) + + def test_the_question_names_the_endpoint(self, monkeypatch, capsys): + self._answer(monkeypatch, "n") + with pytest.raises(RuntimeError): + on_start.run(FakeCtx(**self.REMOTE)) + + assert "remote AI endpoint (https://ai.example.test/v1)" in capsys.readouterr().out + + def test_no_keeps_the_remote_setup_and_starts_nothing(self, monkeypatch): + self._answer(monkeypatch, "n") + ctx = FakeCtx(**self.REMOTE) + + with pytest.raises(RuntimeError, match="still uses"): + on_start.run(ctx) + + assert ctx._cfg == self.REMOTE assert ctx.shell_calls == [] + + def test_no_is_the_default(self, monkeypatch): + self._answer(monkeypatch, "") + ctx = FakeCtx(**self.REMOTE) + + with pytest.raises(RuntimeError): + on_start.run(ctx) + + assert ctx._cfg == self.REMOTE + + def test_yes_switches_chat_and_voice_to_this_mac_together(self, monkeypatch): + """Never one half remote and the other local.""" + self._answer(monkeypatch, "y") + ctx = FakeCtx(**self.REMOTE) + + on_start.run(ctx) + + assert ctx._cfg["provider"] == "managed" + assert ctx._cfg["openai_url"] == "http://localhost:42060/v1" + assert ctx._cfg["whisper_url"] == "http://localhost:42062/v1" + assert ctx._cfg["whisper_key"] == "" + assert ctx._cfg["openai_key"] == "local" + + def test_without_a_terminal_nothing_is_switched(self, monkeypatch): + """Nobody to say yes: an agent or a script running `stack up ai` + must not move the family's AI onto this Mac.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + ctx = FakeCtx(**self.REMOTE) + + with pytest.raises(RuntimeError, match="in a terminal"): + on_start.run(ctx) + + assert ctx._cfg == self.REMOTE From 5e4b2513a912a994ba538f708f139857930f6ee2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:35:02 +0200 Subject: [PATCH 07/14] chore(install): remove the full installer `./stack install --full` ran the first installer, which asked for every stacklet up front. It had drifted: it still offered a `bots` stacklet that no longer exists, and an "external endpoint" for AI that recorded a remote server while installing Whisper and TTS locally anyway. The messages-first installer is the only one now, and takes the name `installer.py`. It keeps the one thing it borrowed from the old one, the summary shown when a configuration already exists, which now points to `./stack ai connect` for another AI server. --- lib/stack/cli.py | 8 +- lib/stack/installer.py | 774 +++++++++++++++----------------------- lib/stack/installer_v2.py | 562 --------------------------- 3 files changed, 302 insertions(+), 1042 deletions(-) delete mode 100644 lib/stack/installer_v2.py diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 17c9cc04..8b34d946 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -1326,11 +1326,7 @@ def handle_setup(stck, args): def handle_install(stck, args): - full = getattr(args, "full", False) - if full: - from .installer import wizard - else: - from .installer_v2 import wizard + from .installer import wizard # Save/restore terminal in case a crash leaves it in raw mode saved_term = None @@ -1892,7 +1888,7 @@ def main(): parser.add_argument("--version", action="store_true") sub = parser.add_subparsers(dest="command") - p = sub.add_parser("install"); p.add_argument("--full", action="store_true") + p = sub.add_parser("install") p = sub.add_parser("uninstall") p.add_argument("--yes", action="store_true") p.add_argument( diff --git a/lib/stack/installer.py b/lib/stack/installer.py index 3fc9cee5..1ec1c7d6 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -1,94 +1,80 @@ -#!/usr/bin/env python3 """ -famstack installer — interactive config wizard. +famstack installer — messages-first, minimal questions. -Gathers preferences, writes stack.toml and users.toml, runs stack init, -then tells the user what to do next. Does NOT start stacklets — the user -runs `stack up ` for each one, in their own time. +Asks for family name, admin first name, and optional family members. +Derives the Matrix server name from the family name. Boots messages +as the first and only stacklet. Everything else is post-install onboarding. """ +import json import os -import secrets -import subprocess import sys -import json +import subprocess from pathlib import Path from .prompt import ( - ORANGE, TEAL, DIM, BOLD, RESET, + ORANGE, TEAL, GREEN, RED, DIM, BOLD, RESET, clear, nl, out, dim, bold, done, warn, - heading, section, banner, rule, kv, - Spinner, ask, confirm, choose, choose_many, + heading, section, banner, rule, Spinner, ask, confirm, kv, status_list, ) +from .users import family_plural -# ── Validation ──────────────────────────────────────────────────────────────── - -def validate_email(value): - if not value: - return "Email is required" - if "@" not in value: - return "Needs an @ symbol" - return None - - -def validate_name(value): - if not value or len(value) < 2: - return "Name is required" - return None +# ── Helpers ────────────────────────────────────────────────────────────────── +HELP_LINKS = ( + f" {TEAL}Discord{RESET} https://discord.com/invite/hfutdmmfBe", + f" {TEAL}GitHub{RESET} https://github.com/famstack-dev/famstack", + f" {TEAL}Email{RESET} hello@famstack.dev", +) -# ── Stacklets ───────────────────────────────────────────────────────────────── - -def load_stacklets(repo_root): - """Load stacklet definitions from stacklet.toml files.""" - import tomllib - - stacklets_dir = repo_root / "stacklets" - stacklets = {} - core = None - - for path in stacklets_dir.glob("*/stacklet.toml"): - try: - with open(path, "rb") as f: - data = tomllib.load(f) - - sid = data.get("id") - if not sid: - continue - - info = { - "name": data.get("name", sid), - "description": data.get("description", ""), - "category": data.get("category", "other"), - "always_on": data.get("always_on", False), - } - - if data.get("always_on"): - core = info - else: - stacklets[sid] = info - - except Exception: - continue - return stacklets, core +def get_help_text(detail=""): + """Format an error footer with optional detail and help links.""" + lines = [] + if detail: + lines.append("") + for line in detail.split("\n"): + lines.append(f" {DIM}{line}{RESET}") + lines.append("") + lines.append(" Sorry for that. If it keeps happening, we'd love to help.") + lines.append(" Please reach out so we can fix it faster:") + lines.append("") + lines.extend(HELP_LINKS) + lines.append("") + return "\n".join(lines) + + +def fail(msg, detail=""): + """Print an error message with help links and exit.""" + nl() + out(f"{RED}✗{RESET} {msg}") + print(get_help_text(detail)) + sys.exit(1) def get_repo_root(): - """Find repo root by looking for stacklets/ directory.""" here = Path(__file__).parent - for candidate in [here.parent, here.parent.parent, Path.cwd()]: + for candidate in [here.parent.parent, here.parent, Path.cwd()]: if (candidate / "stacklets").is_dir(): return candidate return Path.cwd() REPO_ROOT = get_repo_root() -STACKLETS, CORE = load_stacklets(REPO_ROOT) -# ── Helpers ─────────────────────────────────────────────────────────────────── +def validate_name(value): + if not value or len(value) < 2: + return "Name is required" + return None + + +def sanitize_server_name(family_name): + """Turn a family name into a valid Matrix server name.""" + clean = "".join(c for c in family_name.lower() if c.isalnum() or c in "-_.") + return clean or "home" + def detect_timezone(): try: @@ -100,27 +86,64 @@ def detect_timezone(): return "UTC" +# Map timezone to primary language. Only de and en for now -- +# taxonomy.yaml only has these two. Add more as we add translations. +_TZ_LANGUAGE = { + "Europe/Berlin": "de", "Europe/Vienna": "de", "Europe/Zurich": "de", +} + + +def detect_language(timezone: str) -> str: + """Guess the instance language from timezone. Defaults to English.""" + return _TZ_LANGUAGE.get(timezone, "en") + + +# (min_ram_gb, model_id, label) +# Qwen3.6 only shipped a 27B dense and a 35B-A3B MoE — no small variant — so +# the lightweight tier stays on Qwen3.5-9B (3.6's smallest 4bit is 16 GB of +# weights, too big for a 16 GB Mac). +MODEL_TIERS = [ + (32, "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit", "32 GB+ RAM"), + (0, "mlx-community/Qwen3.5-9B-MLX-4bit", "16 GB+ RAM — lightweight"), +] + + +def _detect_ram_gb() -> float: + try: + ram_bytes = int(subprocess.check_output( + ["sysctl", "-n", "hw.memsize"]).strip()) + except Exception: + ram_bytes = 0 + return ram_bytes / (1024 ** 3) + + +def detect_default_model(): + """Pick a default LLM model based on system RAM.""" + ram_gb = _detect_ram_gb() + for min_ram, model_id, _label in MODEL_TIERS: + if ram_gb >= min_ram: + return model_id + return MODEL_TIERS[-1][1] + + def email_from_name(name): first = name.split()[0] if name else "user" clean = "".join(c for c in first.lower() if c.isalnum()) return f"{clean}@home.local" -def generate_password(): - """Human-friendly password for local services.""" - words = ["sun", "moon", "star", "rain", "wind", "leaf", "tree", "bird", - "fish", "wave", "fire", "lake", "hill", "rock", "sand", "snow"] - return "-".join(secrets.choice(words) for _ in range(3)) +def _create_stack(): + """Create a Stack instance from the repo root.""" + from .cli import create_stack + return create_stack(REPO_ROOT) def _has_brew() -> bool: - """Check if Homebrew is installed.""" import shutil return shutil.which("brew") is not None def _ensure_brew(): - """Verify Homebrew is installed. Guide the user if not.""" if _has_brew(): done("Homebrew installed") return @@ -143,11 +166,6 @@ def _ensure_brew(): def _ensure_docker(): - """Verify Docker (OrbStack) is installed and running. - - Checks in order: installed → running. Offers to install via brew - if missing, guides the user to start it if stopped. - """ from .docker import check_docker, init_runtime _, runtime_warn = init_runtime() @@ -159,7 +177,6 @@ def _ensure_docker(): warn(runtime_warn) return - # ── Not installed — offer brew install ─────────────────────── if "not installed" in (err or "").lower(): warn("Docker is not installed.") nl() @@ -171,10 +188,6 @@ def _ensure_docker(): nl() subprocess.run(["brew", "install", "--cask", "orbstack"], timeout=300) nl() - - # OrbStack needs an interactive first-launch (allow Docker - # integration, possibly clear Gatekeeper). A new shell session - # is needed to pick up the PATH entry it installs. out("OrbStack is installed. Opening it for the first-time setup...") subprocess.run(["open", "-a", "OrbStack"], timeout=10) nl() @@ -187,21 +200,17 @@ def _ensure_docker(): raise KeyboardInterrupt - # ── Installed but not running ──────────────────────────────── warn("Docker is not running.") nl() out("Start OrbStack from your Applications folder or menu bar.") nl() - _wait_for_docker() def _wait_for_docker(): - """Poll until Docker is running, with user prompts.""" import time from .docker import check_docker, init_runtime - # Give it a moment to start up for _ in range(5): time.sleep(2) init_runtime() @@ -210,7 +219,6 @@ def _wait_for_docker(): done("Docker is running") return - # Still not up — ask the user while True: if not confirm("Check again?"): raise KeyboardInterrupt @@ -223,92 +231,64 @@ def _wait_for_docker(): nl() -def _announce(service_name: str): - """Short voice announcement after a successful stacklet setup. - - Silently does nothing if TTS isn't available yet. - """ - try: - sys.path.insert(0, str(REPO_ROOT / "stacklets" / "ai")) - from speech import speak - speak(f"{service_name} is ready.") - except Exception: - pass - - -def run_stack(*args): - """Run a stack CLI command, return (success, output).""" - cmd = [sys.executable, "-m", "stack"] + list(args) - env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "lib")} - result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, - cwd=str(REPO_ROOT), env=env) - return result.returncode == 0, (result.stdout + result.stderr).strip() - - -def run_stack_live(*args, confirmed=False): - """Run a stack CLI command with live output and stdin passthrough. - - confirmed=True sets STACK_SETUP_CONFIRMED=1 so stacklet configure - hooks skip interactive prompts the installer already handled. - - Returns True on success, False on failure. - """ - cmd = [sys.executable, "-m", "stack"] + list(args) - env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "lib")} - if confirmed: - env["STACK_SETUP_CONFIRMED"] = "1" - result = subprocess.run(cmd, cwd=str(REPO_ROOT), env=env) - return result.returncode == 0 - - -def print_status(only=None): - """Show compact status of stacklets. Pass only=set to filter.""" - from .prompt import status_list - ok, output = run_stack("list", "--json") - if not ok: - return - try: - data = json.loads(output) - except (json.JSONDecodeError, ValueError): - return - stacklets = data.get("stacklets", []) - if only is not None: - stacklets = [s for s in stacklets if s.get("id") in only] - status_list(stacklets) +# ── Config writers ─────────────────────────────────────────────────────────── +def _model_comments(chosen: str) -> str: + """Render model alternatives as TOML comments.""" + lines = ["# Models by RAM tier — uncomment one to switch, then run:"] + lines.append("# ./stack setup ai") + lines.append("# ./stack ai download ") + for _min_ram, model_id, label in MODEL_TIERS: + if model_id == chosen: + lines.append(f'default = "{model_id}" # ← {label} (selected)') + else: + lines.append(f'# default = "{model_id}" # {label}') + return "\n".join(lines) -# ── Config writers ──────────────────────────────────────────────────────────── - -def write_stack_toml(config): - """Write stack.toml from gathered config.""" - tz = config["timezone"] - data_dir = config["data_dir"] - language = config["language"] - provider = config.get("provider", "") - openai_url = config.get("openai_url", "") - openai_key = config.get("openai_key", "") +def write_stack_toml(family_name, server_name, timezone, language="en"): + """Write a minimal stack.toml — just enough for messages.""" + default_model = detect_default_model() + model_block = _model_comments(default_model) content = f'''# stack.toml — generated by famstack installer # # Edit freely — this file is gitignored and won't conflict with updates. # Run 'stack up ' after changes to apply them. [core] +# Product name. Drives CLI branding and the default paths derived from it +# (~/-extensions). Without it the instance calls itself "stack". +name = "famstack" +stack_owner = "{family_name}" domain = "" -data_dir = "{data_dir}" -timezone = "{tz}" +# Hostname used in port-mode URLs and the `{{ip}}` template var. Empty +# = auto-detect this machine's LAN IP (the LAN-friendly default). +# Set to "localhost" for single-machine testing, or to a custom +# hostname like "mac-mini.local" when the LAN IP shifts. +host = "" +data_dir = "~/famstack-data" +timezone = "{timezone}" language = "{language}" +# Slug for the bucket inside the memory vault that holds institutional +# artifacts (documents, correspondents). Personal entities live at +# //; the shared bucket lives at //. +# Defaults to "family"; rename for a non-family deployment ("office"), surname-based +# households, etc. +shared_bucket = "family" [updates] schedule = "0 0 3 * * *" [ai] -provider = "{provider}" -openai_url = "{openai_url}" -openai_key = "{openai_key}" -whisper_url = "http://localhost:42062/v1" -language = "{language}" -default = "Qwen3.5-9B-MLX-8bit" +provider = "" +openai_url = "" +openai_key = "" +language = "en" +{model_block} + +[messages] +# Permanent — appears in every Matrix user ID (@user:{server_name}) +server_name = "{server_name}" ''' path = REPO_ROOT / "stack.toml" path.write_text(content) @@ -338,7 +318,17 @@ def write_users_toml(users): path.write_text("\n".join(lines)) -# ── Wizard ──────────────────────────────────────────────────────────────────── +# ── Existing config ────────────────────────────────────────────────────────── + +def run_stack(*args): + """Run a stack CLI command, return (success, output).""" + cmd = [sys.executable, "-m", "stack"] + list(args) + env = {**os.environ, "PYTHONPATH": str(REPO_ROOT / "lib")} + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, + cwd=str(REPO_ROOT), env=env) + return result.returncode == 0, (result.stdout + result.stderr).strip() + + def show_existing_config(): """When config already exists, show what's there and how to change it.""" @@ -367,7 +357,7 @@ def show_existing_config(): if core.get("domain"): kv("Domain", core["domain"]) if ai.get("openai_url"): - kv("LLM", ai["openai_url"]) + kv("AI server", ai["openai_url"]) if ai.get("language"): kv("AI language", ai["language"]) if messages.get("server_name"): @@ -390,7 +380,6 @@ def show_existing_config(): pass # All stacklets and their status - from .prompt import status_list ok, output = run_stack("list", "--json") if ok: try: @@ -401,8 +390,9 @@ def show_existing_config(): # How to change things heading("To make changes") - out(f"Edit {TEAL}stack.toml{RESET} to change settings like timezone, LLM endpoint,") - out("or AI language. Changes take effect on the next 'stack up'.") + out(f"Edit {TEAL}stack.toml{RESET} to change settings like timezone or AI language.") + out("Changes take effect on the next 'stack up'.") + out(f"To use another AI server, run {TEAL}stack ai connect {RESET}.") nl() out(f"Edit {TEAL}users.toml{RESET} to add or remove family members.") out("New accounts are created on the next 'stack up' for each service.") @@ -419,65 +409,69 @@ def show_existing_config(): nl() dim("Need help?") dim(" https://github.com/famstack-dev/famstack/issues") - dim(" https://discord.gg/rwyrBRun") + dim(" https://discord.gg/hfutdmmfBe") nl() +# ── Wizard ─────────────────────────────────────────────────────────────────── + def wizard(): - # If config already exists, show it instead of re-running the wizard - has_config = (REPO_ROOT / "stack.toml").exists() - has_users = (REPO_ROOT / "users.toml").exists() - if has_config and has_users: + # Already configured — don't re-run + if (REPO_ROOT / "stack.toml").exists() and (REPO_ROOT / "users.toml").exists(): show_existing_config() return None clear() - # ── Welcome ──────────────────────────────────────────────────────────── + # ── Welcome ──────────────────────────────────────────────────────── banner("famstack", "Your family's private server") - out("Photos, messages, documents, and AI that runs entirely") - out("on your Mac. No cloud, no subscriptions.") + out("Everything stays on your network. Nothing leaves this machine.") + nl() + warn(f"{RED}{BOLD}Local network only.{RESET} famstack runs plain HTTP and trusts your LAN.") + out(" Never install it on a machine reachable from the public internet:") + out(" no VPS, no forwarded ports. For remote access, use a VPN (e.g. Tailscale).") nl() _ensure_brew() _ensure_docker() nl() - out("This wizard sets up your configuration, then walks you") - out("through each service one by one.") + # ── Family name ──────────────────────────────────────────────────── + + section("Family", "Let's set up your family server") + + out("Your family name is used to identify your server") + out("and shows up in chat as @name:family.") + nl() + dim("This is purely local — nothing is shared or sent anywhere.") nl() - if not confirm("Ready?"): + family_name = ask("Family name", validate=validate_name) + if not family_name: return None - # ── You ──────────────────────────────────────────────────────────────── + server_name = sanitize_server_name(family_name) - section("About You", "Create your admin account") + # ── Admin ────────────────────────────────────────────────────────── - out("This creates your admin account. You'll use it to log into") - out("Photos, Chat, and other services.") + nl() + out("Now create your account — the admin who manages the server.") nl() - name = ask("Your name", validate=validate_name) - if not name: - return None - - email = ask("Email", default=email_from_name(name), validate=validate_email) - dim("Used as login username. Doesn't need to be a real email.") - if not email: + admin_name = ask("Your first name", validate=validate_name) + if not admin_name: return None from .users import user_id - admin = {"name": name, "email": email, "role": "admin"} - done(f"{name} (id: {user_id(admin)})") + admin = {"name": admin_name, "email": email_from_name(admin_name), "role": "admin"} + done(f"@{user_id(admin)}:{server_name}") - # ── Family ───────────────────────────────────────────────────────────── + # ── Family members ───────────────────────────────────────────────── - section("Family", "Add family member accounts") - out("Add family members now and we create their accounts") - out("in Photos, Chat, and other services automatically.") - dim("You can always add more people later in users.toml.") + nl() + out("Add family members — they'll get their own chat accounts.") + dim("You can always add more later.") nl() users = [admin] @@ -487,345 +481,177 @@ def wizard(): if not member_name: break - member_email = ask("Email", default=email_from_name(member_name), validate=validate_email) - if not member_email: - break - - member = {"name": member_name, "email": member_email, "role": "member"} + member = {"name": member_name, "email": email_from_name(member_name), "role": "member"} users.append(member) - done(f"{member_name} (id: {user_id(member)})") + done(f"@{user_id(member)}:{server_name}") nl() - # ── Services ─────────────────────────────────────────────────────────── + # ── Confirm ──────────────────────────────────────────────────────── - section("Services", "Pick what to set up") - out("You can always add more later with 'stack up '.") nl() - - if CORE: - dim(f"{CORE['name']} is always included ({CORE['description']})") - nl() - - INSTALL_STACKLETS = ["photos", "messages", "docs", "ai", "chatai", "bots"] - stacklet_ids = [sid for sid in INSTALL_STACKLETS if sid in STACKLETS] - options = [ - f"{STACKLETS[sid]['name']} {STACKLETS[sid]['description']}" - for sid in stacklet_ids - ] - - preselected = list(range(len(stacklet_ids))) - selected = choose_many("Services", options, preselected=preselected) - - if selected is None: - selected = () - - chosen = [stacklet_ids[i] for i in selected] - - # Auto-add dependencies - STACKLET_DEPS = {"bots": ["messages", "ai"]} - for sid in list(chosen): - for dep in STACKLET_DEPS.get(sid, []): - if dep not in chosen: - chosen.append(dep) - dim(f"Adding {STACKLETS[dep]['name']} (needed by {STACKLETS[sid]['name']})") - - if chosen: - nl() - for sid in chosen: - done(STACKLETS[sid]["name"]) - - # ── Summary ──────────────────────────────────────────────────────────── - - timezone = detect_timezone() - data_dir = "~/famstack-data" - - clear() - banner("famstack", "Here's what we'll set up") - - # Services rule() - if CORE: - dim(f" {CORE['name']}") - for sid in chosen: - s = STACKLETS[sid] - out(f" {BOLD}{s['name']}{RESET} {DIM}{s['description']}{RESET}") - if not chosen: - dim(" No services selected") + nl() + bold(f"The {ORANGE}{family_plural(family_name)}{RESET}") + nl() + for u in users: + uid = user_id(u) + tag = f" {DIM}(admin){RESET}" if u["role"] == "admin" else "" + out(f" {TEAL}@{uid}:{server_name}{RESET}{tag}") + dim(f" {u['email']}") + nl() + dim("You can edit users.toml any time to add or remove members.") + nl() rule() nl() - kv("Timezone", timezone) - kv("Data", data_dir) + out(f"{ORANGE}{BOLD}How it works{RESET}") nl() - - for u in users: - if u["role"] == "admin": - out(f" {BOLD}{u['name']}{RESET} {DIM}{u['email']} (admin){RESET}") - else: - out(f" {u['name']} {DIM}{u['email']}{RESET}") + out(" Every family member gets an account on each service you enable.") + out(f" Log in with your {TEAL}first name{RESET} or {TEAL}email{RESET}, depending on the service.") + out(f" Default password is your {TEAL}first name{RESET}. Change it after first login.") + nl() + dim(f" famstack also creates a {TEAL}stackadmin{RESET}{DIM} service account to manage") + dim(" things behind the scenes. You'll find its password in") + dim(f" {TEAL}.stack/secrets.toml{RESET}{DIM} if you ever need it.") nl() - rule() + out(f"{ORANGE}{BOLD}First step{RESET}") nl() - out("This saves to stack.toml and users.toml in your famstack directory.") - out("After that, you'll choose which services to set up.") + out(f" We'll start with {TEAL}Messages{RESET}, your family's private chat.") + out(" It doubles as the operation center: manage your server") + out(" from any device, get notifications, and add more services.") + out(" Once it's running, you can continue the setup from there.") nl() - if not confirm("Save configuration?"): + if not confirm("Ready?"): dim("Cancelled — nothing was changed.") return None - # ── Write config ────────────────────────────────────────────────────── + # ── Write config ────────────────────────────────────────────────── clear() - section("Writing Configuration", "Saving your choices") + section("Setting up", "Writing config and starting messages") nl() - language = "en" - ai_config = {} - - config = { - "timezone": timezone, - "data_dir": data_dir, - "language": language, - **ai_config, - } + timezone = detect_timezone() + language = detect_language(timezone) with Spinner("Writing stack.toml"): - write_stack_toml(config) + write_stack_toml(family_name, server_name, timezone, language) with Spinner("Writing users.toml"): write_users_toml(users) with Spinner("Writing secrets"): - # Default passwords match the user ID (e.g. homer/homer). - # Simple and frictionless for a local network setup. Users can - # change their passwords in each service after first login. from .secrets import TomlSecretStore - from .users import user_id, password_key - secrets = TomlSecretStore(REPO_ROOT / ".stack" / "secrets.toml") + from .users import user_id as uid, password_key + import secrets as _secrets + store = TomlSecretStore(REPO_ROOT / ".stack" / "secrets.toml") for u in users: - secrets.set("global", password_key(u), user_id(u)) - secrets.set("global", "ADMIN_PASSWORD", user_id(admin)) + store.set("global", password_key(u), uid(u)) + # Prefix ensures the password never starts with a dash, which would + # break CLI tools that parse it as a flag (e.g. register_new_matrix_user) + admin_password = "s" + _secrets.token_urlsafe(8) + store.set("global", "ADMIN_PASSWORD", admin_password) with Spinner("Initializing infrastructure") as sp: - ok, output = run_stack("init", "--json") + from .docker import check_docker, init_runtime, ensure_network + ok, err = check_docker() if not ok: sp.fail() - nl() - warn("Infrastructure setup had issues:") - for line in output.split("\n")[-3:]: - dim(f" {line}") - nl() + fail("Docker is not running.", err or "") + init_runtime() + ok, err = ensure_network() + if err: + sp.fail() + fail("Could not create Docker network.", err) - # ── Set up stacklets ───────────────────────────────────────────────── + stck = _create_stack() + stck.data.mkdir(parents=True, exist_ok=True) + (stck.root / ".stack").mkdir(exist_ok=True) + + # ── Start messages, then core ──────────────────────────────────── + # Messages (Matrix) must be up first — the bot runner in core + # needs it to create accounts and log in. nl() - rule() - nl() - bold("Configuration complete!") - nl() - out("Now let's set up your services. Each one takes a minute or two.") - out("You can skip any and run 'stack up ' later.") - - install_order = ["ai", "messages", "docs", "photos", "chatai", "bots"] - ordered = [sid for sid in install_order if sid in chosen] - for sid in chosen: - if sid not in ordered: - ordered.append(sid) - - STACKLET_INTRO = { - "ai": ( - "The AI engine behind famstack. Runs large language models,", - "speech-to-text, and text-to-speech entirely on your Mac.", - "Other services use this for voice transcription and smart features.", - ), - "messages": ( - "Private family WhatsApp-like chat powered by Matrix. With mobile app.", - "Send messages, photos, and voice notes from any device.", - "Other services send notifications here too. Its the backbone of famstack.", - ), - "docs": ( - "Drop a PDF, scan, or photo of a document and it gets digitized,", - "categorized, and made searchable. Tax returns, school letters,", - "receipts — everything in one place, findable in seconds.", - ), - "photos": ( - "Back up photos from every phone automatically and browse", - "by person or place. Like Google Photos, but on your own hardware.", - ), - "chatai": ( - "A browser-based chat interface for your local AI. Ask questions,", - "have conversations, or talk to it. Like ChatGPT, but private", - "and running on your Mac.", - ), - "bots": ( - "Small AI-powered helpers that live in your family chat.", - "Send a document and it gets filed. Send a voice message", - "and it gets transcribed. More bots are coming.", - ), - } - - set_up = [] - skipped = [] - - for sid in ordered: - sname = STACKLETS[sid]["name"] - desc = STACKLETS[sid]["description"] - - print_status(only={"core"} | set(chosen)) - section(sname, desc) - - intro = STACKLET_INTRO.get(sid) - if intro: - for line in intro: - out(line) - nl() + from .cli import CLI + stck = _create_stack() + cli = CLI(stck) - if not confirm(f"Set up {sname}?"): - skipped.append(sid) - dim(f"Skipped — run 'stack up {sid}' when you're ready.") - continue - - if sid == "ai": - # ── Language ──────────────────────────────────────────── - section("AI Language", "Voice and speech recognition language") - out("This controls the voice the AI uses to speak and the") - out("default language for speech recognition.") - dim("English works best for now. Other languages are experimental.") - nl() - idx = choose("What language should the AI speak?", ["English", "Deutsch (experimental)"]) - language = "de" if idx == 1 else "en" - nl() - done(f"{'Deutsch' if language == 'de' else 'English'}") - config["language"] = language + out(f" Bringing up {TEAL}Messages{RESET}...\n") - # ── LLM provider ─────────────────────────────────────── - section("LLM Provider", "Where does the language model run?") - dim("Advanced: if you already run your own OpenAI-compatible LLM") - dim("endpoint, you can use that instead of oMLX.") - nl() + # Skip interactive on_configure prompts — we already wrote server_name + os.environ["STACK_SETUP_CONFIRMED"] = "1" + result = cli.up("messages") - if confirm("Bring your own LLM endpoint?", default=False): - endpoint_ok = False - while not endpoint_ok: - nl() - out("Enter the URL of your OpenAI-compatible endpoint.") - dim("Examples: https://api.openai.com/v1, http://192.168.1.50:11434/v1") - nl() - ep_url = ask("Endpoint URL") - if not ep_url: - break - ep_url = ep_url.strip().rstrip("/") - if not ep_url.startswith("http"): - ep_url = f"http://{ep_url}" - if not ep_url.endswith("/v1"): - ep_url = f"{ep_url}/v1" - - ep_key = ask("API key (leave empty if none)") - ep_key = ep_key.strip() if ep_key else "" - - sys.path.insert(0, str(REPO_ROOT / "stacklets" / "ai")) - from backend import _probe - probe = _probe(ep_url, ep_key) - if probe.reachable: - done(f"Connected to {ep_url}") - config["provider"] = "external" - config["openai_url"] = ep_url - config["openai_key"] = ep_key - if ep_key: - from .secrets import TomlSecretStore - secrets_store = TomlSecretStore(REPO_ROOT / ".stack" / "secrets.toml") - secrets_store.set("ai", "AI_API_KEY", ep_key) - endpoint_ok = True - else: - warn(f"Cannot reach {ep_url}") - out("Check the URL and make sure the server is running.") - nl() - if not confirm("Try again?"): - break - - if not endpoint_ok: - nl() - out("No worries — we'll set up oMLX for you instead.") - config["provider"] = "managed" - else: - config["provider"] = "managed" + if not result.get("ok"): + err_msg = result.get("error", "Unknown error") + fail("Messages failed to start.", + f"{err_msg}\n\nRun 'stack logs messages' to see what went wrong,\n" + "then 'stack up messages' to retry.") - nl() - write_stack_toml(config) + out(f" Bringing up {TEAL}Core{RESET}...\n") + result = cli.up("core") + if not result.get("ok"): + err_msg = result.get("error", "Unknown error") + fail("Core failed to start.", err_msg) - nl() - ok = run_stack_live("up", sid, confirmed=True) - nl() - - if ok: - set_up.append(sid) - _announce(sname) - else: - warn(f"{sname} had issues — check 'stack logs {sid}' for details.") - nl() + nl() # ── Done ────────────────────────────────────────────────────────── clear() - banner("famstack") - print_status(only={"core"} | set(chosen)) - - if set_up: - rule() - nl() - bold(f"{len(set_up)} service{'s' if len(set_up) != 1 else ''} running") - nl() + nl() + out(f"{ORANGE}{BOLD}famstack{RESET}") + out(f"{GREEN}The {family_plural(family_name)} are online{RESET}") - not_selected = [sid for sid in STACKLETS if sid not in chosen and sid not in skipped] - all_skipped = skipped + not_selected - if all_skipped: - out("Set up more services any time:") - for sid in all_skipped: - out(f" {TEAL}stack up {sid}{RESET}") - nl() + from .users import user_id as uid2 + admin_id = uid2(admin) - heading("Useful commands") - out(f" {TEAL}stack status{RESET} See what's running") - out(f" {TEAL}stack down {RESET} Stop a service") - out(f" {TEAL}stack logs {RESET} View logs") - out(f" {TEAL}stack destroy {RESET} Remove a service and its data") - nl() + # Resolve URLs from the Stack instance + messages_url = stck._public_url("messages", 42030) - return {"stacklets": set_up, "skipped": skipped, "users": users, "config": config} + # ── Step-by-step guide ──────────────────────────────────────────── + rule() + nl() + bold("1. Open your browser") + nl() + if messages_url: + out(f" {BOLD}{TEAL}{messages_url}{RESET}") + nl() -# ── Main ────────────────────────────────────────────────────────────────────── + bold("2. Sign in") + nl() + out(f" Welcome to Element. Press {BOLD}Sign in{RESET}.") + out(" If you see 'does not support this browser', click Continue anyway.") + nl() + out(f" Username {BOLD}{TEAL}{admin_id}{RESET}") + out(f" Password {BOLD}{TEAL}{admin_id}{RESET}") + dim(" (your first name, lowercase — change it after login)") + nl() -_saved_term = None + bold("3. Explore your rooms") + nl() + out(f" {TEAL}#famchat{RESET} Your private family conversations") + out(f" {TEAL}#famstack{RESET} Notifications about your server") + nl() -def _save_terminal(): - """Save terminal state before we do anything.""" - global _saved_term - try: - import termios - _saved_term = termios.tcgetattr(sys.stdin.fileno()) - except Exception: - pass + rule() -def _restore_terminal(): - """Restore terminal to saved state.""" - if _saved_term: - try: - import termios - termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term) - except Exception: - pass + heading("Add more to your stack") + out(f" {TEAL}stack up photos{RESET} Private photo library") + out(f" {TEAL}stack up docs{RESET} Document archive with OCR") + out(f" {TEAL}stack up ai{RESET} Local AI engine") + out(f" {TEAL}stack ai connect{RESET} AI on another machine or a hosted provider") + out(f" {TEAL}stack up code{RESET} Private git server") + out(f" {TEAL}stack up memory{RESET} Family wiki and curated knowledge") + nl() + out(f" {TEAL}stack status{RESET} See what's running") + nl() + dim(" Service admin password is in .stack/secrets.toml") + nl() -if __name__ == "__main__": - _save_terminal() - try: - wizard() - except KeyboardInterrupt: - _restore_terminal() - print(f"\n\n {DIM}Cancelled — nothing was changed.{RESET}\n") - sys.exit(1) - finally: - _restore_terminal() + return {"stacklets": ["messages"], "users": users} diff --git a/lib/stack/installer_v2.py b/lib/stack/installer_v2.py deleted file mode 100644 index 233740fc..00000000 --- a/lib/stack/installer_v2.py +++ /dev/null @@ -1,562 +0,0 @@ -""" -famstack installer v2 — messages-first, minimal questions. - -Asks for family name, admin first name, and optional family members. -Derives the Matrix server name from the family name. Boots messages -as the first and only stacklet. Everything else is post-install onboarding. -""" - -import os -import sys -import subprocess -from pathlib import Path - -from .prompt import ( - ORANGE, TEAL, GREEN, RED, DIM, BOLD, RESET, - clear, nl, out, dim, bold, done, warn, - heading, section, banner, rule, Spinner, ask, confirm, -) -from .users import family_plural - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -HELP_LINKS = ( - f" {TEAL}Discord{RESET} https://discord.com/invite/hfutdmmfBe", - f" {TEAL}GitHub{RESET} https://github.com/famstack-dev/famstack", - f" {TEAL}Email{RESET} hello@famstack.dev", -) - - -def get_help_text(detail=""): - """Format an error footer with optional detail and help links.""" - lines = [] - if detail: - lines.append("") - for line in detail.split("\n"): - lines.append(f" {DIM}{line}{RESET}") - lines.append("") - lines.append(" Sorry for that. If it keeps happening, we'd love to help.") - lines.append(" Please reach out so we can fix it faster:") - lines.append("") - lines.extend(HELP_LINKS) - lines.append("") - return "\n".join(lines) - - -def fail(msg, detail=""): - """Print an error message with help links and exit.""" - nl() - out(f"{RED}✗{RESET} {msg}") - print(get_help_text(detail)) - sys.exit(1) - - -def get_repo_root(): - here = Path(__file__).parent - for candidate in [here.parent.parent, here.parent, Path.cwd()]: - if (candidate / "stacklets").is_dir(): - return candidate - return Path.cwd() - - -REPO_ROOT = get_repo_root() - - -def validate_name(value): - if not value or len(value) < 2: - return "Name is required" - return None - - -def sanitize_server_name(family_name): - """Turn a family name into a valid Matrix server name.""" - clean = "".join(c for c in family_name.lower() if c.isalnum() or c in "-_.") - return clean or "home" - - -def detect_timezone(): - try: - link = os.readlink("/etc/localtime") - if "zoneinfo/" in link: - return link.split("zoneinfo/")[1] - except Exception: - pass - return "UTC" - - -# Map timezone to primary language. Only de and en for now -- -# taxonomy.yaml only has these two. Add more as we add translations. -_TZ_LANGUAGE = { - "Europe/Berlin": "de", "Europe/Vienna": "de", "Europe/Zurich": "de", -} - - -def detect_language(timezone: str) -> str: - """Guess the instance language from timezone. Defaults to English.""" - return _TZ_LANGUAGE.get(timezone, "en") - - -# (min_ram_gb, model_id, label) -# Qwen3.6 only shipped a 27B dense and a 35B-A3B MoE — no small variant — so -# the lightweight tier stays on Qwen3.5-9B (3.6's smallest 4bit is 16 GB of -# weights, too big for a 16 GB Mac). -MODEL_TIERS = [ - (32, "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit", "32 GB+ RAM"), - (0, "mlx-community/Qwen3.5-9B-MLX-4bit", "16 GB+ RAM — lightweight"), -] - - -def _detect_ram_gb() -> float: - try: - ram_bytes = int(subprocess.check_output( - ["sysctl", "-n", "hw.memsize"]).strip()) - except Exception: - ram_bytes = 0 - return ram_bytes / (1024 ** 3) - - -def detect_default_model(): - """Pick a default LLM model based on system RAM.""" - ram_gb = _detect_ram_gb() - for min_ram, model_id, _label in MODEL_TIERS: - if ram_gb >= min_ram: - return model_id - return MODEL_TIERS[-1][1] - - -def email_from_name(name): - first = name.split()[0] if name else "user" - clean = "".join(c for c in first.lower() if c.isalnum()) - return f"{clean}@home.local" - - -def _create_stack(): - """Create a Stack instance from the repo root.""" - from .cli import create_stack - return create_stack(REPO_ROOT) - - -def _has_brew() -> bool: - import shutil - return shutil.which("brew") is not None - - -def _ensure_brew(): - if _has_brew(): - done("Homebrew installed") - return - - # PATH is inherited from the parent shell at process start, so a brew - # install completed in another window won't show up here even after a - # "check again". Exit and have the user re-launch in a fresh tab. - warn("Homebrew is not installed.") - nl() - out("famstack uses Homebrew to install dependencies.") - out("Install it with this one-liner:") - nl() - dim(' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"') - nl() - out(f"Then open a {ORANGE}new terminal tab{RESET} ({BOLD}⌘T{RESET}) and re-run:") - nl() - dim(" ./stack") - nl() - sys.exit(1) - - -def _ensure_docker(): - from .docker import check_docker, init_runtime - - _, runtime_warn = init_runtime() - ok, err = check_docker() - - if ok: - done("Docker is running") - if runtime_warn: - warn(runtime_warn) - return - - if "not installed" in (err or "").lower(): - warn("Docker is not installed.") - nl() - out("famstack needs a container runtime. We recommend OrbStack —") - out("it's fast, lightweight, and built for macOS.") - nl() - - if confirm("Install OrbStack via Homebrew?"): - nl() - subprocess.run(["brew", "install", "--cask", "orbstack"], timeout=300) - nl() - out("OrbStack is installed. Opening it for the first-time setup...") - subprocess.run(["open", "-a", "OrbStack"], timeout=10) - nl() - out("Finish OrbStack's setup (allow Docker integration when it asks).") - out(f"Then open a {ORANGE}new terminal tab{RESET} ({BOLD}⌘T{RESET}) and re-run:") - nl() - dim(" ./stack") - nl() - sys.exit(1) - - raise KeyboardInterrupt - - warn("Docker is not running.") - nl() - out("Start OrbStack from your Applications folder or menu bar.") - nl() - _wait_for_docker() - - -def _wait_for_docker(): - import time - from .docker import check_docker, init_runtime - - for _ in range(5): - time.sleep(2) - init_runtime() - ok, _ = check_docker() - if ok: - done("Docker is running") - return - - while True: - if not confirm("Check again?"): - raise KeyboardInterrupt - init_runtime() - ok, _ = check_docker() - if ok: - done("Docker is running") - return - warn("Still not running.") - nl() - - -# ── Config writers ─────────────────────────────────────────────────────────── - -def _model_comments(chosen: str) -> str: - """Render model alternatives as TOML comments.""" - lines = ["# Models by RAM tier — uncomment one to switch, then run:"] - lines.append("# ./stack setup ai") - lines.append("# ./stack ai download ") - for _min_ram, model_id, label in MODEL_TIERS: - if model_id == chosen: - lines.append(f'default = "{model_id}" # ← {label} (selected)') - else: - lines.append(f'# default = "{model_id}" # {label}') - return "\n".join(lines) - - -def write_stack_toml(family_name, server_name, timezone, language="en"): - """Write a minimal stack.toml — just enough for messages.""" - default_model = detect_default_model() - model_block = _model_comments(default_model) - content = f'''# stack.toml — generated by famstack installer -# -# Edit freely — this file is gitignored and won't conflict with updates. -# Run 'stack up ' after changes to apply them. - -[core] -# Product name. Drives CLI branding and the default paths derived from it -# (~/-extensions). Without it the instance calls itself "stack". -name = "famstack" -stack_owner = "{family_name}" -domain = "" -# Hostname used in port-mode URLs and the `{{ip}}` template var. Empty -# = auto-detect this machine's LAN IP (the LAN-friendly default). -# Set to "localhost" for single-machine testing, or to a custom -# hostname like "mac-mini.local" when the LAN IP shifts. -host = "" -data_dir = "~/famstack-data" -timezone = "{timezone}" -language = "{language}" -# Slug for the bucket inside the memory vault that holds institutional -# artifacts (documents, correspondents). Personal entities live at -# //; the shared bucket lives at //. -# Defaults to "family"; rename for a non-family deployment ("office"), surname-based -# households, etc. -shared_bucket = "family" - -[updates] -schedule = "0 0 3 * * *" - -[ai] -provider = "" -openai_url = "" -openai_key = "" -language = "en" -{model_block} - -[messages] -# Permanent — appears in every Matrix user ID (@user:{server_name}) -server_name = "{server_name}" -''' - path = REPO_ROOT / "stack.toml" - path.write_text(content) - - -def write_users_toml(users): - """Write users.toml from the gathered user list.""" - from .users import user_id - lines = [ - "# users.toml — generated by famstack installer", - "#", - "# The admin account is used for all services (Photos, Docs, Chat, etc.).", - "# Family members get their own accounts where supported.", - "", - ] - - for u in users: - lines.append("[[users]]") - lines.append(f'id = "{user_id(u)}"') - lines.append(f'name = "{u["name"]}"') - lines.append(f'email = "{u["email"]}"') - lines.append(f'role = "{u["role"]}"') - lines.append('stacklets = ["photos", "docs", "messages"]') - lines.append("") - - path = REPO_ROOT / "users.toml" - path.write_text("\n".join(lines)) - - -# ── Wizard ─────────────────────────────────────────────────────────────────── - -def wizard(): - # Already configured — don't re-run - if (REPO_ROOT / "stack.toml").exists() and (REPO_ROOT / "users.toml").exists(): - from .installer import show_existing_config - show_existing_config() - return None - - clear() - - # ── Welcome ──────────────────────────────────────────────────────── - - banner("famstack", "Your family's private server") - out("Everything stays on your network. Nothing leaves this machine.") - nl() - warn(f"{RED}{BOLD}Local network only.{RESET} famstack runs plain HTTP and trusts your LAN.") - out(" Never install it on a machine reachable from the public internet:") - out(" no VPS, no forwarded ports. For remote access, use a VPN (e.g. Tailscale).") - nl() - - _ensure_brew() - _ensure_docker() - nl() - - # ── Family name ──────────────────────────────────────────────────── - - section("Family", "Let's set up your family server") - - out("Your family name is used to identify your server") - out("and shows up in chat as @name:family.") - nl() - dim("This is purely local — nothing is shared or sent anywhere.") - nl() - - family_name = ask("Family name", validate=validate_name) - if not family_name: - return None - - server_name = sanitize_server_name(family_name) - - # ── Admin ────────────────────────────────────────────────────────── - - nl() - out("Now create your account — the admin who manages the server.") - nl() - - admin_name = ask("Your first name", validate=validate_name) - if not admin_name: - return None - - from .users import user_id - admin = {"name": admin_name, "email": email_from_name(admin_name), "role": "admin"} - done(f"@{user_id(admin)}:{server_name}") - - # ── Family members ───────────────────────────────────────────────── - - nl() - out("Add family members — they'll get their own chat accounts.") - dim("You can always add more later.") - nl() - - users = [admin] - - while True: - member_name = ask("Name (leave empty to continue)", validate=None) - if not member_name: - break - - member = {"name": member_name, "email": email_from_name(member_name), "role": "member"} - users.append(member) - done(f"@{user_id(member)}:{server_name}") - nl() - - # ── Confirm ──────────────────────────────────────────────────────── - - nl() - rule() - nl() - bold(f"The {ORANGE}{family_plural(family_name)}{RESET}") - nl() - for u in users: - uid = user_id(u) - tag = f" {DIM}(admin){RESET}" if u["role"] == "admin" else "" - out(f" {TEAL}@{uid}:{server_name}{RESET}{tag}") - dim(f" {u['email']}") - nl() - dim("You can edit users.toml any time to add or remove members.") - nl() - rule() - nl() - - out(f"{ORANGE}{BOLD}How it works{RESET}") - nl() - out(" Every family member gets an account on each service you enable.") - out(f" Log in with your {TEAL}first name{RESET} or {TEAL}email{RESET}, depending on the service.") - out(f" Default password is your {TEAL}first name{RESET}. Change it after first login.") - nl() - dim(f" famstack also creates a {TEAL}stackadmin{RESET}{DIM} service account to manage") - dim(" things behind the scenes. You'll find its password in") - dim(f" {TEAL}.stack/secrets.toml{RESET}{DIM} if you ever need it.") - nl() - - out(f"{ORANGE}{BOLD}First step{RESET}") - nl() - out(f" We'll start with {TEAL}Messages{RESET}, your family's private chat.") - out(" It doubles as the operation center: manage your server") - out(" from any device, get notifications, and add more services.") - out(" Once it's running, you can continue the setup from there.") - nl() - - if not confirm("Ready?"): - dim("Cancelled — nothing was changed.") - return None - - # ── Write config ────────────────────────────────────────────────── - - clear() - section("Setting up", "Writing config and starting messages") - nl() - - timezone = detect_timezone() - language = detect_language(timezone) - - with Spinner("Writing stack.toml"): - write_stack_toml(family_name, server_name, timezone, language) - - with Spinner("Writing users.toml"): - write_users_toml(users) - - with Spinner("Writing secrets"): - from .secrets import TomlSecretStore - from .users import user_id as uid, password_key - import secrets as _secrets - store = TomlSecretStore(REPO_ROOT / ".stack" / "secrets.toml") - for u in users: - store.set("global", password_key(u), uid(u)) - # Prefix ensures the password never starts with a dash, which would - # break CLI tools that parse it as a flag (e.g. register_new_matrix_user) - admin_password = "s" + _secrets.token_urlsafe(8) - store.set("global", "ADMIN_PASSWORD", admin_password) - - with Spinner("Initializing infrastructure") as sp: - from .docker import check_docker, init_runtime, ensure_network - ok, err = check_docker() - if not ok: - sp.fail() - fail("Docker is not running.", err or "") - init_runtime() - ok, err = ensure_network() - if err: - sp.fail() - fail("Could not create Docker network.", err) - - stck = _create_stack() - stck.data.mkdir(parents=True, exist_ok=True) - (stck.root / ".stack").mkdir(exist_ok=True) - - # ── Start messages, then core ──────────────────────────────────── - # Messages (Matrix) must be up first — the bot runner in core - # needs it to create accounts and log in. - - nl() - from .cli import CLI - stck = _create_stack() - cli = CLI(stck) - - out(f" Bringing up {TEAL}Messages{RESET}...\n") - - # Skip interactive on_configure prompts — we already wrote server_name - os.environ["STACK_SETUP_CONFIRMED"] = "1" - result = cli.up("messages") - - if not result.get("ok"): - err_msg = result.get("error", "Unknown error") - fail("Messages failed to start.", - f"{err_msg}\n\nRun 'stack logs messages' to see what went wrong,\n" - "then 'stack up messages' to retry.") - - out(f" Bringing up {TEAL}Core{RESET}...\n") - result = cli.up("core") - if not result.get("ok"): - err_msg = result.get("error", "Unknown error") - fail("Core failed to start.", err_msg) - - nl() - - # ── Done ────────────────────────────────────────────────────────── - - clear() - nl() - out(f"{ORANGE}{BOLD}famstack{RESET}") - out(f"{GREEN}The {family_plural(family_name)} are online{RESET}") - - from .users import user_id as uid2 - admin_id = uid2(admin) - - # Resolve URLs from the Stack instance - messages_url = stck._public_url("messages", 42030) - - # ── Step-by-step guide ──────────────────────────────────────────── - - rule() - nl() - bold("1. Open your browser") - nl() - if messages_url: - out(f" {BOLD}{TEAL}{messages_url}{RESET}") - nl() - - bold("2. Sign in") - nl() - out(f" Welcome to Element. Press {BOLD}Sign in{RESET}.") - out(" If you see 'does not support this browser', click Continue anyway.") - nl() - out(f" Username {BOLD}{TEAL}{admin_id}{RESET}") - out(f" Password {BOLD}{TEAL}{admin_id}{RESET}") - dim(" (your first name, lowercase — change it after login)") - nl() - - bold("3. Explore your rooms") - nl() - out(f" {TEAL}#famchat{RESET} Your private family conversations") - out(f" {TEAL}#famstack{RESET} Notifications about your server") - nl() - - rule() - - heading("Add more to your stack") - out(f" {TEAL}stack up photos{RESET} Private photo library") - out(f" {TEAL}stack up docs{RESET} Document archive with OCR") - out(f" {TEAL}stack up ai{RESET} Local AI engine") - out(f" {TEAL}stack ai connect{RESET} AI on another machine or a hosted provider") - out(f" {TEAL}stack up code{RESET} Private git server") - out(f" {TEAL}stack up memory{RESET} Family wiki and curated knowledge") - nl() - out(f" {TEAL}stack status{RESET} See what's running") - nl() - - dim(" Service admin password is in .stack/secrets.toml") - nl() - - return {"stacklets": ["messages"], "users": users} From 3f5f03137062c3cb06dbd91b8525c4dc58e05ce9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:40:02 +0200 Subject: [PATCH 08/14] feat(ai): show an AI server on another machine as remote in stack list After `./stack ai connect `, `./stack list` still showed the ai stacklet as `online localhost:42060` whenever its local containers ran: its LLM check probes the configured address, and the remote server answered it. The row claimed the engine on this Mac was serving when nothing used it. On an instance that never installed it, the row said `available`, as if there were no AI at all. The row now reads `remote` with the server's host, set up locally or not, and is degraded, naming the address, when that server does not answer. The JSON output carries `remote` with the address. A stacklet declares this in its manifest: `remote = "
"`, a template that renders empty when the stacklet serves itself. While it is remote only the health checks marked `remote = true` run; the others probe this Mac. The ai stacklet uses the new `{ai_remote_url}`, the AI address when the provider is external. --- lib/stack/prompt.py | 9 ++- lib/stack/stack.py | 36 ++++++--- stacklets/ai/stacklet.toml | 7 +- tests/unit/stacklets/test_ai_remote_list.py | 86 +++++++++++++++++++++ 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 tests/unit/stacklets/test_ai_remote_list.py diff --git a/lib/stack/prompt.py b/lib/stack/prompt.py index b7f919cf..bf4217ab 100644 --- a/lib/stack/prompt.py +++ b/lib/stack/prompt.py @@ -115,14 +115,15 @@ def stage_badge(s): def status_list(stacklets): """Render a compact stacklet status table. - Six states: online, starting, degraded, failing, stopped, available. + Seven states: online, remote, starting, degraded, failing, stopped, + available. Remote means another machine serves it. Degraded stacklets show health issue hints. """ if not stacklets: return ordered = sorted(stacklets, key=lambda s: ( - not s.get("online"), not s.get("starting"), not s.get("degraded"), + not (s.get("online") or s.get("remote")), not s.get("starting"), not s.get("degraded"), not s.get("failing"), not s.get("enabled"), s.get("id", ""))) nl() @@ -139,6 +140,10 @@ def status_list(stacklets): out(f" {ORANGE}\u26a0{RESET} {label}{' ' * pad} {ORANGE}degraded{RESET}{badges}") for issue in s.get("health_issues", []): out(f" {ORANGE}{issue}{RESET}") + elif s.get("remote"): + from urllib.parse import urlsplit + host = urlsplit(s["remote"]).netloc or s["remote"] + out(f" {GREEN}\u2713{RESET} {label}{' ' * pad} {TEAL}remote{RESET} {DIM}{host}{RESET}{badges}") elif s.get("online"): port = s.get("port") url = f" {DIM}localhost:{port}{RESET}" if port else "" diff --git a/lib/stack/stack.py b/lib/stack/stack.py index c90f524d..17e64b02 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -367,6 +367,10 @@ def _build_template_vars(self) -> dict: "ai_whisper_url": ai_whisper_url, "ai_whisper_url_docker": ai_whisper_url.replace("://localhost", "://host.docker.internal"), "ai_whisper_key": ai_whisper_key, + # The AI address when it is served elsewhere, empty when the + # local engine serves it. Drives the ai stacklet's `remote`. + "ai_remote_url": (ai_openai_url if self._cfg("ai", "provider") == "external" + else ""), # OpenAI's name. Other servers name their speech models and # refuse any other; whisper.cpp ignores it. "ai_whisper_model": self._cfg("ai", "whisper_model") or "whisper-1", @@ -607,14 +611,24 @@ def list(self) -> dict: s["degraded"] = False s["health_issues"] = [] - # Only check health for fully running stacklets — stopped or - # starting services will obviously fail their health checks - if not is_set_up or not s["online"]: + # A stacklet whose manifest renders a `remote` address is + # served by another machine. Its row reports that server, + # whether or not anything of it runs here, and only the checks + # marked `remote` apply: the rest probe this Mac. + remote = self._render_template( + s.get("manifest", {}).get("remote", ""), template_vars) + if remote and not remote.startswith("{"): + s["remote"] = remote + checks = [c for c in self._resolve_health_checks( + s.get("manifest", {}), template_vars) if c.get("remote")] + elif not is_set_up or not s["online"]: + # Only check health for fully running stacklets — stopped or + # starting services will obviously fail their health checks continue + else: + checks = self._resolve_health_checks( + s.get("manifest", {}), template_vars) - # Resolve health checks from manifest - checks = self._resolve_health_checks( - s.get("manifest", {}), template_vars) for check in checks: if not check.get("url"): continue @@ -786,6 +800,7 @@ def _resolve_health_checks(self, manifest, template_vars): name = "..." hint = "..." skip_when_env = "STACK_AI_NO_VOICE" # drop when that var is "1" + remote = true # also runs when served remotely [health.checks.headers] Authorization = "Bearer {api_key}" @@ -807,7 +822,7 @@ def _resolve_health_checks(self, manifest, template_vars): continue url_tpl = c.get("url", "") name = c.get("name", "") - hint = c.get("hint", "") + hint = self._render_template(c.get("hint", ""), template_vars) raw_headers = c.get("headers", {}) url = self._render_template(url_tpl, template_vars) headers = { @@ -817,10 +832,13 @@ def _resolve_health_checks(self, manifest, template_vars): # Drop headers with unresolved templates headers = {k: v for k, v in headers.items() if v and not v.startswith("Bearer {")} + remote = bool(c.get("remote")) if url and not url.startswith("{"): - result.append({"url": url, "name": name, "hint": hint, "headers": headers}) + result.append({"url": url, "name": name, "hint": hint, + "headers": headers, "remote": remote}) elif hint: - result.append({"url": "", "name": name, "hint": hint, "headers": {}}) + result.append({"url": "", "name": name, "hint": hint, + "headers": {}, "remote": remote}) return result # Fallback: urls[] or url diff --git a/stacklets/ai/stacklet.toml b/stacklets/ai/stacklet.toml index a4697672..35baf2fd 100644 --- a/stacklets/ai/stacklet.toml +++ b/stacklets/ai/stacklet.toml @@ -16,6 +16,10 @@ category = "ai" type = "host" port = 42060 +# Served by another machine after `stack ai connect `: `stack list` +# then shows that server, and only the checks marked `remote` run. +remote = "{ai_remote_url}" + hints = [ "oMLX admin UI: http://localhost:42060/admin (password: {ai_openai_key})", "Try: stack ai models", @@ -39,7 +43,8 @@ skip_when_env = "STACK_AI_NO_VOICE" [[health.checks]] name = "LLM" url = "{ai_openai_url}/models" -hint = "LLM endpoint not reachable — run 'stack ai models' to configure" +hint = "AI server not answering at {ai_openai_url}" +remote = true [health.checks.headers] Authorization = "Bearer {ai_openai_key}" diff --git a/tests/unit/stacklets/test_ai_remote_list.py b/tests/unit/stacklets/test_ai_remote_list.py new file mode 100644 index 00000000..1221185b --- /dev/null +++ b/tests/unit/stacklets/test_ai_remote_list.py @@ -0,0 +1,86 @@ +"""`stack list` when the AI runs on another machine. + +After `stack ai connect ` the family's AI is served elsewhere. The +ai stacklet's row used to say `online localhost:42060` whenever its local +containers happened to run, because its LLM check probes the configured +address and the remote server answered it. That claimed the engine on +this Mac was serving when the stack did not use it at all; and on an +instance that never installed it, the row said `available`, as if there +were no AI. + +A stacklet whose manifest renders a `remote` address is shown as +remote, with that server's host, set up locally or not. Only its checks +marked `remote = true` run: the others probe this Mac. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import stack.docker +from stack import Stack +from stack.prompt import status_list + +REPO_ROOT = Path(__file__).resolve().parents[3] + + +@pytest.fixture +def instance(tmp_path, monkeypatch): + (tmp_path / "stack.toml").write_text( + f'[core]\ndata_dir = "{tmp_path / "data"}"\n\n[ai]\nlanguage = "en"\n' + '\n[messages]\nserver_name = "simpson"\n') + (tmp_path / ".stack").mkdir() + monkeypatch.setattr(stack.docker, "project_states", lambda: {}) + return Stack(REPO_ROOT, tmp_path / "data", instance_dir=tmp_path) + + +def _ai_row(instance): + return next(s for s in instance.list()["stacklets"] if s["id"] == "ai") + + +def _remote(instance, url): + instance._set_cfg("ai", "provider", "external") + instance._set_cfg("ai", "openai_url", url) + instance._set_cfg("ai", "openai_key", "sk-test") + + +class TestTheRow: + + def test_a_remote_server_is_shown_as_remote_even_when_never_set_up_here( + self, instance, httpserver): + httpserver.expect_request("/v1/models").respond_with_json({"data": []}) + _remote(instance, httpserver.url_for("/v1")) + + row = _ai_row(instance) + + assert row["remote"] == httpserver.url_for("/v1") + assert not row["degraded"] + + def test_a_remote_server_that_does_not_answer_is_degraded_and_named(self, instance): + _remote(instance, "http://127.0.0.1:9/v1") + + row = _ai_row(instance) + + assert row["degraded"] + assert row["health_issues"] == [ + "AI server not answering at http://127.0.0.1:9/v1"] + + def test_the_local_engine_is_not_remote(self, instance): + instance._set_cfg("ai", "provider", "managed") + instance._set_cfg("ai", "openai_url", "http://localhost:42060/v1") + + assert "remote" not in _ai_row(instance) + + +class TestTheListing: + + def test_it_reads_remote_with_the_servers_host(self, capsys): + status_list([{"id": "ai", "name": "AI", "remote": "https://ai.example.test/v1", + "enabled": True, "online": True}]) + + line = capsys.readouterr().out + assert "remote" in line + assert "ai.example.test" in line + assert "localhost" not in line From dbccd147851ae5f97343924b2e3e44b6fe5776c0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:40:02 +0200 Subject: [PATCH 09/14] fix(ai): stop the local AI engine that a remote setup leaves running Switching to a remote server with `./stack ai connect ` left the local engine running: oMLX, Whisper and the speech container kept their memory, and `./stack restart` offered to restart the ai stacklet, which now asks about switching back to local mode. `./stack ai connect` now says the local engine is no longer used and that `./stack down ai` stops it. That stop reached oMLX only when famstack had installed it, so an instance first set up with a remote endpoint kept oMLX running after `./stack up ai` had started it. `./stack up ai` now records that famstack manages oMLX whenever the local engine is the provider. --- stacklets/ai/cli/connect.py | 13 +++++++++---- stacklets/ai/hooks/on_start.py | 9 +++++++++ tests/unit/framework/test_ai_on_start.py | 13 +++++++++++++ tests/unit/stacklets/test_ai_connect.py | 11 +++++++++++ tests/unit/stacklets/test_ai_start_guard.py | 19 ++++++++++++++++++- 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/stacklets/ai/cli/connect.py b/stacklets/ai/cli/connect.py index 32c65366..fb342348 100644 --- a/stacklets/ai/cli/connect.py +++ b/stacklets/ai/cli/connect.py @@ -122,10 +122,10 @@ def _ai_consumers(repo_root: Path) -> set[str]: return ids -def _running(ids: set[str]) -> list[str]: +def _running() -> set[str]: from stack.docker import project_states - states = project_states() - return sorted(i for i in ids if states.get(i) in ("running", "starting", "failing")) + return {i for i, state in project_states().items() + if state in ("running", "starting", "failing")} def run(args, stacklet, config): @@ -208,12 +208,17 @@ def run(args, stacklet, config): "text, notes, chat questions and voice messages are sent there " "and processed by whoever runs that server.") + running = _running() + if not local and "ai" in running: + notes.append("The local AI engine is still running and no longer " + "used. './stack down ai' stops it and frees its memory.") + result = { "openai_url": url, "model": chosen["model"], "whisper_url": voice_url, "whisper_model": speech_model, - "restart": _running(_ai_consumers(Path(config["repo_root"]))), + "restart": sorted(_ai_consumers(Path(config["repo_root"])) & running), } if warnings: result["warnings"] = warnings diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index 522ad643..ea59b432 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -12,6 +12,9 @@ from stack.prompt import out, nl, warn, dim, TEAL, RESET +# on_stop reads the markers here to know what famstack manages. +STATE_DIR = Path(__file__).resolve().parent.parent / ".state" + def run(ctx): # Local-dev opt-out: clear the "voice" compose profile so docker compose @@ -85,6 +88,12 @@ def _start_local_engine(ctx): """ from stack.ai.probe import probe + # famstack manages oMLX whenever it is the provider, so `stack down ai` + # stops it. on_install records this too, but only when it installed + # oMLX itself, which an instance first set up remotely never did. + STATE_DIR.mkdir(exist_ok=True) + (STATE_DIR / "omlx-managed").touch() + url = ctx.cfg("openai_url", default="") or "http://localhost:42060/v1" if probe(url, ctx.cfg("openai_key", default="")).reachable: return diff --git a/tests/unit/framework/test_ai_on_start.py b/tests/unit/framework/test_ai_on_start.py index 609574ea..5f5a8f86 100644 --- a/tests/unit/framework/test_ai_on_start.py +++ b/tests/unit/framework/test_ai_on_start.py @@ -18,15 +18,28 @@ sys.path.insert(0, str(REPO / "lib")) +_STATE_DIR = None + + def _load_on_start(): # on_start.py lives under stacklets/ai/hooks/, not in a package. path = REPO / "stacklets" / "ai" / "hooks" / "on_start.py" spec = importlib.util.spec_from_file_location("ai_on_start", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) + # Its state markers live in the checkout; keep the test out of it. + mod.STATE_DIR = _STATE_DIR return mod +@pytest.fixture(autouse=True) +def _state_dir(tmp_path): + global _STATE_DIR + _STATE_DIR = tmp_path / "state" + yield + _STATE_DIR = None + + def _ctx(make_stack, env, engine_url): from stack.hooks import StackContext diff --git a/tests/unit/stacklets/test_ai_connect.py b/tests/unit/stacklets/test_ai_connect.py index d6e19a60..3311890c 100644 --- a/tests/unit/stacklets/test_ai_connect.py +++ b/tests/unit/stacklets/test_ai_connect.py @@ -138,6 +138,17 @@ def test_a_server_at_home_raises_no_privacy_warning(self, instance, ai_server): assert "warnings" not in result +class TestTheLocalEngineLeftRunning: + + def test_switching_away_says_the_local_engine_can_be_stopped( + self, instance, ai_server): + """Its containers, oMLX and Whisper keep using memory for nothing, + and a later `stack restart ai` would ask to switch back.""" + result = _connect(instance, ai_server.url_for("/v1")) + + assert any("./stack down ai" in n for n in result["notes"]) + + class TestChoosingAModel: @pytest.fixture diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index 90769327..fa37f24f 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -30,6 +30,15 @@ _spec.loader.exec_module(on_start) +@pytest.fixture(autouse=True) +def state_dir(monkeypatch, tmp_path): + """The hook's state markers live in the checkout. Every test here + writes them somewhere else, or `stack down ai` on this Mac would + start behaving differently.""" + monkeypatch.setattr(on_start, "STATE_DIR", tmp_path) + return tmp_path + + class FakeCtx: """The hook context: config in, shell commands recorded rather than run, since they would start and stop services on this Mac.""" @@ -82,10 +91,18 @@ class TestTheLocalEngineIsStarted: AI as running.""" @pytest.fixture(autouse=True) - def installed(self, monkeypatch): + def installed(self, monkeypatch, tmp_path): monkeypatch.setattr(on_start.shutil, "which", lambda _cmd: "/opt/homebrew/bin/omlx") + def test_a_started_engine_is_stopped_again_by_stack_down_ai(self, state_dir): + """`stack down ai` stops oMLX only when famstack manages it. An + instance first set up with a remote endpoint never recorded that, + so `down` left running what `up` had started.""" + on_start.run(FakeCtx(provider="managed", openai_url="http://127.0.0.1:9/v1")) + + assert (state_dir / "omlx-managed").exists() + def test_a_stopped_engine_is_started(self): ctx = FakeCtx(provider="managed", openai_url="http://127.0.0.1:9/v1") From ae70cfb258d9fe6ed2c769355513084a50de359b Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:40:21 +0200 Subject: [PATCH 10/14] fix(ai): name the models when stack ai connect asks for one With several models on the server and none chosen, `./stack ai connect` asked for `--model ` without saying which ids exist. The list was in the JSON output, but a terminal only shows the error line. The error now lists them. --- stacklets/ai/cli/connect.py | 4 ++-- tests/unit/stacklets/test_ai_connect.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/stacklets/ai/cli/connect.py b/stacklets/ai/cli/connect.py index fb342348..83e05b7b 100644 --- a/stacklets/ai/cli/connect.py +++ b/stacklets/ai/cli/connect.py @@ -76,8 +76,8 @@ def _choose_model(asked: str, current: str, available: list[str]) -> dict: return {"model": found} if len(available) == 1: return {"model": available[0]} - return {"error": "The server lists several models. " - "Choose one with --model .", + return {"error": "The server lists several models. Choose one with " + "--model :\n" + "\n".join(f" {m}" for m in available), "models": available} diff --git a/tests/unit/stacklets/test_ai_connect.py b/tests/unit/stacklets/test_ai_connect.py index 3311890c..4d8dde58 100644 --- a/tests/unit/stacklets/test_ai_connect.py +++ b/tests/unit/stacklets/test_ai_connect.py @@ -161,6 +161,8 @@ def test_several_models_and_no_choice_asks_for_one_and_writes_nothing( assert "--model" in result["error"] assert result["models"] == ["llama3.1:8b", "qwen3:14b"] + # In a terminal only the error line is printed, so it names them. + assert "llama3.1:8b" in result["error"] and "qwen3:14b" in result["error"] assert _ai(instance)["openai_url"] == "" def test_a_chosen_model_the_server_has(self, instance, many_models): From ba41d4c300a32becf98282df2a070dede6e6cfdd Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:42:54 +0200 Subject: [PATCH 11/14] fix(stack): report a declined question as cancelled, not a failure When a hook asked the admin something and got a no, `./stack up` printed "Hook on_start.py error", then "on_start hook failed" twice, and told the family room the stacklet had failed to start. A hook now raises `stack.hooks.Cancelled` for that. `./stack up` prints its message once, exits 1 and tells nobody. The ai stacklet uses it for a no to setting up the local engine and to replacing a remote AI server, and when there is no terminal to ask in. --- docs/stack-reference.md | 15 ++++++ lib/stack/cli.py | 7 ++- lib/stack/hooks.py | 11 ++++ lib/stack/stack.py | 9 ++++ stacklets/ai/hooks/on_configure.py | 3 +- stacklets/ai/local_mode.py | 12 +++-- tests/unit/framework/test_stack_lifecycle.py | 54 ++++++++++++++++++++ tests/unit/stacklets/test_ai_start_guard.py | 10 ++-- 8 files changed, 109 insertions(+), 12 deletions(-) diff --git a/docs/stack-reference.md b/docs/stack-reference.md index b439af80..fce23544 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -666,6 +666,21 @@ The context object (`ctx`) provides: | `ctx["http_post"]` | `callable` | `http_post(url, body, content_type=..., headers=...)` → parsed JSON. Form-encoded by default. | | `ctx["http_get"]` | `callable` | `http_get(url, headers=...)` → parsed JSON. Pass auth explicitly: `headers={"Authorization": "Bearer ..."}`. | +A hook that raises stops the command and is reported as a failure. A +hook that asked the admin and got a no raises `stack.hooks.Cancelled` +instead: `stack up` stops with `{"cancelled": }`, prints the +message once, exits 1, and does not tell the family room that the +stacklet failed to start. + +```python +from stack.hooks import Cancelled +from stack.prompt import confirm + +def run(ctx): + if not confirm("Replace the remote AI server with this Mac?", default=False): + raise Cancelled("Cancelled. Nothing changed.") +``` + Example `hooks/on_install.py` (system work with `ctx.shell()`): ```python diff --git a/lib/stack/cli.py b/lib/stack/cli.py index 8b34d946..2531ba6d 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -219,7 +219,7 @@ def up(self, stacklet_id: str) -> dict: return self._up_all() result = self.stack.up(stacklet_id) - if "error" in result: + if "error" in result or "cancelled" in result: return result stacklet = self.stack._find_stacklet(stacklet_id) @@ -409,7 +409,7 @@ def _up_ordered(self, include: set[str]) -> dict: errors: list[dict] = [] for sid in order: result = self.up(sid) - if "error" in result: + if "error" in result or "cancelled" in result: errors.append({"stacklet": sid, "result": result}) else: started.append(sid) @@ -854,6 +854,9 @@ def handle_up(stck, args): name = stacklet.get("name", args.stacklet) if stacklet else args.stacklet print(f"\n Bringing up {TEAL}{name}{RESET}...\n", file=sys.stderr) result = cli.up(args.stacklet) + if "cancelled" in result: + print(f" {DIM}{result['cancelled']}{RESET}", file=sys.stderr) + sys.exit(1) if result.get("ok"): print_up_success(result, stck) _notify_up(stck, result) diff --git a/lib/stack/hooks.py b/lib/stack/hooks.py index b2ab0642..73bdd4e6 100644 --- a/lib/stack/hooks.py +++ b/lib/stack/hooks.py @@ -25,6 +25,15 @@ from pathlib import Path +class Cancelled(Exception): + """Raised by a hook when the admin answered no. + + It stops the command the way an error does, but it is not one: + nothing is reported as broken and nobody is told a stacklet failed. + The message is what the admin sees, once. + """ + + class StackContext: """Everything a hook needs. No artificial restrictions. @@ -220,6 +229,8 @@ def _run_python(self, path: Path, ctx) -> bool: if hasattr(mod, "run"): mod.run(ctx) return True + except Cancelled: + raise except Exception as e: if hasattr(ctx, "stack") and hasattr(ctx.stack, "output"): ctx.stack.output.error(f"Hook {path.name} error: {e}") diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 17e64b02..b6dd7bca 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -987,6 +987,15 @@ def refresh_env(self, stacklet_id: str) -> dict: return env_dict def up(self, stacklet_id: str) -> dict: + """Bring a stacklet up, or `{"cancelled": message}` when one of + its hooks asked the admin and got a no. See `_up`.""" + from .hooks import Cancelled + try: + return self._up(stacklet_id) + except Cancelled as e: + return {"cancelled": str(e)} + + def _up(self, stacklet_id: str) -> dict: """Bring a stacklet up: render env, run hooks, write .env. First run: check deps → render env → generate secrets → diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index e19a063c..1303dee4 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -13,6 +13,7 @@ import sys from pathlib import Path +from stack.hooks import Cancelled from stack.prompt import section, out, nl, dim, confirm, warn @@ -74,7 +75,7 @@ def run(ctx): out("To use an AI server on another machine or a hosted provider,") out("run './stack ai connect ' instead. Nothing was installed.") nl() - raise RuntimeError("Cancelled: the local AI engine was not set up") + raise Cancelled("Cancelled. The local AI engine was not set up.") if not _check_brew_available(): raise RuntimeError("Homebrew not found") diff --git a/stacklets/ai/local_mode.py b/stacklets/ai/local_mode.py index fc2d3d69..65718339 100644 --- a/stacklets/ai/local_mode.py +++ b/stacklets/ai/local_mode.py @@ -8,6 +8,7 @@ import sys +from stack.hooks import Cancelled from stack.prompt import confirm, dim, nl, out, warn LOCAL_URL = "http://localhost:42060/v1" @@ -17,9 +18,10 @@ def switch_to_local(ctx) -> None: """Switch `[ai]` to the local engine, once the admin has said yes. - Does nothing unless the provider is a remote endpoint. Raises when - the answer is no, or when there is no terminal to ask in, so the - `stack up` stops before anything is installed or started. + Does nothing unless the provider is a remote endpoint. Raises + `Cancelled` when the answer is no, or when there is no terminal to + ask in, so the `stack up` stops before anything is installed or + started, without being reported as a failure. """ if ctx.cfg("provider", default="") != "external": return @@ -32,12 +34,12 @@ def switch_to_local(ctx) -> None: nl() if not sys.stdin.isatty(): - raise RuntimeError( + raise Cancelled( f"The stack uses the remote AI endpoint {url}. Bringing up the " "ai stacklet switches it to local mode, which needs a yes: run " "'./stack up ai' in a terminal.") if not confirm("Are you sure?", default=False): - raise RuntimeError(f"Cancelled. The stack still uses {url}.") + raise Cancelled(f"Cancelled. The stack still uses {url}.") ctx.cfg("provider", "managed") ctx.cfg("openai_url", LOCAL_URL) diff --git a/tests/unit/framework/test_stack_lifecycle.py b/tests/unit/framework/test_stack_lifecycle.py index 4ca0d9e4..f4586e6b 100644 --- a/tests/unit/framework/test_stack_lifecycle.py +++ b/tests/unit/framework/test_stack_lifecycle.py @@ -306,3 +306,57 @@ def test_up_after_destroy_is_fresh(self, tmp_path): s.destroy("myapp") result = s.up("myapp") assert result["first_run"] is True + + +# ── A hook that stops the command on purpose ───────────────────────────── + +_ASK_AND_DECLINE = textwrap.dedent(""" + from stack.hooks import Cancelled + def run(ctx): + raise Cancelled("Cancelled. Nothing changed.") +""") + + +class TestAHookCanCancel: + """A hook that asked the admin and got a no stops `stack up` without + it being a failure: nothing is reported as broken, and the family + room is not told a stacklet failed to start.""" + + def test_up_says_cancelled_not_error(self, tmp_path): + s = _make_stack(tmp_path, {"myapp": {"hooks": {"on_start.py": _ASK_AND_DECLINE}}}) + + result = s.up("myapp") + + assert result == {"cancelled": "Cancelled. Nothing changed."} + + def test_a_no_before_installing_installs_nothing(self, tmp_path): + marker = tmp_path / "installed" + s = _make_stack(tmp_path, {"myapp": {"hooks": { + "on_configure.py": _ASK_AND_DECLINE, + "on_install.py": textwrap.dedent(f""" + from pathlib import Path + def run(ctx): + Path("{marker}").write_text("yes") + """), + }}}) + + assert "cancelled" in s.up("myapp") + assert not marker.exists() + + def test_the_cli_says_it_once_and_tells_nobody(self, tmp_path, capsys, monkeypatch): + import argparse + from stack import cli + + s = _make_stack(tmp_path, {"myapp": {"hooks": {"on_start.py": _ASK_AND_DECLINE}}}) + told = [] + monkeypatch.setattr(cli, "_notify", lambda _stck, msg: told.append(msg)) + + with pytest.raises(SystemExit) as exit_: + cli.handle_up(s, argparse.Namespace(stacklet=["myapp"], no_voice=False)) + + output = capsys.readouterr() + text = output.out + output.err + assert exit_.value.code == 1 + assert text.count("Cancelled. Nothing changed.") == 1 + assert "failed" not in text + assert told == [] diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index fa37f24f..ce6fa72b 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -18,6 +18,8 @@ import pytest +from stack.hooks import Cancelled + _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) @@ -141,7 +143,7 @@ def _answer(self, monkeypatch, answer): def test_the_question_names_the_endpoint(self, monkeypatch, capsys): self._answer(monkeypatch, "n") - with pytest.raises(RuntimeError): + with pytest.raises(Cancelled): on_start.run(FakeCtx(**self.REMOTE)) assert "remote AI endpoint (https://ai.example.test/v1)" in capsys.readouterr().out @@ -150,7 +152,7 @@ def test_no_keeps_the_remote_setup_and_starts_nothing(self, monkeypatch): self._answer(monkeypatch, "n") ctx = FakeCtx(**self.REMOTE) - with pytest.raises(RuntimeError, match="still uses"): + with pytest.raises(Cancelled, match="still uses"): on_start.run(ctx) assert ctx._cfg == self.REMOTE @@ -160,7 +162,7 @@ def test_no_is_the_default(self, monkeypatch): self._answer(monkeypatch, "") ctx = FakeCtx(**self.REMOTE) - with pytest.raises(RuntimeError): + with pytest.raises(Cancelled): on_start.run(ctx) assert ctx._cfg == self.REMOTE @@ -184,7 +186,7 @@ def test_without_a_terminal_nothing_is_switched(self, monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: False) ctx = FakeCtx(**self.REMOTE) - with pytest.raises(RuntimeError, match="in a terminal"): + with pytest.raises(Cancelled, match="in a terminal"): on_start.run(ctx) assert ctx._cfg == self.REMOTE From 33c6083e2589f08f7d985d5f1c60d6a6e834e13f Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:43:44 +0200 Subject: [PATCH 12/14] fix(ai): let other machines on the network reach the local Whisper The local Whisper listened on loopback only, whatever the stack's mode. A second Mac pointed at this one with `./stack ai connect --whisper :42062` was refused, and so was a reverse proxy reaching it over the LAN. It now follows the rule every other service binds by: all interfaces in port mode, loopback in domain mode. Like the other services in port mode it answers anyone on the network, without a password. The next `./stack up ai` rewrites the LaunchAgent and reloads it. --- stacklets/ai/hooks/on_install.py | 7 +- tests/unit/stacklets/test_ai_whisper_bind.py | 70 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/unit/stacklets/test_ai_whisper_bind.py diff --git a/stacklets/ai/hooks/on_install.py b/stacklets/ai/hooks/on_install.py index 593cdd24..5e3f00c8 100644 --- a/stacklets/ai/hooks/on_install.py +++ b/stacklets/ai/hooks/on_install.py @@ -266,6 +266,11 @@ def _setup_whisper_launchd(ctx, data_dir: Path, whisper_bin: Path, model_path: P language = ctx.stack._cfg("core", "language", ctx.cfg("language", default="auto")) + # The framework's bind rule, as every container port follows it: all + # interfaces in port mode so the household's other machines reach + # it, loopback in domain mode where the reverse proxy is the way in. + host = ctx.env.get("PORT_BIND_IP") or "127.0.0.1" + plist_path = agents_dir / f"{PLIST_LABEL}.plist" plist_content = ( f'\n' @@ -287,7 +292,7 @@ def _setup_whisper_launchd(ctx, data_dir: Path, whisper_bin: Path, model_path: P f' --model\n' f' {model_path}\n' f' --host\n' - f' 127.0.0.1\n' + f' {host}\n' f' --port\n' f' {WHISPER_PORT}\n' f' --inference-path\n' diff --git a/tests/unit/stacklets/test_ai_whisper_bind.py b/tests/unit/stacklets/test_ai_whisper_bind.py new file mode 100644 index 00000000..acda9484 --- /dev/null +++ b/tests/unit/stacklets/test_ai_whisper_bind.py @@ -0,0 +1,70 @@ +"""Where the local Whisper listens. + +Every other service binds by the framework's rule: all interfaces in +port mode, so the household's other machines reach it, and loopback in +domain mode, where the reverse proxy is the way in. Whisper was pinned +to loopback in both, so a second Mac pointed at this one with +`stack ai connect --whisper :42062` was refused, and a +proxy on the LAN side could not reach it either. + +The LaunchAgent is written to a temporary home and launchd is not +asked to load it: the test reads what would be loaded. +""" + +from __future__ import annotations + +import importlib.util +import types +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_spec = importlib.util.spec_from_file_location( + "ai_hooks_on_install", _REPO_ROOT / "stacklets" / "ai" / "hooks" / "on_install.py") +on_install = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(on_install) + + +class FakeCtx: + def __init__(self, env): + self.env = env + self.shell_calls: list[str] = [] + self.stack = types.SimpleNamespace(_cfg=lambda _s, _k, default="": "en") + + def cfg(self, key, value=None, default=""): + return default + + def step(self, msg): + pass + + def shell(self, cmd): + self.shell_calls.append(cmd) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + return tmp_path / "home" + + +def _listening_on(home, tmp_path, env) -> str: + data = tmp_path / "data" + (data / "ai" / "logs").mkdir(parents=True) + on_install._setup_whisper_launchd( + FakeCtx(env), data, data / "whisper-server", data / "model.bin", tmp_path) + plist = (home / "Library" / "LaunchAgents" / f"{on_install.PLIST_LABEL}.plist").read_text() + after = plist.split("--host", 1)[1] + return after.split("", 1)[1].split("", 1)[0] + + +def test_port_mode_listens_on_every_interface(home, tmp_path): + assert _listening_on(home, tmp_path, {"PORT_BIND_IP": "0.0.0.0"}) == "0.0.0.0" + + +def test_domain_mode_listens_on_loopback(home, tmp_path): + assert _listening_on(home, tmp_path, {"PORT_BIND_IP": "127.0.0.1"}) == "127.0.0.1" + + +def test_without_the_framework_value_it_stays_on_loopback(home, tmp_path): + assert _listening_on(home, tmp_path, {}) == "127.0.0.1" From 2cb4d363d5045e883e01f8e0db3bfd4486460511 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:45:17 +0200 Subject: [PATCH 13/14] docs: explain running the stack's AI on another machine The admin guide gets a section on `./stack ai connect`: pointing the stack at another machine or a hosted provider, switching back with `local`, where voice messages go and how `--whisper` changes it, what a hosted provider receives, and what `./stack up ai` does on a remote setup. The first-run steps no longer mention the external endpoint `./stack up ai` used to offer. `stack.example.toml` explains the speech server and its key and model, and the operator command map and troubleshooting table cover the new command, the `remote` state in `./stack list`, and voice messages that fail on a remote setup. --- docs/admin-guide.md | 32 +++++++++++++++++++++++++++++++- docs/agent/ops.md | 3 +++ stack.example.toml | 19 +++++++++++++------ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index ca96f056..5027286f 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -405,7 +405,7 @@ Local AI engine. Powers document classification, voice transcription, and text-t First-run setup takes 10 to 20 minutes: -1. Asks managed oMLX (default, recommended) or external OpenAI-compatible endpoint. +1. Asks you to confirm the local engine. To use an AI server somewhere else instead, answer no and see [AI on another machine](#ai-on-another-machine-or-a-hosted-provider) below. 2. Taps and trusts the `jundot/omlx` Homebrew tap, then installs oMLX (see below). 3. Installs `cmake` and `ffmpeg` if missing. 4. Clones and builds whisper.cpp with Metal support (1 to 2 minutes of compilation). @@ -464,11 +464,40 @@ Useful commands: ```bash ./stack ai models ./stack ai download +./stack ai connect ./stack setup ai ``` To switch LLM models: edit `[ai] default` in `stack.toml` (alternatives are listed as commented lines), then `./stack setup ai`. +#### AI on another machine, or a hosted provider + +Everything that uses AI talks to one OpenAI-compatible address, `[ai] openai_url`. The engine `./stack up ai` installs is one option. If your models run on another computer in the house (oMLX, Ollama, LM Studio, anything that speaks the OpenAI API and listens on the network), or you use a hosted provider, skip `./stack up ai` and point the stack there: + +```bash +./stack ai connect 192.168.1.20:11434 # Ollama on another machine +./stack ai connect https://api.example.com --key sk-... # a hosted provider +./stack ai connect local # back to the engine on this Mac +``` + +A second Mac running famstack's AI stacklet in port mode serves both chat and voice to the network, oMLX on port 42060 and Whisper on 42062. Its API key is its own `[ai] openai_key`, `local` unless changed: + +```bash +./stack ai connect 192.168.1.20:42060 --key local --whisper 192.168.1.20:42062 +``` + +The command checks that the server answers, picks a model it has (`--model ` when it lists several), writes `[ai]` in `stack.toml`, and names the running stacklets to restart. It installs nothing and restarts nothing. `./stack list` then shows the AI stacklet as `remote`, with the server's name, and as degraded when that server stops answering. + +**Voice messages** have their own address, because not every AI server can transcribe: + +- `--whisper ` names a speech server. It gets every voice message, whatever the AI server offers. A second Mac running the AI stacklet serves one on port 42062. +- Without one, the AI server transcribes, if it has a speech-to-text model loaded. The command looks for one and warns when there is none. +- `--whisper ai` removes the speech server again. + +**What leaves the house.** With a hosted provider, the text of your documents, notes, chat questions and voice messages is sent to that provider and processed by whoever runs it. The command warns whenever an address is outside your home network; machines on your LAN or your Tailscale network count as home. + +**Switching back.** On a remote setup, `./stack up ai` asks before it switches the stack to the engine on this Mac; no leaves everything as it was. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. + ### ChatAI (`chatai`) optional Open WebUI: a ChatGPT-style interface for your local LLM. @@ -672,6 +701,7 @@ Key things to know: ```bash ./stack ai models # available AI models in the backend +./stack ai connect # use an AI server on another machine, or `local` ./stack config # show resolved configuration ./stack config --secrets # include generated passwords ./stack version # print version diff --git a/docs/agent/ops.md b/docs/agent/ops.md index cc22caf8..24e074f5 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -41,6 +41,7 @@ If a precondition is missing, `./stack` prints exactly what to do. Don't improvi | `./stack host` | yes | no | disk / memory / uptime | | `./stack config [--secrets]` | yes | no | prints resolved config | | `./stack ai models` | yes | no | lists installed AI models | +| `./stack ai connect ` | yes | no | points `[ai]` at an AI server elsewhere, or `local`; checks it, installs nothing, restarts nothing. Warns when the address is outside the home network | | `./stack setup ai` | yes | no | re-runs AI install (model swap path) | **Output contract:** every command returns JSON when piped or when `--json` is passed. Force human output with `--pretty`. Exit code 0 == success. @@ -102,6 +103,8 @@ Port collisions: do not silently rebind. Surface them. The user's fix is "stop t | AI install fails at whisper.cpp build | `xcode-select -p` | Xcode CLT missing. `xcode-select --install`. | | `brew: command not found` after install | PATH on Apple Silicon | Add `eval "$(/opt/homebrew/bin/brew shellenv)"` to `~/.zshrc`. | | LLM OOM / very slow | RAM tier | Edit `[ai] default` to smaller model, then `./stack setup ai`. | +| `stack list` shows ai as `remote` | `[ai] provider` in `stack.toml` | The stack uses an AI server elsewhere (`stack ai connect`). `stack up ai` asks before switching back to this Mac. | +| Voice messages fail on a remote AI setup | `./stack ai connect ` output, "Voice messages" line | The AI server lists no speech-to-text model. Pass `--whisper ` for a speech server. | | Disk full | `./stack host` | Likely the photo library. Move `data_dir` to external SSD. | | Element warns "browser not supported" | n/a | Click Continue. The check is outdated; Element works in every modern browser. | | Wiki pages stale after filings | `docker logs stack-memory-curator` | Curator debounces (~3 min quiet) before rebuilding; topic pages wait for the nightly sweep. Manual override: `./stack memory wiki`. | diff --git a/stack.example.toml b/stack.example.toml index 26365deb..88e1a62d 100644 --- a/stack.example.toml +++ b/stack.example.toml @@ -61,13 +61,15 @@ language = "en" schedule = "0 0 3 * * *" [ai] -# OpenAI-compatible API endpoint for LLM inference. -# Both oMLX (MLX models) and LM Studio (GGUF models) are installed -# automatically by `stack up ai`. Point this at whichever you're using: +# OpenAI-compatible API endpoint for LLM inference. Everything that uses +# AI talks to this one address. `stack up ai` installs the local engine +# and sets it. For a server on another machine or a hosted provider, run +# `./stack ai connect ` instead: it checks the server, picks a model +# it has and writes this section, without installing anything here. # oMLX native app: http://localhost:8000/v1 (default) # oMLX via brew: http://localhost:42060/v1 # LM Studio: http://localhost:42061/v1 -# Containers reach this automatically via host.docker.internal. +# Containers reach localhost automatically via host.docker.internal. openai_url = "http://localhost:8000/v1" # API key for the local LLM server. Any value works — this is just local auth. @@ -95,9 +97,14 @@ default = "mlx-community/Qwen3.5-9B-MLX-8bit" # archivist = "mlx-community/Qwen2.5-14B-Instruct-4bit" # all archivist tasks use this # archivist.classifier = "mlx-community/Qwen2.5-7B-Instruct-4bit" # fast model just for classification -# Whisper.cpp speech-to-text server. Built from source by `stack up ai` -# because the Homebrew formula disables the HTTP server. +# Speech-to-text for voice messages, a dedicated server that answers +# the OpenAI transcription API. `stack up ai` sets it to the local +# Whisper, built from source because the Homebrew formula disables the +# HTTP server. Left unset, voice messages go to openai_url above, which +# then needs a speech-to-text model loaded. whisper_url = "http://localhost:42062/v1" +# whisper_key = "" # only for a speech server that needs one +# whisper_model = "whisper-1" # the speech model to ask the server for # Language for AI interactions — controls TTS voice selection and # default Whisper transcription language. From 7b42d74570164b5de8b44e2e520bcf76cd2686b4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 22 Sep 2026 12:50:57 +0200 Subject: [PATCH 14/14] fix(ai): let other machines on the network use the local AI engine famstack never set where oMLX listens, so it kept its own default, loopback, in every mode. Another machine pointed at this Mac with `./stack ai connect :42060` was refused. `./stack up ai` now sets it by the rule every other service binds by: all interfaces in port mode, loopback in domain mode. It checks on every start and restarts a running oMLX when the address changes, since oMLX reads its settings at start only. In port mode the engine answers anyone on the network who has its API key, `[ai] openai_key`. --- stacklets/ai/hooks/on_start.py | 29 ++++++++++ tests/unit/framework/test_ai_on_start.py | 4 +- tests/unit/stacklets/test_ai_start_guard.py | 60 +++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index ea59b432..b4c3a405 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -94,13 +94,42 @@ def _start_local_engine(ctx): STATE_DIR.mkdir(exist_ok=True) (STATE_DIR / "omlx-managed").touch() + moved = _bind_omlx(ctx) + url = ctx.cfg("openai_url", default="") or "http://localhost:42060/v1" if probe(url, ctx.cfg("openai_key", default="")).reachable: + if moved: + # oMLX reads its settings at start only. + ctx.step("Restarting oMLX on its new address") + ctx.shell("brew services restart omlx") return ctx.step("Starting oMLX") ctx.shell("brew services start omlx") +def _bind_omlx(ctx) -> bool: + """Make oMLX listen where the framework binds every service. + + All interfaces in port mode, so the household's other machines can + use this Mac's engine, loopback in domain mode. famstack never set + it, so oMLX kept its own default, loopback, whatever the mode. + on_install creates the settings file; without it there is nothing + to reconcile yet. Returns whether the address changed. + """ + import json + + path = Path.home() / ".omlx" / "settings.json" + if not path.exists(): + return False + host = ctx.env.get("PORT_BIND_IP") or "127.0.0.1" + settings = json.loads(path.read_text()) + if settings.get("server", {}).get("host") == host: + return False + settings.setdefault("server", {})["host"] = host + path.write_text(json.dumps(settings, indent=2)) + return True + + def _reconcile_whisper(ctx): """Bring the whisper LaunchAgent in line with the current code. diff --git a/tests/unit/framework/test_ai_on_start.py b/tests/unit/framework/test_ai_on_start.py index 5f5a8f86..976c40fd 100644 --- a/tests/unit/framework/test_ai_on_start.py +++ b/tests/unit/framework/test_ai_on_start.py @@ -33,9 +33,11 @@ def _load_on_start(): @pytest.fixture(autouse=True) -def _state_dir(tmp_path): +def _state_dir(tmp_path, monkeypatch): global _STATE_DIR _STATE_DIR = tmp_path / "state" + # oMLX's settings live in the home directory; keep the test out of it. + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") yield _STATE_DIR = None diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index ce6fa72b..ae468c3c 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -16,6 +16,8 @@ import sys from pathlib import Path +import json + import pytest from stack.hooks import Cancelled @@ -41,6 +43,14 @@ def state_dir(monkeypatch, tmp_path): return tmp_path +@pytest.fixture(autouse=True) +def home(monkeypatch, tmp_path): + """oMLX's settings live in the home directory. Every test here gets + its own, so none of them rewrites the engine on this Mac.""" + monkeypatch.setattr(Path, "home", lambda: tmp_path / "home") + return tmp_path / "home" + + class FakeCtx: """The hook context: config in, shell commands recorded rather than run, since they would start and stop services on this Mac.""" @@ -190,3 +200,53 @@ def test_without_a_terminal_nothing_is_switched(self, monkeypatch): on_start.run(ctx) assert ctx._cfg == self.REMOTE + + +class TestWhereOMLXListens: + """Every service binds by the framework's rule: all interfaces in + port mode, so the household's other machines reach it, loopback in + domain mode. famstack never set oMLX's, so it kept its own default, + loopback, and a second Mac pointed at this one was refused.""" + + @pytest.fixture(autouse=True) + def installed(self, monkeypatch, home): + monkeypatch.setattr(on_start.shutil, "which", + lambda _cmd: "/opt/homebrew/bin/omlx") + settings = home / ".omlx" / "settings.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps( + {"server": {"host": "127.0.0.1", "port": 42060}, + "auth": {"api_key": "local"}})) + return settings + + def _ctx(self, url, bind): + ctx = FakeCtx(provider="managed", openai_url=url) + ctx.env["PORT_BIND_IP"] = bind + return ctx + + def test_port_mode_listens_on_every_interface(self, installed): + on_start.run(self._ctx("http://127.0.0.1:9/v1", "0.0.0.0")) + + settings = json.loads(installed.read_text()) + assert settings["server"] == {"host": "0.0.0.0", "port": 42060} + assert settings["auth"] == {"api_key": "local"} + + def test_a_running_engine_is_restarted_to_listen_there(self, installed, httpserver): + """oMLX reads its settings at start, so a running one keeps its + old address until it restarts.""" + httpserver.expect_request("/v1/models").respond_with_json({"data": []}) + ctx = self._ctx(httpserver.url_for("/v1"), "0.0.0.0") + + on_start.run(ctx) + + assert ctx.shell_calls == ["brew services restart omlx"] + + def test_the_right_address_changes_nothing(self, installed, httpserver): + httpserver.expect_request("/v1/models").respond_with_json({"data": []}) + before = installed.read_text() + ctx = self._ctx(httpserver.url_for("/v1"), "127.0.0.1") + + on_start.run(ctx) + + assert installed.read_text() == before + assert ctx.shell_calls == []