# Plan: talk 2.0 — V1 Implementationsplan Plan: .scratch/talk-2-0/plan.md Spec: .scratch/talk-2-0/spec.md (bindende Autorität — Konflikte gehen zu ihren Lasten) Branch: feat/v1 (ab main; Repo wird in Task 01 initialisiert) ## Global Constraints (aus der Spec, bindend für alle Tasks — bei Konflikt gewinnt die Spec) - **Sprache/Laufzeit:** Go; statische Binaries für Windows/macOS/Linux × amd64/arm64; keine Runtime-Abhängigkeiten (kein ffmpeg, kein mpv, kein Python). - **Binaries:** `talk` (CLI) + `talkd` (Daemon). Modulname: `code.m3ta.dev/m3tam3re/talk`. - **CLI-Surface (exakt):** Default-Befehl `talk "Text"` = non-blocking; Flags `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. Subcommands: `say`, `wrap`, `mute`, `unmute`, `status`, `cancel`, `voices`, `test`, `doctor`, `setup`. `--help` auf jedem Subcommand. - **Provider-Kette (Fallback-Reihenfolge):** ElevenLabs → OpenAI TTS → Edge-TTS (frei, kein Key) → OS built-in (macOS `say`, Windows SAPI, Linux espeak/speech-dispatcher). Überschreibbar per Config/Flag. Keys Env-first: `TALK_ELEVENLABS_KEY`, `TALK_OPENAI_KEY`. Provider-Endpunkte konfigurierbar (Fake-bar für Tests). - **Config:** optionale `config.toml` im platform-nativen Verzeichnis: Linux `~/.config/talk`, macOS `~/Library/Application Support/talk`, Windows `%APPDATA%\talk`. Env schlägt Datei. Voice-Mapping `voice.. = id`. - **Audio:** Playback embedded via oto; Audio-Sink wählbar inkl. `null` (CI/Server). Notifications best effort via beeep. - **Daemon:** talkd besitzt Queue (Serialisierung, Prioritäten, Rate-Limit Default 1 Ansage/30 s, prioritätsbewusstes Verwerfen), persistenten Mute-State (Datei im Config-Dir), Ruhezeiten `quiet_hours = "18:00-08:00"` (lokale Zeit; still verwerfen oder toast-only), HTTP-API `POST /speak` auf 127.0.0.1 mit konfigurierbarem CORS, MCP-Endpoint (Streamable HTTP) auf localhost. - **Degraded Mode:** ohne talkd: Lock-File + sequenzielles Abspielen; nichts bricht still. - **TUI (Bubble Tea):** interaktiv nur `setup` (Wizard), `status` (Live-Dashboard), `voices` (Picker mit Preview); alle anderen Befehle plain. Kein TTY → alle Befehle plain. Agenten-Pfade (Args/stdin) starten nie eine TUI. - **Etikette (nur Skill-Doku, kein Daemon-Session-State):** Opt-in; Ansageinhalt = Status/nächster Schritt/Freigabe-Bedarf, nie Langantwort. - **Testing:** Primäre Seam = Prozessgrenze talkd+CLI als Black Box mit injizierbaren Fakes über Config (Provider-Endpunkt → Fake-HTTP-Server mit Audio-Bytes; `audio.sink = "null"`). Reine Unit-Seams: langdetect, Config-Auflösung. Echt-Audio und OS-Toasts ungetestet (doctor-Rendering manuell). Tests testen nur extern beobachtbares Verhalten. - **CI:** Gitea Actions — `.gitea/workflows/test.yml` läuft `go vet` + `go test ./...` bei jedem Push. `go build ./...` muss auf dem Dev-Host (Linux) grün sein; Cross-Compile via `GOOS`/`GOARCH` mindestens einmal in Task 11 verifiziert. - **Lizenz:** MIT (LICENSE-Datei ab Task 01). - **Doku-Sprache:** Deutsch (README, Skill). - **Commits:** Conventional Commits; keine Pushes; keine Änderungen außerhalb des Repos (Ausnahme Task 12, siehe dort). ## Task 01: Repo-Skeleton & CI-Backbone Keine Blocker. Liefert: initialisierbares, grünes Go-Repo. - `git` bereits initialisiert (main); dieser Task entsteht auf Branch `feat/v1` (besteht bereits). - Go-Modul `code.m3ta.dev/m3tam3re/talk` (go.mod, aktuelle stabile Go-Version des Dev-Hosts). - Verzeichnisgerüst: `cmd/talk/`, `cmd/talkd/`, `internal/{provider,queue,audio,notify,config,api,langdetect}/` (jeweils mit placehalterfreiem Miniinhalt oder leer + doc.go — kein toter Code). - `cmd/talk/main.go`: Subcommand-Gerüst (cobra oder stdlib flag — Entscheidung: **stdlib `flag` + eigene Subcommand-Dispatch**, keine externen CLI-Abhängigkeiten in T01) mit `--version` (zeigt `dev` + Commit via ldflags-Variablen, default `dev`) und allen Subcommands als `not implemented`-Stubs mit korrektem `--help`. - `cmd/talkd/main.go`: startet, loggt eine Zeile, exit 0 (Platzhalter). - `.gitea/workflows/test.yml`: `go vet ./...`, `go test ./...`, `go build ./...` bei push/PR. - `.gitignore` (Go-Binaries, `dist/`), `LICENSE` (MIT, Copyright 2026 m3tam3re), `README.md`-Gerüst (Projektname, Ein-Satz-Zweck, Status WIP, Hinweis auf Spec). - Mind. ein sinnvoller Test, der die CI-Struktur verifiziert (z. B. Version-String-Test). - Abnahme: `go vet && go test && go build` grün; Workflow-Datei valider YAML; README/LICENSE vorhanden; Commit(s) conventional. ## Task 02: Erste Ansage end-to-end (OS-TTS) Blockiert durch: 01. Liefert: `talk "Hallo Welt"` spricht über OS-TTS. - `internal/langdetect`: reine Funktion `Detect(text string) string` → `"de"` | `"en"` (Default `"de"`); Heuristik: Common-Word-Listen + Umlaute/ß; tischgesteuerte Tests (de/en/gemischt/leer). - `internal/provider`: Interface `Provider` mit `Name() string`, `Available(ctx) bool`, `Synthesize(ctx, text string, lang string) ([]byte, error)` (Rückgabe: dekodierbare Audio-Bytes, Format von der Implementierung dokumentiert; osbuiltin liefert WAV/PCM). - Implementierung `osbuiltin`: Linux über `espeak`/`speech-dispatcher`-Fallback (externer Prozess — OK, es ist der Notnagel-Provider; wenn Binary fehlt: `Available() == false`), macOS über `say` (Ausgabe in temporäre WAV-Datei), Windows über SAPI (PowerShell `System.Speech`). Fehlende Binary/Runtime → klarer Fehler, kein Panik. - `internal/audio`: `Sink`-Interface (`Play(ctx, data []byte) error`, `Name() string`); Implementierungen `oto` (embedded Playback, PCM/WAV) und `null` (verwirft, loggt). Auswahl per Config `audio.sink = "oto"|"null"` (Default `oto`). - `internal/config`: minimale Auflösung: Defaults + TOML-Datei (falls vorhanden) + Env-Overrides (`TALK_AUDIO_SINK`); Platform-Pfad-Auflösung (`~/.config/talk` | `~/Library/Application Support/talk` | `%APPDATA%\talk`, respektive `XDG_CONFIG_HOME`/Env); tischgesteuerte Unit-Tests für Merge + Pfadlogik (Pfade injizierbar). - `cmd/talk`: `say`-Pfad implementiert: Text (Arg oder stdin) → langdetect → Providerauswahl (hier: osbuiltin, Kette kommt in Task 03; Struktur so, dass `--provider`-Flag schon existiert und `osbuiltin` default ist) → Synthesize → Sink.Play. Blocking (Daemon kommt in Task 04). - Abnahme: `talk "Hallo Welt"` erzeugt hörbare Ausgabe auf dem Dev-Host (Linux; espeak vorhanden oder als Dev-Abhängigkeit dokumentiert); `TALK_AUDIO_SINK=null` läuft ohne Ton durch; `go test ./...` grün (langdetect + config + provider-mit-fake-proc wo sinnvoll). ## Task 03: Provider-Kette mit Cloud-Anbietern Blockiert durch: 02. Liefert: Vier-Stufen-Fallback mit Keys + Fake-barkeit. - Provider-Implementierungen (je `internal/provider/`): - `elevenlabs`: POST `/v1/text-to-speech/`, Header `xi-api-key`, Body `{text, model_id: "eleven_multilingual_v2", voice_settings:{stability:0.5,similarity_boost:0.75}}`, Antwort = Audio-Bytes (mp3). Key: `TALK_ELEVENLABS_KEY`; Default-Voice-ID `KXxZd16DiBqt82nbarJx`. Endpunkt konfigurierbar (Default `https://api.elevenlabs.io`). - `openai`: POST `/v1/audio/speech`, Bearer-Key `TALK_OPENAI_KEY`, Body `{model:"gpt-4o-mini-tts"|"tts-1", voice, input, response_format:"mp3"}` — Entscheidungsnotiz: Modell-String konfigurierbar, Default `tts-1`. Endpunkt Default `https://api.openai.com`. - `edge`: Microsoft Edge-TTS-Endpunkt (kostenlos, kein Key) — WebSocket/REST nach aktuellem bekannten Protokoll (edge-tts-kompatibel, Stimmen z. B. `de-DE-KatjaNeural`/`en-US-AriaNeural`); Endpunkt konfigurierbar; bei Protokoll-Änderung: klarer Fehler. - `osbuiltin`: existiert (Task 02). - Kette `internal/provider/chain.go`: Resolver liefert geordnete Kette laut Config (Default-Reihenfolge oben); `--provider`-Flag und `provider = "…"`-Config pinnen; `Available()` prüft Key/Endpunkt; Synthesize fällt bei Fehler (HTTP != 2xx, Timeout, Netz) auf den nächsten durch; letzter Fehler wird geloggt/gemeldet, nie still. - Voice-Mapping: `voice..` aus config.toml; Defaults je Provider+Sprache hinterlegt (neutral, dokumentiert). - Tests: jeder Cloud-Provider gegen `httptest`-Fake (200 mit Bytes; 500 → Fehler; 401 → Fehler) **außer edge** (WebSocket-Protokoll — edge wird über Interface + Fehlerpfad-Mock und eine als `testing.Short` überspringbare Integration-Testmarkierung behandelt; Dokumentationspflicht im Report, welche Teile fake-bar getestet sind); Kettentests: erster 500 → zweiter getroffen → Bytes. - Abnahme: `talk "Test" --provider=elevenlabs` mit echtem Key funktioniert am Dev-Host ( einmalig manuell, im Report dokumentiert); ohne jeden Key wählt die Kette edge bzw. osbuiltin; `go test ./...` grün. ## Task 04: talkd-Daemon + Queue + Mute Blockiert durch: 02, 03. Liefert: Daemon-betriebene, serialisierte Ansagen mit persistentem Mute. - `cmd/talkd`: Daemon-Prozess; HTTP-Server auf `127.0.0.1:` (Port Default `7800`, konfigurierbar `TALK_PORT`/config `server.port`); Endpunkte: - `POST /speak` — JSON `{text, priority?, provider?, voice?, lang?, now?}` → enqueued, Antwort `202 {id}`; bei Mute: `202 {muted:true}` (verworfen, dokumentiertes Verhalten). - `GET /status` — JSON: Queue-Länge, aktuelles Item, Mute-State, aktiver Provider, Version. - `POST /cancel` — bricht aktive Ansage ab + leert Queue, Antwort `200 {cancelled:n}`. - `POST /mute`, `POST /unmute` — toggelt persistenten Mute-State (Datei `mute` im Config-Dir). - `internal/queue`: serialisierte Abarbeitung (eine Ansage gleichzeitig), Priorität (high > normal > low; gleiches Level: FIFO), `now`-Items springen vor; IDs (z. B. fortlaufend/zufällig kurz); konfigurierbares Rate-Limit existiert als Struktur, Limit-Wiring folgt Task 05 (Default unendlich in T04-Tests, dokumentiert). - CLI-Anbindung: `talk say` (und Default) erkennt Daemon via `GET /status`; läuft er: enqueue + sofort zurück (non-blocking Default); `--wait` pollt bis abgearbeitet; `--now` setzt `now:true`; `mute`/`unmute`/`status`/`cancel` werden Daemon-Aufrufe mit plain Output. - Degraded Mode: Daemon antwortet nicht → Lock-File `talk.lock` im Config-Dir, sequenzielles lokale Abspielen (best-effort; Kollision = warten), mute wird respektiert. - Tests an der **Prozessgrenze** (primäre Seam): talkd mit Fake-Provider-Endpunkt + `audio.sink=null` starten; per CLI/HTTP enqueue; assert: Serialisierung (2 schnell nacheinander → nacheinander abgespielt, an Fake+Sink beobachtbar), Priorität, Mute-Persistenz über Daemon-Restart, cancel, non-blocking-Verhalten (say kehrt vor Abspielende zurück), Degraded Mode (Daemon tot → Lock-File-Pfad). - Abnahme: alle Prozessgrenzen-Tests grün; `talkd &` + 3 parallele `talk`-Aufrufe → drei Ansagen nacheinander; `talk mute` → Daemon-Restart → weiter stumm. ## Task 05: Rate-Limit + Ruhezeiten Blockiert durch: 04. Liefert: Daemon-Gesetz gegen Spam. - Rate-Limit in Queue: Default **1 Ansage / 30 s** (konfigurierbar `queue.rate_limit = "30s"`); Tokens/Zeitfenster-Logik; `now:true` umgeht Warteschlangen-Wartezeit nicht das Rate-Limit; bei Überschreitung: Items mit `priority=low` werden **verworfen**, `normal` wartet, `high` darf konfigurierbar verstoßen (`queue.allow_high_burst = true|false`, Default true). - `quiet_hours`: Config `quiet_hours = "18:00-08:00"` (lokale Zeit, 24h, über Mitternacht); Verhalten configbar: `quiet_mode = "discard"|"toast"` (Default `discard`); Mute (manuell) hat immer Vorrang; `now:true` bricht auch quiet hours (bewusster Ausbruch, dokumentiert). - Tests an der Prozessgrenze: kurzes Rate-Limit (z. B. 1/s) konfigurieren; 5 enqueues → 1 abgespielt, low verworfen, normal geparkt; quiet_hours-Fenster künstlich um die aktuelle Zeit legen → discard/toast-Verhalten. - Abnahme: Tests grün; `talk status` zeigt Rate-Limit- und Quiet-Hours-State. ## Task 06: CORS-Tür + MCP-Endpoint Blockiert durch: 04. Liefert: Browser- und MCP-Anbindung auf localhost. - CORS: `POST /speak` (und nur dieser) beantwortet `OPTIONS`-Preflight und setzt `Access-Control-Allow-Origin` aus Config-Liste `server.allowed_origins = ["https://chat.az-gruppe.com"]` (Wildcard `*` erlaubt aber nicht Default; leere Liste = kein CORS-Header). Tests: Preflight 204 + Header; nicht gelistete Origin → kein ACAO-Header. - MCP: Streamable-HTTP-Endpoint `POST /mcp` (und ggf. `GET /mcp` für Stream) auf localhost; Implementierung des MCP-Protokolls (initialize, tools/list, tools/call) ohne schwergewichtige SDK — Decision: **eigene schlanke Implementierung des JSON-RPC-Subsets** (initialize, notifications/initialized, tools/list, tools/call), kein externer MCP-SDK-Dependency-Zwang; falls eine begründete leichte SDK existiert, im Report begründen. - Ein Tool `speak`: Input-Schema `{text: string, priority?, provider?, lang?, now?}` → verhält sich wie POST /speak; Response = MCP-Tool-Result (Text: "queued: " o. Ä.). - `GET /status` um Felder `mcp: true, cors: [origins]` ergänzt (Compat). - Tests: MCP-Handshake + tools/call an der Prozessgrenze (HTTP-Payloads literal getestet); CORS-Preflights. - Abnahme: curl-Preflight von gelisteter Origin ok; ein MCP-fähiger Client (z. B. manuelle JSON-RPC-Curl-Sequenz im Test/Report) sieht `speak` und enqueued erfolgreich. ## Task 07: `talk wrap` Blockiert durch: 04. Liefert: Befehlsummwicklung mit Ansage. - `talk wrap -- [args…]`: führt Befehl aus (stdin durchreichen, stdout/stderr durchreichen), misst Dauer, Ansage nach Ende: Erfolg: „