refactor: switch the whole project to English

talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+291 -282
View File
@@ -1,40 +1,41 @@
# talk
> **Status: WIP** — aktive Entwicklung (talk 2.0). Spezifikation mit
> Entscheidungs-Nachtrag: [`docs/spec.md`](docs/spec.md)
> **Status: WIP**, under active development. Specification with the decision
> addendum: [`docs/spec.md`](docs/spec.md)
**talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
spricht eine kurze Nachricht über eine TTS-Provider-Kette
(ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch einen
Hintergrund-Daemon, den talk beim ersten Aufruf selbst startet. Ein
statisches Binary, cross-platform (Windows/macOS/Linux, amd64/arm64), keine
Runtime-Abhängigkeiten.
**talk** gives scripts and AI agents a voice: one call, `talk "Build done"`,
speaks a short announcement through a chain of TTS providers
(ElevenLabs → OpenAI → Edge TTS → OS voice), serialized by a background
daemon that talk starts itself on first use. One static binary,
cross-platform (Windows, macOS, Linux; amd64 and arm64), no runtime
dependencies.
## Befehle
## Commands
| Befehl | Zweck |
| Command | Purpose |
| --- | --- |
| `talk "Text"` | Ansage — Abkürzung für `talk say`, non-blocking |
| `talk say "Text"` | Ansage (Text als Argument oder von stdin) |
| `talk wrap -- <Befehl>` | Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen |
| `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
| `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
| `talk cancel` | laufende und geplante Ansagen abbrechen |
| `talk daemon run\|start\|stop` | Daemon im Vordergrund betreiben / im Hintergrund starten / stoppen (normalerweise nicht nötig, siehe unten) |
| `talk voices` | bekannte Stimmen je Provider/Sprache, Default markiert (`--provider`, `--lang`, `--json`) |
| `talk test` | Testsatz sprechen; zeigt Provider und Zeiten (`--lang`, `--provider`, `--voice`) |
| `talk doctor` | Selbstdiagnose: Config, Daemon, Audio, Provider, Mute, Gesetze (`--probe`, `--json`) |
| `talk setup` | Ersteinrichtung in wenigen Fragen: Stimmen probehören, Ruhezeit, config.toml |
| `talk "text"` | announce; shorthand for `talk say`, non-blocking |
| `talk say "text"` | announce (text as argument or from stdin) |
| `talk wrap -- <command>` | run a command, announce success/failure and duration |
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
| `talk status` | daemon status (queue, mute, provider, laws, version) |
| `talk cancel` | cancel the playing and all queued announcements |
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`) |
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`.
## Installation
**Release-Binary** (Linux, macOS, Windows; amd64/arm64): von den
[Releases](https://code.m3ta.dev/m3tam3re/talk/releases) das passende
`talk_<os>_<arch>` laden, Prüfsumme gegen `checksums.txt` vergleichen, als
`talk` in den PATH legen. Keine weiteren Abhängigkeiten.
**Release binary** (Linux, macOS, Windows; amd64/arm64): download the
matching `talk_<os>_<arch>` from the
[releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the
checksum against `checksums.txt`, put it in your PATH as `talk`. No other
dependencies.
```console
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
@@ -42,209 +43,215 @@ sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/rel
install -m 755 talk_linux_amd64 ~/.local/bin/talk
```
**Nix** (Flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`
oder in der Konfiguration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
und `inputs.talk.packages.${system}.talk` bzw. `inputs.talk.overlays.default`.
Das Paket bringt espeak-ng (OS-Stimme) und libnotify (Toast) mit.
**Nix** (flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`,
or in your configuration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
and `inputs.talk.packages.${system}.talk` or `inputs.talk.overlays.default`.
The package brings espeak-ng (OS voice) and libnotify (toast) along.
**Aus dem Quelltext:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
**From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
## Erste Schritte
## Getting started
```console
talk doctor # Was ist da, was fehlt? Eine Zeile pro Punkt.
talk test # Hörst du etwas? Welche Stimme war das?
talk setup # Stimme aussuchen, Ruhezeit, fertig.
talk "Hallo Welt" # Ab jetzt: Skripte und Agenten rufen genau das.
talk doctor # What is there, what is missing? One line per item.
talk test # Do you hear something? Which voice was it?
talk setup # Pick a language and voices, set quiet hours, done.
talk "Hello world" # From now on scripts and agents call exactly this.
```
Ohne jeden Key funktioniert talk sofort: Es nutzt die kostenlose Edge-
Stimme von Microsoft (Internet nötig) und sonst die Stimme des
Betriebssystems. Wer eine Premium-Stimme will, setzt `TALK_ELEVENLABS_KEY`
oder `TALK_OPENAI_KEY` als Umgebungsvariable — Keys gehören nie in die
config.toml.
talk works without any key: it uses Microsoft's free Edge voice (needs
internet) and otherwise your operating system's voice. For a premium voice,
set `TALK_ELEVENLABS_KEY` or `TALK_OPENAI_KEY` as an environment variable.
Keys never go into config.toml.
### Diagnose und Einrichtung
### Diagnosis and setup
- **`talk doctor`** berichtet zeilenweise mit ✔ (ok), ⚠ (Hinweis), ✘
(Fehler): Config-Datei, Daemon (läuft / startet beim nächsten
Sprechbefehl / fremder Dienst auf dem Port), Audio-Ausgabe (oto
initialisierbar? `null` = kein Ton), jede Provider-Stufe (Key vorhanden,
Endpunkt, Default-Stimme; OS-Stimme: welcher Mechanismus gefunden
wurde), Mute, Gesetze inkl. aktiver Ruhezeit. Exit 0 ohne ✘, sonst 1;
ein nicht laufender Daemon ist ⚠. `--probe` macht echte Synthese-Proben
(Netz), `--json` liefert die Liste maschinenlesbar. Auf Windows-Konsolen
stehen `[OK]`/`[WARN]`/`[FAIL]` statt der Symbole.
- **`talk test`** spricht einen festen Testsatz direkt in diesem Prozess
(nicht über den Daemon; Mute und Ruhezeit gelten hier nicht, ein aktives
Mute wird genannt) und zeigt, welche Stufe gesprochen hat und wie lange
Synthese und Wiedergabe gedauert haben.
- **`talk voices`** listet den kuratierten Stimmen-Katalog (ElevenLabs-
Premade, OpenAI, Edge-Neural je Locale) als Tabelle; `*` markiert die
Stimme, die talk ohne `--voice` nimmt (config `voice.<provider>.<lang>`
oder Repo-Default). Config-Einträge außerhalb des Katalogs erscheinen
als eigene Zeile. Jede Voice-ID des Providers funktioniert auch ohne
Katalog-Eintrag.
- **`talk setup`** fragt im Terminal: Keys (nur Hinweis — ohne Key läuft
die Edge-Stimme), Stimme für Deutsch und Englisch der aktiven Stufe
(Nummer aus dem Katalog oder Voice-ID, probehören, übernehmen),
Ruhezeit. Dann schreibt es die config.toml — eine bestehende Datei wird
zusammengeführt (andere Einstellungen bleiben) und als `.bak` gesichert
— und spricht einen Testsatz. `q` bricht jederzeit ab, geschrieben wird
erst ganz am Ende. Ohne Terminal (Pipe, Agent) stellt setup keine Fragen
und zeigt nur die wirksame Konfiguration.
- **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘
(error): config file, daemon (running / starts on the next speak command
/ foreign service on the port), audio output (can oto initialize? `null`
= no sound), every provider tier (key present, endpoint, default voice;
OS voice: which mechanism was found), notifications, mute, laws
including an active quiet window. Exit 0 without ✘, else 1; a daemon that
is not running is ⚠. `--probe` runs real synthesis probes (network),
`--json` returns the list for machines. Windows consoles get
`[OK]`/`[WARN]`/`[FAIL]` instead of the symbols.
- **`talk test`** speaks a fixed test sentence directly in this process (not
via the daemon; mute and quiet hours do not apply here, an active mute is
mentioned) and shows which tier spoke and how long synthesis and playback
took.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
built-in default). Config entries outside the catalog appear as their own
row. Any voice ID the provider knows works without a catalog entry.
- **`talk setup`** asks in the terminal: keys (only a hint; without a key
the Edge voice speaks), the fallback language, a voice for each language
of the active tier (number from the catalog or a voice ID, preview,
accept), quiet hours. Then it writes config.toml, merging an existing
file (other settings stay) and backing it up as `.bak`, and speaks a
test sentence. `q` aborts at any point; the file is written only at the
very end. Without a terminal (pipe, agent) setup asks nothing and only
shows the effective configuration.
### Language
talk detects German and English per announcement (word lists plus umlauts;
no ML) and picks the voice accordingly. When it cannot tell (numbers,
single words, ties) it uses the configured fallback language, which is also
the language of talk's own announcements such as the `wrap` result:
```toml
lang = "en" # de | en; default en (env TALK_LANG)
```
## Daemon
Es gibt genau ein Binary. Der Daemon ist `talk daemon run` — derselbe Code,
im Hintergrund. Er besitzt die Ansage-Queue und spricht seriell ab — genau
eine Ansage gleichzeitig, Reihenfolge `now` > `high` > `normal` > `low`
(FIFO je Level). Der HTTP-Server lauscht nur auf `127.0.0.1`; Port: Default
`7800`, config `[server] port`, Env `TALK_PORT` (schlagen den Default).
There is exactly one binary. The daemon is `talk daemon run`: the same code,
in the background. It owns the announcement queue and plays serially, exactly
one announcement at a time, in the order `now` > `high` > `normal` > `low`
(FIFO per level). The HTTP server listens on `127.0.0.1` only; port: default
`7800`, config `[server] port`, env `TALK_PORT`.
**Autostart:** Läuft kein Daemon, startet `talk say` (und `talk wrap`) ihn
beim ersten Sprechbefehl selbst — losgelöst vom Terminal, mit Protokoll in
`daemon.log` und Prozess-ID in `daemon.pid` im Config-Dir; stderr meldet
einmalig „Daemon gestartet". Der Daemon läuft dann weiter (Leerlauf kostet
nichts Nennenswertes), bis `talk daemon stop`, Abmelden oder Neustart.
Parallele erste Aufrufe sind harmlos: Verliert einer das Port-Rennen,
benutzt er den Daemon des anderen. Wer den Daemon selbst verwaltet
(systemd, launchd, Task Scheduler mit `talk daemon run`), schaltet den
Autostart ab:
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
the first speak command, detached from the terminal, logging to `daemon.log`
with its PID in `daemon.pid` in the config directory; stderr says "daemon
started" once. The daemon then keeps running (idle costs next to nothing)
until `talk daemon stop`, logout or reboot. Parallel first calls are
harmless: whoever loses the port race uses the other one's daemon. If you
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
daemon run`), turn autostart off:
```toml
[daemon]
autostart = false # Env TALK_AUTOSTART=false
autostart = false # env TALK_AUTOSTART=false
```
Steuerbefehle (`mute`, `unmute`, `status`, `cancel`) starten den Daemon
bewusst nicht: `talk status` sagt dann „läuft nicht" (Exit 1), `talk mute`
schreibt die Mute-Datei direkt.
Control commands (`mute`, `unmute`, `status`, `cancel`) deliberately do not
start the daemon: `talk status` then says "not running" (exit 1), `talk
mute` writes the mute file directly.
| Endpunkt | Wirkung |
| Endpoint | Effect |
| --- | --- |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true`, wenn auf `max_text` gekürzt); bei Mute: `202 {"id":"","muted":true}`; bei Gesetz-Verwurf: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` im Toast-Modus) — verworfen, dokumentiertes Verhalten |
| `OPTIONS /speak` | CORS-Preflight → `204` mit Freigabe-Headern, wenn die `Origin` in `server.allowed_origins` steht (siehe unten); sonst `204` ohne CORS-Header |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true` when cut to `max_text`); muted: `202 {"id":"","muted":true}`; law discard: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` in toast mode); documented behaviour, not an error |
| `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers |
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` |
| `POST /cancel` | bricht die laufende Ansage ab + leert die Queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistenter Mute-State → `200 {"muted":bool}` |
| `POST /shutdown` | fährt den Daemon herunter (`talk daemon stop`) → `200 {"ok":true}` |
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
| `GET /mcp` | `405` — der Daemon bietet keine server-initiierten Streams an |
| `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` |
| `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` |
| `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` |
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) |
| `GET /mcp` | `405`; the daemon offers no server-initiated streams |
- **CLI-Verhalten:** `talk say` (und `talk "Text"`) erkennt den Daemon via
`GET /status`. Läuft er: enqueue + sofortige Rückkehr (non-blocking,
stdout erhält die Item-ID); `--wait` pollt bis zur Fertigstellung;
`--now` springt vor die Warteschlange; bei Mute sagt die CLI `muted`,
bei Gesetz-Verwurf `discarded: <grund>`. `talk mute`/`unmute`/`status`/
`cancel` sind Daemon-Aufrufe mit plain Output.
- **Mute ist persistent** (Datei `mute` im Config-Dir, Inhalt `true`/
`false`) und überlebt Daemon-Restarts. `talk mute` ohne laufenden Daemon
schreibt die Datei direkt — der Daemon liest sie bei jeder Ansage neu,
der Zustand greift also auch offline.
- **Direkt-Pfad:** Ist kein Daemon erreichbar und keiner startbar
(Autostart aus, Port fremd belegt, Start gescheitert), spricht `talk`
direkt im eigenen Prozess — blockierend, ohne Warteschlange (parallele
Aufrufe können überlappen), mit Hinweis und Grund auf stderr. Mute wird
auch hier respektiert. Nichts bricht still.
- **CLI behaviour:** `talk say` (and `talk "text"`) detects the daemon via
`GET /status`. If it runs: enqueue and return at once (non-blocking,
stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the
queue; when muted the CLI prints `muted`, on a law discard
`discarded: <reason>`. `talk mute`/`unmute`/`status`/`cancel` are daemon
calls with plain output.
- **Mute is persistent** (file `mute` in the config directory, content
`true`/`false`) and survives daemon restarts. `talk mute` without a
running daemon writes the file directly; the daemon re-reads it on every
announcement, so the state applies offline too.
- **Direct path:** if no daemon is reachable and none can be started
(autostart off, port taken by something else, start failed), `talk`
speaks directly in its own process: blocking, without a queue (parallel
calls may overlap), with a note and the reason on stderr. Mute is
honoured here too. Nothing fails silently.
### Rate-Limit + Ruhezeiten (Task 05)
### Rate limit, quiet hours, length and age
Der Daemon erzwingt das Anti-Spam-Gesetz als Backstop unter allem
Prompt-Verhalten. **Rate-Limit:** Jede *gestartete* Ansage bucht ein
Zeitfenster — Default **1 Ansage / 30 s**. Bis zum Fenster-Ablauf gilt:
The daemon enforces the anti-spam laws as a backstop under whatever a
prompt does. **Rate limit:** every *started* announcement books a time
window, default **1 announcement / 30 s**. Until the window elapses:
- `priority=low` wird **verworfen** (schon beim Eintritt, die 202-Antwort
nennt `discarded: "rate_limit"`); war das Fenster beim Eintritt noch
frei, entscheidet der Worker später.
- `normal` **parkt** in der Queue und spielt zum freien Slot (in `/status`
als `rate_limit.next_slot` sichtbar).
- `priority=high` darf mit `queue.allow_high_burst = true` (Default)
**bewusst verstoßen**; ohne Burst-Recht wartet high wie normal.
- `now:true` **umgeht auch das Rate-Limit** — der dokumentierte Ausbruch
für Dringendes (startet trotzdem ein neues Fenster).
- `priority=low` is **discarded** (already on admission; the 202 response
says `discarded: "rate_limit"`); if the window was open on admission, the
worker decides later.
- `normal` **parks** in the queue and plays at the next open slot (visible
in `/status` as `rate_limit.next_slot`).
- `priority=high` may **deliberately break** the limit with
`queue.allow_high_burst = true` (default); without burst rights high
waits like normal.
- `now:true` **bypasses the rate limit too**; the documented escape for
urgent matters (it still books a new window).
**Ruhezeiten:** `quiet_hours = "18:00-08:00"` (lokale Zeit, überspannt
Mitternacht; leer/`off` = aus — bewusst kein Default-Fenster, Ruhezeiten
sind Opt-in wie Mute). In der Ruhezeit verfällt Audio, auch für high
(Burst ist ein Rate-Recht, kein Ruhe-Recht):
**Quiet hours:** `quiet_hours = "18:00-08:00"` (local time, may span
midnight; empty/`off` = disabled; deliberately no default window, quiet
hours are opt-in like mute). During quiet hours audio is dropped, also for
high (burst is a rate right, not a quiet right):
- `quiet_mode = "discard"` (Default): still verwerfen.
- `quiet_mode = "toast"`: Desktop-Benachrichtigung statt Audio — über die
Bordmittel des Systems (Linux `notify-send`, macOS Notification Center,
Windows Toast), best effort: fehlt das Werkzeug, bleibt es bei der
Protokoll-Zeile, `talk doctor` zeigt es unter „Toast".
- `now:true` **bricht auch die Ruhezeit** (bewusster Ausbruch).
- `quiet_mode = "discard"` (default): drop silently.
- `quiet_mode = "toast"`: a desktop notification instead of audio, through
the system's own tools (Linux `notify-send`, macOS Notification Center,
Windows toast), best effort: if the tool is missing, only the log line
remains and `talk doctor` shows it under "Toast".
- `now:true` **breaks out of quiet hours too** (a deliberate interruption).
**Länge und Alter:** Zwei weitere Gesetze gegen das, was Prompts gern tun
— die Langantwort vorlesen und Ansagen stapeln:
**Length and age:** two more laws against what prompts like to do, reading
out the long answer and piling up announcements:
- `max_text` (Default **400 Zeichen**, etwa zwei bis drei Sätze): Längere
Texte werden beim Eintritt an einer Wortgrenze **gekürzt** und so
gesprochen; die Antwort meldet `"truncated": true`, die CLI warnt auf
stderr, das MCP-Ergebnis sagt es im Text. Der Nutzer hört den Anfang
statt zehn Minuten Vortrag, das API-Budget bleibt begrenzt.
- `max_age` (Default **2 Minuten**): Was länger in der Queue parkt (Rate-
Limit, Stau), ist keine Nachricht mehr und wird beim Fälligwerden
**verworfen** (`discarded: "expired"` im Protokoll; die 202-Antwort beim
Eintritt bleibt eine Zusage, denn beim Eintritt ist nichts veraltet).
`now:true` wartet nie lange und ist ausgenommen.
- `max_text` (default **400 characters**, two to three sentences): longer
texts are **cut** at a word boundary on admission and spoken like that;
the response reports `"truncated": true`, the CLI warns on stderr, the
MCP result says so in its text. The user hears the beginning instead of a
ten-minute lecture, and the API budget stays bounded.
- `max_age` (default **2 minutes**): whatever parks longer in the queue
(rate limit, backlog) is no longer news and is **discarded** when it
comes due (`discarded: "expired"` in the log; the 202 on admission stays a
promise, because nothing is stale on admission). `now:true` never waits
long and is exempt.
**Rangordnung:** manuelles Mute gewinnt immer > `now` > veraltet (`max_age`)
> Ruhezeit > Rate-Limit; `max_text` greift beim Eintritt auf alles.
Konfiguration (config.toml, Env schlägt Datei):
**Precedence:** manual mute always wins > `now` > stale (`max_age`) > quiet
hours > rate limit; `max_text` applies on admission to everything.
Configuration (config.toml; env beats the file):
```toml
[queue]
rate_limit = "30s" # Dauer; "0s" = unbegrenzt (TALK_RATE_LIMIT)
allow_high_burst = true # high darf verstoßen (TALK_ALLOW_HIGH_BURST)
max_text = 400 # Zeichen; 0 = unbegrenzt (TALK_MAX_TEXT)
max_age = "2m" # Wartezeit; "0s" = unbegrenzt (TALK_MAX_AGE)
rate_limit = "30s" # duration; "0s" = unlimited (TALK_RATE_LIMIT)
allow_high_burst = true # high may break the limit (TALK_ALLOW_HIGH_BURST)
max_text = 400 # characters; 0 = unlimited (TALK_MAX_TEXT)
max_age = "2m" # longest wait; "0s" = unlimited (TALK_MAX_AGE)
quiet_hours = "18:00-08:00" # leer = aus; Env TALK_QUIET_HOURS ("off" = aus)
quiet_hours = "18:00-08:00" # empty = off; env TALK_QUIET_HOURS ("off" = off)
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
```
`talk status` zeigt den Gesetz-State: `rate-limit: 30s (high-burst
erlaubt), nächster slot …`, `ruhezeit: 18:00-08:00 (modus discard,
aktiv bis 08:00)` und `limits: max. 400 Zeichen, max. Wartezeit 2m0s`.
`talk status` shows the law state: `rate-limit: 30s (high burst allowed),
next slot …`, `quiet-hours: 18:00-08:00 (mode discard, active until 08:00)`
and `limits: max 400 characters, max wait 2m0s`.
### CORS-Tür + MCP-Endpoint (Task 06)
### CORS door and MCP endpoint
**CORS** gilt bewusst nur für `POST /speak` — genau die eine Tür, die eine
spätere Browser-Anbindung (z. B. LibreChat) braucht; Steuer-Endpunkte
bleiben browserseitig unerreicht. Die Erlaubnis ist eine exakte Origin-
Liste; `"*"` ist erlaubt, aber nicht Default; **leere Liste (Default) =
gar keine CORS-Header = der Browser blockt**:
**CORS** applies to `POST /speak` only, the one door a later browser
integration (for example a web chat UI) needs; control endpoints stay out of
reach of browsers. The allow-list holds exact origins; `"*"` is allowed but
not the default; **an empty list (default) means no CORS headers at all**:
```toml
[server]
allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS
```
- `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit
`Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST,
- `OPTIONS /speak` (preflight) from a listed origin → `204` with
`Access-Control-Allow-Origin` (the origin or `*`), `-Methods: POST,
OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`.
- Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header.
- **Die Tür ist serverseitig zu, nicht nur browserseitig.** CORS allein
verhindert nur das *Lesen* der Antwort; einen POST mit `text/plain`-Body
oder einen Formular-POST schickt jede Webseite ohne Preflight ab. Deshalb
lehnt der Daemon jede Anfrage mit fremdem `Origin`-Header mit `403` ab, die
nicht POST /speak von einer gelisteten Origin ist — also ungelistete
Origins auf /speak, jeden Browser-Zugriff auf `/status`, `/mute`,
`/unmute`, `/cancel` und `/mcp`, auch von gelisteten Origins. Nur der
Preflight `OPTIONS /speak` bleibt für ungelistete Origins ein `204` ohne
CORS-Header (ohne Wirkung; der Browser verwirft den geplanten POST).
CLI, curl und MCP-Clients senden keinen `Origin`-Header und sind nicht
betroffen.
- A real `POST /speak` with a listed `Origin` carries the same header.
- **The door is shut server-side, not only browser-side.** CORS alone only
prevents *reading* the response; any web page can send a POST with a
`text/plain` body or a form POST without a preflight. Therefore the
daemon rejects every request with a foreign `Origin` header with `403`
unless it is POST /speak from a listed origin: unlisted origins on
/speak, and any browser access to `/status`, `/mute`, `/unmute`,
`/cancel` and `/mcp`, even from listed origins. Only the preflight
`OPTIONS /speak` stays a `204` without CORS headers for unlisted origins
(it has no effect; the browser drops the planned POST). CLI, curl and MCP
clients send no `Origin` header and are unaffected.
**MCP** (`POST /mcp`, Streamable HTTP): schlanke eigene Implementierung
des JSON-RPC-Subsets ohne SDK — `initialize` (angefragte
`protocolVersion` wird geechot; Ergebnis `protocolVersion`,
`capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized`
(Notification → `202` ohne Body), `ping`, `tools/list`,
`tools/call`. Der Server ist zustandslos (keine Session-IDs) und pusht
nichts (daher `GET /mcp` → `405`). Ein Tool:
**MCP** (`POST /mcp`, Streamable HTTP): a lean own implementation of the
JSON-RPC subset, no SDK: `initialize` (the requested `protocolVersion` is
echoed; result `protocolVersion`, `capabilities: {tools:{}}`,
`serverInfo`), `notifications/initialized` (notification → `202` without
body), `ping`, `tools/list`, `tools/call`. The server is stateless (no
session IDs) and pushes nothing (hence `GET /mcp` → `405`). One tool:
```json
{"name": "speak",
@@ -257,13 +264,13 @@ nichts (daher `GET /mcp` → `405`). Ein Tool:
"required": ["text"]}}
```
`tools/call` fährt exakt denselben Aufnahme-Pfad wie `POST /speak`
(inklusive Mute, Rate-Limit, Ruhezeiten). Ergebnis ist immer Text-Content:
`"queued: <id>"` bei Aufnahme; `muted`/`discarded: <grund>` mit Erklärung
— bewusst `isError: false`, denn ein Verwurf ist das korrekte
dokumentierte Ergebnis, kein Protokollfehler. Validierungsfehler
(fehlender Text, ungültige Priorität, unbekanntes Tool) sind dagegen
JSON-RPC-Fehler (`-32602`). Handshake als curl-Sequenz:
`tools/call` takes exactly the same admission path as `POST /speak`
(including mute, rate limit, quiet hours, truncation). The result is always
text content: `"queued: <id>"` on admission; `muted`/`discarded: <reason>`
with an explanation, deliberately `isError: false`, because a discard is
the correct documented result, not a protocol error. Validation errors
(missing text, invalid priority, unknown tool) are JSON-RPC errors
(`-32602`). Handshake as a curl sequence:
```sh
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
@@ -271,140 +278,142 @@ curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build fertig"}}}'
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}'
```
### Provider-Kette (Task 03)
### Provider chain
Ohne Pin läuft die Vier-Stufen-Fallback-Kette
**ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in**: Bei fehlendem Key,
HTTP-Fehler, Timeout oder Netz-Problem fällt die Kette automatisch auf die
nächste Stufe; jeder Fallback wird auf stderr protokolliert (nie still).
Pinnen per `--provider=<name>` oder `provider = "<name>"` in der
config.toml.
Without a pin, the four-tier fallback chain runs
**ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an
HTTP error, a timeout or a network problem the chain falls through to the
next tier automatically; every fallback is logged to stderr (never silent).
Pin with `--provider=<name>` or `provider = "<name>"` in config.toml.
| Stufe | Key (Env) | Default-Endpunkt | Audio |
| Tier | Key (env) | Default endpoint | Audio |
| --- | --- | --- | --- |
| `elevenlabs` | `TALK_ELEVENLABS_KEY` | `https://api.elevenlabs.io` | mp3 |
| `openai` | `TALK_OPENAI_KEY` | `https://api.openai.com` | mp3 |
| `edge` | — (kostenlos) | `wss://speech.platform.bing.com/...` | mp3 |
| `osbuiltin` | — | lokale OS-TTS | WAV/PCM |
| `edge` | none (free) | `wss://speech.platform.bing.com/...` | mp3 |
| `osbuiltin` | none | local OS TTS | WAV/PCM |
Keys laufen ausschließlich über Env-Variablen (nie in die config.toml).
Endpunkte sind konfigurierbar — Datei `[providers.<name>] endpoint = "…"`
bzw. Env `TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` /
`TALK_EDGE_ENDPOINT` (Env schlägt Datei; so zeigen Tests/CI die Kette auf
Fake-Server). Das OpenAI-Modell ist konfigurierbar
(`[providers.openai] model`, Default `tts-1`).
Keys come exclusively from environment variables (never config.toml).
Endpoints are configurable: file `[providers.<name>] endpoint = "…"` or env
`TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT`
(env beats the file; this is how tests and CI point the chain at fake
servers). The OpenAI model is configurable (`[providers.openai] model`,
default `tts-1`).
Stimmen: neutralen Repo-Defaults je Provider+Sprache (ElevenLabs
„Rachel", OpenAI „alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`)
kann man pro Sprache überordnen — config.toml:
Voices: the neutral built-in defaults per provider and language (ElevenLabs
"Rachel", OpenAI "alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) can
be overridden per language in config.toml:
```toml
[voice.elevenlabs]
de = "21m00Tcm4TlvDq8ikWAM" # eigene Stimme je Sprache
de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language
en = "21m00Tcm4TlvDq8ikWAM"
[voice.edge]
de = "de-DE-KatjaNeural"
```
Rangfolge: `--voice`-Flag > `voice.<provider>.<lang>` > Repo-Default.
MP3 wird von der Binärdatei selbst dekodiert (reiner Go-Dekoder, kein
ffmpeg, CGO-frei).
Precedence: `--voice` flag > `voice.<provider>.<lang>` > built-in default.
MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free).
### OS-TTS auf Linux (Dev-Abhängigkeit espeak)
Edge TTS is an undocumented Microsoft endpoint with a DRM token scheme that
Microsoft changes now and then. When that happens, the edge tier fails with
a clear "protocol changed?" error and the chain falls through to the OS
voice; `talk doctor --probe` makes it visible.
Der OS-Provider `osbuiltin` nutzt auf Linux zwei Wege:
### OS TTS on Linux
- **Audio-Bytes** (Wiedergabe über den eingebauten oto-Sink): benötigt
`espeak` (oder `espeak-ng`). Auf NixOS als Dev-Abhängigkeit:
The `osbuiltin` provider uses two routes on Linux:
- **Audio bytes** (playback through the built-in oto sink): needs
`espeak-ng` (or `espeak`). On NixOS as a dev dependency:
`nix shell nixpkgs#espeak-ng`.
- **Direktansage** ohne espeak: `spd-say` (speech-dispatcher) — spielt
selbst über den Daemon ab, kann aber nach `spd-say --help` keine Bytes
in Dateien/Pipes liefern. `talk` nutzt diesen Weg automatisch, wenn
kein espeak vorhanden ist.
- **Direct speech** without espeak: `spd-say` (speech-dispatcher) plays by
itself through its daemon but cannot deliver bytes to a file or pipe.
`talk` takes this route automatically when espeak is missing.
`TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml)
schaltet jede Tongabe ab — für CI und Server.
`TALK_AUDIO_SINK=null` (or `audio.sink = "null"` in config.toml) turns all
sound off, for CI and servers.
## `talk wrap` — Befehle annoncieren (Task 07)
## `talk wrap`: announce commands
`talk wrap -- <Befehl> [Argumente …]` führt den Befehl aus und macht das
Ergebnis hörbar: stdin/stdout/stderr werden durchgereicht — stdout gehört
allein dem Befehl, Informationszeilen von talk (z. B. die Item-ID der
Ansage) gehen auf stderr, damit Pipes und Redirects sauber bleiben. Die
Wandzeit wird gemessen und nach dem Ende des Befehls angesagt:
`talk wrap -- <command> [args …]` runs the command and makes the result
audible: stdin/stdout/stderr are passed through; stdout belongs to the
command alone, talk's information lines (for example the announcement's
item ID) go to stderr so pipes and redirects stay clean. Wall time is
measured and announced after the command ends:
- Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden."
- Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer)
- success: "sleep succeeded. Duration: 2 seconds."
- failure: "sh failed, code 2." (without duration)
```console
talk wrap -- sleep 2
talk wrap --wait -- make test
```
- **`--` schirmt den Befehl komplett ab:** Das erste allein stehende `--`
beendet die talk-Flag-Auswertung — alles danach geht unverändert an den
Befehl. `talk wrap -- foo --bar` reicht `--bar` an foo weiter, statt es
als talk-Flag zu fehlzuinterpretieren. talk-Flags für die Ansage stehen
deshalb davor (`talk wrap --wait -- …`); ohne `--` gelten die üblichen
Regeln (Flags überall, auch nach Positionalargumenten).
- **Label** der Ansage: `wrap.label` in der config.toml (Env
`TALK_WRAP_LABEL` schlägt die Datei) oder der Basisname des Befehls
(`/usr/bin/make` → „make"). Die Sprache erkennt langdetect am Label
(de/en, deutscher Default); `--lang` überschreibt.
- **Exit-Code-Politik:** wrap exitet immer mit dem Code des Befehls —
scheitert die Ansage selbst, bleibt der Befehls-Code trotzdem (nur eine
Warning auf stderr; talk-Fehler ≠ Befehls-Fehler). Nicht startbare
Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention),
Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein
Befehl, ungültige Flags) scheitern vor dem Start des Befehls.
- **Ansageweg:** derselbe wie say — Daemon (enqueue, non-blocking;
`--wait` respektiert; Autostart), ohne Daemon der Direkt-Pfad.
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) und reicht sie an die Ansage durch.
- **`--` shields the command completely:** the first standalone `--` ends
talk's flag parsing; everything after it goes to the command unchanged.
`talk wrap -- foo --bar` passes `--bar` to foo instead of misreading it
as a talk flag. talk's flags for the announcement therefore go before it
(`talk wrap --wait -- …`); without `--` the usual rules apply (flags
anywhere, also after positional arguments).
- **Label** of the announcement: `wrap.label` in config.toml (env
`TALK_WRAP_LABEL` beats the file) or the command's base name
(`/usr/bin/make` → "make"). Language detection runs on the label (de/en,
falling back to the configured `lang`); `--lang` overrides.
- **Exit code policy:** wrap always exits with the command's code; if the
announcement itself fails, the command's code still stands (only a
warning on stderr; talk errors are not command errors). Commands that
cannot start (binary not found) count as 127 (shell convention), signal
deaths portably as 1. Usage errors of talk itself (no command, invalid
flags) fail before the command starts.
- **Announcement path:** the same as say: daemon (enqueue, non-blocking;
`--wait` honoured; autostart), without a daemon the direct path. wrap
inherits the say flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) and passes them on.
```toml
[wrap]
label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)
label = "Deployment" # announcement label instead of the command's base name (TALK_WRAP_LABEL)
```
## Für Agenten: Skill und Standing-Order
## For agents: skill and standing order
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) ist die Etikette als
Skill-Artefakt: **striktes Opt-in** (Sprechen nur auf Aufforderung in der
Session oder Standing-Order in der AGENTS.md, session-sticky bis Widerruf),
Inhalt = Status · nächster Schritt · wofür der Nutzer gebraucht wird, nie
die Langantwort, `muted`/`discarded` respektieren. Der Drei-Zeilen-Block
für Projekt-AGENTS.md steht in
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). Der
Daemon erzwingt Rate-Limit, Längen- und Alters-Grenze, Mute und Ruhezeit
unabhängig davon, was im Prompt steht.
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) is the etiquette as a skill
artifact: **strict opt-in** (speak only when asked in the session or by a
standing order in the project's AGENTS.md, session-sticky until revoked),
content = status · next step · what the user is needed for, never the long
answer, honour `muted`/`discarded`. The three-line block for project
AGENTS.md files is in
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). The daemon
enforces rate limit, length and age limits, mute and quiet hours regardless
of what the prompt says.
## Entwicklung
## Development
```console
go build ./... # bauen
go test ./... # Tests
go vet ./... # statische Analyse
gofmt -l . # Formatierung prüfen (leerer Output = ok)
nix develop # devShell mit go, gopls, espeak-ng, libnotify
go build ./... # build
go test ./... # tests
go vet ./... # static analysis
gofmt -l . # formatting check (empty output = ok)
nix develop # devShell with go, gopls, espeak-ng, libnotify
```
CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
und Build bei jedem Push und Pull Request.
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
tests and build on every push and pull request.
**Release:** Tag `v*` pushen → `.gitea/workflows/release.yml` testet, baut
mit `scripts/release.sh` die sechs Targets (statisch, `CGO_ENABLED=0`,
Version per ldflags) und legt ein Gitea-Release mit `talk_<os>_<arch>[.exe]`
und `checksums.txt` an. Lokal: `scripts/release.sh 1.2.3` → `dist/`. Bewusst
ohne GoReleaser: ein Shell-Skript über `go build` ist dasselbe Artefakt,
lokal wie in CI, ohne zusätzliches Werkzeug. `nix build .#talk` baut das
Nix-Paket (nach Änderungen an go.mod den `vendorHash` in flake.nix
nachziehen — `nix build` nennt den erwarteten Wert).
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
`checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately
without GoReleaser: a shell script around `go build` yields the same
artifact locally and in CI without another tool. `nix build .#talk` builds
the Nix package (after changes to go.mod, update the `vendorHash` in
flake.nix; `nix build` prints the expected value).
## Lizenz
## License
MIT — siehe [LICENSE](LICENSE).
MIT, see [LICENSE](LICENSE).