Files
talk/README.md
T
m3ta-chiron acfba19fc2 docs(readme): talk wrap dokumentieren — --Semantik, Label, Exit-Codes
Neuer Abschnitt zur Befehlsummwicklung: Aufrufform, Schutzwirkung des
ersten „--“, Label (wrap.label/TALK_WRAP_LABEL/Basisname) samt Sprach-
erkennung, Exit-Code-Politik (Befehls-Code gewinnt immer, 127 für nicht
startbare Befehle, 1 für Signaltode), geerbte say-Flags und der
Ansageweg über Daemon/Degraded Mode; Implementiert-Liste um wrap ergänzt.
2026-10-18 21:01:13 +02:00

280 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# talk
> **Status: WIP** — aktive Entwicklung (talk 2.0). Binde Spezifikation:
> [`.scratch/talk-2-0/spec.md`](.scratch/talk-2-0/spec.md) ·
> Implementierungsplan: [`.scratch/talk-2-0/plan.md`](.scratch/talk-2-0/plan.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 den Daemon
`talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
keine Runtime-Abhängigkeiten.
## Befehle
| Befehl | Zweck |
| --- | --- |
| `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 voices` | verfügbare Stimmen anzeigen |
| `talk test` | Test-Ansage ausgeben |
| `talk doctor` | Selbstdiagnose des Setups |
| `talk setup` | geführte Ersteinrichtung (Wizard) |
Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
`--voice`, `--lang`, `--provider`.
**Hinweis:** In diesem Stand sind implementiert: `say` (und `talk "Text"`)
mit der kompletten Provider-Kette und Daemon-Anbindung (non-blocking über
`talkd`, siehe unten), `mute`, `unmute`, `status`, `cancel` sowie `wrap`.
Die übrigen Befehle sind Stubs und melden `not implemented yet` (Exit-Code
≠ 0).
## Daemon talkd (Task 04–06)
`talkd` 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).
| Endpunkt | Wirkung |
| --- | --- |
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}`; 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 |
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "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 /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call` — ein Tool `speak` (siehe unten) |
| `GET /mcp` | `405` — talkd bietet keine server-initiierten Streams an |
- **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.
- **Degraded Mode:** Antwortet kein Daemon, spielt `talk` lokal und
sequenziell weiter — serialisiert über die Lock-Datei `talk.lock` im
Config-Dir (Kollision = warten; verwaisste Locks gelten nach 60 s als
verfallen). Mute wird auch hier respektiert. Nichts bricht still.
### Rate-Limit + Ruhezeiten (Task 05)
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:
- `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).
**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_mode = "discard"` (Default): still verwerfen.
- `quiet_mode = "toast"`: Benachrichtigung statt Audio — derzeit eine
Protokoll-Zeile über den Notification-Pfad (OS-Toast folgt mit der
Notification-Anbindung).
- `now:true` **bricht auch die Ruhezeit** (bewusster Ausbruch).
**Rangordnung:** manuelles Mute gewinnt immer > `now` > Ruhezeit >
Rate-Limit. Konfiguration (config.toml, Env schlägt Datei):
```toml
[queue]
rate_limit = "30s" # Dauer; "0s" = unbegrenzt (TALK_RATE_LIMIT)
allow_high_burst = true # high darf verstoßen (TALK_ALLOW_HIGH_BURST)
quiet_hours = "18:00-08:00" # leer = aus; Env TALK_QUIET_HOURS ("off" = aus)
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
```
`talk status` zeigt den Gesetz-State: `rate-limit: 30s (high-burst
erlaubt), nächster slot …` und `ruhezeit: 18:00-08:00 (modus discard,
aktiv bis 08:00)`.
### CORS-Tür + MCP-Endpoint (Task 06)
**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**:
```toml
[server]
allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS
```
- `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit
`Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST,
OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`.
- Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header.
- Ungelistete Origins bekommen saubere `204`/`202` **ohne** CORS-Header —
die Blockade macht der Browser.
**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:
```json
{"name": "speak",
"inputSchema": {"type": "object",
"properties": {"text": {"type": "string"},
"priority": {"enum": ["low","normal","high"]},
"provider": {"type": "string"},
"lang": {"enum": ["de","en"]},
"now": {"type": "boolean"}},
"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:
```sh
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
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"}}}'
```
### Provider-Kette (Task 03)
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.
| Stufe | Key (Env) | Default-Endpunkt | 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 |
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`).
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:
```toml
[voice.elevenlabs]
de = "21m00Tcm4TlvDq8ikWAM" # eigene Stimme je Sprache
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).
### OS-TTS auf Linux (Dev-Abhängigkeit espeak)
Der OS-Provider `osbuiltin` nutzt auf Linux zwei Wege:
- **Audio-Bytes** (Wiedergabe über den eingebauten oto-Sink): benötigt
`espeak` (oder `espeak-ng`). Auf NixOS als Dev-Abhängigkeit:
`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.
`TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml)
schaltet jede Tongabe ab — für CI und Server.
## `talk wrap` — Befehle annoncieren (Task 07)
`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:
- Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden."
- Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer)
```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-Pfad (enqueue, non-blocking;
`--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei.
wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`,
`--lang`, `--provider`) und reicht sie an die Ansage durch.
```toml
[wrap]
label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)
```
## Entwicklung
```console
go build ./... # bauen
go test ./... # Tests
go vet ./... # statische Analyse
gofmt -l . # Formatierung prüfen (leerer Output = ok)
```
CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
und Build bei jedem Push und Pull Request.
## Lizenz
MIT — siehe [LICENSE](LICENSE).