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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ Useful commands:
```bash
./stack ai models
./stack ai download <model-id>
./stack ai connect <url>
./stack ai switch <url>
./stack setup ai
```

Expand All @@ -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 <id>` 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.
Expand All @@ -515,7 +516,7 @@ The command checks that the server answers, picks a model it has (`--model <id>`

**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.
**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:<port>`, 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

Expand Down Expand Up @@ -726,7 +727,7 @@ Key things to know:

```bash
./stack ai models # available AI models in the backend
./stack ai connect <url> # use an AI server on another machine, or `local`
./stack ai switch <url> # 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
Expand Down
2 changes: 2 additions & 0 deletions docs/agent/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/agent/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` | 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 <url>` | 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 |

Expand Down Expand Up @@ -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` | `[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 <url>` output, "Voice messages" line | The AI server lists no speech-to-text model. Pass `--whisper <url>` 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 <url>` output, "Voice messages" line | The AI server lists no speech-to-text model. Pass `--whisper <url>` 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://<ip>: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`. |
Expand Down
10 changes: 9 additions & 1 deletion docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -814,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]`.

---

Expand Down
15 changes: 15 additions & 0 deletions lib/stack/ai/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions lib/stack/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>{RESET}.")
out(f"To use another AI server, run {TEAL}stack ai switch <url>{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.")
Expand Down Expand Up @@ -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"),
]
Expand Down
22 changes: 17 additions & 5 deletions lib/stack/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 ""
Expand Down
9 changes: 6 additions & 3 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion stack.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` instead: it checks the server, picks a model
# `./stack ai switch <url>` 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
Expand Down
4 changes: 2 additions & 2 deletions stacklets/agent/hooks/on_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>'."
"elsewhere with './stack ai switch <url>'."
)

url = _host_url(url)
Expand All @@ -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 <id>'.")
f"Pick one it has with './stack ai switch {url} --model <id>'.")
2 changes: 1 addition & 1 deletion stacklets/agent/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>`).
# machine on the network, or a hosted provider (`stack ai switch <url>`).
#
# 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).
Expand Down
Loading
Loading