From 979ed305e06285d8f06e7bebfc530bd7bd660b61 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 15:03:32 +0200 Subject: [PATCH 1/5] fix(ai): keep the chosen AI server when the ai stacklet starts With chat on an AI server elsewhere and voice on this Mac, every `./stack up ai` warned about a remote endpoint and asked to switch the whole stack to the local engine. A no cancelled the start, so the speech services could not be started or restarted without giving up the chosen server. The question now belongs to the first `./stack up ai` only, which installs the stacklet. Once installed, `./stack up ai` starts what the stacklet runs and keeps `[ai]` as it is; it names `./stack ai connect local` as the way to switch to this Mac's engine. Fixes: 6d994d3 ("feat(ai): ask before stack up ai replaces a remote AI server") --- docs/admin-guide.md | 2 +- docs/agent/ops.md | 2 +- stacklets/ai/hooks/on_configure.py | 5 +- stacklets/ai/hooks/on_start.py | 11 +- stacklets/ai/local_mode.py | 16 +-- tests/unit/stacklets/test_ai_start_guard.py | 109 ++++++++++++++------ 6 files changed, 98 insertions(+), 47 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index c3ee312f..c36276b8 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -515,7 +515,7 @@ The command checks that the server answers, picks a model it has (`--model ` **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. +**Switching back.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet on a remote setup, the first `./stack up ai`, asks before it does the same; no leaves everything as it was. Once installed, `./stack up ai` keeps the server you chose, so chat can stay elsewhere while voice runs on this Mac. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. ### ChatAI (`chatai`) optional diff --git a/docs/agent/ops.md b/docs/agent/ops.md index 2afdf650..03866829 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -110,7 +110,7 @@ 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. | +| `stack list` shows ai as `remote` | `[ai] provider` in `stack.toml` | The stack uses an AI server elsewhere (`stack ai connect`). `stack up ai` keeps it; `stack ai connect local` switches 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", then never loads | Which address is open? | Port mode over the LAN IP: plain HTTP is not a secure context. On the server Mac open `http://localhost:42030`; other computers use the Element desktop app with `http://:42031`. | diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index 1303dee4..5ddeb525 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -2,8 +2,9 @@ `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. +nothing here; after that, installing this stacklet asks before it +switches the stack back to local mode. Once installed, the engine is +chosen with `stack ai connect`, and `stack up ai` only starts it. Runs before on_install. Only fires on first 'stack up ai'. Skipped when STACK_SETUP_CONFIRMED=1 (installer already confirmed). diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index b4c3a405..919ea8a9 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -7,7 +7,6 @@ import os import shutil -import sys from pathlib import Path from stack.prompt import out, nl, warn, dim, TEAL, RESET @@ -25,11 +24,6 @@ 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: @@ -70,6 +64,11 @@ def run(ctx): out(f" {TEAL}stack destroy ai && stack up ai{RESET}") nl() raise RuntimeError("Missing openai_url for external provider") + # The engine is chosen with `stack ai connect`, never by starting + # the stacklet: text elsewhere with voice on this Mac is a setup + # an admin chose, and `up` only starts what the stacklet runs. + dim(f"Chat uses the AI server at {url}. " + "'./stack ai connect local' switches to the engine on this Mac.") if provider == "managed": _start_local_engine(ctx) diff --git a/stacklets/ai/local_mode.py b/stacklets/ai/local_mode.py index 65718339..af6a6c6b 100644 --- a/stacklets/ai/local_mode.py +++ b/stacklets/ai/local_mode.py @@ -1,9 +1,11 @@ -"""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. +"""Ask before installing the ai stacklet moves a remote AI setup onto this Mac. + +`stack ai connect ` points the stack at a server elsewhere. Installing +the ai stacklet after that, the first `stack up ai`, 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. An installed stacklet never asks: its engine is chosen +with `stack ai connect`, and `stack up ai` only starts it. """ import sys @@ -29,7 +31,7 @@ def switch_to_local(ctx) -> None: 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("Installing 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() diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index ae468c3c..1ee694a8 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -33,6 +33,12 @@ on_start = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(on_start) +_spec = importlib.util.spec_from_file_location( + "ai_hooks_on_configure", + _REPO_ROOT / "stacklets" / "ai" / "hooks" / "on_configure.py") +on_configure = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(on_configure) + @pytest.fixture(autouse=True) def state_dir(monkeypatch, tmp_path): @@ -132,57 +138,100 @@ def test_a_running_engine_is_left_alone(self, httpserver): -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") + - 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") +def _answer(monkeypatch, answer): + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda _prompt: answer) + + +class TestAnInstalledStackletKeepsItsEngine: + """Chat on an AI server elsewhere with voice on this Mac is a setup an + admin chooses with `stack ai connect`. Starting the installed stacklet + starts what it runs and leaves that choice alone: a production Mac + serving oMLX as an app, with the stacklet for speech, was asked on + every `stack up ai` to hand its engine over.""" @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): + def test_it_starts_without_asking(self, monkeypatch): monkeypatch.setattr(sys.stdin, "isatty", lambda: True) - monkeypatch.setattr("builtins.input", lambda _prompt: answer) + monkeypatch.setattr("builtins.input", _no_question_expected) + ctx = FakeCtx(**REMOTE) + + on_start.run(ctx) + + assert ctx._cfg == REMOTE + assert ctx.shell_calls == [] + + def test_it_starts_without_a_terminal(self, monkeypatch): + """An agent or a script restarting the stacklet is not stopped.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: False) + ctx = FakeCtx(**REMOTE) + + on_start.run(ctx) + + assert ctx._cfg == REMOTE + + def test_it_names_the_command_that_switches_the_engine(self, capsys): + on_start.run(FakeCtx(**REMOTE)) + + out = capsys.readouterr().out + assert "https://ai.example.test/v1" in out + assert "stack ai connect local" in out + + +def _no_question_expected(prompt): + raise AssertionError(f"asked {prompt!r}") + + +class TestInstallingOverARemoteEndpoint: + """After `stack ai connect `, installing 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.""" + + @pytest.fixture(autouse=True) + def interactive_setup(self, monkeypatch): + monkeypatch.delenv("STACK_SETUP_CONFIRMED", raising=False) def test_the_question_names_the_endpoint(self, monkeypatch, capsys): - self._answer(monkeypatch, "n") + _answer(monkeypatch, "n") with pytest.raises(Cancelled): - on_start.run(FakeCtx(**self.REMOTE)) + on_configure.run(FakeCtx(**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) + def test_no_keeps_the_remote_setup(self, monkeypatch): + _answer(monkeypatch, "n") + ctx = FakeCtx(**REMOTE) with pytest.raises(Cancelled, match="still uses"): - on_start.run(ctx) + on_configure.run(ctx) - assert ctx._cfg == self.REMOTE - assert ctx.shell_calls == [] + assert ctx._cfg == REMOTE def test_no_is_the_default(self, monkeypatch): - self._answer(monkeypatch, "") - ctx = FakeCtx(**self.REMOTE) + _answer(monkeypatch, "") + ctx = FakeCtx(**REMOTE) with pytest.raises(Cancelled): - on_start.run(ctx) + on_configure.run(ctx) - assert ctx._cfg == self.REMOTE + assert ctx._cfg == 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) + _answer(monkeypatch, "y") + ctx = FakeCtx(**REMOTE) - on_start.run(ctx) + on_configure.run(ctx) assert ctx._cfg["provider"] == "managed" assert ctx._cfg["openai_url"] == "http://localhost:42060/v1" @@ -191,15 +240,15 @@ def test_yes_switches_chat_and_voice_to_this_mac_together(self, monkeypatch): 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` + """Nobody to say yes: an agent or a script installing the stacklet must not move the family's AI onto this Mac.""" monkeypatch.setattr(sys.stdin, "isatty", lambda: False) - ctx = FakeCtx(**self.REMOTE) + ctx = FakeCtx(**REMOTE) - with pytest.raises(Cancelled, match="in a terminal"): - on_start.run(ctx) + with pytest.raises(RuntimeError, match="terminal"): + on_configure.run(ctx) - assert ctx._cfg == self.REMOTE + assert ctx._cfg == REMOTE class TestWhereOMLXListens: From cec3a0c184062e18492b1ed6eb509c40121f4c9c Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 15:12:29 +0200 Subject: [PATCH 2/5] feat(ai): install only voice when chat stays on another AI server After pointing the stack at another AI server, the first `./stack up ai` offered two answers: move chat and voice to this Mac, or cancel. A Mac that serves its own AI app, or uses a server elsewhere for chat, could not get speech-to-text and text-to-speech from the ai stacklet without first installing an engine it does not use. The question is now whether this Mac's engine takes over. Yes works as before. No, the default, keeps the server for chat and installs only Whisper and the speech container. A server on this Mac, such as `localhost:8888`, is described as one the stack does not manage instead of as remote. --- docs/admin-guide.md | 2 +- lib/stack/ai/probe.py | 15 ++++++ stacklets/ai/hooks/on_configure.py | 14 ++--- stacklets/ai/local_mode.py | 57 +++++++++++---------- tests/unit/framework/test_ai_probe.py | 22 +++++++- tests/unit/stacklets/test_ai_start_guard.py | 42 ++++++++------- 6 files changed, 98 insertions(+), 54 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index c36276b8..f9fc1eb9 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -515,7 +515,7 @@ The command checks that the server answers, picks a model it has (`--model ` **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.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet on a remote setup, the first `./stack up ai`, asks before it does the same; no leaves everything as it was. Once installed, `./stack up ai` keeps the server you chose, so chat can stay elsewhere while voice runs on this Mac. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. +**Switching back.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet after `connect`, the first `./stack up ai`, asks whether its engine takes over. Yes moves chat and voice to this Mac; no keeps your server for chat and installs only speech-to-text and text-to-speech here. That is also the setup for an AI app you run on this Mac yourself: `./stack ai connect localhost:`, then `./stack up ai`. Once installed, `./stack up ai` keeps the server you chose. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. ### ChatAI (`chatai`) optional diff --git a/lib/stack/ai/probe.py b/lib/stack/ai/probe.py index 7f2a64b3..2acb0b7f 100644 --- a/lib/stack/ai/probe.py +++ b/lib/stack/ai/probe.py @@ -78,6 +78,21 @@ def transcribes(url: str, key: str = "", *, timeout: float = 3.0) -> bool: _TAILNET = ipaddress.ip_network("100.64.0.0/10") +def on_this_mac(url: str) -> bool: + """Whether ``url`` names this machine: ``localhost`` or a loopback address. + + Decided from the address as written, without a DNS lookup, so it is + cheap enough for a status line. + """ + host = urllib.parse.urlsplit(url).hostname or "" + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + 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. diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index 5ddeb525..4f051f11 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -2,9 +2,9 @@ `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, installing this stacklet asks before it -switches the stack back to local mode. Once installed, the engine is -chosen with `stack ai connect`, and `stack up ai` only starts it. +nothing here; after that, installing this stacklet asks whether the +engine here takes over or only voice is installed. Once installed, the +engine is chosen with `stack ai connect`, and `stack up ai` only starts it. Runs before on_install. Only fires on first 'stack up ai'. Skipped when STACK_SETUP_CONFIRMED=1 (installer already confirmed). @@ -49,12 +49,12 @@ 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. + # A server set with `stack ai connect`: the admin says whether the + # engine here takes over, or only the voice services are installed. if provider == "external": sys.path.insert(0, str(Path(__file__).parent.parent)) - from local_mode import switch_to_local - switch_to_local(ctx) + from local_mode import choose_engine + choose_engine(ctx) return # Already configured — nothing to do diff --git a/stacklets/ai/local_mode.py b/stacklets/ai/local_mode.py index af6a6c6b..d3954213 100644 --- a/stacklets/ai/local_mode.py +++ b/stacklets/ai/local_mode.py @@ -1,47 +1,50 @@ -"""Ask before installing the ai stacklet moves a remote AI setup onto this Mac. - -`stack ai connect ` points the stack at a server elsewhere. Installing -the ai stacklet after that, the first `stack up ai`, 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. An installed stacklet never asks: its engine is chosen -with `stack ai connect`, and `stack up ai` only starts it. +"""Ask what installing the ai stacklet does to an AI server set with `connect`. + +`stack ai connect ` points the stack at a server the stack does not +manage: on another machine, a hosted provider, or an app on this Mac. +Installing the ai stacklet after that, the first `stack up ai`, can mean +two setups. Yes moves chat and voice onto the engine this stacklet +installs. No keeps that server for chat and installs only speech-to-text +and text-to-speech here, which is how a Mac running its own AI app gets +voice. An installed stacklet never asks: its engine is chosen with +`stack ai connect`, and `stack up ai` only starts it. """ -import sys - -from stack.hooks import Cancelled +from stack.ai.probe import on_this_mac 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. +def describe(url: str) -> str: + """The configured server the way the admin knows it.""" + if on_this_mac(url): + return f"an AI server on this Mac that the stack does not manage ({url})" + return f"a remote AI server ({url})" + - 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. +def choose_engine(ctx) -> None: + """Switch `[ai]` to the local engine on a yes; keep the server on a no. + + Does nothing unless the provider is a server set with `connect`. The + caller has made sure there is a terminal to ask in. """ 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("Installing the ai stacklet switches the stack to local mode: the") - out("AI engine and speech-to-text on this Mac take over from it.") + warn(f"The stack uses {describe(url)}.") + out("Yes installs the AI engine on this Mac and moves chat and voice to it.") + out("No keeps that server for chat and installs only speech-to-text and") + out("text-to-speech here.") nl() - if not sys.stdin.isatty(): - 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 Cancelled(f"Cancelled. The stack still uses {url}.") + if not confirm("Use the AI engine on this Mac instead?", default=False): + dim(f"Chat stays on {url}.") + nl() + return ctx.cfg("provider", "managed") ctx.cfg("openai_url", LOCAL_URL) diff --git a/tests/unit/framework/test_ai_probe.py b/tests/unit/framework/test_ai_probe.py index f166291d..bc4861fd 100644 --- a/tests/unit/framework/test_ai_probe.py +++ b/tests/unit/framework/test_ai_probe.py @@ -8,7 +8,7 @@ import pytest -from stack.ai.probe import stays_home, transcribes +from stack.ai.probe import on_this_mac, stays_home, transcribes class TestTranscription: @@ -47,3 +47,23 @@ def test_addresses_outside_it(self, url): def test_a_name_that_does_not_resolve_is_not_assumed_home(self): assert not stays_home("https://nowhere.invalid/v1") + + +class TestThisMac: + """An AI server on this Mac that the stack does not manage, such as + an AI app, is described as on this Mac, not as remote.""" + + @pytest.mark.parametrize("url", [ + "http://localhost:8888/v1", + "http://127.0.0.1:8000/v1", + "http://[::1]:8000/v1", + ]) + def test_loopback_addresses(self, url): + assert on_this_mac(url) + + @pytest.mark.parametrize("url", [ + "http://192.168.1.20:11434/v1", + "https://ai.example.test/v1", + ]) + def test_other_machines(self, url): + assert not on_this_mac(url) diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index 1ee694a8..64482e01 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -20,7 +20,6 @@ import pytest -from stack.hooks import Cancelled _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent sys.path.insert(0, str(_REPO_ROOT / "lib")) @@ -191,29 +190,37 @@ def _no_question_expected(prompt): raise AssertionError(f"asked {prompt!r}") -class TestInstallingOverARemoteEndpoint: - """After `stack ai connect `, installing 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.""" +class TestInstallingOverAServerSetWithConnect: + """After `stack ai connect `, installing the ai stacklet can mean + two setups. Yes moves chat and voice onto the engine installed here. + No keeps that server for chat and installs only the voice services, + the setup of a Mac that serves its own AI app.""" @pytest.fixture(autouse=True) def interactive_setup(self, monkeypatch): monkeypatch.delenv("STACK_SETUP_CONFIRMED", raising=False) - def test_the_question_names_the_endpoint(self, monkeypatch, capsys): + def test_the_question_names_a_remote_server(self, monkeypatch, capsys): _answer(monkeypatch, "n") - with pytest.raises(Cancelled): - on_configure.run(FakeCtx(**REMOTE)) + on_configure.run(FakeCtx(**REMOTE)) - assert "remote AI endpoint (https://ai.example.test/v1)" in capsys.readouterr().out + assert "a remote AI server (https://ai.example.test/v1)" in capsys.readouterr().out - def test_no_keeps_the_remote_setup(self, monkeypatch): + def test_a_server_on_this_mac_is_not_called_remote(self, monkeypatch, capsys): + _answer(monkeypatch, "n") + on_configure.run(FakeCtx(**{**REMOTE, "openai_url": "http://localhost:8888/v1"})) + + out = capsys.readouterr().out + assert "on this Mac that the stack does not manage (http://localhost:8888/v1)" in out + assert "remote" not in out + + def test_no_keeps_the_server_and_goes_on_to_install_voice(self, monkeypatch): + """The install that follows skips the engine for a server set + with connect, and installs speech-to-text and text-to-speech.""" _answer(monkeypatch, "n") ctx = FakeCtx(**REMOTE) - with pytest.raises(Cancelled, match="still uses"): - on_configure.run(ctx) + on_configure.run(ctx) # does not cancel assert ctx._cfg == REMOTE @@ -221,8 +228,7 @@ def test_no_is_the_default(self, monkeypatch): _answer(monkeypatch, "") ctx = FakeCtx(**REMOTE) - with pytest.raises(Cancelled): - on_configure.run(ctx) + on_configure.run(ctx) assert ctx._cfg == REMOTE @@ -239,9 +245,9 @@ def test_yes_switches_chat_and_voice_to_this_mac_together(self, monkeypatch): 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 installing the stacklet - must not move the family's AI onto this Mac.""" + def test_without_a_terminal_nothing_is_installed(self, monkeypatch): + """Nobody to answer: an agent or a script installing the stacklet + must not change the family's AI setup.""" monkeypatch.setattr(sys.stdin, "isatty", lambda: False) ctx = FakeCtx(**REMOTE) From d03cd00b2ec36adf03c900b679ddf477aeed9d59 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 15:12:29 +0200 Subject: [PATCH 3/5] fix(stack): check what a remote stacklet still runs on this Mac A stacklet served by another machine ran only its checks marked `remote`. With chat on a server elsewhere and voice from the ai stacklet here, a stopped Whisper or speech container was never reported by `./stack list` or `./stack status`. When the stacklet's containers run here too, all its checks apply. The row shows `localhost` instead of `remote` when the server is on this Mac and the stack does not manage it, since no other machine is involved. --- docs/admin-guide.md | 2 +- docs/agent/ops.md | 2 +- docs/stack-reference.md | 8 ++++ lib/stack/prompt.py | 22 +++++++--- lib/stack/stack.py | 9 ++-- tests/unit/stacklets/test_ai_remote_list.py | 46 +++++++++++++++++++++ 6 files changed, 79 insertions(+), 10 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index f9fc1eb9..dcea1bb9 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -515,7 +515,7 @@ The command checks that the server answers, picks a model it has (`--model ` **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.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet after `connect`, the first `./stack up ai`, asks whether its engine takes over. Yes moves chat and voice to this Mac; no keeps your server for chat and installs only speech-to-text and text-to-speech here. That is also the setup for an AI app you run on this Mac yourself: `./stack ai connect localhost:`, then `./stack up ai`. Once installed, `./stack up ai` keeps the server you chose. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. +**Switching back.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet after `connect`, the first `./stack up ai`, asks whether its engine takes over. Yes moves chat and voice to this Mac; no keeps your server for chat and installs only speech-to-text and text-to-speech here. That is also the setup for an AI app you run on this Mac yourself: `./stack ai connect localhost:`, then `./stack up ai`; `./stack list` shows the ai row as `localhost`. Once installed, `./stack up ai` keeps the server you chose. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. ### ChatAI (`chatai`) optional diff --git a/docs/agent/ops.md b/docs/agent/ops.md index 03866829..b6f09fdf 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -110,7 +110,7 @@ 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` keeps it; `stack ai connect local` switches back to this Mac. | +| `stack list` shows ai as `remote` or `localhost` | `[ai] provider` in `stack.toml` | The stack uses an AI server it does not manage (`stack ai connect`): another machine, or an app on this Mac (`localhost`). `stack up ai` keeps it; `stack ai connect local` switches 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", then never loads | Which address is open? | Port mode over the LAN IP: plain HTTP is not a secure context. On the server Mac open `http://localhost:42030`; other computers use the Element desktop app with `http://:42031`. | diff --git a/docs/stack-reference.md b/docs/stack-reference.md index c89fbec9..1ecc0f0f 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -258,6 +258,14 @@ them and then reports containers it was told to skip as broken. Any other value, including `"0"` or empty, leaves the check in place, so an accidentally exported variable cannot quietly disable monitoring. +A stacklet that can be served by another machine declares a top-level +`remote = "{template}"` in its manifest. When it renders to an address, +`stack list` shows the row as `remote` with that host, or `localhost` when +the address is this machine, meaning something on it that the stack does +not manage serves it. Only the checks marked `remote = true` probe that +server; the others probe this Mac, so they run only while the stacklet's +own containers run here as well. + ### Native Services (host stacklets) Host stacklets (`type = "host"`) declare native macOS services that run diff --git a/lib/stack/prompt.py b/lib/stack/prompt.py index bf4217ab..c82fa3bd 100644 --- a/lib/stack/prompt.py +++ b/lib/stack/prompt.py @@ -112,11 +112,24 @@ def stage_badge(s): return " " + " ".join(parts) if parts else "" +def _served_by(url: str) -> tuple[str, str]: + """The state word and detail of a row served from ``url``.""" + from urllib.parse import urlsplit + + from stack.ai.probe import on_this_mac + parts = urlsplit(url) + if on_this_mac(url): + port = f"port {parts.port}, " if parts.port else "" + return "localhost", f"{port}not managed by the stack" + return "remote", parts.netloc or url + + def status_list(stacklets): """Render a compact stacklet status table. - Seven states: online, remote, starting, degraded, failing, stopped, - available. Remote means another machine serves it. + Eight states: online, remote, localhost, starting, degraded, failing, + stopped, available. Remote means another machine serves it, localhost + that something on this machine the stack does not manage serves it. Degraded stacklets show health issue hints. """ if not stacklets: @@ -141,9 +154,8 @@ def status_list(stacklets): 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}") + state, where = _served_by(s["remote"]) + out(f" {GREEN}\u2713{RESET} {label}{' ' * pad} {TEAL}{state}{RESET} {DIM}{where}{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 c313c69f..70726a14 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -748,14 +748,17 @@ def list(self) -> dict: # 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. + # whether or not anything of it runs here. Only the checks + # marked `remote` apply, unless its containers run here as + # well: then the rest probe what they serve on this Mac. remote = self._render_template( s.get("manifest", {}).get("remote", ""), template_vars) if remote and not remote.startswith("{"): s["remote"] = remote + runs_here = is_set_up and s["online"] checks = [c for c in self._resolve_health_checks( - s.get("manifest", {}), template_vars) if c.get("remote")] + s.get("manifest", {}), template_vars) + if runs_here or 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 diff --git a/tests/unit/stacklets/test_ai_remote_list.py b/tests/unit/stacklets/test_ai_remote_list.py index 1221185b..c1e580f2 100644 --- a/tests/unit/stacklets/test_ai_remote_list.py +++ b/tests/unit/stacklets/test_ai_remote_list.py @@ -74,6 +74,42 @@ def test_the_local_engine_is_not_remote(self, instance): assert "remote" not in _ai_row(instance) +class TestVoiceOnThisMac: + """Chat on a server elsewhere, speech-to-text and text-to-speech from + the ai stacklet on this Mac. The row reports the server, and the + checks of what runs here still apply: a dead Whisper was never + reported, because only the checks marked `remote` ran.""" + + @pytest.fixture + def probed(self, monkeypatch): + """The addresses the listing checks, each answering. The checks + of this Mac's voice services probe fixed ports, which a test + cannot own.""" + urls = [] + monkeypatch.setattr(stack.docker, "check_health", + lambda url, headers=None: urls.append(url) or True) + return urls + + def test_what_runs_here_is_checked_too(self, instance, monkeypatch, probed): + _remote(instance, "http://localhost:8888/v1") + (instance.instance_dir / ".stack" / "ai.setup-done").touch() + monkeypatch.setattr(stack.docker, "project_states", lambda: {"ai": "running"}) + + row = _ai_row(instance) + + assert row["remote"] == "http://localhost:8888/v1" + assert "http://localhost:42062/" in probed + assert "http://localhost:42063/" in probed + assert "http://localhost:8888/v1/models" in probed + + def test_without_it_running_here_only_the_server_is_checked(self, instance, probed): + _remote(instance, "http://localhost:8888/v1") + + _ai_row(instance) + + assert probed == ["http://localhost:8888/v1/models"] + + class TestTheListing: def test_it_reads_remote_with_the_servers_host(self, capsys): @@ -84,3 +120,13 @@ def test_it_reads_remote_with_the_servers_host(self, capsys): assert "remote" in line assert "ai.example.test" in line assert "localhost" not in line + + def test_a_server_on_this_mac_reads_localhost(self, capsys): + """Not another machine: an app on this Mac the stack does not run.""" + status_list([{"id": "ai", "name": "AI", "remote": "http://localhost:8888/v1", + "enabled": True, "online": True}]) + + line = capsys.readouterr().out + assert "localhost" in line + assert "port 8888, not managed by the stack" in line + assert "remote" not in line From c2cd0e1473dfc5764c2f5d30d11240518cf3e632 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 15:34:43 +0200 Subject: [PATCH 4/5] feat(ai): choose the AI server with stack ai switch `./stack ai switch ` replaces `./stack ai connect`. The target is an address the stack does not manage (another machine, a hosted provider, or an AI app on this Mac such as `localhost:8888`), or `managed` for the engine the ai stacklet runs. `managed` runs `./stack up ai`, which installs oMLX when it is missing and starts it, then checks the engine and picks a model as for any other server. When the engine does not come up, `[ai]` stays as it was. Starting the ai stacklet with the stack managing the engine now installs a missing oMLX instead of stopping with a `stack destroy ai` recovery. --- docs/admin-guide.md | 15 +-- docs/agent/ops.md | 7 +- docs/stack-reference.md | 2 +- lib/stack/installer.py | 4 +- stack.example.toml | 2 +- stacklets/agent/hooks/on_start.py | 4 +- stacklets/agent/stacklet.toml | 2 +- stacklets/ai/cli/{connect.py => switch.py} | 114 ++++++++++++------ stacklets/ai/hooks/on_configure.py | 10 +- stacklets/ai/hooks/on_install.py | 4 +- stacklets/ai/hooks/on_start.py | 46 +++---- stacklets/ai/local_mode.py | 27 +++-- stacklets/ai/stacklet.toml | 2 +- tests/unit/framework/test_ai_probe.py | 2 +- tests/unit/stacklets/test_agent_ai_server.py | 2 +- tests/unit/stacklets/test_ai_remote_list.py | 2 +- tests/unit/stacklets/test_ai_start_guard.py | 40 ++++-- .../{test_ai_connect.py => test_ai_switch.py} | 92 ++++++++------ tests/unit/stacklets/test_ai_whisper_bind.py | 2 +- tests/unit/stacklets/test_voice_routing.py | 2 +- 20 files changed, 227 insertions(+), 154 deletions(-) rename stacklets/ai/cli/{connect.py => switch.py} (67%) rename tests/unit/stacklets/{test_ai_connect.py => test_ai_switch.py} (74%) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index dcea1bb9..2d0fc162 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -483,7 +483,7 @@ Useful commands: ```bash ./stack ai models ./stack ai download -./stack ai connect +./stack ai switch ./stack setup ai ``` @@ -494,15 +494,16 @@ To switch LLM models: edit `[ai] default` in `stack.toml` (alternatives are list 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 +./stack ai switch 192.168.1.20:11434 # Ollama on another machine +./stack ai switch https://api.example.com --key sk-... # a hosted provider +./stack ai switch localhost:8888 # an AI app you run on this Mac +./stack ai switch managed # the engine the stack runs 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 +./stack ai switch 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. @@ -515,7 +516,7 @@ The command checks that the server answers, picks a model it has (`--model ` **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.** `./stack ai connect local` switches the stack to the engine on this Mac. Installing the ai stacklet after `connect`, the first `./stack up ai`, asks whether its engine takes over. Yes moves chat and voice to this Mac; no keeps your server for chat and installs only speech-to-text and text-to-speech here. That is also the setup for an AI app you run on this Mac yourself: `./stack ai connect localhost:`, then `./stack up ai`; `./stack list` shows the ai row as `localhost`. Once installed, `./stack up ai` keeps the server you chose. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. +**The engine the stack manages.** `./stack ai switch managed` moves the stack to the engine the ai stacklet runs on this Mac. It runs `./stack up ai`, which installs oMLX when it is missing, and keeps the previous server when that fails. Installing the ai stacklet after `switch`, the first `./stack up ai`, asks whether its engine takes over. Yes moves chat and voice to this Mac; no keeps your server for chat and installs only speech-to-text and text-to-speech here. That is also the setup for an AI app you run on this Mac yourself: `./stack ai switch localhost:`, then `./stack up ai`; `./stack list` shows the ai row as `localhost`. Once installed, `./stack up ai` keeps the server you chose. After switching to a remote server, `./stack down ai` stops a local engine that is still running and frees its memory. ### ChatAI (`chatai`) optional @@ -726,7 +727,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 ai switch # use another AI server; `managed` for the engine the stack runs ./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 b6f09fdf..c03de101 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -41,7 +41,8 @@ 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 ai switch ` | yes | no | points `[ai]` at an AI server the stack does not manage; checks it, installs nothing, restarts nothing. Warns when the address is outside the home network | +| `./stack ai switch managed` | yes | no | moves `[ai]` to the engine the stack runs here: runs `stack up ai`, installing oMLX when missing; keeps the previous server if that fails | | `./stack setup ai` | yes | no | re-runs AI install (model swap path) | | `./stack infra dns-token` | yes | no | stores or replaces the DNS provider API token (HTTPS in domain mode); `./stack up infra` applies it | @@ -110,8 +111,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` or `localhost` | `[ai] provider` in `stack.toml` | The stack uses an AI server it does not manage (`stack ai connect`): another machine, or an app on this Mac (`localhost`). `stack up ai` keeps it; `stack ai connect local` switches 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. | +| `stack list` shows ai as `remote` or `localhost` | `[ai] provider` in `stack.toml` | The stack uses an AI server it does not manage (`stack ai switch`): another machine, or an app on this Mac (`localhost`). `stack up ai` keeps it; `stack ai switch managed` switches back to this Mac. | +| Voice messages fail on a remote AI setup | `./stack ai switch ` 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", then never loads | Which address is open? | Port mode over the LAN IP: plain HTTP is not a secure context. On the server Mac open `http://localhost:42030`; other computers use the Element desktop app with `http://:42031`. | | 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/docs/stack-reference.md b/docs/stack-reference.md index 1ecc0f0f..b784a6ba 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -822,7 +822,7 @@ 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]`. +`stack ai switch` uses it to write `[ai]`. --- diff --git a/lib/stack/installer.py b/lib/stack/installer.py index 9b9f6cd4..b0db6ec4 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -421,7 +421,7 @@ def show_existing_config(): heading("To make changes") 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}.") + out(f"To use another AI server, run {TEAL}stack ai switch {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.") @@ -701,7 +701,7 @@ def wizard(): ("up photos", "Private photo library"), ("up docs", "Document archive with OCR"), ("up ai", "Local AI engine"), - ("ai connect", "AI on another machine or a hosted provider"), + ("ai switch", "AI on another machine or a hosted provider"), ("up code", "Private git server"), ("up memory", "Family wiki and curated knowledge"), ] diff --git a/stack.example.toml b/stack.example.toml index fb55e84f..a2ff2d56 100644 --- a/stack.example.toml +++ b/stack.example.toml @@ -73,7 +73,7 @@ schedule = "0 0 3 * * *" # 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 +# `./stack ai switch ` 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 diff --git a/stacklets/agent/hooks/on_start.py b/stacklets/agent/hooks/on_start.py index b7244def..1248c58a 100644 --- a/stacklets/agent/hooks/on_start.py +++ b/stacklets/agent/hooks/on_start.py @@ -33,7 +33,7 @@ def run(ctx): 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 '." + "elsewhere with './stack ai switch '." ) url = _host_url(url) @@ -46,4 +46,4 @@ def run(ctx): 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 '.") + f"Pick one it has with './stack ai switch {url} --model '.") diff --git a/stacklets/agent/stacklet.toml b/stacklets/agent/stacklet.toml index c291874e..9c057493 100644 --- a/stacklets/agent/stacklet.toml +++ b/stacklets/agent/stacklet.toml @@ -5,7 +5,7 @@ # 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 # AI server configured in stack.toml [ai]: the local ai stacklet, another -# machine on the network, or a hosted provider (`stack ai connect `). +# machine on the network, or a hosted provider (`stack ai switch `). # # 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). diff --git a/stacklets/ai/cli/connect.py b/stacklets/ai/cli/switch.py similarity index 67% rename from stacklets/ai/cli/connect.py rename to stacklets/ai/cli/switch.py index 83e05b7b..197830c8 100644 --- a/stacklets/ai/cli/connect.py +++ b/stacklets/ai/cli/switch.py @@ -1,11 +1,15 @@ -"""stack ai connect — choose the AI server the stack uses. +"""stack ai switch — 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. +whoever serves it: the engine the stack manages on this Mac, an AI app +on this Mac, another machine on the network, or a hosted provider. + +`managed` is the engine the stack runs: it installs oMLX when it is +missing and starts it, through `stack up ai`, and gives up with `[ai]` +as it was when that fails. Any other target is an address the stack +does not manage: nothing is installed. Either way the command checks the +server answers, picks a model it actually has, and writes `[ai]` in +stack.toml. 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, @@ -17,15 +21,18 @@ 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 + stack ai switch managed + stack ai switch localhost:8888 + stack ai switch 192.168.1.20:11434 + stack ai switch https://api.example.com --key sk-... --model gpt-4.1-mini + stack ai switch 192.168.1.20:8000 --whisper 192.168.1.20:42062 """ -HELP = "Choose the AI server: another machine, a hosted provider, or local" +HELP = "Choose the AI server: the engine the stack manages, or any other address" import argparse +import os +import subprocess import sys import tomllib from pathlib import Path @@ -34,18 +41,21 @@ # 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" +sys.path.insert(0, str(Path(__file__).parent.parent)) +from local_mode import MANAGED_ENGINE # noqa: E402 + +# The target that names the engine the stack manages. +MANAGED = "managed" # `--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 = argparse.ArgumentParser(prog="stack ai switch", description=HELP) + p.add_argument("target", help="'managed' for the engine the stack runs on " + "this Mac, or the address of an " + "OpenAI-compatible server") 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="", @@ -88,11 +98,11 @@ def _speech_models(models: list[str]) -> list[str]: 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: +def _speech_server(opts, managed: 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 + An explicit `--whisper` decides. Otherwise `managed` brings the + stack's Whisper back with its 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. """ @@ -101,8 +111,8 @@ def _speech_server(opts, local: bool, current: str) -> str: if opts.whisper: from backend import normalize_url return normalize_url(opts.whisper) - if local: - return _LOCAL_WHISPER + if managed: + return MANAGED_ENGINE["whisper_url"] return current @@ -128,30 +138,57 @@ def _running() -> set[str]: if state in ("running", "starting", "failing")} +def _run_the_engine(config: dict, ai: dict) -> dict | None: + """Hand `[ai]` to the engine the stack manages and bring it up. + + `stack up ai` installs whatever is missing (the stacklet, or oMLX on + a Mac that ran only the voice services) and starts it. When it fails, + `[ai]` goes back to what it was, so the stack keeps a server that + answers. Returns an error, or None when the engine is up. + """ + set_cfg = config["set_cfg"] + before = {k: ai.get(k, "") for k in MANAGED_ENGINE} + for key, value in MANAGED_ENGINE.items(): + set_cfg("ai", key, value) + if _stack_up_ai(config) == 0: + return None + for key, value in before.items(): + set_cfg("ai", key, value) + kept = before["openai_url"] or "no AI server" + return {"error": f"Bringing up the ai stacklet failed, so the stack " + f"still uses {kept}. Its output above says why."} + + +def _stack_up_ai(config: dict) -> int: + """`stack up ai` on this instance, its output to stderr so that a + `--json` answer stays the only thing on stdout.""" + stack = Path(config["repo_root"]) / "stack" + env = {**os.environ, "STACK_DIR": config["instance_dir"]} + return subprocess.run([str(stack), "up", "ai"], env=env, + stdout=sys.stderr).returncode + + 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 + managed = opts.target == MANAGED + if managed and (failed := _run_the_engine(config, ai)): + return failed + url = MANAGED_ENGINE["openai_url"] if managed else normalize_url(opts.target) + key = _NO_KEY if managed 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'."} + if managed: + return {"error": f"The engine the stack manages is not answering " + f"at {url}. './stack logs ai' may say why."} return {"error": f"Nothing answers at {url}. Check the address " "and that the server is running."} @@ -161,7 +198,7 @@ def run(args, stacklet, config): if "error" in chosen: return chosen - whisper = _speech_server(opts, local, ai.get("whisper_url", "")) + whisper = _speech_server(opts, managed, 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.", @@ -189,7 +226,7 @@ def run(args, stacklet, config): "speech server.") set_cfg = config["set_cfg"] - set_cfg("ai", "provider", "managed" if local else "external") + set_cfg("ai", "provider", "managed" if managed else "external") set_cfg("ai", "openai_url", url) set_cfg("ai", "openai_key", key or _NO_KEY) if chosen["model"]: @@ -209,9 +246,10 @@ def run(args, stacklet, config): "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.") + if not managed and "ai" in running and ai.get("provider") == "managed": + notes.append("The engine the stack manages is still running and no " + "longer used. './stack down ai' stops it and frees its " + "memory.") result = { "openai_url": url, diff --git a/stacklets/ai/hooks/on_configure.py b/stacklets/ai/hooks/on_configure.py index 4f051f11..5664b902 100644 --- a/stacklets/ai/hooks/on_configure.py +++ b/stacklets/ai/hooks/on_configure.py @@ -1,10 +1,10 @@ """AI stacklet first-run configuration — confirm the local AI engine. `stack up ai` installs and manages oMLX, Whisper and TTS on this Mac. -An AI server elsewhere is `stack ai connect `, which installs +An AI server elsewhere is `stack ai switch `, which installs nothing here; after that, installing this stacklet asks whether the engine here takes over or only voice is installed. Once installed, the -engine is chosen with `stack ai connect`, and `stack up ai` only starts it. +engine is chosen with `stack ai switch`, and `stack up ai` only starts it. Runs before on_install. Only fires on first 'stack up ai'. Skipped when STACK_SETUP_CONFIRMED=1 (installer already confirmed). @@ -49,7 +49,7 @@ def run(ctx): provider = ctx.cfg("provider", default="") - # A server set with `stack ai connect`: the admin says whether the + # A server set with `stack ai switch`: the admin says whether the # engine here takes over, or only the voice services are installed. if provider == "external": sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -70,11 +70,11 @@ def run(ctx): nl() # `stack up ai` is the local engine. A server elsewhere is - # `stack ai connect`, which installs nothing on this Mac. + # `stack ai switch`, which installs nothing on this Mac. if not confirm("Set up oMLX?", default=True): nl() out("To use an AI server on another machine or a hosted provider,") - out("run './stack ai connect ' instead. Nothing was installed.") + out("run './stack ai switch ' instead. Nothing was installed.") nl() raise Cancelled("Cancelled. The local AI engine was not set up.") diff --git a/stacklets/ai/hooks/on_install.py b/stacklets/ai/hooks/on_install.py index 5e3f00c8..63cf3a72 100644 --- a/stacklets/ai/hooks/on_install.py +++ b/stacklets/ai/hooks/on_install.py @@ -49,7 +49,7 @@ def run(ctx): # ── oMLX (managed provider only) ──────────────────────────────── if provider == "managed": - _install_omlx(ctx, state_dir) + install_omlx(ctx, state_dir) # ── Whisper ───────────────────────────────────────────────────── # STACK_AI_NO_VOICE=1 is the local-dev opt-out: skip the whisper build + @@ -102,7 +102,7 @@ def _install_omlx_formula(ctx) -> None: ctx.shell_live("brew install omlx --with-grammar") -def _install_omlx(ctx, state_dir: Path): +def install_omlx(ctx, state_dir: Path): section("oMLX", "MLX inference (Metal GPU)") if shutil.which("brew") is None: diff --git a/stacklets/ai/hooks/on_start.py b/stacklets/ai/hooks/on_start.py index 919ea8a9..cef2409b 100644 --- a/stacklets/ai/hooks/on_start.py +++ b/stacklets/ai/hooks/on_start.py @@ -37,22 +37,11 @@ def run(ctx): raise RuntimeError("AI provider not configured") if provider == "managed" and shutil.which("omlx") is None: - # Setup ran once and left a marker, so the install hook will not run - # again on its own. If the binary has since gone -- a Python upgrade - # that broke its virtualenv, a brew cleanup, a migrated machine -- - # nothing notices: the containers start, `stack up` reports success, - # and the only symptom is an LLM health check failing with no stated - # cause. Say what is actually wrong and how to undo it, using the - # same two-command recovery this hook already teaches above. - nl() - warn("oMLX is not installed (no `omlx` command found).") - out("The AI engine was set up before but is no longer on this Mac.") - out("Reinstall it with:") - nl() - out(f" {TEAL}stack destroy ai{RESET} (keeps your downloaded models)") - out(f" {TEAL}stack up ai{RESET} (reinstalls oMLX)") - nl() - raise RuntimeError("oMLX missing for managed provider") + # The stack manages the engine, so a missing one is installed: + # after `stack ai switch managed` on a Mac that has only the voice + # services, or when a Python upgrade, a brew cleanup or a migrated + # machine took the binary away after setup. + _install_hook().install_omlx(ctx, STATE_DIR) if provider == "external": url = ctx.cfg("openai_url", default="") @@ -64,11 +53,11 @@ def run(ctx): out(f" {TEAL}stack destroy ai && stack up ai{RESET}") nl() raise RuntimeError("Missing openai_url for external provider") - # The engine is chosen with `stack ai connect`, never by starting + # The engine is chosen with `stack ai switch`, never by starting # the stacklet: text elsewhere with voice on this Mac is a setup # an admin chose, and `up` only starts what the stacklet runs. dim(f"Chat uses the AI server at {url}. " - "'./stack ai connect local' switches to the engine on this Mac.") + "'./stack ai switch managed' moves it to the engine the stack runs.") if provider == "managed": _start_local_engine(ctx) @@ -148,15 +137,7 @@ def _reconcile_whisper(ctx): # hook's contract, keeping the plist current is a courtesy. if getattr(ctx, "stack", None) is None: return - import importlib.util - from pathlib import Path - - hooks_dir = Path(__file__).resolve().parent - spec = importlib.util.spec_from_file_location( - "hook.ai_on_install", hooks_dir / "on_install.py") - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - + mod = _install_hook() data_dir = Path(ctx.stack.data) whisper_bin = data_dir / "ai" / "whisper.cpp" / "build" / "bin" / "whisper-server" model_path = data_dir / "ai" / "whisper-models" / mod.WHISPER_MODEL @@ -164,3 +145,14 @@ def _reconcile_whisper(ctx): return # whisper never installed here (or opted out) — nothing to reconcile state_dir = Path(__file__).resolve().parent.parent / ".state" mod._setup_whisper_launchd(ctx, data_dir, whisper_bin, model_path, state_dir) + + +def _install_hook(): + """The install hook, for the parts of setup a start repeats.""" + import importlib.util + + spec = importlib.util.spec_from_file_location( + "hook.ai_on_install", Path(__file__).resolve().parent / "on_install.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod diff --git a/stacklets/ai/local_mode.py b/stacklets/ai/local_mode.py index d3954213..34647c40 100644 --- a/stacklets/ai/local_mode.py +++ b/stacklets/ai/local_mode.py @@ -1,20 +1,26 @@ -"""Ask what installing the ai stacklet does to an AI server set with `connect`. +"""Ask what installing the ai stacklet does to an AI server set with `switch`. -`stack ai connect ` points the stack at a server the stack does not +`stack ai switch ` points the stack at a server the stack does not manage: on another machine, a hosted provider, or an app on this Mac. Installing the ai stacklet after that, the first `stack up ai`, can mean two setups. Yes moves chat and voice onto the engine this stacklet installs. No keeps that server for chat and installs only speech-to-text and text-to-speech here, which is how a Mac running its own AI app gets voice. An installed stacklet never asks: its engine is chosen with -`stack ai connect`, and `stack up ai` only starts it. +`stack ai switch`, and `stack up ai` only starts it. """ from stack.ai.probe import on_this_mac from stack.prompt import confirm, dim, nl, out, warn -LOCAL_URL = "http://localhost:42060/v1" -LOCAL_WHISPER = "http://localhost:42062/v1" +# `[ai]` for the engine the stack manages on this Mac, and its Whisper. +MANAGED_ENGINE = { + "provider": "managed", + "openai_url": "http://localhost:42060/v1", + "openai_key": "local", + "whisper_url": "http://localhost:42062/v1", + "whisper_key": "", +} def describe(url: str) -> str: @@ -27,7 +33,7 @@ def describe(url: str) -> str: def choose_engine(ctx) -> None: """Switch `[ai]` to the local engine on a yes; keep the server on a no. - Does nothing unless the provider is a server set with `connect`. The + Does nothing unless the provider is a server set with `switch`. The caller has made sure there is a terminal to ask in. """ if ctx.cfg("provider", default="") != "external": @@ -46,14 +52,11 @@ def choose_engine(ctx) -> None: nl() return - 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", "") + for key, value in MANAGED_ENGINE.items(): + ctx.cfg(key, value) 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 '.") + "one with './stack ai switch managed --model '.") nl() diff --git a/stacklets/ai/stacklet.toml b/stacklets/ai/stacklet.toml index 35baf2fd..553adb28 100644 --- a/stacklets/ai/stacklet.toml +++ b/stacklets/ai/stacklet.toml @@ -16,7 +16,7 @@ category = "ai" type = "host" port = 42060 -# Served by another machine after `stack ai connect `: `stack list` +# Served by another machine after `stack ai switch `: `stack list` # then shows that server, and only the checks marked `remote` run. remote = "{ai_remote_url}" diff --git a/tests/unit/framework/test_ai_probe.py b/tests/unit/framework/test_ai_probe.py index bc4861fd..a136a7fc 100644 --- a/tests/unit/framework/test_ai_probe.py +++ b/tests/unit/framework/test_ai_probe.py @@ -1,6 +1,6 @@ """What the stack can learn about an AI server before using it. -`stack ai connect` asks two things beyond "does it answer": whether the +`stack ai switch` 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. diff --git a/tests/unit/stacklets/test_agent_ai_server.py b/tests/unit/stacklets/test_agent_ai_server.py index 435dee0c..1a1a561d 100644 --- a/tests/unit/stacklets/test_agent_ai_server.py +++ b/tests/unit/stacklets/test_agent_ai_server.py @@ -47,7 +47,7 @@ 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) + assert "stack ai switch" in str(e.value) def test_a_server_that_answers_starts_quietly(httpserver, capsys): diff --git a/tests/unit/stacklets/test_ai_remote_list.py b/tests/unit/stacklets/test_ai_remote_list.py index c1e580f2..7d1df3a0 100644 --- a/tests/unit/stacklets/test_ai_remote_list.py +++ b/tests/unit/stacklets/test_ai_remote_list.py @@ -1,6 +1,6 @@ """`stack list` when the AI runs on another machine. -After `stack ai connect ` the family's AI is served elsewhere. The +After `stack ai switch ` 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 diff --git a/tests/unit/stacklets/test_ai_start_guard.py b/tests/unit/stacklets/test_ai_start_guard.py index 64482e01..140153d4 100644 --- a/tests/unit/stacklets/test_ai_start_guard.py +++ b/tests/unit/stacklets/test_ai_start_guard.py @@ -7,7 +7,8 @@ that reports itself set up and starts its containers happily, with a failing LLM health check as the only clue. -`stack up ai` now says what is wrong before starting anything. +`stack up ai` installs the engine again when the stack manages it, and +otherwise leaves the AI server the admin chose alone. """ from __future__ import annotations @@ -78,12 +79,31 @@ def shell(self, cmd): self.shell_calls.append(cmd) +class NoEngineCtx(FakeCtx): + """A Mac where Homebrew has no oMLX.""" + + def shell(self, cmd): + super().shell(cmd) + if cmd == "brew list omlx": + raise RuntimeError("Error: No such keg") + + def shell_live(self, cmd): + self.shell_calls.append(cmd) + + class TestManagedProviderNeedsItsEngine: - def test_a_missing_engine_stops_the_start(self, monkeypatch): - monkeypatch.setattr(on_start.shutil, "which", lambda _cmd: None) - with pytest.raises(RuntimeError, match="oMLX missing"): - on_start.run(FakeCtx(provider="managed")) + def test_a_missing_engine_is_installed(self, monkeypatch): + """After `stack ai switch managed` on a Mac with only the voice + services, or a brew cleanup that took the binary: the stack + manages the engine, so it installs it rather than refusing.""" + monkeypatch.setattr(on_start.shutil, "which", + lambda cmd: "/opt/homebrew/bin/brew" if cmd == "brew" else None) + ctx = NoEngineCtx(provider="managed", openai_url="http://127.0.0.1:9/v1") + + on_start.run(ctx) + + assert "brew install omlx --with-grammar" in ctx.shell_calls def test_an_installed_engine_starts_normally(self, monkeypatch): monkeypatch.setattr(on_start.shutil, "which", @@ -149,7 +169,7 @@ def _answer(monkeypatch, answer): class TestAnInstalledStackletKeepsItsEngine: """Chat on an AI server elsewhere with voice on this Mac is a setup an - admin chooses with `stack ai connect`. Starting the installed stacklet + admin chooses with `stack ai switch`. Starting the installed stacklet starts what it runs and leaves that choice alone: a production Mac serving oMLX as an app, with the stacklet for speech, was asked on every `stack up ai` to hand its engine over.""" @@ -183,15 +203,15 @@ def test_it_names_the_command_that_switches_the_engine(self, capsys): out = capsys.readouterr().out assert "https://ai.example.test/v1" in out - assert "stack ai connect local" in out + assert "stack ai switch managed" in out def _no_question_expected(prompt): raise AssertionError(f"asked {prompt!r}") -class TestInstallingOverAServerSetWithConnect: - """After `stack ai connect `, installing the ai stacklet can mean +class TestInstallingOverAServerSetWithSwitch: + """After `stack ai switch `, installing the ai stacklet can mean two setups. Yes moves chat and voice onto the engine installed here. No keeps that server for chat and installs only the voice services, the setup of a Mac that serves its own AI app.""" @@ -216,7 +236,7 @@ def test_a_server_on_this_mac_is_not_called_remote(self, monkeypatch, capsys): def test_no_keeps_the_server_and_goes_on_to_install_voice(self, monkeypatch): """The install that follows skips the engine for a server set - with connect, and installs speech-to-text and text-to-speech.""" + with switch, and installs speech-to-text and text-to-speech.""" _answer(monkeypatch, "n") ctx = FakeCtx(**REMOTE) diff --git a/tests/unit/stacklets/test_ai_connect.py b/tests/unit/stacklets/test_ai_switch.py similarity index 74% rename from tests/unit/stacklets/test_ai_connect.py rename to tests/unit/stacklets/test_ai_switch.py index 4d8dde58..654fe17b 100644 --- a/tests/unit/stacklets/test_ai_connect.py +++ b/tests/unit/stacklets/test_ai_switch.py @@ -1,11 +1,12 @@ -"""`stack ai connect `: choose the AI server the stack uses. +"""`stack ai switch `: 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. +A household may run its models on another machine on the network, in an +AI app on the server Mac, or with a hosted provider. Pointing the stack +there is one command, and it must not need the ai stacklet installed: +that is the whole point. `stack ai switch managed` moves it to the +engine the stack runs on this Mac. -The command runs through the same path as `./stack ai connect`, against +The command runs through the same path as `./stack ai switch`, 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 @@ -82,8 +83,8 @@ def ai_server(httpserver): return _serve(httpserver, ["llama3.1:8b", "whisper-large-v3-turbo"]) -def _connect(instance, *args): - return instance.run_cli_command("ai", "connect", list(args)) +def _switch(instance, *args): + return instance.run_cli_command("ai", "switch", list(args)) def _ai(instance): @@ -96,7 +97,7 @@ def test_the_address_is_written_the_way_the_clients_need_it(self, instance, ai_s """People write `host:port`; the clients need a scheme and `/v1`.""" address = ai_server.url_for("/").removeprefix("http://").rstrip("/") - result = _connect(instance, address) + result = _switch(instance, address) assert "error" not in result, result assert _ai(instance)["openai_url"] == ai_server.url_for("/v1") @@ -106,18 +107,18 @@ def test_the_only_chat_model_the_server_has_becomes_the_default(self, instance, """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")) + _switch(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")) + _switch(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")) + _switch(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 @@ -128,26 +129,36 @@ def test_it_names_the_running_stacklets_to_restart(self, instance, ai_server): 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")) + result = _switch(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")) + result = _switch(instance, ai_server.url_for("/v1")) assert "warnings" not in result -class TestTheLocalEngineLeftRunning: +class TestTheManagedEngineLeftRunning: - def test_switching_away_says_the_local_engine_can_be_stopped( + def test_switching_away_says_the_managed_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")) + """oMLX keeps using memory for nothing.""" + instance._set_cfg("ai", "provider", "managed") + + result = _switch(instance, ai_server.url_for("/v1")) assert any("./stack down ai" in n for n in result["notes"]) + def test_voice_on_this_mac_is_not_called_unused(self, instance, ai_server): + """Chat moves between servers while the ai stacklet keeps serving + speech here: nothing of it is idle.""" + instance._set_cfg("ai", "provider", "external") + + result = _switch(instance, ai_server.url_for("/v1")) + + assert not any("./stack down ai" in n for n in result.get("notes", [])) + class TestChoosingAModel: @@ -157,7 +168,7 @@ def many_models(self, httpserver): 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")) + result = _switch(instance, many_models.url_for("/v1")) assert "--model" in result["error"] assert result["models"] == ["llama3.1:8b", "qwen3:14b"] @@ -166,12 +177,12 @@ def test_several_models_and_no_choice_asks_for_one_and_writes_nothing( 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") + _switch(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") + result = _switch(instance, many_models.url_for("/v1"), "--model", "gpt-4.1") assert "gpt-4.1" in result["error"] assert _ai(instance)["openai_url"] == "" @@ -180,7 +191,7 @@ def test_a_chosen_model_the_server_lacks_is_refused(self, instance, many_models) class TestServersThatDoNotAnswer: def test_nothing_listening_writes_nothing(self, instance): - result = _connect(instance, "http://127.0.0.1:9") + result = _switch(instance, "http://127.0.0.1:9") assert "Nothing answers" in result["error"] assert _ai(instance)["openai_url"] == "" @@ -188,7 +199,7 @@ def test_nothing_listening_writes_nothing(self, instance): 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")) + result = _switch(instance, httpserver.url_for("/v1")) assert "--key" in result["error"] @@ -197,18 +208,25 @@ def test_the_key_is_sent_and_stored(self, instance, httpserver): "/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") + _switch(instance, httpserver.url_for("/v1"), "--key", "sk-test") assert _ai(instance)["openai_key"] == "sk-test" -class TestBackToTheLocalEngine: +class TestTheManagedEngine: + """`managed` brings up the engine the stack runs, through `stack up ai`, + which installs what is missing. The real command runs here: without a + terminal it stops at the install question, before anything is + installed, which is the failure this pins.""" - def test_local_without_the_engine_installed_says_how_to_get_it(self, instance): - result = _connect(instance, "local") + def test_when_it_cannot_come_up_the_stack_keeps_its_server(self, instance, ai_server): + _switch(instance, ai_server.url_for("/v1")) + before = dict(_ai(instance)) - assert "./stack up ai" in result["error"] - assert _ai(instance)["openai_url"] == "" + result = _switch(instance, "managed") + + assert "still uses " + ai_server.url_for("/v1") in result["error"] + assert _ai(instance) == before class TestVoiceMessages: @@ -220,7 +238,7 @@ class TestVoiceMessages: 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")) + result = _switch(instance, ai_server.url_for("/v1")) assert result["whisper_url"] == ai_server.url_for("/v1") assert _ai(instance)["whisper_url"] == "" @@ -231,7 +249,7 @@ 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")) + result = _switch(instance, httpserver.url_for("/v1")) assert any("no speech-to-text model" in w for w in result["warnings"]) @@ -240,7 +258,7 @@ def test_whisper_ai_without_a_speech_model_is_refused_and_writes_nothing( _serve(httpserver, ["llama3.1:8b"]) instance._set_cfg("ai", "whisper_url", "http://localhost:42062/v1") - result = _connect(instance, httpserver.url_for("/v1"), "--whisper", "ai") + result = _switch(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" @@ -250,13 +268,13 @@ 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) + result = _switch(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"), + result = _switch(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"]) @@ -268,7 +286,7 @@ def test_a_speech_server_already_set_wins_over_the_ai_servers_model( stt = _speech_server(ai_server) instance._set_cfg("ai", "whisper_url", stt) - result = _connect(instance, ai_server.url_for("/v1")) + result = _switch(instance, ai_server.url_for("/v1")) assert _ai(instance)["whisper_url"] == stt assert "whisper_model" not in _ai(instance) @@ -278,7 +296,7 @@ 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") + result = _switch(instance, ai_server.url_for("/v1"), "--whisper", "ai") assert _ai(instance)["whisper_url"] == "" assert _ai(instance)["whisper_key"] == "" diff --git a/tests/unit/stacklets/test_ai_whisper_bind.py b/tests/unit/stacklets/test_ai_whisper_bind.py index acda9484..4c0c31e5 100644 --- a/tests/unit/stacklets/test_ai_whisper_bind.py +++ b/tests/unit/stacklets/test_ai_whisper_bind.py @@ -4,7 +4,7 @@ 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 +`stack ai switch --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 diff --git a/tests/unit/stacklets/test_voice_routing.py b/tests/unit/stacklets/test_voice_routing.py index 8a20f767..bf45e8e5 100644 --- a/tests/unit/stacklets/test_voice_routing.py +++ b/tests/unit/stacklets/test_voice_routing.py @@ -68,7 +68,7 @@ def test_the_speech_model_reaches_every_stacklet_that_transcribes( 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 + """`stack ai switch --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", "") From fe56e81ee4c964fee9b58c72e757cc0952334e46 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 15:34:55 +0200 Subject: [PATCH 5/5] docs: measure breaking changes against the last release A change to something that never shipped breaks no existing setup, so it takes no `!` and no `BREAKING CHANGE:` footer. The footer table did not say what "existing" is measured against. --- docs/agent/dev.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 357d5e03..1299dd25 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -433,6 +433,8 @@ subject is not: it appears in plain-text contexts too, including | `Refs: #127` | Links the GitHub issue. `Closes: #127` closes it when the commit lands on `main`; `Fixes:` is kept for the commit that caused a bug. | | `Co-Authored-By:` | Never. Project rule, enforced: `tools/commit-lint` rejects the trailer, a "Generated with Claude Code" line, its link and an Anthropic noreply address, in commit messages and in PR descriptions. | +Breaking is measured against the last release. Changing something that only lived on `main`, such as a command added since the last tag, breaks no existing setup: it takes no `!` and no footer. + `tools/commit-lint` enforces the header, and is the same check in every place that matters: the `commit-msg` hook rejects it before the commit exists, CI rejects the PR title and every commit in the PR, and the