chore(sdd): handoff snapshot — ledger, briefs, reports, review packages + HANDOFF.md for cross-machine continuation

This commit is contained in:
m3ta-chiron committed 2026-10-18 21:03:24 +02:00
1 parent acfba19fc2
commit b934571b97
25 files changed
+17590

No files matched your search

+1
View File
@@ -0,0 +1 @@
.scratch/talk-2-0/plan.md
+51
View File
@@ -0,0 +1,51 @@
# SDD ledger — plan: .scratch/talk-2-0/plan.md
Repo: ~/p/talk, Branch feat/v1, Start-BASE: (initial commit auf main)
Model-Politik: alle Subagents auf zai-coding-plan/glm-5.3#max (Nutzer-Vorgabe).
## Pre-Flight Scan (Tabelle)
| Paar/Task | Befund | Ruling |
|---|---|---|
| T01 ↔ T02–T11 | T01 erzeugt Modul-Layout, das alle konsumieren | Kein Konflikt; T01 zuerst |
| T02 ↔ T03 | T02 definiert Provider-Interface, T03 erweitert Implementierungen | Sequenziell (Kante gesetzt) — Interface-Signatur in T02 ist Vertrag |
| T04 ↔ T05 | beide berühren internal/queue | Sequenziell; T04 baut Struktur mit Rate-Limit-Platzhalter, T05 verdrahtet |
| T04 ↔ T06 | gleiche HTTP-Oberfläche (/speak) | Sequenziell; T06 erweitert Router, rewrites nicht |
| T07 ↔ T04 | wrap nutzt enqueue-Pfad | Sequenziell; kein Konflikt |
| T08 ↔ T09 | teilen cmd/talk-Dispatch; T09 darf plain-Pfade nicht verändern | Im Brief verankert (TTY-Weiche in derselben Command-Funktion) |
| T11 oto-Cross-Compile | Risiko: CGO-Abhängigkeit bricht statische Targets | Plan verankert Ausweg: build-tag-Plattform-Fallback-Player; Report-Pflicht. Load-bearing für Release |
| T12 greift in AGENTS-Repo | Side effect außerhalb des Worktrees | Nutzer hat Redirect-Stubs in Grilling R4/Q2 explizit freigegeben → dispatch-fähig; nur Branch chore/talk2-migration, kein Push, keine main-Commits |
| T13 talk.sh-Löschung | destruktiv + Key noch nicht rotiert | Parked bis Nutzer Rotation bestätigt. Kein Dispatch |
| T02 espeak-Verfügbarkeit | Dev-Host braucht espeak für OS-TTS-Demo | Faktencheck läuft; falls fehlend: Demo über edge/osbuiltin-Fallback dokumentieren, espeak-Installation bleibt Nutzer-Entscheid |
Keine plan-mandierten Rubrik-Verstöße gefunden (keine assert-losen Tests, keine Duplikations-Vorgaben).
## Verlauf
(befüllt im Lauf)
Task 1: complete (commits c8f3902..fa4f193, review clean; Approved, 0 Critical/Important)
Task 1: minor (deferred): -h-Zweig ungetestet; Version-Test mutiert Package-Var; README-.scratch-Links beim Push prüfen; help-Pseudocommand als additive Surface akzeptiert; "[Flags]"-Kosmetik bei flaglosen Befehlen
Task 1: ⚠️ CI-Runner-Validierung → löst sich beim ersten Push (Gitea-Runner führt Workflow aus)
Task 2: fix round 1/5 (1 Important + 1 Minor addressed, 0 open; commits 8306c6b..ae9f3b6)
Task 2: complete (commits fa4f193..ae9f3b6, review clean nach Fix)
Task 2: minor (deferred): oto-Context-Fehler dauerhaft gecached (Task-4-Thema); WAV-chunk int-Konversion 32-bit-theoretisch; bare talk am Terminal ungetestet
Task 2: Interface-Notiz für Task 3: NewSink(name string, log io.Writer); DirectSpeaker-Erweiterung + ErrNoByteOutput als letzte Kettenstufe berücksichtigen; oto v3.5.0 + BurntSushi/toml v1.6.0 gepinnt; Cross-Compile nur mit CGO_ENABLED=0 (Release-Task merken)
Env-Fakt (Nutzer, 2026-10-18): Windows-Test-VM 192.168.122.115 via WinRM erreichbar (Zugangsdaten vom Nutzer im Chat, bewusst NICHT in Dateien). Ruling: Task 11 erhält echte Windows-Laufzeit-Verifikation (SAPI, Toast, Binary-Smoke) statt nur Cross-Compile; Task 8 doctor darf die VM opportunistisch nutzen. WinRM-Zugriff via ansible/pywinrm zum Zeitpunkt der Nutzung prüfen.
Task 3: implementiert (commits 7a843ae..8351d40), Review ausstehend
Task 3: fix round 1/5 (1 Important addressed, 0 open; commits 8351d40..3b44605)
Task 3: complete (commits ae9f3b6..3b44605, review clean nach Fix)
Task 3: Ruling: Real-Key-Abnahme (plan-mandiert) auf Nutzer-Checkpoint NACH ElevenLabs-Key-Rotation verschoben — Keys nachweislich nicht in Env; Ersatzbeweise (wörtliche Contract-Fakes, realer Edge-Lauf mit 17 kB Audio) stark; natürlicher Nachtest via T8 test/doctor. Kostet falls falsch: ein realer API-Vertrag könnte abweichen — Risiko eingegrenzt.
Task 3: minor (deferred): parseInterspersed bricht --Shielding; io.ReadAll unbegrenzt; unbekannte Config-Sections ohne Warnung; edgeClockSkew package-global; Kommentar-Typos
Task 4-Kontext: Chain (Speak/Synthesize/SpeakDirect, walk einmalig), Player-Interface strukturell == audio.Sink, injizierbar Getenv/Log/Player — Daemon MUSS Chain unverändert wiederverwenden
Task 4: complete (commits 3b44605..dc94f45, review clean; Approved, 0 Critical/Important)
Task 4: minor (deferred): Stale-Lock-Remove-Race (rename-before-remove/PID-Check); waitForItem ohne Timeout; Lost-202-Doppelansage (Idempotien-Key später); setMute lumped errors; Timing-Margins CI-Flake-Risiko; Port-in-use-Pfad ungetestet
Task 4-Kontext für Task 5: RateLimiter-Struktur in internal/queue/rate.go wartet auf Verdrahtung (Seam dokumentiert); /status-Felder erweiterbar; queue rank/loop unverändert lassen
Task 5: complete (commits dc94f45..24ceff3, review clean; Approved, 0 Critical/Important)
Task 5: minor (deferred): quiet_mode TOML-Validierung fehlt (env hat sie); Worker-zeitige Verwurf/toast-Zweige ungetestet; TestRuhezeitToast schwache Log-Assertion ("ersetzt" statt "toast"); Timing-Margins CI-Flake-Risiko; Degraded respektiert nur Mute (akzeptiert, dokumentiert)
Task 6-Kontext: api/server.go Router + types.go StatusResponse (additive Felder via omitempty); Gesetz-Präzedenz mute > now > quiet > rate in rate.go decide(); Prozess-Test-Harness test/ mit Fake-Provider
Task 6: complete (commits 24ceff3..30bd535, review clean; Approved, 0 Critical/Important; nach Resume nach externer Unterbrechung)
Task 6: minor (deferred): Preflight prüft ACRM nicht; Allow-Header-Inkonsistenz GET /mcp; Origin-Syntax-Validierung; MaxBody-Fehlercode -32700; voice im Tool-Schema nicht beworben
Task 6-Kontext: admitSpeak = gemeinsame Aufnahme (Validierung → Mute → Enqueue); MCP + /speak strukturell paritätisch; Remote origin gesetzt (git@code.m3ta.dev) — PUSH BLOCKIERT bis Nutzer ssh-add ~/.ssh/gitea ODER Token; Repo via HTTPS öffentlich lesbar
Task 6: Env-Fakt: Remote https://code.m3ta.dev/m3tam3re/talk vom Nutzer angelegt
Task 7: implementiert (commits a3424c3..acfba19, 5 Conventional) — Dispatch extern unterbrochen NACH Commits, VOR Report/Return; Report-Datei fehlt. Review nachträglich angestoßen (ohne Implementer-Report, Diff-vs-Brief).
Task 7: Handoff-Snapshot für Gitea-Push erstellt (HANDOFF.md + force-add dieses Workspaces)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,145 @@
# Review package: 8306c6b..HEAD
## Commits
ae9f3b6 fix(audio): NullSink-Verwurf-Zeile im CLI-Pfad sichtbar machen
## Files changed
cmd/talk/say.go | 2 +-
cmd/talk/say_test.go | 7 ++++++-
internal/audio/audio.go | 12 +++++++-----
internal/audio/audio_test.go | 17 +++++++++++++++--
internal/langdetect/detect.go | 2 +-
internal/langdetect/detect_test.go | 1 +
6 files changed, 31 insertions(+), 10 deletions(-)
## Diff
cmd/talk/say.go --- Go
106 return exitFailure
107 }
108
109 sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
110 if err != nil {
111 fmt.Fprintf(stderr, "talk: %v\n", err)
112 return exitFailure
cmd/talk/say_test.go --- 1/2 --- Go
52 52
53 // TestSayArgumentMitNullSink: `talk 53 // TestSayArgumentMitNullSink: `talk
.. "Hallo Welt"` läuft mit .. "Hallo Welt"` läuft mit
54 // TALK_AUDIO_SINK=null tonlos durch 54 // TALK_AUDIO_SINK=null tonlos durch
.. (Abnahmekriterium) — Synthese läuft .. (Abnahmekriterium) — Synthese läuft
55 // (fake espeak wurde gerufen), die 55 // (fake espeak wurde gerufen), und
.. Wiedergabe wird verworfen. .. die Wiedergabe wird mit sichtbarer
.. 56 // Verwurf-Zeile auf stderr verworfe
.. .. n (extern beobachtbares Verhalten de
.. .. s
.. 57 // null-Sinks auf dem CLI-Pfad).
56 func TestSayArgumentMitNullSink(t *t 58 func TestSayArgumentMitNullSink(t *t
.. esting.T) { .. esting.T) {
57 seen := withFakeEspeak(t) 59 seen := withFakeEspeak(t)
58 setGetenv(t, map[string]string{" 60 setGetenv(t, map[string]string{"
.. TALK_AUDIO_SINK": "null"}) .. TALK_AUDIO_SINK": "null"})
cmd/talk/say_test.go --- 2/2 --- Go
65 67 if _, err := os.Stat(seen); err != nil {
66 68 t.Errorf("Synthese lief nicht (fake espeak nicht gerufen): %v", err)
67 69 }
.. 70 if !strings.Contains(errBuf.String(), "verworfen (null sink)") {
.. 71 t.Errorf("stderr = %q, will Verwurf-Zeile des null-Sinks (Synthese war erfolgreich)", errBuf.String())
.. 72 }
68 73 }
69 74
70 75 // TestSaySubcommandWieDefault: `talk say "Text"` verhält sich wie der
internal/audio/audio.go --- Go
24 } 24 }
25 25
26 // NewSink liefert den Sink zum konf 26 // NewSink liefert den Sink zum konf
.. igurierten Namen ("oto" oder "null") .. igurierten Namen ("oto" oder "null")
.. . .. .
.. 27 // log ist ein optionaler Log-Output
.. .. — die CLI übergibt ihr stderr, über
.. .. den
.. 28 // der null-Sink seine Verwurf-Zeile
.. .. schreibt (nil = still verwerfen); d
.. .. er
27 // Unbekannte Namen sind ein klarer 29 // oto-Sink braucht kein Log. Unbeka
.. Fehler. .. nnte Namen sind ein klarer Fehler.
28 func NewSink(name string) (Sink, err 30 func NewSink(name string, log io.Wri
.. or) { .. ter) (Sink, error) {
29 switch name { 31 switch name {
30 case NameOto: 32 case NameOto:
31 return OtoSink{}, nil 33 return OtoSink{}, nil
32 case NameNull: 34 case NameNull:
33 return NullSink{}, nil 35 return NullSink{W: log}, nil
34 default: 36 default:
35 return nil, fmt.Errorf("unbe 37 return nil, fmt.Errorf("unbe
.. kannter audio sink %q (erlaubt: %q, .. kannter audio sink %q (erlaubt: %q,
.. %q)", name, NameOto, NameNull) .. %q)", name, NameOto, NameNull)
36 } 38 }
37 } 39 }
38 40
39 // NullSink verwirft jede Ansage ohn 41 // NullSink verwirft jede Ansage ohn
.. e Ton — für CI, Server und Tests. Wu .. e Ton — für CI, Server und Tests. Wu
.. rde .. rde
40 // ein W gesetzt, loggt Play eine Ze 42 // ein W gesetzt (NewSink reicht daf
.. ile darüber (der CLI-Pfad hängt stde .. ür den Log-Output der CLI durch), lo
.. rr .. ggt
41 // an, damit „läuft ohne Ton durch" 43 // Play eine Zeile darüber, damit „l
.. auch sichtbar bleibt). .. äuft ohne Ton durch" sichtbar bleibt
.. .. .
42 type NullSink struct { 44 type NullSink struct {
43 W io.Writer // optionaler Log-Ou 45 W io.Writer // optionaler Log-Ou
.. tput; nil = still verwerfen .. tput; nil = still verwerfen
44 } 46 }
internal/audio/audio_test.go --- Go
115 115
116 116 func TestNewSink(t *testing.T) {
117 117 for _, name := range []string{NameOto, NameNull} {
118 118 s, err := NewSink(name, nil)
119 119 if err != nil {
120 120 t.Fatalf("NewSink(%q): %v", name, err)
121 121 }
122 122 if s.Name() != name {
123 123 t.Errorf("NewSink(%q).Name() = %q", name, s.Name())
124 124 }
125 125 }
126 126 if _, err := NewSink("mpv", nil); err == nil {
127 127 t.Error("NewSink(\"mpv\") soll fehlschlagen")
128 128 }
... 129 t.Run("null-Sink loggt über den Factory-Writer", func(t *testing.T) {
... 130 var buf bytes.Buffer
... 131 s, err := NewSink(NameNull, &buf)
... 132 if err != nil {
... 133 t.Fatalf("NewSink(%q): %v", NameNull, err)
... 134 }
... 135 if err := s.Play(context.Background(), []byte("audio")); err != nil {
... 136 t.Fatalf("Play: %v", err)
... 137 }
... 138 if !strings.Contains(buf.String(), "verworfen (null sink)") {
... 139 t.Errorf("Log = %q, will Verwurf-Zeile über den Factory-Writer", buf.String())
... 140 }
... 141 })
129 142 }
130 143
131 144 func TestNullSinkVerwirftUndLoggt(t *testing.T) {
internal/langdetect/detect.go --- Go
68 "finished": {}, "started": {}, "completed": {}, "error": {},
69 "warning": {}, "thanks": {}, "you": {}, "your": {},
70 "we": {}, "it": {}, "this": {}, "that": {}, "these": {},
71 "those": {}, "was": {}, "were": {}, "are": {}, "be": {},
72 "been": {}, "has": {}, "have": {}, "had": {}, "can": {},
73 "will": {}, "would": {}, "should": {}, "could": {}, "just": {},
74 "only": {}, "still": {}, "already": {}, "now": {}, "today": {},
internal/langdetect/detect_test.go --- Go
15 15 {"nur whitespace", " \n\t ", "de"},
16 16 {"nur interpunktion", "!!! ??? ...", "de"},
17 17 {"deutsch satz", "Der Build wurde erfolgreich abgeschlossen und das Deployment ist fertig.", "de"},
.. 18 {"deutsch frage mit was", "Was macht er?", "de"},
18 19 {"deutsch umlaute", "Größe früh Ärzteübung — schön daß es klappt", "de"},
19 20 {"ß alleine", "weiß", "de"},
20 21 {"englisch satz", "The build has finished and the deployment is done.", "en"},
@@ -0,0 +1,178 @@
# Review package: 8351d40..HEAD
## Commits
3b44605 fix(provider): Chain.Speak läuft im Direkt-Endgame nur einmal durch die Kette
## Files changed
internal/provider/chain.go | 39 +++++++++++++++++++-----------
internal/provider/chain_test.go | 53 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 78 insertions(+), 14 deletions(-)
## Diff
internal/provider/chain.go --- 1/2 --- Go
205 } 205 }
206 return nil, fmt.Errorf("%w 206 return nil, fmt.Errorf("%w
... (Stufe %s spricht nur direkt)", Err ... (Stufe %s spricht nur direkt)", Err
... NoByteOutput, r.directName) ... NoByteOutput, r.directName)
207 } 207 }
208 return nil, fmt.Errorf("kette: 208 return nil, r.noTierErr()
... keine Stufe konnte sprechen (nicht ...
... verfügbar: %s; fehlgeschlagen: %s)" ...
... , ...
209 joinOr(r.skipped, "keine"), ...
... joinOr(r.failed, "keine")) ...
210 } 209 }
211 210
212 // SpeakDirect implementiert Direct 211 // SpeakDirect implementiert Direct
... Speaker: Direktansage über die Stuf ... Speaker: Direktansage über die Stuf
... e, ... e,
internal/provider/chain.go --- 2/2 --- Go
225 joinOr(r.skipped, "keine"), 224 joinOr(r.skipped, "keine"),
... joinOr(r.failed, "keine")) ... joinOr(r.failed, "keine"))
226 } 225 }
227 226
228 // Speak ist die komplette Ansage: 227 // Speak ist die komplette Ansage i
... Synthese mit Fallback → Wiedergabe ... n EINEM Ketten-Durchlauf: Synthese
... über ... mit
229 // den Player; fällt die Kette auf 228 // Fallback → Wiedergabe über den P
... die Direkt-Stufe zurück, entscheide ... layer; fällt die Kette auf die Dire
... t der ... kt-
230 // Player-Name über Ton (alles auße 229 // Stufe zurück, entscheidet der Pl
... r „null") oder tonloses Verwerfen ( ... ayer-Name über Ton (alles außer „nu
... null: ... ll")
231 // CI/Server bleiben fehler- und to 230 // oder tonloses Verwerfen (null: C
... nfrei, der Verwurf wird protokollie ... I/Server bleiben fehler- und tonfre
... rt). ... i,
... 231 // der Verwurf wird protokolliert).
... ... Der Durchlauf findet bewusst genau
... 232 // einmal statt: fehlgeschlagene Cl
... ... oud-Stufen werden nicht ein zweites
... ... Mal
... 233 // angefragt (keine doppelten POSTs
... ... auf bezahlten APIs, keine doppelte
... ... n
... 234 // Protokoll-Zeilen), und Bytes aus
... ... diesem Durchlauf werden abgespielt
... ... ,
... 235 // statt sie wegzuwerfen und neu zu
... ... laufen. Nur ein Fehler aller Stufe
... ... n
232 // Nur ein Fehler aller Stufen oder 236 // oder eine fehlgeschlagene Wieder
... eine fehlgeschlagene Wiedergabe si ... gabe sind Fehler.
... nd ...
233 // Fehler. ...
234 func (c *Chain) Speak(ctx context.C 237 func (c *Chain) Speak(ctx context.C
... ontext, text, lang string) error { ... ontext, text, lang string) error {
235 data, err := c.Synthesize(ctx, 238 r := c.walk(ctx, text, lang)
... text, lang) ...
236 switch { 239 switch {
237 case err == nil: 240 case r.data != nil:
238 if c.cfg.Player == nil { 241 if c.cfg.Player == nil {
239 return fmt.Errorf("kett 242 return fmt.Errorf("kett
... e: kein Player konfiguriert — Bytes ... e: kein Player konfiguriert — Bytes
... können nicht abgespielt werden") ... können nicht abgespielt werden")
240 } 243 }
241 if err := c.cfg.Player.Play 244 if err := c.cfg.Player.Play
... (ctx, data); err != nil { ... (ctx, r.data); err != nil {
242 return fmt.Errorf("kett 245 return fmt.Errorf("kett
... e: Wiedergabe über %s fehlgeschlage ... e: Wiedergabe über %s fehlgeschlage
... n: %w", c.cfg.Player.Name(), err) ... n: %w", c.cfg.Player.Name(), err)
243 } 246 }
244 return nil 247 return nil
245 case errors.Is(err, ErrNoByteOu 248 case r.direct != nil:
... tput): ...
246 if c.cfg.Player != nil && c 249 if c.cfg.Player != nil && c
... .cfg.Player.Name() == NullPlayerNam ... .cfg.Player.Name() == NullPlayerNam
... e { ... e {
247 chainLogf(c.cfg, "kette 250 chainLogf(c.cfg, "kette
... : Direktansage verworfen (audio.sin ... : Direktansage verworfen (audio.sin
... k=null)\n") ... k=null)\n")
248 return nil 251 return nil
249 } 252 }
250 return c.SpeakDirect(ctx, t 253 return r.direct.SpeakDirect
... ext, lang) ... (ctx, text, lang)
251 default: 254 default:
252 return err 255 return r.noTierErr()
253 } 256 }
254 } 257 }
... 258
... 259 // noTierErr bündelt den Ende-ohne-
... ... Ergebnis-Fehler eines Durchlaufs: k
... ... eine
... 260 // Stufe lieferte Bytes oder Direkt
... ... ansage — übersprungene (nicht verfü
... ... gbar)
... 261 // und fehlgeschlagene Stufen werde
... ... n genannt, nie still.
... 262 func (r walkResult) noTierErr() err
... ... or {
... 263 return fmt.Errorf("kette: keine
... ... Stufe konnte sprechen (nicht verfü
... ... gbar: %s; fehlgeschlagen: %s)",
... 264 joinOr(r.skipped, "keine"),
... ... joinOr(r.failed, "keine"))
... 265 }
255 266
256 // chainLogf schreibt eine Protokol 267 // chainLogf schreibt eine Protokol
... l-Zeile, wenn ein Log gesetzt ist. ... l-Zeile, wenn ein Log gesetzt ist.
257 func chainLogf(cfg ChainConfig, for 268 func chainLogf(cfg ChainConfig, for
... mat string, args ...any) { ... mat string, args ...any) {
internal/provider/chain_test.go --- Go
341 341 }
342 342 }
343
344 // TestChainDirektEndgameEinDurchlauf (Review-Fix): Im DirectSpeaker-
345 // Endgame läuft die Kette genau EINMAL — die fehlgeschlagene Cloud-Stufe
346 // wird nur einmal angefragt (keine doppelten POSTs auf bezahlten APIs,
347 // keine verdoppelte Latenz), jede Stufe protokolliert ihren Fehlschlag
348 // genau einmal, und die Direktansage kommt zustande.
349 func TestChainDirektEndgameEinDurchlauf(t *testing.T) {
350 eleven, elHits := countingTTSServer(t, 500, []byte("boom"))
351 // elevenlabs: Key + Fake (500). openai: Endpunkt, aber KEIN Key →
352 // übersprungen. edge: toter Loopback-Port. osbuiltin: PATH nur mit
353 // fake spd-say (kein espeak) → keine Bytes, Direktansage.
354 dir := t.TempDir()
355 seen := filepath.Join(dir, "seen.txt")
356 fakeProcBin(t, dir, "spd-say", `printf '%s\n' "$*" > "`+seen+`"`)
357 t.Setenv("PATH", dir)
358
359 var log bytes.Buffer
360 ch, err := NewChain(ChainConfig{
361 Getenv: chainEnv(map[string]string{
362 EnvElevenLabsKey: "k",
363 EnvElevenLabsEndpoint: eleven,
364 EnvOpenAIEndpoint: eleven, // ohne Key ohnehin übersprungen
365 EnvEdgeEndpoint: "ws://127.0.0.1:1/",
366 }),
367 Player: &fakePlayer{name: "oto"},
368 Log: &log,
369 })
370 if err != nil {
371 t.Fatalf("NewChain: %v", err)
372 }
373
374 if err := ch.Speak(context.Background(), "Hallo Welt", "de"); err != nil {
375 t.Fatalf("Speak (Direkt-Endgame): %v", err)
376 }
377 if got := elHits.Load(); got != 1 {
378 t.Errorf("elevenlabs wurde %d× angefragt, want genau 1 (kein zweiter Durchlauf)", got)
379 }
380 for _, tier := range []string{"elevenlabs", "edge"} {
381 if got := strings.Count(log.String(), "Stufe "+tier+" fehlgeschlagen"); got != 1 {
382 t.Errorf("„Stufe %s fehlgeschlagen“ steht %d× im Log, want genau 1:\n%s", tier, got, log.String())
383 }
384 }
385 if got := strings.Count(log.String(), "Stufe openai übersprungen"); got != 1 {
386 t.Errorf("openai-Skip steht %d× im Log, want genau 1", got)
387 }
388 args, err := os.ReadFile(seen)
389 if err != nil {
390 t.Fatalf("fake spd-say nicht gerufen: %v", err)
391 }
392 if got := strings.TrimSpace(string(args)); got != "-w -l de Hallo Welt" {
393 t.Errorf("spd-say ARGS = %q, want %q", got, "-w -l de Hallo Welt")
394 }
395 }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,663 @@
# Review package: c8f3902..HEAD
## Commits
fa4f193 ci: add gitea actions test workflow (vet, gofmt, test, build)
33b97a5 feat(cli): add subcommand dispatch, --version and stub commands
24dfe4d chore: bootstrap go module, license, readme and package skeleton
## Files changed
.gitea/workflows/test.yml | 30 ++++++
.gitignore | 10 ++
LICENSE | 21 ++++
README.md | 49 +++++++++
cmd/talk/cli.go | 259 +++++++++++++++++++++++++++++++++++++++++++++
cmd/talk/cli_test.go | 146 +++++++++++++++++++++++++
cmd/talk/main.go | 21 ++++
cmd/talkd/main.go | 13 +++
go.mod | 3 +
internal/api/doc.go | 4 +
internal/audio/doc.go | 5 +
internal/config/doc.go | 7 ++
internal/langdetect/doc.go | 4 +
internal/notify/doc.go | 4 +
internal/provider/doc.go | 6 ++
internal/queue/doc.go | 6 ++
16 files changed, 588 insertions(+)
## Diff
.gitea/workflows/test.yml --- YAML
1 name: test
2
3 on:
4 push:
5 pull_request:
6
7 jobs:
8 test:
9 runs-on: ubuntu-latest
10 steps:
11 - name: Checkout
12 uses: actions/checkout@v4
13 - name: Go einrichten
14 uses: actions/setup-go@v5
15 with:
16 go-version-file: go.mod
17 - name: go vet
18 run: go vet ./...
19 - name: gofmt-Check
20 run: |
21 unformatted=$(gofmt -l .)
22 if [ -n "$unformatted" ]; then
23 echo "Folgende Dateien sind nicht gofmt-formatiert:"
24 echo "$unformatted"
25 exit 1
26 fi
27 - name: go test
28 run: go test ./...
29 - name: go build
30 run: go build ./...
31
.gitignore --- Text
1 # Go-Binaries
2 /talk
3 /talkd
4 /talk.exe
5 /talkd.exe
6 *.test
7 *.out
8
9 # Release-Artefakte (GoReleaser, Task 11)
10 /dist/
11
LICENSE --- Text
1 MIT License
2
3 Copyright (c) 2026 m3tam3re
4
5 Permission is hereby granted, free of charge, to any person obtaining a copy
6 of this software and associated documentation files (the "Software"), to deal
7 in the Software without restriction, including without limitation the rights
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 copies of the Software, and to permit persons to whom the Software is
10 furnished to do so, subject to the following conditions:
11
12 The above copyright notice and this permission notice shall be included in all
13 copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 SOFTWARE.
22
README.md --- Text
1 # talk
2
3 > **Status: WIP** — aktive Entwicklung (talk 2.0). Binde Spezifikation:
4 > [`.scratch/talk-2-0/spec.md`](.scratch/talk-2-0/spec.md) ·
5 > Implementierungsplan: [`.scratch/talk-2-0/plan.md`](.scratch/talk-2-0/plan.md)
6
7 **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
8 spricht eine kurze Nachricht über eine TTS-Provider-Kette
9 (ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
10 `talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
11 keine Runtime-Abhängigkeiten.
12
13 ## Befehle
14
15 | Befehl | Zweck |
16 | --- | --- |
17 | `talk "Text"` | Ansage — Abkürzung für `talk say`, non-blocking |
18 | `talk say "Text"` | Ansage (Text als Argument oder von stdin) |
19 | `talk wrap -- <Befehl>` | Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen |
20 | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
21 | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
22 | `talk cancel` | laufende und geplante Ansagen abbrechen |
23 | `talk voices` | verfügbare Stimmen anzeigen |
24 | `talk test` | Test-Ansage ausgeben |
25 | `talk doctor` | Selbstdiagnose des Setups |
26 | `talk setup` | geführte Ersteinrichtung (Wizard) |
27
28 Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
29 `--voice`, `--lang`, `--provider`.
30
31 **Hinweis:** Alle Befehle sind in diesem Stand Stubs und melden
32 `not implemented yet` (Exit-Code ≠ 0). Die Implementierung entsteht
33 Aufgabenweise gemäß Plan; `--version` und `--help` funktionieren.
34
35 ## Entwicklung
36
37 ```console
38 go build ./... # bauen
39 go test ./... # Tests
40 go vet ./... # statische Analyse
41 gofmt -l . # Formatierung prüfen (leerer Output = ok)
42 ```
43
44 CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
45 und Build bei jedem Push und Pull Request.
46
47 ## Lizenz
48
49 MIT — siehe [LICENSE](LICENSE).
50
cmd/talk/cli.go --- Go
1 package main
2
3 import (
4 "errors"
5 "flag"
6 "fmt"
7 "io"
8 )
9
10 // Exit-Codes.
11 const (
12 exitOK = 0 // Erfolg (auch --help, --version)
13 exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert
14 exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl)
15 )
16
17 // command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
18 // datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
19 // hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
20 // anzufassen.
21 type command struct {
22 name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
23 short string // Kurzbeschreibung für `talk --help`
24 args string // Positionsargumente-Hinweis für die Usage-Zeile
25 detail string // Beschreibung für `talk <Befehl> --help`
26 register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet
27 }
28
29 // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
30 // bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen.
31 var commands = []command{
32 {
33 name: "say",
34 short: "Text ansagen (Default-Befehl)",
35 args: `"Text"`,
36 detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.",
37 register: addSayFlags,
38 },
39 {
40 name: "wrap",
41 short: "Befehl ausführen und Ergebnis ansagen",
42 args: "-- <Befehl> [Argumente …]",
43 detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.",
44 },
45 {
46 name: "mute",
47 short: "Ansagen dauerhaft stummschalten",
48 detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).",
49 },
50 {
51 name: "unmute",
52 short: "Stummschaltung aufheben",
53 detail: "Hebt den persistenten Mute-State des Daemons wieder auf.",
54 },
55 {
56 name: "status",
57 short: "Daemon-Status anzeigen",
58 detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version.",
59 },
60 {
61 name: "cancel",
62 short: "Laufende und geplante Ansagen abbrechen",
63 detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons.",
64 },
65 {
66 name: "voices",
67 short: "Verfügbare Stimmen anzeigen",
68 detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.",
69 },
70 {
71 name: "test",
72 short: "Test-Ansage ausgeben",
73 detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.",
74 },
75 {
76 name: "doctor",
77 short: "Setup-Diagnose anzeigen",
78 detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.",
79 },
80 {
81 name: "setup",
82 short: "Geführte Ersteinrichtung (Wizard)",
83 detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.",
84 },
85 }
86
87 // addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den
88 // Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der
89 // Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04
90 // (Daemon-Pfad).
91 func addSayFlags(fs *flag.FlagSet) {
92 fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist")
93 fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
94 fs.String("priority", "normal", "Priorität: low | normal | high")
95 fs.String("voice", "", "Stimme als provider-spezifische ID")
96 fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)")
97 fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
98 }
99
100 // run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
101 // Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne
102 // Prozessstart testbar ist. Rückgabe ist der Exit-Code.
103 func run(argv []string, stdout, stderr io.Writer) int {
104 if len(argv) == 0 {
105 // Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser
106 // (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler.
107 writeUsage(stderr)
108 return exitUsage
109 }
110
111 if argv[0] == "help" {
112 if len(argv) > 1 {
113 if cmd, ok := lookupCommand(argv[1]); ok {
114 writeCommandHelp(cmd, stdout)
115 return exitOK
116 }
117 fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1])
118 return exitUsage
119 }
120 writeUsage(stdout)
121 return exitOK
122 }
123
124 if cmd, ok := lookupCommand(argv[0]); ok {
125 return runCommand(cmd, argv[1:], stdout, stderr)
126 }
127
128 // Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
129 // bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub.
130 return runDefault(argv, stdout, stderr)
131 }
132
133 func lookupCommand(name string) (command, bool) {
134 for _, cmd := range commands {
135 if cmd.name == name {
136 return cmd, true
137 }
138 }
139 return command{}, false
140 }
141
142 // newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
143 // explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext
144 // ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird.
145 func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) {
146 fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
147 fs.SetOutput(out)
148 fs.Usage = func() {}
149 if cmd.register != nil {
150 cmd.register(fs)
151 }
152 help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
153 return fs, help
154 }
155
156 // runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub.
157 func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
158 fs, help := newCommandFlagSet(cmd, stderr)
159 if err := fs.Parse(args); err != nil {
160 if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
161 writeCommandHelp(cmd, stdout)
162 return exitOK
163 }
164 // Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben.
165 fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name)
166 return exitUsage
167 }
168 if *help {
169 writeCommandHelp(cmd, stdout)
170 return exitOK
171 }
172 return notImplemented(cmd.name, stderr)
173 }
174
175 // runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
176 // `talk --version`, `talk --help` sowie say-Flags vor dem Text.
177 func runDefault(argv []string, stdout, stderr io.Writer) int {
178 fs := flag.NewFlagSet("talk", flag.ContinueOnError)
179 fs.SetOutput(stderr)
180 fs.Usage = func() {}
181 showVersion := fs.Bool("version", false, "Version anzeigen")
182 help := fs.Bool("help", false, "diese Hilfe anzeigen")
183 addSayFlags(fs)
184
185 if err := fs.Parse(argv); err != nil {
186 if errors.Is(err, flag.ErrHelp) {
187 writeUsage(stdout)
188 return exitOK
189 }
190 fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.")
191 return exitUsage
192 }
193 if *showVersion {
194 fmt.Fprintln(stdout, versionString())
195 return exitOK
196 }
197 if *help {
198 writeUsage(stdout)
199 return exitOK
200 }
201 return notImplemented("say", stderr)
202 }
203
204 // versionString rendert die Versionsausgabe: `talk version <version>` plus
205 // Commit-Hash in Klammern, falls per ldflags injiziert.
206 func versionString() string {
207 if commit == "" {
208 return fmt.Sprintf("talk version %s", version)
209 }
210 return fmt.Sprintf("talk version %s (%s)", version, commit)
211 }
212
213 // notImplemented meldet einen noch nicht implementierten Befehl auf stderr
214 // und liefert den dafür vorgesehenen Exit-Code (non-zero).
215 func notImplemented(name string, stderr io.Writer) int {
216 fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name)
217 return exitFailure
218 }
219
220 // writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der
221 // Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen.
222 func writeUsage(w io.Writer) {
223 fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten
224
225 Usage:
226 talk [Flags] "Text" Text ansagen (Abkürzung für: talk say)
227 talk <Befehl> [Flags] Befehl ausführen
228 talk help [Befehl] Hilfe anzeigen
229
230 Befehle:
231 `)
232 for _, cmd := range commands {
233 fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
234 }
235 fmt.Fprint(w, `
236 Flags:
237 --version Version anzeigen
238 --help diese Hilfe anzeigen
239
240 Die Flags von say gelten auch für talk "Text"; Details: talk say --help
241 `)
242 }
243
244 // writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags.
245 func writeCommandHelp(cmd command, w io.Writer) {
246 fmt.Fprintf(w, "Usage:\n talk %s", cmd.name)
247 if cmd.args != "" {
248 fmt.Fprintf(w, " %s", cmd.args)
249 }
250 fmt.Fprintln(w, " [Flags]")
251 fmt.Fprintln(w)
252 if cmd.detail != "" {
253 fmt.Fprintf(w, "%s\n\n", cmd.detail)
254 }
255 fmt.Fprintln(w, "Flags:")
256 fs, _ := newCommandFlagSet(cmd, w)
257 fs.PrintDefaults()
258 fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
259 }
260
cmd/talk/cli_test.go --- Go
1 package main
2
3 import (
4 "bytes"
5 "strings"
6 "testing"
7 )
8
9 // wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als
10 // hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet),
11 // damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen.
12 var wantSubcommands = []string{
13 "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
14 }
15
16 func TestHelpListetAlleSubcommands(t *testing.T) {
17 var out, errBuf bytes.Buffer
18 code := run([]string{"--help"}, &out, &errBuf)
19 if code != exitOK {
20 t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
21 }
22 lines := strings.Split(out.String(), "\n")
23 for _, name := range wantSubcommands {
24 found := false
25 for _, ln := range lines {
26 if strings.HasPrefix(strings.TrimLeft(ln, " "), name+" ") {
27 found = true
28 break
29 }
30 }
31 if !found {
32 t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String())
33 }
34 }
35 }
36
37 func TestVersionDefault(t *testing.T) {
38 var out, errBuf bytes.Buffer
39 code := run([]string{"--version"}, &out, &errBuf)
40 if code != exitOK {
41 t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
42 }
43 if got, want := out.String(), "talk version dev\n"; got != want {
44 t.Errorf("--version Output = %q, want %q", got, want)
45 }
46 }
47
48 func TestVersionStringMitCommit(t *testing.T) {
49 oldCommit := commit
50 commit = "c0ffee1"
51 defer func() { commit = oldCommit }()
52
53 if got, want := versionString(), "talk version dev (c0ffee1)"; got != want {
54 t.Errorf("versionString() = %q, want %q", got, want)
55 }
56 }
57
58 func TestSubcommandStubs(t *testing.T) {
59 for _, name := range wantSubcommands {
60 t.Run(name, func(t *testing.T) {
61 var out, errBuf bytes.Buffer
62 code := run([]string{name}, &out, &errBuf)
63 if code == exitOK {
64 t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
65 }
66 if !strings.Contains(errBuf.String(), "not implemented yet") {
67 t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String())
68 }
69 if !strings.Contains(errBuf.String(), name) {
70 t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String())
71 }
72 if out.Len() != 0 {
73 t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String())
74 }
75 })
76 }
77 }
78
79 func TestDefaultAufrufMapptAufSay(t *testing.T) {
80 var out, errBuf bytes.Buffer
81 code := run([]string{"Hallo Welt"}, &out, &errBuf)
82 if code != exitFailure {
83 t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure)
84 }
85 if !strings.Contains(errBuf.String(), "talk say is not implemented yet") {
86 t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String())
87 }
88 }
89
90 func TestSayHelpZeigtFlags(t *testing.T) {
91 var out, errBuf bytes.Buffer
92 code := run([]string{"say", "--help"}, &out, &errBuf)
93 if code != exitOK {
94 t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
95 }
96 for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} {
97 if !strings.Contains(out.String(), f) {
98 t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
99 }
100 }
101 }
102
103 func TestHelpSubcommand(t *testing.T) {
104 t.Run("ohne Argument", func(t *testing.T) {
105 var out, errBuf bytes.Buffer
106 if code := run([]string{"help"}, &out, &errBuf); code != exitOK {
107 t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
108 }
109 for _, name := range wantSubcommands {
110 if !strings.Contains(out.String(), name) {
111 t.Errorf("talk help listet %q nicht", name)
112 }
113 }
114 })
115 t.Run("mit Befehl", func(t *testing.T) {
116 var out, errBuf bytes.Buffer
117 if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
118 t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
119 }
120 if !strings.Contains(out.String(), "talk mute") {
121 t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String())
122 }
123 })
124 }
125
126 func TestOhneArgumenteZeigtUsage(t *testing.T) {
127 var out, errBuf bytes.Buffer
128 code := run(nil, &out, &errBuf)
129 if code != exitUsage {
130 t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage)
131 }
132 if !strings.Contains(errBuf.String(), "Usage") {
133 t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String())
134 }
135 }
136
137 func TestUnbekanntesFlag(t *testing.T) {
138 var out, errBuf bytes.Buffer
139 code := run([]string{"--bogus"}, &out, &errBuf)
140 if code != exitUsage {
141 t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
142 }
143 if !strings.Contains(errBuf.String(), "bogus") {
144 t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String())
145 }
146 }
147
cmd/talk/main.go --- Go
1 // talk ist die CLI für Sprach-Ausgaben (talk 2.0).
2 //
3 // Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version
4 // und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09.
5 package main
6
7 import "os"
8
9 // Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
10 //
11 // go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk
12 //
13 // Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11).
14 var (
15 version = "dev"
16 commit = ""
17 )
18
19 func main() {
20 os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
21 }
22
cmd/talkd/main.go --- Go
1 // talkd ist der Daemon für talk 2.0 (Queue, Mute-State, HTTP-API, MCP).
2 //
3 // Dieser Stand (Task 01) ist ein Platzhalter: talkd startet, loggt eine Zeile
4 // und beendet sich mit Exit-Code 0. Die eigentliche Daemon-Implementierung
5 // (HTTP-Server auf 127.0.0.1, serialisierte Ansage-Queue, persistentes Mute)
6 // entsteht in Task 04.
7 package main
8
9 import "log"
10
11 func main() {
12 log.Println("talkd: Platzhalter — Daemon-Implementierung folgt in Task 04")
13 }
14
go.mod --- Text
1 module code.m3ta.dev/m3tam3re/talk
2
3 go 1.26.8
4
internal/api/doc.go --- Go
1 // Package api implementiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
2 // POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute (Task 04),
3 // CORS für /speak und den MCP-Endpoint (Streamable HTTP, Task 06).
4 package api
5
internal/audio/doc.go --- Go
1 // Package audio implementiert die Audio-Ausgabe von talk über das
2 // Sink-Interface: Playback embedded via oto (PCM/WAV) und ein null-Sink,
3 // das verwirft (CI, Server, Tests). Auswahl per Config audio.sink.
4 // Implementierung folgt in Task 02.
5 package audio
6
internal/config/doc.go --- Go
1 // Package config implementiert die Config-Auflösung von talk: Defaults +
2 // optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
3 // ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
4 // (TALK_*); Env schlägt Datei. Enthält Voice-Mapping voice.<provider>.<lang>
5 // und Provider-Endpunkte (Fake-bar für Tests). Implementierung folgt in
6 // Task 02.
7 package config
8
internal/langdetect/doc.go --- Go
1 // Package langdetect erkennt die Sprache eines Ansage-Texts als reine
2 // Funktion Detect(text) → "de" | "en" (Default "de"; Heuristik über
3 // Common-Word-Listen und Umlaute/ß). Implementierung folgt in Task 02.
4 package langdetect
5
internal/notify/doc.go --- Go
1 // Package notify implementiert Best-effort-Desktop-Notifications parallel
2 // zum Audio (beeep: notify-send / macOS Notification Center / Windows Toast),
3 // mit Abbrechen-Button, wo das OS ihn hergibt. Implementierung folgt.
4 package notify
5
internal/provider/doc.go --- Go
1 // Package provider implementiert die TTS-Provider-Kette von talk:
2 // ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in (macOS say, Windows SAPI,
3 // Linux espeak/speech-dispatcher), überschreibbar per Config/Flag, mit
4 // Fallback bei Fehlern. Implementierung folgt in Task 02 (osbuiltin) und
5 // Task 03 (Cloud-Provider, Kette).
6 package provider
7
internal/queue/doc.go --- Go
1 // Package queue implementiert die Ansage-Queue des Daemons: serialisierte
2 // Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
3 // FIFO je Level), now-Items springen vor, Rate-Limit mit prioritätsbewusstem
4 // Verwerfen. Implementierung folgt in Task 04 (Queue) und Task 05
5 // (Rate-Limit, Ruhezeiten).
6 package queue
7
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -0,0 +1,14 @@
## Task 1: 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.
+115
View File
@@ -0,0 +1,115 @@
# Task 1 Report: Repo-Skeleton & CI-Backbone
**Status:** DONE
**Branch:** `feat/v1` (Basis `c8f3902`, nie gepusht)
**Commits:**
- `24dfe4d` chore: bootstrap go module, license, readme and package skeleton
- `33b97a5` feat(cli): add subcommand dispatch, --version and stub commands
- `fa4f193` ci: add gitea actions test workflow (vet, gofmt, test, build)
## Implementiert
### Go-Modul & Layout
- `go.mod`: `module code.m3ta.dev/m3tam3re/talk`, `go 1.26.8` (aktuelle stabile
Version des Dev-Hosts; CI liest sie per `go-version-file: go.mod`). Keine
externen Dependencies, keine go.sum.
- Verzeichnisgerüst komplett:
- `cmd/talk/` (3 Dateien), `cmd/talkd/` (1 Datei)
- `internal/{provider,queue,audio,notify,config,api,langdetect}/doc.go` —
jeweils nur Package-Doku mit Zweck + Task-Verweis, kein toter Code.
### CLI (`cmd/talk/`)
- **`main.go`**: ldflags-Variablen `version = "dev"`, `commit = ""` verdrahtet
(Paket `main`, injizierbar per `-X main.version=… -X main.commit=…`);
`main()` delegiert an `run()` mit injizierbaren Writer/Return-Code-Design.
- **`cli.go`**: datengetriebene Befehlstabelle (alle 10 Subcommands aus der
bindenden CLI-Surface: say, wrap, mute, unmute, status, cancel, voices,
test, doctor, setup) + Dispatch:
- `talk "Text"` → Default-Pfad, mappt auf say (Stub vermeldet
`talk say is not implemented yet`).
- `talk <Befehl>` → Subcommand-Stub: non-zero (Exit 1) + klare
`not implemented yet`-Meldung auf stderr.
- `--version` → `talk version dev` (bzw. `talk version <v> (<commit>)` wenn
Commit injiziert — manuell verifiziert: `talk version 1.0.0-test (abc1234)`).
- `--help` / `help [Befehl]` / `<Befehl> --help` → Hilfe aus derselben
Tabelle generiert (keine Drift zwischen Hilfe und Dispatch möglich).
- say-Flags gemäß Spec bereits registriert und dokumentiert:
`--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider` (gelten
auch für den Default-Pfad `talk "Text" --wait …`).
- Exit-Code-Konvention: 0 = OK (inkl. help/version), 1 = Stub/Fehler,
2 = Benutzungsfehler (unbekannte Flags, Aufruf ohne Argumente).
- Erweiterbar für Task 09 (Bubble Tea): neuer Eintrag in `commands` bzw.
austauschbares `register`/Run-Verhalten, ohne `run()`-Dispatch-Umbau.
### Daemon-Stub (`cmd/talkd/main.go`)
Startet, loggt eine Zeile (`talkd: Platzhalter — Daemon-Implementierung folgt
in Task 04`), beendet mit Exit 0. Verifiziert.
### CI (`.gitea/workflows/test.yml`)
Push + PR; Steps: checkout@v4 → setup-go@v5 (`go-version-file: go.mod`) →
`go vet ./...` → gofmt-Check (`gofmt -l .`, fail bei nicht-leerem Output) →
`go test ./...` → `go build ./...`. YAML validiert (nixpkgs-yq-Parser).
### Meta
- `.gitignore`: `/talk`, `/talkd`, `.exe`-Varianten, `*.test`, `*.out`, `/dist/`.
- `LICENSE`: MIT, `Copyright (c) 2026 m3tam3re`.
- `README.md` (deutsch): Zweck (1 Satz), Status WIP + Spec/Plan-Verweis,
Befehlstabelle, say-Flags, Entwicklungs-/CI-Hinweise, Lizenz.
## Getestet (Ergebnisse)
`cmd/talk/cli_test.go` — 9 Black-Box-Tests über die `run()`-Seam (in-process,
kein Subprocess-Bedarf, alle extern beobachtbares Verhalten):
| Test | Assertion |
| --- | --- |
| `TestHelpListetAlleSubcommands` | `--help` listet alle 10 Befehle (zeilenpräzise, hartkodierte Soll-Liste aus der Spec, nicht aus der Tabelle abgeleitet) |
| `TestVersionDefault` | `--version` → Exit 0, exakt `talk version dev\n` |
| `TestVersionStringMitCommit` | Commit-Injection → `talk version dev (c0ffee1)` |
| `TestSubcommandStubs` | alle 10 Befehle: Exit non-zero, stderr enthält Befehlsnamen + `not implemented yet`, stdout leer |
| `TestDefaultAufrufMapptAufSay` | `talk "Hallo Welt"` → `talk say is not implemented yet` |
| `TestSayHelpZeigtFlags` | `say --help` zeigt alle 6 say-Flags |
| `TestHelpSubcommand` | `help` + `help mute` → Exit 0, korrekte Hilfe |
| `TestOhneArgumenteZeigtUsage` | nackter Aufruf → Usage auf stderr, Exit 2 |
| `TestUnbekanntesFlag` | `--bogus` → Exit 2 + Fehlermeldung |
Vollständige Abnahme vor Commit und final auf dem Commit-Stand:
`gofmt -l .` leer · `go vet ./...` grün · `go test -count=1 ./...` grün ·
`go build ./...` grün · Working Tree clean.
Manuelle Smoke-Tests: `--version`, `--help`, `say --help`, `talk "Hallo Welt"`
(Exit 1), `talk mute` (Exit 1), `talk --bogus` (Exit 2), `talkd` (loggt, Exit
0), ldflags-Injection.
## Self-Review
- Vollständigkeit gegen Brief: jedes Punkt abgedeckt (Modul ✓, Gerüst ✓,
Dispatch ✓, Version ✓, talkd ✓, CI ✓, .gitignore/LICENSE/README ✓,
sinnvoller Test ✓, Conventional Commits ✓, kein Push ✓, `.superpowers/`/
`.scratch/` unangetastet ✓).
- YAGNI-Check: Say-Flags im Stub bewusst registriert — sie sind Teil der
bindenden CLI-Surface und machen `<Befehl> --help` korrekt (Brief fordert
„korrektes --help“); keine weiteren Vorab-Strukturen gebaut.
- Test-Hygiene: nur extern beobachtbares Verhalten (Exit-Codes, stdout,
stderr); keine Implementationsdetails; Spec-Liste hartcodiert als
Drift-Wächter.
## Concerns / Hinweise für Folgetasks
1. **Stdlib-flag-Parsing-Stil:** Flags müssen vor dem Text stehen
(`talk --wait "Text"`, nicht `talk "Text" --wait`) — stdlib-Limitierung.
Task 02 sollte das Parsing-Verhalten explizit festlegen.
2. **Nackter `talk`-Aufruf** ist aktuell Benutzungsfehler (Exit 2); Task 02
ändert das ggf. zu stdin-Lesung (Kommentar im Code markiert die Stelle).
3. **`--version` nur auf Root-Ebene** (`talk --version`); Subcommand-Flagsets
kennen es nicht (`talk say --version` → Exit 2) — bewusst, Standard bei
Subcommand-CLIs.
4. `.gitea/workflows/test.yml` ist noch nie in echter Gitea-Runner-Umgebung
gelaufen (Syntax validiert, Semantik nach bekannten Actions-Konventionen);
erster echter Push zeigt es.
+12
View File
@@ -0,0 +1,12 @@
## Task 2: 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).
+225
View File
@@ -0,0 +1,225 @@
# Task 2 Report: Erste Ansage end-to-end (OS-TTS)
**Status: DONE** · Branch `feat/v1` · Basis `fa4f193` → HEAD `8306c6b` (6 Commits)
## Was geliefert wurde
`talk "Hallo Welt"` spricht über OS-TTS, blockierend, end-to-end:
Text (Arg oder stdin) → `langdetect.Detect` → Provider `osbuiltin`
(`--provider`-Flag existiert, Default `osbuiltin`; Kette folgt Task 03) →
`Synthesize` → `Sink.Play`. `TALK_AUDIO_SINK=null` läuft tonlos mit Exit 0 durch.
### spd-say-Mechanismus (verifiziert am Host, nicht geraten)
`spd-say --help` auf dem Dev-Host (18.10.2026) zeigt: **keine Option, Audio in
eine Datei oder Pipe zu schreiben**. Es gibt nur `-w/--wait` (auf Sprechende
warten), `-e/--pipe-mode` (Text durchreichen, Sprache geht an den Daemon) und
Stop/Cancel. SSIP/die Daemon-Schnittstelle liefert Audio ausschließlich an den
Server-Audio-Output, nie an den aufrufenden Prozess. `espeak` ist auf dem
Dev-Host **nicht** installiert, `spd-say`/`speech-dispatcher` systemweit verfügbar.
**Getroffene Entscheidung (der im Brief erlaubte pragmatische Split):**
- `osbuiltin.Synthesize` liefert WAV-Bytes, wo die Plattform es kann:
Linux via `espeak`/`espeak-ng --stdout` (WAV auf stdout), macOS via
`say --data-format=LEI16@22050 -o <tmp>.wav` (Stimmen de→Anna, en→Samantha,
vorinstalliert), Windows via SAPI/PowerShell (`System.Speech`,
`SetOutputToWaveFile`; Skript als `-EncodedCommand` UTF-16LE/Base64, Text
darin base64-kodiert — kein Quoting-Problem; `SelectVoiceByHints` mit
try/catch-Fallback auf die Systemstimme).
- Fehlt byte-fähiges TTS (Linux ohne espeak), liefert `Synthesize` den Sentinel
`provider.ErrNoByteOutput` — und die **dokumentierte optionale
Interface-Erweiterung** `provider.DirectSpeaker` (`SpeakDirect(ctx, text, lang)`,
blockierend via `spd-say -w -l <lang> <text>`) spielt selbst ab. Auf
macOS/Windows ist `SpeakDirect` ein klarer Fehler (dort unnötig — kein toter
Code-Pfad im CLI).
- Der Basis-Contract aus dem Brief bleibt exakt intakt:
`Provider{Name() string; Available(ctx) bool; Synthesize(ctx, text, lang string) ([]byte, error)}`
— nur additive Erweiterung `DirectSpeaker` + Sentinel `ErrNoByteOutput`.
- Semantik im say-Pfad: `ErrNoByteOutput` + Sink `oto` → `SpeakDirect` (hörbar);
`ErrNoByteOutput` + Sink `null` → Ansage wird **verworfen** (Exit 0, Hinweis
auf stderr) — null-Sink darf nie Ton erzeugen (CI/Server-Garantie).
`Available()` ist auf Linux true, wenn irgendein Mechanismus existiert
(espeak, espeak-ng oder spd-say); fehlt alles: klarer Fehler, kein Panik.
### Audio: oto-Version gepinnt
`github.com/ebitengine/oto/v3` **v3.5.0** — aktuelle stabile Version des
aktuellen Major (v3.6.0 existiert nur als alpha; v2/v1 sind alt). Der Brief
nannte „oto v2 … oto/v3 is the current major — verify and pin": verifiziert via
`go list -m -versions`, v3.5.0 gepinnt. Transitiv: `purego` (dlopen der
Audiolibres ohne CGO), `jfreymuth/pulse`, `x/sys`. **Kein CGO nötig** →
cross-compilierbar (siehe unten). oto erlaubt nur einen Context pro Prozess und
bindet ihn an ein PCM-Format → der OtoSink cachet einen Context pro Format
(`map[pcmFormat]*oto.Context` + Mutex). WAV wird von einem eigenen kleinen
RIFF-Parser zerlegt (PCM 8/16 Bit, Float32; Mono/Stereo; klare Fehler für
alles andere) — kein zweites Audio-Dependency.
Zweites (und letztes) Dependency dieses Tasks:
`github.com/BurntSushi/toml` v1.6.0 (MIT, pure Go, de-facto-Standard) für
config.toml — ohne eigenen Subset-Parser zu bauen.
## Wie getestet
- `internal/langdetect`: tischgesteuert (14 Fälle: de/en/gemischt/leer/
Interpunktion/Umlaute/Gleichstand → Default „de"). Mehrdeutige Wörter
(„was", „in", „build") bewusst aus den Listen.
- `internal/config`: tischgesteuert — Pfadlogik `dirFor(goos, …)` für alle drei
Plattformen vom Linux-Host aus (XDG-Vorrang, relativer XDG ignoriert,
fehlende HOME/APPDATA → klarer Fehler); Merge Defaults → TOML → Env
(inkl. Env-schlägt-Datei, ungültige Werte → klarer Fehler, kaputte TOML →
klarer Fehler, ohne HOME → Defaults + Env).
- `internal/provider`: Fake-Prozesse über PATH-Manipulation (echte
exec-Prozessgrenze, kein Mock-Framework): fake `espeak` (WAV + ARGS-Pro Tokoll),
fake `spd-say` (ARGS-Protokoll); leerer PATH → `Available()==false`,
`Synthesize→ErrNoByteOutput`, crashendes espeak → klarer Fehler mit stderr.
Echte hörbare spd-say-Ausgabe nur hinter `TALK_TEST_REAL_SPD=1`.
- `internal/audio`: WAV-Parser gegen handgebaute RIFF-Fixtures (inkl.
Extra-Chunk mit word-alignment, Float32, Fehlerfälle); NewSink/NullSink;
oto-Fehlerpfad (ungültige Daten scheitern VOR Audio-Gerät — CI-safe).
Echte Wiedergabe nur hinter `TALK_TEST_REAL_AUDIO=1`.
- `cmd/talk`: CLI-End-to-End über die `run(argv, stdin, stdout, stderr)`-Seam
mit injizierbarem `getenv`: `talk "Hallo Welt"` + sink null → Exit 0 und
fake-espeak nachweislich gerufen; stdin-Pfad; Sprachwahl (`-v de`/`-v en`,
`--lang` pinnt); Direktfall ohne espeak + fake spd-say (oto-Sink →
SpeakDirect mit korrekten Argumenten; null-Sink → Verwurf, spd-say NICHT
gerufen); ungültige `--lang`/`--provider`/`TALK_AUDIO_SINK` → klare Fehler;
Task-1-Tests angepasst (stdin-Parameter; say kein Stub mehr).
**Manuelle Abnahme am Dev-Host (Linux, NixOS, kein espeak, spd-say vorhanden):**
- `talk "Hallo Welt, dies ist die erste Ansage von talk zwei Punkt null"` →
Exit 0, **hörbar** via `spd-say -w` (Direktansage, blockierend).
- Englische Ansage (`langdetect` → en) → Exit 0, gesprochen.
- `TALK_TEST_REAL_SPD=1 go test ./internal/provider -run TestSpeakDirectEchtesSpdSay`
→ PASS in 2,4 s (= reale Sprechdauer, `-w` blockiert bis Ende).
- `TALK_AUDIO_SINK=null talk "Hallo Welt"` → „Ansage verworfen", Exit 0, tonlos.
- Espeak als Dev-Abhängigkeit im README dokumentiert (`nix shell nixpkgs#espeak-ng`).
## Verifikation
- `go vet ./...` ✓ · `gofmt -l .` leer ✓ · `go test ./... -count=1` ✓ (5 Pakete)
· `go build ./...` ✓
- Cross-Compile-Smoke (je `go build ./...`): **darwin/amd64 ✓, darwin/arm64 ✓,
windows/amd64 ✓, windows/arm64 ✓, linux/amd64 ✓, linux/arm64 ✓** — mit
`CGO_ENABLED=0`. Hinweis: ohne `CGO_ENABLED=0` scheitern darwin/linux-arm64
am lokalen gcc (`-arch`/ARM-Assembler) — Go versucht dort cgo; windows/amd64
ging auch mit Default-Settings. Release/Task 11 muss `CGO_ENABLED=0` pinnen
(statische Binaries sind ohnehin Global Constraint; oto/purego braucht kein CGO).
## Dateien
| Datei | Zweck |
| --- | --- |
| `internal/langdetect/detect.go` + Test | `Detect(text) → "de"|"en"` |
| `internal/config/config.go`, `path.go` + Test | Defaults→TOML→Env, Plattformpfade injizierbar |
| `internal/provider/provider.go` | Contract, `DirectSpeaker`, `ErrNoByteOutput`, Registry |
| `internal/provider/osbuiltin.go` + Test | Linux(espeak/spd-say)/macOS(say)/Windows(SAPI) |
| `internal/audio/audio.go` | Sink-Interface, NewSink, NullSink |
| `internal/audio/wav.go` | RIFF/WAVE-Parser |
| `internal/audio/oto.go` + Test | OtoSink v3.5.0, Context-Cache pro Format |
| `cmd/talk/say.go` + Test | say-Pfad end-to-end |
| `cmd/talk/cli.go`, `main.go`, `cli_test.go` | run(+stdin)-Seam, handler-Dispatch, say-Hilfe |
| `go.mod`/`go.sum`, `README.md` | Deps gepinnt, espeak-Doku |
## Selbstreview (`git diff fa4f193..HEAD`)
- Interfaces exakt wie vom Controller vorgegeben; nur dokumentiert-additive
Erweiterungen (`DirectSpeaker`, `ErrNoByteOutput`) — Task 3 kann darauf bauen.
- Eine(Task-1-)Seam-Änderung: `run(argv, stdout, stderr)` →
`run(argv, stdin, stdout, stderr)` (nötig für den stdin-Pfad; Task-1-Tests
mechanisch mitangepasst). Env bleibt über Paket-Var `getenv` injizierbar.
- **Go-Falle gefunden und gefixt:** Flag-Bindung über Wertkopie der Options-
Struct hätte nach `Parse` weiterhin Defaults geliefert (flag-Zeiger zeigten
auf die tote lokale Variable) — `addSayFlags` gibt deshalb einen Pointer
zurück; Debug-Test hatte das aufgedeckt (`--provider=elevenlabs` lief still
auf osbuiltin durch).
- Kein toter Code: `wait/now/priority` sind registrierte Flags ohne Verhalten
(Daemon-Semantik Task 04), `--voice` gibt einen Hinweis (Voice-Registry
Task 03). Beides bewusst so und kommentiert.
- Coverage: langdetect 100 %, config 91,9 %, cmd/talk 80,9 %, audio 56,6 %,
provider 44,2 % — die Lücken sind die hardware-/OS-gebundenen Pfade
(echtes oto-Rendering, macOS-/Windows-Synth, echte spd-say-Ausgabe), die
laut Spec bewusst ungetestet bleiben (doctor übernimmt das manuell).
## Concerns / Hinweise für Folge-Tasks
1. **macOS/Windows-Synth-Pfade sind zur Laufzeit ungetestet** (Dev-Host ist
Linux). Mechanismen sind dokumentiert und cross-compilieren; ein echter
Laufbarkeitstest bleibt Task 11 (Release-Matrix) oder manuellen Checks
auf Zielplattformen vorbehalten.
2. **Task 03 muss `DirectSpeaker` in der Kette beachten** (letzte Stufe, wenn
kein Provider Bytes liefert) und die Registry um elevenlabs/openai/edge
erweitern; `--provider`-Validierung passiert bereits zentral in
`provider.New`.
3. **`CGO_ENABLED=0` muss in CI/Release festgezogen werden** (Task 11),
sonst brechen darwin/arm64-Builds am lokalen gcc.
4. `espeak -v <lang>` nutzt den Sprachcode direkt als Voicenamen („de"/„en"
existieren); feinere Stimmenwahl kommt mit der Voice-Registry (Task 03).
5. otos Poll-Loop (5 ms) für das Blockieren ist simpel und ausreichend;
falls Task 04 Abbruch-Latenz kritisch findet, dort verfeinern.
---
## Fix-Report (Review-Nachbereitung, 18.10.2026)
### Important: NullSink-Logging war im CLI-Pfad toter Code
**Befund:** `NewSink` lieferte `NullSink{}` mit nil `W`; `runSay` übergab
stderr nie an den Sink. Bei erfolgreicher Synthese (Provider mit Byte-Output,
z. B. espeak oder ab Task 03 Cloud-Provider) verwarf
`TALK_AUDIO_SINK=null talk "Text"` **stumm** — die sichtbare
„Ansage verworfen (audio.sink=null)"-Zeile kam nur aus dem
ErrNoByteOutput-Zweig. Task 04 hätte fälschlich auf null-Sink-Sichtbarkeit
vertraut.
**Fix:**
- `audio.NewSink(name string, log io.Writer)` — neuer zweiter Parameter;
der null-Sink bekommt ihn als `W` (nil = still verwerfen), der oto-Sink
ignoriert ihn. **Aufrufstellen Task 03+ müssen den Log-Writer übergeben.**
Das `Sink`-Interface selbst (Play/Name) ist unverändert.
- `runSay` übergibt jetzt stderr: `audio.NewSink(cfg.Audio.Sink, stderr)`.
- Kommentar an `NullSink` korrigiert: Der Writer kommt jetzt wirklich über
`NewSink` von der CLI (nicht mehr das claims-hafige „der CLI-Pfad hängt
stderr an").
**Tests:**
- `TestSayArgumentMitNullSink` (cmd/talk) erweitert: fake espeak
(Synthese erfolgreich) + `TALK_AUDIO_SINK=null` → Exit 0 UND stderr
enthält „verworfen (null sink)" — extern beobachtbares Verhalten auf dem
CLI-Pfad, genau der geforderte Nachweis.
- `TestNewSink/null-Sink_loggt_über_den_Factory-Writer` (internal/audio)
neu: Factory verdrahtet den Writer (`NewSink(NameNull, &buf).Play` loggt).
**Live-Beweis am Dev-Host** (espeak-ng via `nix shell` in PATH,
config.toml mit `sink = "null"`):
```console
$ nix shell nixpkgs#espeak-ng -c sh -c 'talk "Hallo Welt"'
audio: 42850 Bytes verworfen (null sink) # stderr — vor dem Fix: stumm
EXIT=0
```
### Minor: „was" aus englishWords entfernt
`detect.go` führte „was" in der englischen Liste — widersprach dem
germanWords-Kommentar („Mehrdeutigkeiten wie „was" und „in" wurden
weggelassen") und dem Task-2-Report. Entfernt; neuer tischgesteuerter Fall
`TestDetect/deutsch_frage_mit_was` („Was macht er?" → „de") — vorher hätte
die Heuristik fälschlich „en" geliefert.
### Verifikation
```console
$ go vet ./internal/audio/ ./internal/langdetect/ ./cmd/talk/ && gofmt -l internal/audio internal/langdetect cmd/talk
(leer = ok)
$ go test ./internal/audio/ ./internal/langdetect/ ./cmd/talk/ -count=1 -run 'Sink|null|Null|Detect|Say'
ok code.m3ta.dev/m3tam3re/talk/internal/audio 0.002s
ok code.m3ta.dev/m3tam3re/talk/internal/langdetect 0.001s
ok code.m3ta.dev/m3tam3re/talk/cmd/talk 0.017s
$ go test ./... -count=1 # kompletter Durchlauf als Absicherung
ok (alle 5 Pakete)
```
+14
View File
@@ -0,0 +1,14 @@
## Task 3: Provider-Kette mit Cloud-Anbietern
Blockiert durch: 02. Liefert: Vier-Stufen-Fallback mit Keys + Fake-barkeit.
- Provider-Implementierungen (je `internal/provider/<name>`):
- `elevenlabs`: POST `<endpoint>/v1/text-to-speech/<voiceID>`, 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 `<endpoint>/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.<provider>.<lang>` 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.
+83
View File
@@ -0,0 +1,83 @@
# Task 3 Report: Provider-Kette mit Cloud-Anbietern
Status: **DONE** · Branch `feat/v1` · Commits `7a843ae..8351d40` (5 Commits auf `ae9f3b6`)
## Geliefert
- **`internal/provider/elevenlabs.go`** — POST `<endpoint>/v1/text-to-speech/<voiceID>`, Header `xi-api-key` (Key ausschließlich `TALK_ELEVENLABS_KEY`), Body `{text, model_id: "eleven_multilingual_v2", voice_settings: {stability: 0.5, similarity_boost: 0.75}}` (Brief-Werte wörtlich), Antwort-Bytes (MP3) unverändert. Default-Endpunkt `https://api.elevenlabs.io`.
- **`internal/provider/openai.go`** — POST `<endpoint>/v1/audio/speech`, `Authorization: Bearer <TALK_OPENAI_KEY>`, Body `{model, voice, input, response_format: "mp3"}`. Modell konfigurierbar (`[providers.openai] model`, Default `tts-1` — Entscheidungsnotz aus dem Brief umgesetzt). Default-Endpunkt `https://api.openai.com`.
- **`internal/provider/edge.go` + `edgedrm.go`** — Microsofts „Read Aloud"-WSS-Protokoll (edge-tts-kompatibel), kostenlos, kein Key. Siehe „Edge-Protokoll-Notizen" unten.
- **`internal/provider/chain.go`** — geordnete Vier-Stufen-Kette `elevenlabs → openai → edge → osbuiltin` (`DefaultOrder`); Pin per `--provider` (schlägt `provider = "…"` aus der config.toml); `Available()` rein lokal (Key/Endpunkt/Binary, keine Netz-Calls); Fallback bei HTTP ≠ 2xx, Timeout, Netz — jede übersprungene/fehlgeschlagene Stufe wird ins `Log` geschrieben (nie still), am Ende zusätzlich Fehler mit allen Stufen. **Chain implementiert Provider UND DirectSpeaker**: `Speak(ctx, text, lang)` orchestriert Bytes → `Player.Play` bzw. letzte Stufe Direktansage; bei Player-Name „null" wird die Direktansage tonlos verworfen (loggt, exit clean). Der `Player`-Vertrag ist strukturell identisch zu `audio.Sink` (`Play`+`Name`) — CLI und Task-04-Daemon übergeben ihr `audio.NewSink(name, log)`-Ergebnis unverändert; die Kette importiert das audio-Paket nicht.
- **`internal/provider/voices.go`** — Voice-Auflösung mit dokumentierter Rangfolge: `--voice` (VoicePinner) > `voice.<provider>.<lang>` aus config.toml > neutrale Repo-Defaults > en-Default. Neutrale Defaults: elevenlabs „Rachel" `21m00Tcm4TlvDq8ikWAM` (de+en), openai `alloy` (de+en), edge `de-DE-KatjaNeural` / `en-US-AriaNeural`.
- **Config (`internal/config`)** — `provider` (Pin, Env `TALK_PROVIDER`), `[providers.<name>] endpoint/model`, `[voice.<provider>] <lang> = id`. TOML-Key bewusst Mehrzahl `providers`, damit Pin und Tabelle koexistieren. Endpunkt-Env-Overrides (`TALK_ELEVENLABS_ENDPOINT` etc.) liest die Provider-Schicht (Env > Datei > Default) — config-Paket bleibt frei von Provider-Namen, Tests kommen ohne Config-Datei aus.
- **CLI (`cmd/talk/say.go` + `cli.go`)** — say baut die Kette aus Config+Flags und ruft `chain.Speak`; der Task-02-Direktansage-/null-Sink-Sonderweg steckt jetzt in der Kette. **Zusätzlich gefundener und gefixter CLI-Bug**: Go-stdlib-flag stoppt beim ersten Nicht-Flag-Argument — `talk "Test" --provider=elevenlabs` (Abnahme-Aufrufform!) hätte das Flag als Text geschluckt. `parseInterspersed` parst Flags überall (Regressionstest vorhanden).
- **Audio (`internal/audio/mp3.go`)** — MP3-Dekodierung, siehe Entscheidung unten.
## Entscheidungsnotiz: MP3-Dekodierung (Brief-Frage)
**Entschieden: audio-Paket dekodiert MP3 mit reinem Go-Dekoder `github.com/hajimehoshi/go-mp3 v0.3.4`.**
Begründung: (1) Nur OpenAI kann alternatives `response_format` (wav/pcm) anfordern — ElevenLabs und Edge-TTS liefern MP3 als einziges Format; eine einheitliche MP3-Pipeline hält die Kette symmetrisch statt zwei Wege zu pflegen. (2) Reinheits-Nachweis: kein `import "C"` im Modul (grep-geprüft), CGO-frei. (3) Cross-Compile-Nachweis: `CGO_ENABLED=0 go build` für **alle 6 Targets** (linux/darwin/windows × amd64/arm64, talk + talkd) grün — die Verpflichtung aus den Global Constraints bleibt erfüllt. (4) Ausgabe-Vertrag des Dekoders: immer 16-Bit-LE-PCM mit 2 Kanälen (Mono wie Edge 24 kHz wird gespiegelt), Sample-Rate aus dem ersten Frame — passt direkt an den oto-Sink, der dafür eine Format-Weiche (`decodeAudio`: RIFF→WAV-Parser, ID3/Frame-Sync→MP3) bekommen hat. Fixtures in `mp3_test.go` sind echte MP3s (einmalig per ffmpeg erzeugt, base64 eingebettet): mono 24 kHz 48 kbit/s (= exakt Edges Ausgabeformat) und stereo 44,1 kHz.
## Edge-Protokoll-Notizen (Dokumentationspflicht aus dem Brief)
Stand der Referenz (rany2/edge-tts, master, 2026-10 abgerufen): Das Protokoll hat sich seit dem klassischen Stand geändert — mein erster Real-Endpoint-Versuch mit dem alten Schema (plain SHA-256-Sec-MS-GEC v1) bekam **HTTP 403**. Aktuell nötig:
1. **DRM-Token Sec-MS-GEC v2**: `<16 Hex CS64>-<64 Hex SHA256-Variante>` über „5-Minuten-gerundete Windows-Filetime-Ticks + TrustedClientToken". Beide Digests sind verschleierte Varianten (CS64-Checksumme + modifizierte SHA-256 mit Mid-Block-Einstieg) — `edgedrm.go` ist eine wörtliche Portierung und gegen 4 Referenzvektoren der Python-Implementierung getestet (inkl. Zeitfenster-Raster). Sec-MS-GEC-Version `2-143.0.3650.75`.
2. **MUID-Cookie** (`Cookie: muid=<32 Zufalls-Hex groß>`) im Handshake, dazu Origin chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold und Edge-Chrome-143-User-Agent.
3. Query: `TrustedClientToken`, `ConnectionId`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` (Default-Endpunkt konfigurierbar; Schema-Validierung ws/wss mit klarem Fehler).
4. Nachrichten wie bekannt: `speech.config` (Output `audio-24khz-48kbitrate-mono-mp3`), SSML-Anfrage (X-Timestamp mit Referenz-Bug-Nachstell-Z), Binär-Rahmen (2-Byte-BE-Header-Länge, `Path:audio`), Ende bei `Path:turn.end`. Bei 403: Uhrenkorrektur aus dem Date-Header + genau ein Retry (wie die Referenz).
5. **Bei künftiger Protokoll-Änderung**: alle Fehlerpfade nennen explizit „Netz/Timeout oder das Edge-TTS-Protokoll hat sich geändert" — nie still.
**Real-Verifikation (einmalig, manuell)**: `TALK_TEST_REAL_EDGE=1 go test -run TestEdgeIntegrationEcht` → **PASS, 17136 Bytes echtes Audio** vom echten Microsoft-Endpunkt; zusätzlich `talk "Sprachtest über Edge TTS" --provider=edge` mit `TALK_AUDIO_SINK=null` → „Ansage über edge", 26784 Bytes verworfen, Exit 0. Ohne jeden Key wählt die Kette damit real edge.
## Was wie fake-bar getestet ist (Brief-Pflicht)
- **elevenlabs/openai**: `httptest`-Fakes — 200 (Bytes unverändert; Methode/Pfad inkl. Voice-ID, Header, JSON-Body wörtlich geprüft), 500, 401; Available mit/ohne Key; Voice-Rangfolge (--voice > Mapping > Default > en-Fallback); leerer Text. Für die Kette zusätzlich zählende Fakes (erster 500 → zweiter getroffen → Bytes).
- **edge** (vom Brief explizit von httptest ausgenommen): Interface-/Fehlerpfad-Tests (toter Loopback-Port, falsches Schema, Nullwert-Available), reine Protokollfunktionen (SSML, Escaping inkl. Steuerzeichen, speech.config, Request-URL/Token-Redaction, CS64/SHA-Referenzvektoren, Binär-Rahmen-Split), ein **Loopback-WSS-Fake** (gorilla-Upgrader auf 127.0.0.1: voller Protokoll-Durchlauf ohne echtes Netz; prüft Query-Token, Origin, beide Client-Nachrichten, Audio-Payload; Abbruch-Variante → Protokoll-Fehler) und die **als `testing.Short` überspringbare Integrations-Markierung** `TestEdgeIntegrationEcht` (Doppel-Gate: `-short` UND `TALK_TEST_REAL_EDGE=1`; einmalig manuell ausgeführt, s. o.).
- **Kette**: Fallback 500→Stufe 2, alles kaputt (Fehler nennt Stufen), ohne Keys → edge(osbuiltin)-Pfad mit fake espeak, Pin (nur gepinnte Stufe wird angefragt), Bytes→Player, Direktansage mit ton-Player (spd-say-ARGS) und null-Player (Verwurf, spd-say NICHT gerufen), ErrNoByteOutput-Durchreichen, Default-Reihenfolge, Voice-Pin-Durchreichung.
- **CLI** (`cmd/talk`): Cloud-End-to-End über die Env-Seam (`--provider=elevenlabs` gegen Fake, null-Sink-Verwurfzeile), `--voice` im Request-Pfad, 500-Fallback bis osbuiltin, Flag-nach-Text, gepinnter Provider ohne Key → klarer Fehler. Tests fassen nie echte Endpoints: Die Env-Seam injiziert jedem Test einen toten Loopback-Port für edge.
- **Kein Standard-Test hängt am Netz** (auch nicht an Microsoft); `go test -short ./...` grün.
## Verifikation
- `gofmt -l .` leer · `go vet ./...` grün · `go test ./...` grün (80 Testfälle) · `go build ./...` grün.
- Cross-Compile: `CGO_ENABLED=0` linux/darwin/windows × amd64/arm64, je `talk` + `talkd` — 6/6 OK.
- `go.mod`: +`github.com/hajimehoshi/go-mp3 v0.3.4`, +`github.com/gorilla/websocket v1.5.3` (beide reines Go, MIT/BSD).
- Daemon `cmd/talkd` unangetastet.
## Echter-Key-Check (Abnahmekriterium)
`talk "Test" --provider=elevenlabs` mit echtem ElevenLabs-/OpenAI-Key: **nicht durchgeführt** — `TALK_ELEVENLABS_KEY` und `TALK_OPENAI_KEY` sind in der Dev-Host-Umgebung nicht gesetzt (geprüft, nicht gefragt). Ersatz-Evidenz: Request-Vertrag beider Provider ist wörtlich gegen Fakes getestet (Pfad, Header, Body inkl. model_id/voice_settings/response_format); der Edge-Provider wurde zusätzlich real verifiziert (s. o.). Nachreichende einmalige Abnahme mit Key empfohlen (gleiche Form wie der CLI-Test, nur mit echtem Endpunkt).
## Abweichungen & Begründungen
1. **ElevenLabs-Default-Stimme ist „Rachel" (21m00Tcm4TlvDq8ikWAM), nicht KXxZd16DiBqt82nbarJx.** Brief/Plan nennen KXxZ… als „Default-Voice-ID"; die Spec (bindende Autorität, „bei Konflikt gewinnt die Spec") führt KXxZ… ausdrücklich als **privaten** Default („nicht Repo-Default", Further Notes) und verlangt „neutrale Defaults im Repo". Umsetzung: neutraler Repo-Default Rachel; die persönliche Stimme gehört in die eigene config.toml (`[voice.elevenlabs] de/en = "KXxZ…"` — so auch im Code-Kommentar und README dokumentiert). Ein-Wort-Flip, falls der Controller es anders will.
2. **TOML-Key `providers` (Mehrzahl)** statt `[provider.<name>]`: `provider = "…"` (Pin, Singular) und eine Einstellungs-Tabelle können in TOML nicht denselben Schlüssel belegen — Mehrzahl löst den Konflikt ohne Spec-Verstoß (Spec schreibt keinen Tabellennamen vor).
3. **Interspersed-Flags-Fix in der CLI** (kleiner Umfang, `cli.go`): ohne ihn wäre das Abnahmekriterium `talk "Test" --provider=elevenlabs` (Flags nach dem Text) funktional unmöglich gewesen. Ändert Task-01-Dispatch minimal, alle Task-01/02-Tests bleiben grün, neuer Regressionstest deckt die Form ab.
4. **`TALK_PROVIDER` als Env-Override des Pins** (kleine Ergänzung über Brief): konsistent mit „Env schlägt Datei", erleichtert Task-04-Prozessgrenzen-Tests. Analog Endpunkt-Env-Overrides `TALK_*_ENDPOINT` (Test-Seam laut Spec-Testing-Entscheidung).
## Bedenken / Risiken für Folgetasks
- Edge-DRM ist ein undokumentierter Endpoint: weitere Microsoft-Änderungen brechen die Stufe (klar benannte Fehler + Kette fällt auf osbuiltin). Referenzvektor-Tests machen Regressionen sofort sichtbar.
- `chain.Speak` blockiert (wie Task 02) — non-blocking kommt mit dem Daemon (Task 04), der die Kette unverändert übernimmt (Player/Log/Getenv injizierbar).
- Default-Kette versucht edge real, wenn elevenlabs/openai ohne Key sind: gewollt (User Story 10), aber Offline-Hosts warten die edgeIOTimeout (20 s) nur dann ab, wenn auch edge nicht erreichbar ist und osbuiltin die Ansage macht. Verschwiegener Träger: Fehler wird protokolliert.
---
## Review-Fix: Chain.Speak läuft im DirectSpeaker-Endgame nur noch einmal
**Fund (Important):** `Speak` lief die Kette zweimal — `Speak` → `Synthesize` (Durchlauf 1) und bei ErrNoByteOutput mit Nicht-null-Player → `SpeakDirect` (eigener kompletter Durchlauf 2). Folge: fehlgeschlagene Cloud-Stufen wurden erneut angefragt (doppelte POSTs auf bezahlten APIs), doppelte „Stufe X fehlgeschlagen"-Zeilen im Log, verdoppelte Worst-Case-Latenz, und eine transiente Erholung in Durchlauf 2 hätte den Fehler „liefert Bytes — über Synthesize …" produziert statt die geholten Bytes abzuspielen.
**Änderung (`internal/provider/chain.go`):** `Speak` macht jetzt EINEN `walk` und entscheidet aus dessen Ergebnis: Bytes → Player.Play; nur-Direkt-Stufe → `r.direct.SpeakDirect` (null-Player → Verwurf-Log + clean exit, unverändert); nichts → gebündelter Stufen-Fehler. Die Fehleraggregation steckt jetzt im geteilten Helper `walkResult.noTierErr()`, den `Synthesize` und `Speak` gemeinsam nutzen (identische Meldung wie zuvor). `Synthesize` und `SpeakDirect` behalten ihre öffentlichen Verträge unverändert (SpeakDirect macht als eigenständiger Einstieg weiter seinen eigenen Durchlauf — von `Speak` wird es nicht mehr gerufen). Externe Verträge unverändert: je Stufe genau eine Fehlschlag-Zeile, gleiche Fehler-Aggregation, DirectSpeaker nur bei ErrNoByteOutput mit Nicht-null-Player, null-Sink → Verwurf + Exit 0.
**Deckende Tests (`internal/provider/chain_test.go`):**
- NEU `TestChainDirektEndgameEinDurchlauf`: elevenlabs-Fake mit 500 wird im Direkt-Endgame **genau 1×** angefragt (Zähler), „Stufe elevenlabs fehlgeschlagen" und „Stufe edge fehlgeschlagen" stehen je **genau 1×** im Log, openai-Skip genau 1×, spd-say erhält `-w -l de Hallo Welt`.
- Bestehende Kettentests (Fallback 500→Stufe 2, Pin, Bytes→Player, Direkt mit/ohne null-Player, ErrNoByteOutput-Durchreichen, Voice-Pin) unverändert grün — sie pinnen das unveränderte Außenverhalten.
**Befehl / Output:**
```
go test -count=1 ./internal/provider/ -run 'TestChain' -v # 12/12 PASS (inkl. neuem Test)
go test -count=1 ./... # ok cmd/talk, audio, config, langdetect, provider
gofmt -l . ; go vet ./... ; go build ./... # leer / grün / grün
```
+15
View File
@@ -0,0 +1,15 @@
## Task 4: 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>` (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.
+87
View File
@@ -0,0 +1,87 @@
# Task 4 Report: talkd-Daemon + Queue + Mute
**Status:** DONE
**Branch:** feat/v1
**Commits:** 7 (716f6b2 → dc94f45, auf 3b44605)
**Verifikation:** `go vet ./...` ✓ · `gofmt -l .` leer ✓ · `go test ./... -count=1` ✓ (inkl. `-race` für cmd/internal, `count=5` für die Prozessgrenzen-Tests gegen Flakiness) · `go build ./...` ✓ · Cross-Compile-Smoke `CGO_ENABLED=0` für darwin/windows/amd64+arm64 und linux/arm64 ✓
## Geliefert
### internal/queue (queue.go, mute.go, rate.go)
- **Queue:** Worker-Loop mit serialisierter Abarbeitung (genau eine Ansage gleichzeitig). Reihenfolge: `now` > `high` > `normal` > `low`, FIFO je Level (Enqueue-Seq als Tiebrecher). IDs: 6 Hex-Zeilen (crypto/rand, Fallback praktisch unerreichbar).
- **MuteStore:** persistenter Mute-State als Datei `mute` im Config-Dir, Inhalt `true`/`false`, atomar geschrieben (Temp-Datei + Rename, MkdirAll bei Bedarf). Fehlende/unlesbare Datei = nicht stumm (nie „stumm aus Versehen“). Da der Daemon die Datei bei jeder /speak-Anfrage neu liest, wirkt auch eine offline (CLI, Degraded Mode) geschriebene Datei sofort.
- **RateLimiter (Seam für Task 05):** Struktur mit `Allow`/`Reserve`/`Next` steht, ist getestet (internal/queue/queue_test.go) und bewusst NOCH NICHT in den Worker-Loop verdrahtet — Task-04-Default unbegrenzt, wie der Brief verlangt. Die Seam ist in rate.go wörtlich dokumentiert (Task 05: Parking/Discard/burst-Entscheidung + config `queue.rate_limit`).
### internal/api (types.go, server.go, client.go)
- Endpunkte exakt nach Brief, methodengebunden (ServeMux-Muster → 405/404 von selbst):
- `POST /speak {text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}`; bei Mute `202 {"id":"","muted":true}` (verworfen, dokumentiert). Validierung → 400 mit `{"error":…}`: kaputtes JSON, leerer Text, Müll-Priority, unbekannter Provider, Müll-Lang; Body-Limit 64 KiB.
- `GET /status` → `{version, queue, queue_ids, current (null im Leerlauf), muted, provider}`. `queue_ids` ist die dokumentierte Ergänzung (wartende IDs in Abarbeitungs-Reihenfolge) — die CLI braucht sie für `--wait`.
- `POST /cancel` → `200 {"cancelled":n}` (n = laufende + wartende).
- `POST /mute` / `POST /unmute` → `200 {"muted":bool}`.
- **Client** (CLI-Seite): `Status/Speak/Cancel/SetMute`. `api.HTTPError` (unerwarteter Status) ist von Netz-Fehlern unterscheidbar — die CLI degeneriert nur bei Netz-Fehlern und zeigt HTTP-Fehler ehrlich an.
### cmd/talkd (main.go ersetzt den Platzhalter)
- HTTP nur auf `127.0.0.1:<port>`, Port aus config `[server] port` / Env `TALK_PORT` / Default 7800. Port belegt → klarer Fehler mit Hinweis.
- Ansagen laufen über die **unveränderte Provider-Kette** (internal/provider wurde nicht angefasst): pro Item wird `provider.NewChain` gebaut (Pin/Voice sind Anfrage-Properties), `chain.Speak(ctx, …)` spielt mit Fallback + Sink. Sprache: Item-Pin oder langdetect im Daemon.
- `/status` „aktiver Provider“: Pin des laufenden Items, sonst erste verfügbare Stufe einer Basiskette (Available ist billig, kein Netz-Call); im Leerlauf der zuletzt aktive. Die Kette meldet die erfolgreiche Stufe nicht selbst — deshalb Auflösung über Available (dokumentierte Entscheidung, s. „Offene Punkte“).
- Signale (SIGINT/SIGTERM): erst `srv.Shutdown` (keine neuen Anfragen), dann `q.Close()` (laufende Ansage abbrechen, Worker-Exit abwarten).
- Mute ohne Config-Dir: Start-Warnung, Daemon läuft weiter (Mute dann deaktiviert, `/mute` → 500).
### cmd/talk (daemon.go, degraded.go, control.go, say.go refaktoriert)
- **say/Default:** Daemon-Erkennung via `GET /status` (1 s Budget). Läuft er: enqueue + sofort zurück (non-blocking Default; stdout = Item-ID, eine Zeile); `--wait` pollt /status (100 ms), bis die Ansage weder läuft noch wartet; `--now` → `now:true`; bei Mute → stdout `muted`, Exit 0.
- **Degraded Mode:** Daemon nicht erreichbar → stderr-Hinweis, dann lokal: Mute-Datei respektieren, Lock-Datei `talk.lock` im Config-Dir (O_CREATE|O_EXCL, reine stdlib; Kollision = warten, max 10 s, danach ungeschützt mit Warnung; verwaistes Lock nach 60 s entfernen), dann dieselbe Kette blockierend im CLI-Prozess. Ohne Config-Dir: ohne Lock/Mute weiterlaufen (Best-effort, konsequent zu config.Load).
- **mute/unmute:** Daemon-Aufruf (`POST /mute`/`/unmute`), stdout `muted`/`unmuted`. Daemon tot: Mute-Datei direkt schreiben (dieselbe Quelle, die der Daemon liest — greift sofort und überlebt Restarts).
- **status:** plain Block (`daemon: … (version …)`, `queue: N`, `current: …`, `mute: an|aus`, `provider: …`). Daemon tot → Exit 1 mit Hinweis.
- **cancel:** `POST /cancel`, stdout `cancelled: N`. Daemon tot → Exit 1.
- Handler-Signatur der Befehlstabelle um `stdout` erweitert (say/Steuer-Befehle geben Ergebnisse aus); `--priority` wird früh validiert; `--lang`-Validierung unverändert früh.
### config
- `Server.Port` (`[server] port`), Env `TALK_PORT` (validiert 1–65535, klarer Fehler bei Müll), Default 7800. Tischgesteuerte Tests ergänzt.
### Tests
**Prozessgrenze (primäre Seam), `test/`-Paket:** talk + talkd werden einmal pro Lauf gebaut und als echte Prozesse gefahren; ElevenLabs-Fake per httptest (protokolliert Ankünfte separat und Antworten mit Zeiten + Completed-Flag für Abbrüche), `TALK_AUDIO_SINK=null`, `TALK_EDGE_ENDPOINT` auf toten Loopback (Kette fällt nie auf echte Fremd-Endpunkte), Config-Dir isoliert (HOME/XDG auf Temp), **Ports: immer freie ephemere Ports per TALK_PORT — nie 7800, nie ein echter Daemon**. Getestet:
- Serialisierung (2+1 schnelle Enqueues → nacheinander, nicht überlappend, beobachtbar an Fake-Ankunfts-/Antwort-Zeiten + null-Sink-Verwurf-Zeilen im Daemon-Log)
- Priorität (queue_ids in Abarbeitungs-Reihenfolge + echte Abspiel-Reihenfolge high→normal→low, FIFO je Level)
- `--now` über die CLI springt vor wartende normal-Items (unterbricht nicht den Laufenden)
- cancel: bricht die Synthese ab (Fake sieht den Abbruch), leert die Queue, `cancelled:2`, Daemon danach gesund
- non-blocking (say kehrt in <500 ms zurück, Ansage läuft weiter) + `--wait` (kehrt erst nach Ende zurück)
- Mute-Persistenz über Daemon-Restart (Abnahmekriterium) + offline geschriebene Mute-Datei wirkt im laufenden Daemon
- Degraded Mode: Daemon tot → Lock-Pfad (Lock danach freigegeben), Lock-Serialisierung zweier paralleler CLIs, mute respektiert
- Steuer-Befehle über CLI mit/ohne Daemon (plain Output, Exit-Codes)
- 3 parallele talk-Aufrufe → 3 Ansagen nacheinander (Abnahmekriterium, wörtlich)
- HTTP-Vertrag: 400-Validierung, 405, Status-Felder im Leerlauf (version dev, provider elevenlabs)
**Unit (Neben-Seams):** Queue-Abarbeitung/Current/Pending/Cancel/Close/IDs über steuerbaren Speaker (deterministisch, kein Timing), MuteStore-Roundtrip, RateLimiter-Semantik, config-Port-Auflösung, CLI-Steuerbefehle ohne Daemon (in-process über die run-Seam).
**Test-Isolation der Bestands-Tests:** `setGetenv`-Grundausstattung hat jetzt `TALK_PORT=1` (analog zum toten TALK_EDGE_ENDPOINT) — die vorhandenen say-Tests laufen deterministisch durch den Degraded Mode und können nie einen zufällig laufenden echten talkd treffen.
## Concurrency-Design (geforderte Notizen)
**Serialisierung.** Ein einziger Worker-Goroutine (`loop`) entnimmt Items; nur er ruft den Speaker. Enqueue (HTTP-Handler, beliebig parallel) hängt unter `mu` an die Liste und signalisiert `notEmpty`. Wait/Signal folgt dem klassischen Muster: Der Worker prüft die Predicate (`len(items)==0 && !closed`) unter demselben Mutex, unter dem Enqueue anhängt — verlorene Wakeups sind strukturell ausgeschlossen; das Signal ist bewusst ein `sync.Cond` (kein Kanal-Select nötig, kein Missed-Wake-Reasoning).
**Cancel.** `Cancel()` nimmt `mu`, verwirft die Warteschlange und ruft bei laufender Ansage `cancelCur()`. Pop und das Setzen von `current`/`cancelCur` geschehen im Worker unter EINEM Lock-Halten — es gibt kein Fenster, in dem ein Item weder „wartend“ noch „laufend“ ist und der Cancelisierung entwischen könnte. Der Abbruch wirkt kooperativ: Der Speaker (Provider-Kette) erhält den ctx in alle Synthese-Calls (HTTP via NewRequestWithContext, Prozesse via CommandContext) — die Kette bricht ab, ohne dass der Worker etwas tun müsste. Nach Rückkehr des Speakers räumt der Worker `current` unter `mu` ab. Zählt ein Item als abgebrochen, das in dem Moment just fertig wurde, ist das Punkt-in-Zeit-Semantik (kosmetisch, dokumentiert).
**Shutdown.** `Close()` setzt `closed`, verwirft wartende Items, bricht die laufende Ansage ab (_cancelCur_) und broadcastet; danach wartet Close auf `done` (geschlossen vom Worker-Loop nach dessen letztem Durchlauf). Enqueue nach Close → `ErrClosed` → API 503. Die Daemon-Shutdown-Reihenfolge ist: HTTP-Listener dicht (`srv.Shutdown`, keine neuen Anfragen) → `q.Close()` (Abbruch + Worker-Exit abwarten) → Prozessende. So kann nie eine Ansage den Daemon überleben, und ein Handler kann nie in eine geschlossene Queue enqueuen, ohne die 503 zu sehen. `Close` ist idempotent (idempotenter Zweitaufruf wartet nur noch auf `done`).
**Bewusste Einfachheit:** EIN Mutex für alles Queue-interne (Items, current, cancelCur, closed) statt feiner Locks; lineare Pop-Suche (n ist klein); `logf` nie unter dem Mutex (Aufrufer achten darauf). `-race` über cmd/internal grün, Queue-Tests zusätzlich 3× wiederholt.
## Entscheidungen & Dokumentationspflichten
- **Mute-Discard-Punkt:** Mute wird bei `/speak` geprüft (202 `{muted:true}`, Item verworfen) — wie im Brief. Bereits laufende Ansagen werden durch `talk mute` NICHT abgebrochen (cancel ist der explizite Befehl dafür); das ist die schlichte, dokumentierte Lesart des Briefs.
- **„Aktiver Provider“ in /status:** Die Kette (unveränderlich, Task-03-Bestand) meldet die erfolgreiche Stufe nicht zurück. Auflösung daher über Pin des Items bzw. erste `Available()`-Stufe der Basiskette (billig, ohne Netz-Call); im Leerlauf der zuletzt aktive. Für doctor/status ausreichend; Task 09 (Status-Dashboard) kann das ggf. verfeinern.
- **CLI-stdout-Formate (plain, skriptbar):** say → Item-ID (eine Zeile) bzw. `muted`; mute/unmute → `muted`/`unmuted`; cancel → `cancelled: N`; status → Block mit `daemon:/queue:/current:/mute:/provider:`. In README dokumentiert.
- **Degraded-Lock:** Warten max 10 s, danach ungeschützt mit Warnung („best-effort“); verwaiste Locks (Crash) gelten nach 60 s als verfallen und werden entfernt. Bewusst stdlib-only (O_EXCL statt flock — Letzteres ist auf Windows nicht portabel).
- **status/cancel ohne Daemon = Exit 1** (sie sind Daemon-Anfragen); say ohne Daemon = Degraded Mode Exit 0.
## Selbst-Review (3b44605..HEAD, 26 Dateien, +3166/−102)
- `internal/provider` und `internal/audio`: **null Zeilen geändert** (Bindung „Kette unverändert“ eingehaltenn).
- Bestehende Tests nur minimal angefasst: `setGetenv`-Basis (TALK_PORT=1, Isolation), `wantStubCommands` (vier Befehle sind jetzt real). Kein Assert geschwächt.
- Ein im Review gefundener Wortlaut-Fehler („nicht erreichbar“ bei HTTP-500 in setMute) ist gefixt (Commit e1a3981 amendiert).
- Bekannte kosmetische Grenzen: Cancel-Zählung bei exakt gleichzeitigem Natürliche-Ende (s. o.); `--wait` ohne Timeout (bewusst — Escape ist Ctrl-C); Daemon-Probe-Budget 1 s kann bei pathologisch langsamen Loopbacks einen unnötigen Degraded-Lauf auslösen (ohne Doppl-Ansage, da kein enqueue stattfand).
## Offene Punkte für Folge-Tasks
- **Task 05:** RateLimiter in den Worker-Loop verdrahten (`queue.rate_limit`, Parking/Discard/`allow_high_burst`), Ruhezeiten; `talk status` um deren State ergänzen. Struktur + `Next()` sind vorbereitet.
- **Task 06:** CORS (nur /speak) und MCP — Handler-Konstruktion in `api.NewHandler` ist dafür die eine Stelle.
- .scratch/ unangetastet; kein Push erfolgt.
+9
View File
@@ -0,0 +1,9 @@
## Task 5: 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.
+50
View File
@@ -0,0 +1,50 @@
# Task-5-Report: Rate-Limit + Ruhezeiten
Status: **DONE** · Branch `feat/v1` · Basis `dc94f45` (Task 4)
Commits: `b20e6dd` (config) → `5385a4b` (queue) → `b56c8ab` (api/talkd/cli) → `5979d45` (Prozessgrenzen-Tests) → `24ceff3` (README). Kein Push, `.scratch/` unangetastet.
## Timing-Semantik (verbindlich, wer wartet / wer verfällt / was now darf)
Rangordnung: **mute (API, immer) > now > Ruhezeit > Rate-Limit.**
| Situation | low | normal | high | now:true |
| --- | --- | --- | --- | --- |
| Rate-Fenster voll (Start der letzten Ansage < `queue.rate_limit` her) | **verworfen** — schon beim Eintritt, 202 `{id:"",discarded:"rate_limit"}`; kam es bei freiem Fenster rein, verfällt es beim Worker-Durchlauf (Log-Zeile) | **parkt** in der Queue, spielt zum Slot (`rate_limit.next_slot` in /status; Slot-Wecker weckt den Worker) | mit `allow_high_burst=true` (Default): **spielt sofort** (bewusster Verstoß); `false`: parkt wie normal | **spielt sofort** — umgeht auch das Rate-Limit (korrigierte Brief-Entscheidung umgesetzt) |
| Ruhezeit aktiv (`quiet_hours`, lokal, über Mitternacht) | **verworfen**, 202 `{discarded:"quiet_hours"}` | dito | dito — Burst ist ein Rate-Recht, kein Ruhe-Recht | **spielt sofort** — bricht auch die Ruhezeit (dokumentierter Ausbruch) |
| `quiet_mode="toast"` | / | / | / | / : statt Verwurf **Toast-Ersetzung**, 202 `{discarded:"quiet_hours",toast:true}` |
| Fenster frei + keine Ruhezeit | spielt | spielt | spielt | spielt + springt vor |
Weitere Regeln, dokumentiert und getestet:
- **Jede gestartete Ansage bucht das Fenster neu** (`Reserve` vor dem Abspielen) — auch eine per now oder high-burst ausgebrochene und auch eine fehlgeschlagene (kaputter Provider darf nicht zum Maschinengewehr werden). Fenster = Mindestabstand zwischen Ansage-**Starts**.
- While parked bleibt ein normal/high-Item voll sichtbar (`queue_ids`, `--wait` funktioniert); neu eintreffende now-/burst-fähige Items werden vorgezogen (Rank-Ordnung unverändert: now > high > normal > low, FIFO je Level).
- `--wait` auf ein verworfenes Item endet sauber (ID verschwindet aus der Queue — dasselbe Muster wie bei cancel).
## Umsetzung
- `internal/queue/quiet.go` (neu): `QuietHours` — Parse `HH:MM-HH:MM` (leer = aus, Start==End = aus), `Active` (Tag-/Nachtfenster, Start inklusiv, End exklusiv), `Until` (Fenster-Ende für /status), `String` (Roundtrip).
- `internal/queue/rate.go`: `RateLimiter` (aus T4, `Next()` liefert jetzt Nullzeit bei nie gebuchtem Fenster laut Doku), dazu `Laws` + reine `decide`-Ordnung, `Admission` (Enqueue-Ergebnis mit Verwurfs-Grund), `LawState` (/status-Sicht), Grund-Konstanten `rate_limit`/`quiet_hours`.
- `internal/queue/queue.go`: Loop erweitert (nicht umgeschrieben — Rank/Serialisierung/Mute unangetastet): `readyLocked`/`takeLocked` (parken ohne Entnahme), `armWakeLocked` (Slot-Wecker broadcastet die Cond; Wait-Bedingung prüft die Gesetze neu → verlorene Signale harmlos), `Enqueue` entscheidet Ruhezeit/low-Budget schon beim Eintritt (damit die 202 den Grund nennen kann). Lock-Ordnung Queue → RateLimiter durchgehend gewahrt; Race-Detektor grün.
- `internal/config`: `[queue] rate_limit = "30s"` (Dauer-String, `"0s"` = unbegrenzt; TOML-Decode via `time.Duration`-TextUnmarshaler, verifiziert), `allow_high_burst` (Default true; partielle `[queue]`-Tabellen behalten Defaults — getestet), `quiet_hours`/`quiet_mode` (Default aus/discard). Env: `TALK_RATE_LIMIT`, `TALK_ALLOW_HIGH_BURST`, `TALK_QUIET_HOURS` (`off` = aus), `TALK_QUIET_MODE`. Validierung gegen `queue.ParseQuietHours` (eine Quelle; Import config→queue, kein Zyklus).
- `internal/api`: `/speak` antwortet bei Verwurf 202 `{id:"",discarded:…}` (+`toast:true`) — analog zum Mute-Muster, kein Fehler; `/status` additiv `rate_limit {interval, allow_high_burst, next_slot}` + `quiet_hours {window, active, mode, until}` (RFC3339 bzw. null). Kein Feld gebrochen.
- `cmd/talkd`: verwired `Laws` aus der Config, Startup-Log nennt das Gesetz.
- CLI: `say` meldet `discarded: <grund>` (+ ` (toast)`) plain auf stdout mit Exit 0 (wie `muted`); `talk status` zeigt `rate-limit: … (high-burst …), nächster slot …` und `ruhezeit: … (modus …, aktiv bis …/inaktiv)`.
- README: kompletter Abschnitt zu Semantik + Config/Env + Rangordnung; Endpunkt-Tabelle aktualisiert.
## Tests
- **Prozessgrenze** (`test/ratelimit_test.go`, echte Binaries, Fake-Provider, sink=null, ephemeral Ports): kurzes Fenster (1 s) → 5 Ansagen: genau 1 spielt sofort, low verfällt beim Eintritt (HTTP **und** CLI-Output), normal parkt (queue_ids + next_slot sichtbar) und spielt frühestens zum Slot (Timing am Fake gemessen, ≥ 850 ms Abstand); high-burst an (spielt im Fenster) / aus (parkt, cancel räumt, Daemon gesund — das Warten-Aus mit langem Fenster deckt der Park-Test); `now` bricht durch Rate-Limit **und** Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit (±1 min) → discard (auch high, kein Provider-Kontakt, Log-Zeile, until in /status) und toast (Toast-Protokoll-Zeile); `/status`- + `talk status`-Felder (Defaults 30 s/Burst an/Slot frei, gebuchter Slot nach Ansage).
- **Unit** (`internal/queue`): QuietHours-Parse/Active/Until/String (tischgesteuert inkl. Mitternacht); Queue-Gesetze mit gesteuerter Spielerin (low verfällt, normal parkt + spielt, burst ein/aus, now-Ausbruch + Folgebuchung, Ruhezeit discard/toast auch für high, LawState).
- **Config** (`internal/config`): Defaults, volle/partielle TOML, Env-Overrides inkl. `off`, klare Fehler für Müll.
- Harness: Basis-Env `TALK_RATE_LIMIT=0s` — die Task-04-Semantik-Tests (Serialisierung, Priorität, …) erwarten dichtes Durchspielen und blieben unverändert; Gesetz-Tests überschreiben bewusst.
Verifikation: `gofmt -l .` leer · `go vet ./...` grün · `go test ./...` grün · `go test -race -count=1 ./...` grün (queue+test zusätzlich `-count=2` zur Flakiness-Probe) · `go build ./...` grün · Cross-Compile-Smoke `CGO_ENABLED=0 GOOS=darwin/windows GOARCH=amd64` für talk+talkd erfolgreich.
## Entscheidungen & Hinweise
1. **`now:true` umgeht auch das Rate-Limit** — die CORRECTION im Brief ist bindend umgesetzt und überall (Code-Kommentare, README, Tests) konsistent dokumentiert; ein now-Bruch startet trotzdem ein neues Fenster.
2. **Ruhezeiten sind per Default aus** (leer). Ein Default-Fenster „18:00–08:00" würde jeden Cron/Test in den Abendstunden stilllegen (CI läuft zu jeder Uhrzeit) — Opt-in wie Mute, im Config-Kommentar begründet.
3. **`quiet_mode="toast"` ist derzeit eine Daemon-Protokoll-Zeile** über den Notification-Pfad (Log-Writer), keine OS-Benachrichtigung — die Brief-Vorgabe „log line via notification path if trivial, else document limitation": der Notification-Pfad existiert noch nicht (internal/notify ist doc-only), also die dokumentierte Einschränkung. Die Ersetzung sitzt im Gesetz an genau einer Stelle (`verdictToast`), die OS-Anbindung ersetzt nur das Log-Ziel.
4. **Degraded Mode respektiert weiterhin nur Mute**, nicht Rate-Limit/Ruhezeit (Task-4-Umfang unverändert; der Brief fordert das Gesetz für den Daemon). Der Lock-File-Pfad bleibt sequenziell-best-effort. Für V1 akzeptiert; falls gewünscht, als Folge-Task.
5. Verwurf beim **Eintritt** (low über Budget, Ruhezeit) vs. **Worker-Durchlauf**: Eintritt, damit die 202-Antwort den Grund nennen kann (so der Brief); was bei freiem Fenster eintritt und erst später verfällt, verfällt im Worker mit Log-Zeile — beides getestet bzw. in der Semantik-Tabelle dokumentiert.
6. Stale Slot-Wecker nach Close sind harmlos (Broadcast ohne Waiter; Code-Kommentar erläutert es).
+11
View File
@@ -0,0 +1,11 @@
## Task 6: 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: <id>" 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.
+143
View File
@@ -0,0 +1,143 @@
# Task 6 Report: CORS-Tür + MCP-Endpoint
**Status:** DONE
**Datum:** 2026-10-18
**Basis:** 24ceff3 (Tasks 1–5 verifiziert) → HEAD `30bd535`
**Commits (feat/v1, kein Push, .scratch/ unberührt):**
1. `cef6e6b` feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)
2. `156c20d` feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config
3. `a577597` feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool
4. `150c374` test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake
5. `30bd535` docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert
## Was geliefert wurde
### 1. CORS-Tür (nur POST /speak)
- **`internal/api/cors.go`** (neu): `OPTIONS /speak` beantwortet Browser-Preflights mit
`204 No Content` + `Access-Control-Allow-Origin` (exakt gematchte Origin bzw. literales
`*` bei Wildcard-Konfiguration), `Access-Control-Allow-Methods: POST, OPTIONS`,
`Access-Control-Allow-Headers: Content-Type`, `Access-Control-Max-Age: 600`.
Echte (non-preflight) `POST /speak`-Antworten tragen ACAO, wenn die Anfrage-Origin
gelistet ist. `Vary: Origin` steht immer auf /speak (Cache-Korrektheit).
- **Ungelistete Origin:** sauberer 204/202 **ohne** jegliche CORS-Header — die Blockade
macht der Browser; HTTP bleibt für Nicht-Browser-Clients sauber erreichbar.
- **Leere Liste (Default):** gar keine CORS-Header — Tür zu, bis konfiguriert.
- **Nur /speak:** `OPTIONS /status` → 405 (kein Preflight-Support), `GET /status` ohne
ACAO; kein anderer Endpunkt sendet CORS-Header (Prozessgrenzen-Test `TestCORSNurAufSpeak`).
- **`internal/config`:** `server.allowed_origins` (TOML-Liste) + Env-Override
`TALK_ALLOWED_ORIGINS` (kommagetrennt, Whitespace toleriert; `off` leert die Liste).
Wildcard `*` ist explizit erlaubt, NICHT Default. Leere Einträge → klarer Fehler.
Unit-Tests: `TestLoadAllowedOrigins` (Defaults, TOML, Env schlägt Datei, off,
Wildcard, Fehlerfälle).
- **`GET /status`:** additives Feld `cors: [origins]` — immer vorhanden, `[]` (nicht
`null`) bei geschlossener Tür (Compat-Anforderung des Briefs; Test `TestStatusCorsFeld`).
### 2. MCP-Endpoint (Streamable HTTP)
- **`internal/api/mcp.go`** (neu): `POST /mcp` — **eigene schlanke Implementierung des
JSON-RPC-2.0-Subsets, kein SDK-Dependency** (bindende Decision des Briefs; es wurde
auch keins in Betracht gezogen — der benötigte Umfang sind ~370 Zeilen inkl. Doku,
ein SDK wäre schwerer als der Payload). Methoden: `initialize`,
`notifications/initialized`, `ping`, `tools/list`, `tools/call`.
- **Ein Tool `speak`:** Input-Schema exakt nach Brief — `{text: string, priority?,
provider?, lang?, now?}`, `required: ["text"]`, enums für priority/lang (Test prüft
Exactly-those-5-Properties).
- **`tools/call` = `POST /speak`:** Beide Türen fahren denselben Aufnahme-Pfad
`admitSpeak` (Refactoring in `internal/api/server.go`): Validierung → Mute-Check →
Enqueue inkl. Gesetz (Rate-Limit-Budget, Ruhezeit; Rangordnung mute > now > quiet >
rate aus Task 05 bleibt unverändert in `internal/queue`). Kein Contract-Drift möglich.
- **Ergebnis-Texte:** Erfolg → `queued: <id>`; Mute → `muted: Ansage verworfen (Mute
ist aktiv)`; Verwurf → `discarded: rate_limit …` / `discarded: quiet_hours …`
(+ Toast-Hinweis). **Bewusst `isError: false`** bei muted/discarded (deliberate
Decision laut Brief: dokumentiertes Aufnahme-Ergebnis, kein Protokollfehler — der
Agent soll den Grund lesen, nicht crashen).
- **`GET /status`:** additives Feld `mcp: true` (Test `TestStatusMCPFeld`).
- Daemon-Log-Zeile nennt jetzt cors-Liste und mcp.
## MCP-Protokoll-Entscheidungen (dokumentationspflichtig)
1. **Version-Handling:** `initialize` **echot die angefragte `protocolVersion`**
(akzeptiert sie); fehlt sie, antwortet talkd mit `2025-06-18`. Inhaltlich
implementiert ist der 2025-06-18-Umfang: Initialize-Ergebnis mit
`protocolVersion`, `capabilities: {tools: {}}`, `serverInfo: {name: "talkd",
version: <daemon-version>}`; Streamable HTTP auf einem Endpoint; keine Batches
(seit 2025-06-18 entfernt → `-32600` mit klarer Meldung). Getestet: Echo von
`2025-06-18` und `2025-03-26`.
2. **GET /mcp:** talkd pusht nie (statische Tool-Liste, keine Server-Notifications,
kein Sampling) — server-initiierte SSE-Streams entfallen daher komplett. `GET /mcp`
antwortet **`405 Method Not Allowed` mit `Allow: POST, OPTIONS`** — exakt die im
Streamable-HTTP-Transport vorgesehene Antwort für Server ohne Server-Streams.
`DELETE /mcp` (Session-Terminierung) fällt auf den auto-405 des Routers.
3. **Content-Types:** Antworten auf Requests: `200 OK` + `application/json` — auch für
JSON-RPC-Fehlerobjekte (der HTTP-Teil war erfolgreich; getestet in
`TestMCPFehlerPfade`). Notifications: `202 Accepted` mit leerem Body (JSON-RPC 2.0:
nie eine Antwort). Requests: der Daemon erzwingt kein `Content-Type` (curl-freundlich,
strikter wäre SDK-Komfort ohne Vertragsgewinn).
4. **Zustandslos, keine Session-Enforcement:** Jeder POST ist unabhängig; `Mcp-Session-Id`
wird ignoriert (nie gesetzt), und die Initialize-vor-allem-Regel wird NICHT erzwungen —
ein curl kann `tools/call` direkt rufen. MCP-Clients fahren den Handshake freiwillig;
Tests fahren ihn als volle Sequenz.
5. **JSON-RPC-Subset-Details:** Notification = `id` fehlt oder ist explizit `null`
(→ 202; unbekannte Notifications werden still ignoriert). Fehlercodes: kaputtes
JSON `-32700`, falsche jsonrpc-Version/Batch `-32600`, unbekannte Methode `-32601`,
Validierung/unbekanntes Tool `-32602`, interner Fehler (enqueue, HTTP-Äquivalent
503) `-32603`. HTTP-400-Validierung mapped also auf `-32602` + deutscher Klartext
(identische Meldungen wie POST /speak). `id` wird roh geechot (number/string/
null-sicher via `json.RawMessage`). `ping` → leeres Result (billig, hilft Clients).
Body-Limit 64 KiB wie /speak.
6. **Kein CORS auf /mcp** (bindend: CORS nur auf POST /speak) — MCP-Clients sind keine
Browser. Sollte später ein Browser-MCP-Client entstehen, muss die Tür explizit
erweitert werden (bewusste Grenze, Spec: „LibreChat-Frontend-Anbindung out of scope").
## Tests (Prozessgrenze, primäre Seam — harness erweitert, nicht ersetzt)
`test/harness_test.go`: `statusBody` +`mcp`/`cors`; neue Helfer `doRequest` (rohe
HTTP-Requests mit Headern), `preflight`, `mcpPost` (literale JSON-RPC-Payloads, kein
Client-SDK), `mcpDecode`.
- **`test/cors_test.go`:** Preflight von gelisteter Origin → 204 + alle Header
(Abnahmekriterium); ungeliste Origin → kein ACAO; echter POST mit/ohne gelistete
Origin; leere Liste → gar keine CORS-Header (Default); Wildcard `*` → ACAO `*`;
CORS nur auf /speak (OPTIONS /status 405, GET /status ohne ACAO); `cors`-Feld in
/status (gelistet + `[]` bei leerer Liste).
- **`test/mcp_test.go`:** kompletter Handshake als literal JSON-RPC über HTTP —
initialize (Version-Echo 2025-06-18 und 2025-03-26, capabilities.tools, serverInfo)
→ notifications/initialized (202, leerer Body) → tools/list (genau speak, Schema mit
required=[text], genau die 5 Properties) → tools/call (`queued: <id>`, isError
falsch, **Ansage am Fake-Provider sichtbar** = echte Queue-Aufnahme, Abnahmekriterium).
Gesetze: muted → isError:false + „muted …", keine Synthese; rate_limit (30 s) →
zweite low verworfen als „discarded: rate_limit …"; quiet_hours → „discarded:
quiet_hours …". Fehlerpfade: text fehlt/leer, bad priority, unbekanntes Tool,
unbekannte Methode, kaputtes JSON, falsche jsonrpc-Version, Batch — je korrekter
Code + Meldungsanteil, Status 200; ping; GET /mcp → 405 + Allow; unbekannte
Notification still 202. Alle Daemons auf ephemeralen Ports (TALK_PORT), isoliertes
HOME/XDG, sink=null, Fake-Provider.
## Verifikation
- `gofmt -l .` leer; `go vet ./...` grün; `go build ./...` grün;
`go test ./...` komplett grün (inkl. aller Tasks 1–5-Tests, keine Regressionen —
die CLI dekodiert /status mit den additiven Feldern ohne Anpassung).
- **Cross-Compile-Smoke** (CGO_ENABLED=0): `GOOS=darwin GOARCH=amd64` und
`GOOS=windows GOARCH=amd64` für `talk` UND `talkd` gebaut — erfolgreich.
## Self-Review (git diff 24ceff3..HEAD)
- Ein Verantwortlicher pro Datei: cors.go (CORS), mcp.go (MCP), server.go (Routing +
admitSpeak-Refactoring), config.go (Liste + Env), Tests getrennt nach(cors|mcp).
- `admitSpeak` ist die einzige Aufnahme-Stelle — POST /speak-Verhalten unverändert
(alle Task-04/05-Prozesstests laufen unverändert grün).
- Keine Abnahme-Lücken gegenüber dem Brief: alle vier bindenden Testszenarien
(preflight gelistet / ungelistet / leere Liste / \*), Handshake + tools/call mit
Fake-Beobachtung, /status-Felder, curl-Sequenz in README.
## Concerns / Grenzen
- `notifications/initialized` MIT id (falsch benutzter Client) bekommt ein leeres
Result statt Fehler (tolerante Wahl, dokumentiert im Code).
- CORS-Preflight wird bewusst auch bei fehlendem `Origin`-Header mit 204 beantwortet
(kein CORS-Kontext, keine Header) — harmlos, dokumentiert.
- OS-Toast in Ruhezeit bleibt wie in Task 5 eine Log-Zeile (Notification-Anbindung
folgt später) — betrifft MCP-/CORS-Pfad nicht.
+10
View File
@@ -0,0 +1,10 @@
## Task 7: `talk wrap`
Blockiert durch: 04. Liefert: Befehlsummwicklung mit Ansage.
- `talk wrap -- <befehl> [args…]`: führt Befehl aus (stdin durchreichen, stdout/stderr durchreichen), misst Dauer, Ansage nach Ende: Erfolg: „<label> erfolgreich. Dauer: X." / Fehler: „<label> fehlgeschlagen, Code N." — Label = Config `wrap.label` oder der Basisname des Befehls; Ansage läuft über den Daemon-Pfad (enqueue, non-blocking; `--wait`-Flag respektiert).
- Exit-Code des gewrappten Befehls wird durchgereicht (talk-Fehler ≠ Befehls-Fehler: wrap exited mit Befehls-Code; wenn die Ansage selbst scheitert: Warning auf stderr, Befehls-Code bleibt).
- Dauer-Formatierung: menschlich („3 Minuten 42 Sekunden", lokalisiert de/en nach langdetect des Labels).
- Tests: Unit für Formatierung + Prozessgrenze: `talk wrap -- sleep 1` mit Fake/Sink null → enqueue-Call mit Erfolgs-Ansage verifiziert; fehlschlagender Befehl → Code 2 durchgereicht + Fehler-Ansage.
- Abnahme: `talk wrap -- sleep 2` annonciert Erfolg + Dauer; Exit-Codes korrekt.