Files
talk/README.md
T
m3ta-chironandClaude Fable 5.1 2e2bc27acf docs: talk-Skill mit Opt-in-Etikette, AGENTS-Snippet, README Installation/Release/Toast
skills/talk/SKILL.md: wann sprechen (nur auf Trigger in der Session oder
Standing-Order in der AGENTS.md, session-sticky bis Widerruf), wann nicht,
was eine gute Ansage ist (Status · nächster Schritt · wofür der Nutzer
gebraucht wird; Beispiele gut/schlecht), Umgang mit muted/discarded/
--now/Prioritäten, Pitfalls. AGENTS-snippet.md: der Block für Projekt-
AGENTS.md.

README: Installation (Release-Asset mit Prüfsumme, Nix-Flake, go install),
Skill-Abschnitt, Toast als echte Desktop-Benachrichtigung, Release-Ablauf
und nix build in „Entwicklung". Spec-Nachtrag: Release ohne GoReleaser,
Notifications ohne beeep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 10:37:46 +02:00

411 lines
21 KiB
Markdown

# talk
> **Status: WIP** — aktive Entwicklung (talk 2.0). Spezifikation mit
> Entscheidungs-Nachtrag: [`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.
## 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 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 |
Say-Flags (gelten auch für `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.
```console
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/checksums.txt)
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.
**Aus dem Quelltext:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
## Erste Schritte
```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.
```
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.
### Diagnose und Einrichtung
- **`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.
## 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).
**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:
```toml
[daemon]
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.
| Endpunkt | Wirkung |
| --- | --- |
| `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 |
| `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 |
- **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.
### 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"`: 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).
**Länge und Alter:** Zwei weitere Gesetze gegen das, was Prompts gern tun
— die Langantwort vorlesen und Ansagen stapeln:
- `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.
**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):
```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)
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 …`, `ruhezeit: 18:00-08:00 (modus discard,
aktiv bis 08:00)` und `limits: max. 400 Zeichen, max. Wartezeit 2m0s`.
### 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.
- **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.
**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 (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.
```toml
[wrap]
label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL)
```
## Für Agenten: Skill und 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.
## Entwicklung
```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
```
CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
und Build bei jedem Push und 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).
## Lizenz
MIT — siehe [LICENSE](LICENSE).