A bare `talk version` was spoken as an announcement because only the
--version flag existed. The subcommand prints the same line.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The daemon (and talk test --toast) now register the AppUserModelID
m3ta.talk under HKCU with display name and the installed icon, so
toasts appear as "talk" with the icon instead of Windows PowerShell;
the PowerShell identity stays as fallback until the registration has
run once. The redundant "talk" title line is dropped under the own
identity. notify.Setup bundles icon install, identity and the talk:
protocol handler for both callers. Linux notifications carry the
installed icon as app_icon. doctor names the active identity.
Verified on the Windows 11 VM: registry key, icon file, toast with
working Cancel button, exe icon; Linux via quickshell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One SVG source (assets/icon/talk.svg: speech bubble with level bars).
assets/icon/gen.go renders it with inkscape into the embedded PNG
(internal/icon, installed next to the config for notifications), the
multi-size talk.ico and the COFF .rsrc objects for windows/amd64 and
arm64 that give talk.exe its icon. The generator writes the resource
objects itself, so no resource compiler is needed; everything it
produces is committed and a plain go build picks it up.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Clicking Cancel reached the daemon and the queue cancelled the item, but
the sound played on to the end: oto v3's Player.Close only relies on the
garbage collector's finalizer, so the player stayed in the mix. Pause takes
it out immediately; only the device buffer (about 100 ms) drains. Same fix
covers talk cancel and daemon shutdown.
Direct speech via spd-say: killing the waiting client did not stop
speech-dispatcher either; on cancel it now sends `spd-say -S`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Windows toasts cannot call back into the daemon, so the Cancel button opens
a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon
registers the talk: URL protocol for the current user (HKCU, no admin) and
points it at `talk uri "%1"`; Windows starts that on the click, and `talk
uri` posts to the new endpoint POST /cancel-item, which cancels only the
announcement with that id (queue.CancelItem). The toast is tagged and
removed again when the announcement ends. For now the toast is shown under
Windows PowerShell's app identity, which unpackaged programs can use
without a Start Menu shortcut.
Verified on the Windows 11 VM: registry entry written by the daemon,
Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops
the playing announcement within two seconds, no toast errors in the log.
Tests: `talk uri` against a real handler on a loopback port (playing vs
waiting id, dead daemon, talk:test, garbage links), POST /cancel-item
process test (only the playing item, waiting one plays next, 400 without
id).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every playing announcement now shows a desktop notification with its text
and, on Linux, a Cancel button (Abbrechen for German) that stops exactly
that announcement; the notification is closed when the announcement ends.
This brings back what the old talk.sh had with notify-send --action, as a
default: a long announcement can be cut short without a terminal.
Implementation: the freedesktop Notifications service on the session bus
via godbus (pure Go): one connection per process, Notify with the action
list, ActionInvoked dispatched to the announcement, CloseNotification at
the end, expire_timeout 0 so the button stays for the whole announcement.
Without a session bus the notify-send fallback shows a plain notification;
macOS and Windows show one without a button for now. queue.CancelItem(id)
cancels only the playing item. Config [notify] on_speak (default true, env
TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a
click; `talk doctor` names the service and whether it supports the button.
Config hygiene found on the way: a key written below a [table] header
belongs to that table in TOML (lang under [voice] was silently ignored).
config.Load now collects unknown keys (undecoded ones and non-language
keys under [voice.<provider>]) and `talk doctor` warns with the hint.
Tests: process test on a private dbus-daemon with a fake
org.freedesktop.Notifications service (Notify payload, cancel click aborts
the synthesis, CloseNotification), queue.CancelItem, config keys. All test
binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever
pops a notification on the developer's desktop. flake vendorHash updated
for the new dependency.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Notifications could only be tried through an active quiet window with
quiet_mode = toast and a daemon restart. `talk test --toast` sends a test
notification directly through the same notify path, reports the tool
(notify-send / Notification Center / Windows toast) and the result, and
exits 1 with a hint when no tool exists or the call fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Verified on a Windows 11 VM over WinRM: doctor, SAPI synthesis, Edge,
oto/WASAPI playback, autostart, mute file, wrap exit codes, doctor --probe
and toast mode all work. Three findings fixed:
- The autostarted daemon died with the WinRM shell: Windows session hosts
(WinRM, SSH, CI runners) put children into a job object that is
terminated with the session. Spawn now starts the daemon with
CREATE_BREAKAWAY_FROM_JOB first and falls back to a start without it when
the job forbids breakaway. Confirmed: the daemon now outlives the WinRM
shell and `talk daemon stop` still ends it.
- `talk mute` without a daemon printed "daemon call failed (<nil>)": the
outer err was reported instead of the call's. Now "no daemon (<reason>);
writing the mute file directly".
- `talk wrap` printed "command did not exit cleanly" for every non-zero
exit code. The note is now reserved for commands that produced no exit
code of their own (not found, signal); a plain failure is carried by the
announcement and the exit code alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
skills/talk/SKILL.md: wann sprechen (nur auf Trigger in der Session oder
Standing-Order in der AGENTS.md, session-sticky bis Widerruf), wann nicht,
was eine gute Ansage ist (Status · nächster Schritt · wofür der Nutzer
gebraucht wird; Beispiele gut/schlecht), Umgang mit muted/discarded/
--now/Prioritäten, Pitfalls. AGENTS-snippet.md: der Block für Projekt-
AGENTS.md.
README: Installation (Release-Asset mit Prüfsumme, Nix-Flake, go install),
Skill-Abschnitt, Toast als echte Desktop-Benachrichtigung, Release-Ablauf
und nix build in „Entwicklung". Spec-Nachtrag: Release ohne GoReleaser,
Notifications ohne beeep.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- scripts/release.sh baut die sechs Targets (linux/darwin/windows ×
amd64/arm64) statisch mit CGO_ENABLED=0, -trimpath, Version und Commit
per ldflags, und schreibt checksums.txt. Asset-Vertrag für das Fleet-
Rollout: talk_<os>_<arch>[.exe]. Bewusst ohne GoReleaser — dasselbe
Artefakt lokal, in CI und unter nix, ohne zusätzliches Werkzeug.
- .gitea/workflows/release.yml: bei Tag v* Tests, Build, Gitea-Release mit
Assets (actions/gitea-release-action, Repo-Token).
- flake.nix: Paket (buildGoModule, ein Binary), App, Overlay, devShell,
Formatter. Linux-Wrapper legt alsa-lib in den LD_LIBRARY_PATH (oto lädt
ALSA per dlopen, spricht PulseAudio/PipeWire aber direkt in Go) und
espeak-ng/libnotify in den PATH. Verifiziert: nix build .#talk, nix flake
check, Snapshot-Build aller sechs Targets.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
quiet_mode = "toast" war bisher nur eine Protokollzeile. Jetzt zeigt der
Daemon eine Desktop-Benachrichtigung — über die Bordmittel des Systems,
exec-basiert wie osbuiltin, ohne Bibliothek und ohne CGO: notify-send
(Linux), osascript display notification (macOS), PowerShell Toast
(Windows; Text base64, kein Quoting-Problem). Best effort: fehlt das
Werkzeug, bleibt es bei der Protokollzeile plus Start-Warnung; `talk
doctor` zeigt den Weg unter „Toast". Der Aufruf läuft asynchron, ein
hängender Notification-Daemon blockiert die Queue nie.
Queue: OnToast-Hook, gefeuert bei Toast-Verwurf beim Eintritt und im Worker
(nie unter dem Mutex). Tests: notify-Paket mit fake notify-send im PATH,
Prozessgrenze: Ruhezeit + toast → fake notify-send bekommt Titel und Text,
der Provider keinen Treffer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
TestRuhezeitDiscard kippte, wenn der Lauf (Daemon-Start, Race-Detector)
über eine Minutengrenze lief: um hh:mm:59 gestartet, war das Fenster
hh:mm−1 bis hh:mm+1 beim Prüfen schon inaktiv. Der im Ledger notierte
Timing-Flake.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Usability-Schicht für Nutzer, die kein Terminal-Handbuch lesen wollen:
- `talk doctor`: eine Zeile je Prüfpunkt mit klarem Satz, was Sache ist und
was zu tun wäre — Config (kaputt → ✘, die übrigen Prüfungen laufen mit
Defaults weiter), Daemon (läuft / startet beim nächsten Sprechbefehl /
fremder Dienst auf dem Port → ✘ mit Port-Hinweis), Audio (oto-Kontext
wirklich initialisieren; null → ⚠), jede Provider-Stufe (Key-Env-Name,
Endpunkt, Default-Stimme; OS-Stimme nennt den gefundenen Mechanismus),
Mute (an → ⚠ mit unmute-Hinweis), Gesetze inkl. gerade aktiver Ruhezeit.
--probe macht echte Synthese-Proben, --json liefert die Liste. Exit 0
ohne ✘. Windows-Konsolen bekommen [OK]/[WARN]/[FAIL].
- `talk test`: Testsatz direkt über den Sprech-Kern (nicht über den
Daemon), nennt die Stufe, die wirklich gesprochen hat, Synthese- und
Wiedergabezeit; Mute/Ruhezeit gelten bewusst nicht, Mute wird genannt.
- `talk voices`: kuratierter Katalog mit Namen (ElevenLabs-Premade, OpenAI,
Edge-Neural de/en-Locales) als Tabelle, „*“ = effektive Default-Stimme
(Config vor Repo-Default), Config-IDs außerhalb des Katalogs als eigene
Zeile, Filter --provider/--lang, --json.
- `talk setup`: wenige Fragen statt Bubble Tea — Keys nur als Hinweis
(ohne Key läuft die Edge-Stimme), Stimme je Sprache aus dem Katalog mit
Probehören, Ruhezeit; schreibt die config.toml zusammengeführt mit
bestehenden Werten (.bak), spricht einen Testsatz. q bricht ab, bevor
etwas geschrieben wird; ohne Terminal keine Fragen, nur die wirksame
Konfiguration.
Unterbau: provider.Catalog/VoiceName/DefaultVoice, Chain.SpeakOutcome
(Stufe, Bytes, Zeiten) durch speak.Engine bis in Daemon-Log und test,
audio.Check (oto-Initialisierung ohne Ton), config.Path,
OSBuiltin.Describe. README: Abschnitt „Erste Schritte“.
Tests: voices-Zeilen (Default-Markierung, Config-Override, Filter, JSON),
doctor (ohne Daemon, Mute/Key via JSON, fremder Dienst → Exit 1, kaputte
Config → ✘ aber vollständiger Bericht), test (fake espeak), setup
(stdin-Antworten → config.toml-Inhalt, Merge mit .bak, Abbruch schreibt
nichts, Pipe stellt keine Fragen), Prozessgrenze: doctor/--probe/voices/
test gegen echten Daemon.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
28 Dateien Prozess-Zustand (Ledger, Briefs, Reports, Review-Diffs, Plan,
Handoff) sind für eine Veröffentlichung Rauschen. Sie leben weiter auf dem
Branch sdd-state; .scratch/ und .superpowers/ sind gitignored. Restore und
Update stehen in .gitignore und in der Commit-Nachricht von sdd-state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Die Spezifikation ist ein Produkt-Dokument und gehört nicht in einen
Scratch-Ordner. Der Nachtrag hält fest, was gegenüber dem Grilling-Stand
geändert wurde: ein Binary mit Autostart statt talk + talkd, CLI als
Agenten-Schnittstelle, now bricht Ruhezeit, Gesetze max_text/max_age,
Zielbild Veröffentlichung mit Usability-Vorrang. Der Implementierungsplan
bleibt unter .scratch, weil das SDD-Werkzeug ihn dort erwartet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.
- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
/speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
Fälligwerden länger gewartet haben, werden verworfen (discarded
"expired", Protokoll nennt Wartezeit und Grenze). now:true ist
ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.
Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Der Worker rief cancel() auf seinem eigenen Kontext, BEVOR er ctx.Err()
prüfte — damit war jede normal beendete Ansage im Protokoll „abgebrochen",
auch bei Erfolg. Der Abbruch wird jetzt vor dem eigenen cancel() abgelesen.
Regressionstest: fertig heißt „fertig nach", ein echter Cancel heißt
„abgebrochen".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.
Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
(daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
neu.
Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Das feste Fenster 18:00-08:00 mit Toast-Modus toastete die Testansage
zwischen 18 und 8 Uhr statt sie zu sprechen; der Test und damit CI waren
14 Stunden am Tag rot. Jetzt beginnt das Fenster zwei Stunden nach jetzt
und dauert eine Stunde; ein Sweep über alle 24 Stunden sichert ab, dass es
zu keiner Tageszeit aktiv ist. Assertions bleiben gleich stark und prüfen
zusätzlich QuietActive/QuietUntil im LawState.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CORS allein verhindert nur das Lesen der Antwort, nicht die Wirkung: Ein
POST mit text/plain-Body oder ein Formular-POST geht ohne Preflight raus,
und talkd hat den Body trotzdem als JSON gelesen bzw. /mute, /cancel und
/mcp ohne Body ausgeführt. Jede Webseite konnte so sprechen, muten und
abbrechen.
Neue Middleware vor dem Router: Anfragen mit Origin-Header kommen nur als
OPTIONS /speak (Preflight, ohne Wirkung) oder POST /speak von gelisteter
Origin durch; alles andere ist 403, bevor ein Body gelesen wird. CLI, curl
und MCP-Clients senden keinen Origin und sind nicht betroffen.
Prozesstests angepasst (ungelistete Origin auf /speak → 403, Steuer-
Endpunkte aus dem Browser → 403) und TestBrowserKannNichtSteuern ergänzt,
der den Angriff mit simple requests durchspielt und prüft, dass weder
Provider noch Mute-State berührt werden.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Neuer Abschnitt zur Befehlsummwicklung: Aufrufform, Schutzwirkung des
ersten „--“, Label (wrap.label/TALK_WRAP_LABEL/Basisname) samt Sprach-
erkennung, Exit-Code-Politik (Befehls-Code gewinnt immer, 127 für nicht
startbare Befehle, 1 für Signaltode), geerbte say-Flags und der
Ansageweg über Daemon/Degraded Mode; Implementiert-Liste um wrap ergänzt.
talk läuft als echter Prozess gegen talkd mit Fake-Provider und sink null
(Harness aus Task 04, unverändert erweitert): Erfolg mit Dauer
(sleep-Ansage), Exit-Code-Durchreichung (sh -c 'exit 2' → 2 +
Fehler-Ansage wortgenau), --bar-Shielding-Regression (true --bar →
Exit 0 + Erfolg-Ansage, kein talk-Flag-Fehler), Label-Override per
TALK_WRAP_LABEL, non-blocking (wrap kehrt vor Ansage-Ende zurück,
Befehls-stdout bleibt frei von talk-Zeilen), --wait und Degraded Mode
ohne Daemon (Befehls-Code bleibt).
talk wrap -- <Befehl> [Argumente …] führt den Befehl aus (stdin/stdout/
stderr durchgereicht — stdout gehört allein dem Befehl, talk-Informations-
zeilen gehen auf stderr), misst die Wandzeit und sagt danach das Ergebnis
an: „<label> erfolgreich. Dauer: X.“ bzw. „<label> fehlgeschlagen,
Code N.“, menschlich formatiert (Stunden/Minuten/Sekunden, Einzahl
dekliniert) und lokalisiert de/en nach langdetect des Labels (--lang
überschreibt). Label: config wrap.label bzw. TALK_WRAP_LABEL oder der
Basisname des Befehls.
Exit-Code-Politik: wrap exitet immer mit dem Code des Befehls — scheitert
die Ansage selbst (Daemon lehnt ab, Kette leer), bleibt der Befehls-Code
mit Warning auf stderr (talk-Fehler ≠ Befehls-Fehler). Nicht startbare
Befehle zählen als 127 (Shell-Konvention), Signaltode portabel als 1
(syscall.WaitStatus wäre nicht frei cross-compilierbar; dokumentiert).
Benutzungsfehler von talk (kein Befehl, ungültige say-Flags) scheitern
vor dem Befehlsstart; Config-Fehler nach dem Lauf — Ansage ausgesetzt.
Ansageweg wie say: Daemon-Pfad (enqueue, non-blocking; --wait pollt),
ohne Daemon Degraded Mode; wrap erbt die say-Flags und reicht sie durch.
Dafür bekommen sayViaDaemon/sayDegraded einen info-Writer: say schreibt
Item-ID/muted/discarded weiter auf stdout, wrap auf stderr — Pipes des
Befehls bleiben sauber. Unit-Tests: Dauer-Formatierung (de/en), Ansage-
Texte, Label-Auflösung, In-Prozess-Durchreichung gegen Fake-Provider
(Erfolg/Fehlschlag/127/Label/Ansage-scheitert/stdout-Reinheit).
Der Interspersed-Loop von Task 03 nahm nach jedem Positionsargument das
Flag-Parsen wieder auf und hob damit die Stdlib-Terminator-Semantik von
„--“ auf: Argumente, die für einen NACHFOLGENDEN Befehl gemeint waren,
wurden als talk-Flags fehlinterpretiert (talk wrap -- foo --bar wäre an
--bar als unbekanntem Flag gescheitert; auch talk -- „Text --flag“ brach).
Jetzt trennt parseInterspersed argv am ersten allein stehenden „--“: Nur
der Teil davor wird interspersed geparst, der Rest ist bedingungslos
Positionsargument. Die Abnahme-Form „Flag nach dem Text“ ohne „--“
(talk "Text" --provider=x) bleibt unverändert — Regressionstests für
beide Formen in parse_test.go.
Config-Schlüssel [wrap] label für talk wrap (Task 07): ersetzt den
Basisnamen des Befehls in der Ansage („Deployment erfolgreich. Dauer: …“
statt „terraform erfolgreich. …“). Auflösung wie üblich Defaults →
config.toml → Env (TALK_WRAP_LABEL schlägt die Datei); Tisch-Tests für
alle drei Schichten.
CORS: Preflight 204+Header von gelisteter Origin, kein ACAO für ungeliste/
leere Liste, Wildcard, nur-/speak, cors-Feld in /status. MCP: kompletter
Handshake als literal JSON-RPC über HTTP, tools/call enqueue sichtbar am
Fake-Provider, muted/rate_limit/quiet_hours als isError:false-Ergebnisse,
Fehlerpfade (-32700/-32600/-32601/-32602), GET /mcp 405.
OPTIONS /speak beantwortet Preflights mit 204 + Allow-Headers/Methods/Max-Age,
wenn die Origin in server.allowed_origins steht; ungeliste Origins und leere
Liste bekommen keine CORS-Header (Browser blockt). Nur /speak, kein anderer
Endpunkt. /status ergänzt additiv cors:[origins] (immer vorhanden, [] wenn zu).
Endpunkt-Tabelle (discarded-Feld, Gesetz-State in /status), neuer
Abschnitt zu Semantik (wer wartet, wer verfällt, was now darf),
Rangordnung mute > now > Ruhezeit > Rate-Limit, Config-/Env-Referenz
und die status-Zeilen der CLI.
Kurzes Fenster (1 s): fünf Ansagen → genau eine spielt sofort, low
verfällt beim Eintritt (202 + CLI 'discarded: rate_limit'), normal parkt
(sichtbar in queue_ids/next_slot) und spielt frühestens zum Slot
(Timing-am-Fake gemessen). High-Burst an/aus; now bricht durch Rate-Limit
UND Ruhezeit; Ruhezeit-Fenster künstlich um die lokale Jetztzeit gelegt
→ discard (auch high) und toast (Protokoll-Zeile, kein Provider-Kontakt);
/status- und talk-status-Felder im Default. Harness-Basis: TALK_RATE_LIMIT=0s
— die Task-04-Semantik-Tests erwarten dichtes Durchspielen, Gesetz-Tests
überschreiben bewusst.
POST /speak antwortet bei Verwurf dokumentiert mit 202 {id:"",
discarded:"rate_limit"|"quiet_hours"} (+ toast:true im Toast-Modus)
— analog zum Mute-Muster, kein Fehler. GET /status ergänzt additiv
rate_limit {interval, allow_high_burst, next_slot} und quiet_hours
{window, active, mode, until} (RFC3339 bzw. null). talkd verwired die
Gesetze aus der Config und loggt sie beim Start. Die CLI meldet
'discarded: <grund>' plain (wie muted) und zeigt in talk status die
Zeilen rate-limit/ruhezeit mit Slot bzw. Fenster-Ende.
Das Anti-Spam-Gesetz wird an den Loop verwired (die Rate-Limiter-Struktur
aus Task 04): Jede gestartete Ansage bucht das Fenster — low über dem
Budget verfällt (beim Eintritt, Admission nennt den Grund für die
202-Antwort), normal parkt bis zum Slot (Slot-Wecker broadcastet die
Cond, geparkte bleiben in der Queue und in /status sichtbar), high darf
mit Burst-Recht verstoßen, now bricht bewusst aus Rate-Limit UND
Ruhezeit aus und bucht trotzdem ein neues Fenster. Ruhezeiten
(quiet.go: lokales Fenster HH:MM-HH:MM über Mitternacht) verwerfen still
oder ersetzen durch Toast — gilt auch für high. Rangordnung: mute (API)
> now > Ruhezeit > Rate-Limit. Enqueue liefert Admission statt nur ID;
LawState für /status. Lock-Ordnung Queue → RateLimiter bleibt gewahrt.
Queue-Section ([queue] rate_limit = "30s", allow_high_burst = true) und
Ruhezeiten (quiet_hours = "18:00-08:00" lokal über Mitternacht,
quiet_mode = discard|toast) mit Env-Overrides TALK_RATE_LIMIT,
TALK_ALLOW_HIGH_BURST, TALK_QUIET_HOURS ("off" = aus), TALK_QUIET_MODE.
Default: 1 Ansage / 30 s, Burst an, Ruhezeit aus (Opt-in wie Mute —
ein Uhrzeit-Gesetz als Default würde jeden Cron in den Abendstunden
stilllegen). Validierung gegen queue.ParseQuietHours als eine Quelle;
partielle [queue]-Tabellen behalten Defaults (tischgetestet).
- say/Default erkennt den Daemon via GET /status: enqueue + sofort zurück
(stdout = Item-ID), --wait pollt bis zur Fertigstellung, --now springt vor
- mute/unmute/status/cancel mit plain Output; mute/unmute schreiben ohne
Daemon die Mute-Datei direkt (gleiche Quelle, wirkt sofort im Daemon)
- Degraded Mode: Lock-Datei talk.lock im Config-Dir (Kollision = warten,
verwaist nach 60 s), sequenzielles lokales Abspielen, mute respektiert
- Handler-Signatur der Befehlstabelle erhält stdout (say/Steuer-Befehle
geben Ergebnisse aus); --priority wird validiert
- HTTP-Server nur auf 127.0.0.1 (Port aus config server.port/TALK_PORT),
klarer Fehler wenn der Port belegt ist
- Ansagen laufen über die unveränderte Provider-Kette (pro Item gebaut,
Pin/Voice je Anfrage); /status meldet den aktiven Provider
- SIGINT/SIGTERM: erst HTTP dicht, dann Queue schließen (laufende Ansage
wird abgebrochen, Worker-Exit wird abgewartet)
- Mute ohne Config-Dir: dokumentierte Warnung statt Absturz
- POST /speak (202 {id}; bei Mute 202 {muted:true}), GET /status,
POST /cancel, POST /mute, POST /unmute (methodengebunden: 405/404)
- Request/Response-Typen als einzige Vertragsschicht für Daemon und CLI
- Client inkl. HTTPError: Netz-Fehler (Daemon tot) bleibt von HTTP-Fehlern
unterscheidbar — Basis für den Degraded Mode
- Worker-Loop: eine Ansage gleichzeitig; now > high > normal > low, FIFO je Level
- Cancel bricht die laufende Ansage ab (ctx) und leert die Queue (Zahl als Rückgabe)
- Close verwirft wartende Items, bricht die laufende Ansage ab und wartet auf den Worker
- MuteStore: persistenter Mute-State als Datei mute im Config-Dir (atomar geschrieben)
- RateLimiter: Struktur + Semantik für Task 05, bewusst noch nicht angebunden
(Task-04-Default unbegrenzt, dokumentierte Seam in rate.go)
Speak rief bei ErrNoByteOutput SpeakDirect auf, das einen eigenen
vollständigen walk machte: fehlgeschlagene Cloud-Stufen wurden zweimal
angefragt (doppelte POSTs auf bezahlten APIs), Fehlschlag-Zeilen
verdoppelten sich im Log, die Worst-Case-Latenz verdoppelte sich, und
Bytes aus einer transienten Erholung im zweiten Durchlauf hätten einen
Fehler statt Wiedergabe produziert.
Speak entscheidet jetzt aus EINEM walk-Ergebnis (Bytes → Player,
nur-Direkt-Stufe → SpeakDirect direkt daran, sonst gebündelter
Stufen-Fehler aus dem geteilten Helper walkResult.noTierErr).
Synthesize/SpeakDirect behalten ihre öffentlichen Verträge; null-Player
verwirft tonlos mit Exit 0 wie zuvor.
Test: TestChainDirektEndgameEinDurchlauf pinnt Cloud-Stufe genau 1×
angefragt, jede Fehlschlag-Zeile genau 1×, Direktansage kommt zustande.
runSay baut jetzt die Kette aus Config + Flags (Pin: --provider schlägt
config provider; --voice wird über VoicePinner durchgepinnt) und ruft
chain.Speak — Synthese mit Fallback, Wiedergabe über den Sink, Direkt-
ansage inklusive tonlosem Verwerfen am null-Sink steckt komplett in der
Kette (wiederverwendbar für den Daemon aus Task 04).
Zusätzlich: Flags dürfen jetzt überall stehen — Go-stdlib-flag stoppt
bei dem ersten Nicht-Flag-Argument, die Abnahme-Aufrufform ist aber
`talk "Test" --provider=elevenlabs`. parseInterspersed parst wiederholt
über den Rest; Positionsargumente (der Text) bleiben vollständig.
Tests: Cloud-Provider-End-to-End über die Env-Seam gegen httptest-Fakes,
--voice im Request-Pfad sichtbar, 500-Fallback bis osbuiltin auf CLI-
Ebene, Flag-nach-Text-Regression.