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
17 changes: 9 additions & 8 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,16 @@ What it does, in order:
1. **Checks Homebrew.** If missing, prints the install one-liner and waits.
2. **Checks Docker.** If missing, offers to install OrbStack via Homebrew. If installed but not running, asks you to start it and waits.
3. **Asks for a family name.** Becomes your Matrix server identity. Permanent. Pick something short and lowercase-friendly. `mueller` is fine; "The Müller-Schmidt Family" gets sanitized to `mueller-schmidt`.
4. **Asks for your first name.** Becomes the admin account on every stacklet. Default password is your first name in lowercase. Change it at first login.
5. **Asks for additional family members.** Empty input ends the loop. Each member gets their own account on each stacklet.
6. **Writes config.** Three files appear:
4. **Asks for your family's language.** `en` or `de`, proposed from the Mac's time zone. The bots answer in it, documents are tagged in it, and voice messages are transcribed in it. Choose it deliberately: changing it later leaves the first set of document tags behind.
5. **Asks for your first name.** Becomes the admin account on every stacklet. Default password is your first name in lowercase. Change it at first login.
6. **Asks for additional family members.** Empty input ends the loop. Each member gets their own account on each stacklet.
7. **Writes config.** Three files appear:
- `stack.toml`: central config, gitignored, yours to edit.
- `users.toml`: family roster, gitignored.
- `.stack/secrets.toml`: auto-generated passwords for service accounts. Treat like a password manager export.
7. **Brings up `messages` and `core`.** Pulls Synapse, Element and Postgres images (about 600 MB), starts them, creates Matrix accounts, seeds two default rooms (`#famchat`, `#famstack`).
8. **Installs the `famstack` command.** A small script in Homebrew's `bin` directory that runs this checkout's `./stack`, so `famstack status` works from any directory. If another command called `famstack` is already on your PATH, the installer leaves it alone and the final screen says `./stack` instead.
9. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`.
8. **Brings up `messages` and `core`.** Pulls Synapse, Element and Postgres images (about 600 MB), starts them, creates Matrix accounts, seeds two default rooms (`#famchat`, `#famstack`).
9. **Installs the `famstack` command.** A small script in Homebrew's `bin` directory that runs this checkout's `./stack`, so `famstack status` works from any directory. If another command called `famstack` is already on your PATH, the installer leaves it alone and the final screen says `./stack` instead.
10. **Prints a sign-in URL.** Something like `http://192.168.1.42:42030`.

Total time on a fresh Mac with a decent connection: 5 to 10 minutes.

Expand Down Expand Up @@ -588,7 +589,7 @@ schedule = "0 0 3 * * *" # Watchtower nightly image updates

[ai]
default = "mlx-community/Qwen3.5-9B-MLX-4bit" # change to match your RAM
language = "en" # "de" for German voice/transcription
language = "en" # the voice that reads answers aloud; can differ from [core]

[memory]
wiki_auto_rebuild = true # curator refreshes member pages after filings
Expand All @@ -602,7 +603,7 @@ Key things to know:
- **dns_provider**: with a domain, `"hetzner"` or `"cloudflare"` serves everything over HTTPS, with certificates Caddy obtains through the provider's API. Empty serves plain HTTP. The API token is stored with `./stack infra dns-token`, never in `stack.toml`.
- **host**: the hostname used in port-mode URLs and hints. Empty = auto-detect the LAN IP (right for households: phones need it). Set to `localhost` for a single-machine setup, or to a fixed name like `mac-mini.local` if the LAN IP keeps shifting.
- **data_dir**: where all persistent data lives. Back this up. Outside the git repo.
- **language**: detected from your timezone. Controls which document categories get seeded (German or English). Change it and run `./stack restart docs` to seed missing tags.
- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. `stack doctor` warns when `[core] language` is missing, which only happens on installs from before the installer wrote it.
- **AI model**: installer picks one for your RAM tier. Alternatives are listed as comments in `stack.toml`. Switch by uncommenting a different line and running `./stack setup ai`.

### users.toml
Expand Down
5 changes: 5 additions & 0 deletions lib/stack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,11 @@ def missing_secrets(stacklet_id):
running={sid for sid in stacklets if docker.containers_for(sid)},
)

language = doctor.check_language(stck._cfg("core", "language", ""),
stck._cfg("ai", "language", ""))
if language:
findings.append(language)

# What this instance is running, and whether a release has passed it.
# No fetch: doctor is run often, and often when something is
# unreachable, so it answers from the tags this clone already has.
Expand Down
25 changes: 25 additions & 0 deletions lib/stack/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,31 @@ def check_release(position: str, latest: str, up_to_date: bool) -> Finding | Non
)


def check_language(core_language: str, ai_language: str) -> Finding | None:
"""Whether the family's language, `[core] language`, is set.

Installs from before the installer wrote it have none. Everything that
reads the family language then falls back to `[ai] language`, the
language the stack speaks to you, which may be a different one: the
bots, the document categories and transcription follow the voice.
A `[core] language` different from `[ai] language` is a valid setup
and not reported.
"""
if core_language:
return None
fallback = ai_language or "en"
return Finding(
level=WARN,
title="no family language set",
detail=(f"stack.toml has no `language` under [core]. The bots, document "
f"tags and transcription fall back to \"{fallback}\""
+ (", the language of the voice under [ai]." if ai_language else ".")),
fix=(f'add language = "{fallback}" under [core] in stack.toml (or the '
f"language your family reads), then restart what is running: "
f"stack restart <id>..."),
)


def check_endpoint(name: str, url: str, reachable: bool) -> Finding | None:
"""A configured endpoint that does not answer.

Expand Down
37 changes: 32 additions & 5 deletions lib/stack/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ def detect_language(timezone: str) -> str:
return _TZ_LANGUAGE.get(timezone, "en")


LANGUAGES = {"en": "English", "de": "Deutsch"}


def validate_language(value: str) -> str | None:
"""The languages the seeded taxonomy and the bots' texts exist in."""
if value in LANGUAGES:
return None
return "Choose " + " or ".join(LANGUAGES)


# (min_ram_gb, model_id, label)
# Qwen3.6 only shipped a 27B dense and a 35B-A3B MoE — no small variant — so
# the lightweight tier stays on Qwen3.5-9B (3.6's smallest 4bit is 16 GB of
Expand Down Expand Up @@ -297,7 +307,10 @@ def write_stack_toml(family_name, server_name, timezone, language="en"):
provider = ""
openai_url = ""
openai_key = ""
language = "en"
# The language the stack speaks to you: the voice that reads answers
# aloud. Starts as the family language; it can differ. Transcription and
# document tags follow [core] language.
language = "{language}"
{model_block}

[messages]
Expand Down Expand Up @@ -372,8 +385,10 @@ def show_existing_config():
kv("Domain", core["domain"])
if ai.get("openai_url"):
kv("AI server", ai["openai_url"])
if core.get("language"):
kv("Language", core["language"])
if ai.get("language"):
kv("AI language", ai["language"])
kv("Voice", ai["language"])
if messages.get("server_name"):
kv("Chat server", messages["server_name"])
nl()
Expand Down Expand Up @@ -467,6 +482,20 @@ def wizard():

server_name = sanitize_server_name(family_name)

# ── Language ───────────────────────────────────────────────────────
# Asked, not only guessed: it names the document categories seeded
# into Paperless, and changing it later leaves the first set behind.

timezone = detect_timezone()
nl()
out("Which language does your family read? The bots answer in it,")
out("documents are tagged in it, and voice messages are transcribed in it.")
nl()
language = ask(f"Language ({', '.join(LANGUAGES)})",
default=detect_language(timezone), validate=validate_language)
if not language:
return None

# ── Admin ──────────────────────────────────────────────────────────

nl()
Expand Down Expand Up @@ -506,6 +535,7 @@ def wizard():
rule()
nl()
bold(f"The {ORANGE}{family_plural(family_name)}{RESET}")
dim(f"Language: {LANGUAGES[language]}")
nl()
for u in users:
uid = user_id(u)
Expand Down Expand Up @@ -547,9 +577,6 @@ def wizard():
section("Setting up", "Writing config and starting messages")
nl()

timezone = detect_timezone()
language = detect_language(timezone)

with Spinner("Writing stack.toml"):
write_stack_toml(family_name, server_name, timezone, language)

Expand Down
4 changes: 2 additions & 2 deletions stack.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ whisper_url = "http://localhost:42062/v1"
# whisper_key = "" # only for a speech server that needs one
# whisper_model = "whisper-1" # the speech model to ask the server for

# Language for AI interactions — controls TTS voice selection and
# default Whisper transcription language.
# The language the stack speaks to you: the voice that reads answers
# aloud. Can differ from [core] language, which decides transcription.
# "en" = English (alloy voice), "de" = German (onyx voice)
language = "en"

Expand Down
9 changes: 5 additions & 4 deletions stacklets/core/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,11 @@ TOOLS_PORT = "42000"
# midnight is filed under the previous day.
TIMEZONE = "{timezone}"

# Household language — drives bot-side i18n, ontology rendering, and
# any prompt that should be in the family's primary language. Distinct
# from `[ai].language` (`{ai_language}`), which controls AI subsystem
# settings like TTS voice and Whisper transcription language.
# Household language — drives bot-side i18n, ontology rendering, any
# prompt that should be in the family's primary language, and the
# language voice messages are transcribed in. Distinct from
# `[ai].language` (`{ai_language}`), the language the stack speaks to
# you: it picks the voice that reads answers aloud.
LANGUAGE = "{language}"
AI_DEFAULT_MODEL = "{ai_default_model}"
AI_MODELS_JSON = "{ai_models_json}"
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/install/test_first_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
def _answers() -> dict[str, list[str]]:
return {
"Family name": [FAMILY],
"Language (en, de)": ["en"],
"Your first name": [ADMIN],
"Name (leave empty to continue)": [*MEMBERS, ""],
"Ready?": ["y"],
Expand Down Expand Up @@ -389,6 +390,13 @@ def test_the_secrets_file_holds_the_service_admin_password(install):
login(homeserver(printed_url(install)), account[1], secrets["global__ADMIN_PASSWORD"])


def test_the_answered_language_is_the_family_language(install):
# Answered "en" on a Mac whose time zone proposes "de": the answer wins,
# for the family and as the first voice language.
cfg = tomllib.loads((Path(CLONE) / "stack.toml").read_text())
assert (cfg["core"]["language"], cfg["ai"]["language"]) == ("en", "en")


def test_the_printed_commands_name_real_commands(install):
"""Run from the checkout as `./stack`, each printed command parses.

Expand Down
22 changes: 22 additions & 0 deletions tests/unit/framework/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

from stack.doctor import (
check_language,
ERROR,
Finding,
check_endpoint,
Expand Down Expand Up @@ -438,3 +439,24 @@ def test_a_second_provider_is_ignored_and_doctor_says_which(self):
[found] = check_oidc(["id", "sso"], {}, running={"id"})
assert found.level == WARN
assert "id" in found.detail and "sso" in found.detail


class TestLanguage:
"""`[core] language` is the family's language; `[ai] language` the
language the stack speaks to you. Early installs have only the second,
and everything silently falls back to it. Doctor says so."""

def test_an_install_without_a_family_language_is_told_to_set_one(self):
finding = check_language("", "de")

assert finding.title == "no family language set"
assert 'language = "de"' in finding.fix
assert "voice" in finding.detail
assert finding.is_error is False

def test_without_either_it_falls_back_to_english(self):
assert 'language = "en"' in check_language("", "").fix

def test_a_voice_in_another_language_is_a_valid_setup(self):
"""German documents, an English voice: nothing to report."""
assert check_language("de", "en") is None
43 changes: 43 additions & 0 deletions tests/unit/framework/test_install_language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""The family's language, chosen once in the install wizard.

It names the document categories seeded into Paperless, the language the
bots answer in and the language voice messages are transcribed in. The
wizard proposes one from the Mac's time zone and asks; the answer is
`[core] language`, and also the first value of `[ai] language`, the
language the stack speaks to you, which can be changed on its own.
"""

import tomllib

from stack import installer


def test_the_proposal_follows_the_time_zone():
assert installer.detect_language("Europe/Berlin") == "de"
assert installer.detect_language("America/New_York") == "en"


def test_only_languages_with_a_seeded_taxonomy_are_accepted():
assert installer.validate_language("de") is None
assert installer.validate_language("en") is None
assert installer.validate_language("fr") == "Choose en or de"


def test_the_chosen_language_is_the_family_language_and_the_first_voice(tmp_path, monkeypatch):
monkeypatch.setattr(installer, "REPO_ROOT", tmp_path)

installer.write_stack_toml("Simpson", "simpson", "Europe/Berlin", "de")

cfg = tomllib.loads((tmp_path / "stack.toml").read_text())
assert cfg["core"]["language"] == "de"
# The voice starts in the same language and can be changed on its own.
assert cfg["ai"]["language"] == "de"


def test_an_english_choice_in_a_german_time_zone_is_kept(tmp_path, monkeypatch):
monkeypatch.setattr(installer, "REPO_ROOT", tmp_path)

installer.write_stack_toml("Simpson", "simpson", "Europe/Berlin", "en")

cfg = tomllib.loads((tmp_path / "stack.toml").read_text())
assert cfg["core"]["language"] == "en"
Loading