refactor: switch the whole project to English

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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+51 -49
View File
@@ -1,9 +1,9 @@
// Package daemon ist der Hintergrundprozess von talk: HTTP-API auf
// 127.0.0.1, serialisierte Ansage-Queue mit Prioritäten und now-Sprung,
// persistenter Mute-State, die Anti-Spam-Gesetze (Rate-Limit, Ruhezeiten),
// CORS-Tür und MCP-Endpoint. Es gibt genau ein Binary: `talk daemon run`
// betreibt den Daemon im Vordergrund, die CLI startet ihn mit Spawn bei
// Bedarf selbst im Hintergrund (daemon.autostart).
// Package daemon is talk's background process: HTTP API on 127.0.0.1, a
// serialized announcement queue with priorities and the now jump,
// persistent mute state, the anti-spam laws (rate limit, quiet hours), the
// CORS door and the MCP endpoint. There is exactly one binary: `talk daemon
// run` runs the daemon in the foreground, and the CLI starts it in the
// background itself via Spawn when needed (daemon.autostart).
package daemon
import (
@@ -28,37 +28,38 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Dateien des Daemons im Config-Verzeichnis.
// Files of the daemon in the config directory.
const (
// LogFileName ist das Protokoll eines im Hintergrund gestarteten
// Daemons (Spawn leitet stdout/stderr dorthin; pro Start neu).
// LogFileName is the log of a daemon started in the background (Spawn
// redirects stdout/stderr there; fresh per start).
LogFileName = "daemon.log"
// PIDFileName hält die Prozess-ID des laufenden Daemons (für
// Diagnose und als Notbremse, wenn die HTTP-API nicht antwortet).
// PIDFileName holds the process ID of the running daemon (for
// diagnostics and as an emergency brake when the HTTP API does not
// answer).
PIDFileName = "daemon.pid"
)
// Options sind die Abhängigkeiten von Run.
// Options are the dependencies of Run.
type Options struct {
// Config ist die geladene Konfiguration (Port, Gesetze, Sink, …).
// Config is the loaded configuration (port, laws, sink, ...).
Config config.Config
// Getenv liefert Keys und Endpunkt-Overrides (nil = os.Getenv).
// Getenv supplies keys and endpoint overrides (nil = os.Getenv).
Getenv func(string) string
// Version ist die Daemon-Version für /status und MCP serverInfo.
// Version is the daemon version for /status and MCP serverInfo.
Version string
// Log empfängt das Protokoll (Vordergrund: stderr; Hintergrund: die
// Log-Datei). nil = os.Stderr.
// Log receives the log output (foreground: stderr; background: the log
// file). nil = os.Stderr.
Log io.Writer
}
// Run betreibt den Daemon, bis ctx endet (Signal), POST /shutdown kommt
// oder der HTTP-Server scheitert. Eine laufende Ansage wird beim
// Herunterfahren abgebrochen — der Daemon soll prompt reagieren. Ein
// belegter Port ist ein klarer Fehler („läuft bereits ein Daemon?").
// Run runs the daemon until ctx ends (signal), POST /shutdown arrives or
// the HTTP server fails. A playing announcement is cancelled on shutdown;
// the daemon should react promptly. A port in use is a clear error ("is
// another daemon running?").
func Run(ctx context.Context, opts Options) error {
getenv := opts.Getenv
if getenv == nil {
@@ -77,8 +78,8 @@ func Run(ctx context.Context, opts Options) error {
return err
}
// Anti-Spam-Gesetz: config.Load hat quiet_hours validiert — ein Fehler
// hier ist praktisch unmöglich, darf aber nie still werden.
// Anti-spam law: config.Load has validated quiet_hours; an error here
// is practically impossible but must never be silent.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
@@ -98,15 +99,15 @@ func Run(ctx context.Context, opts Options) error {
Voice: item.Voice,
})
if err == nil && !out.Discarded {
logger.Printf("ansage %s über %s (synthese %s, wiedergabe %s)", item.ID, out.Provider,
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
}
return err
}, lw)
// Ruhezeit-Toast: Desktop-Benachrichtigung statt Audio (quiet_mode =
// "toast"); best effort, Fehler nur im Protokoll. Läuft asynchron, damit
// ein hängender Notification-Daemon nie die Queue blockiert.
// Quiet-hours toast: desktop notification instead of audio (quiet_mode
// = "toast"); best effort, errors only in the log. Runs asynchronously
// so a hanging notification daemon never blocks the queue.
if cfg.QuietMode == queue.QuietModeToast {
if notify.Available() {
q.OnToast(func(item queue.Item) {
@@ -117,18 +118,19 @@ func Run(ctx context.Context, opts Options) error {
}()
})
} else {
logger.Printf("warnung: quiet_mode = toast, aber %s — Ruhezeit-Ansagen werden nur protokolliert", notify.Describe())
logger.Printf("warning: quiet_mode = toast, but %s; quiet-hours announcements are only logged", notify.Describe())
}
}
// Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir
// läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern.
// Persistent mute state and PID file in the config dir. Without a
// config dir the daemon keeps running without both (warning) instead
// of failing.
var mutePath, pidPath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
pidPath = filepath.Join(dir, PIDFileName)
} else {
logger.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State und PID-Datei deaktiviert (%v)", err)
logger.Printf("warning: config directory unknown; mute state and PID file disabled (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
@@ -156,12 +158,12 @@ func Run(ctx context.Context, opts Options) error {
ln, err := net.Listen("tcp", addr)
if err != nil {
q.Close()
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talk daemon?", addr, err)
return fmt.Errorf("bind %s: %w; is another talk daemon running?", addr, err)
}
if pidPath != "" {
writePID(pidPath, logger)
// Die PID-Datei verschwindet als Letztes — `talk daemon stop`
// wartet darauf und weiß dann: Prozess ist fertig, nicht nur HTTP.
// The PID file disappears last: `talk daemon stop` waits for that
// and then knows the process is done, not just HTTP.
defer os.Remove(pidPath)
}
@@ -169,11 +171,11 @@ func Run(ctx context.Context, opts Options) error {
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
corsLog := "zu (kein CORS)"
corsLog := "closed (no CORS)"
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
@@ -182,14 +184,14 @@ func Run(ctx context.Context, opts Options) error {
q.Close()
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Printf("Signal empfangen — fahre herunter (laufende Ansage wird abgebrochen)")
logger.Printf("signal received; shutting down (the playing announcement is cancelled)")
case <-shutdown:
logger.Printf("Shutdown angefordert (talk daemon stop) — fahre herunter")
logger.Printf("shutdown requested (talk daemon stop); shutting down")
}
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
// Order: close HTTP first (no new requests), then close the queue
// (cancel the playing announcement, stop the worker and wait for it).
// That way no announcement ever keeps playing after the daemon ends.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
@@ -199,19 +201,19 @@ func Run(ctx context.Context, opts Options) error {
return nil
}
// writePID schreibt die eigene Prozess-ID (best effort; ein Fehler ist nur
// eine Protokollzeile — der Daemon funktioniert auch ohne PID-Datei).
// writePID writes our own process ID (best effort; an error is only a log
// line, the daemon works without a PID file too).
func writePID(path string, logger *log.Logger) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
return
}
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
logger.Printf("warnung: PID-Datei: %v", err)
logger.Printf("warning: PID file: %v", err)
}
}
// ReadPID liest die PID-Datei; 0, wenn keine (lesbare) Datei existiert.
// ReadPID reads the PID file; 0 if no (readable) file exists.
func ReadPID(dir string) int {
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
if err != nil {
@@ -224,9 +226,9 @@ func ReadPID(dir string) int {
return pid
}
// lineWriter reicht Zeilen an den Logger weiter (Zeitstempel + Präfix),
// statt sie roh zu schreiben: Provider-Kette, Queue und null-Sink schreiben
// zeilenweise, so entsteht ein einheitliches Protokoll.
// lineWriter forwards lines to the logger (timestamp + prefix) instead of
// writing them raw: provider chain, queue and null sink write line by line,
// which yields one uniform log.
type lineWriter struct{ l *log.Logger }
func (w lineWriter) Write(p []byte) (int, error) {
+12 -11
View File
@@ -6,15 +6,16 @@ import (
"os/exec"
)
// Spawn startet `exe daemon run` als eigenständigen Hintergrundprozess
// (eigene Session bzw. eigene Prozessgruppe, losgelöst vom Terminal), mit
// stdout/stderr in die Log-Datei logPath (pro Start neu angelegt; leer =
// verwerfen). env ist die Umgebung des Kindes (Keys, TALK_*-Overrides).
// Spawn starts `exe daemon run` as an independent background process (own
// session or own process group, detached from the terminal), with
// stdout/stderr going to the log file logPath (created fresh per start;
// empty = discard). env is the child's environment (keys, TALK_*
// overrides).
//
// Rückgabe ist ein Kanal, der genau einmal das Wait-Ergebnis liefert, wenn
// das Kind endet — der Aufrufer erkennt daran einen fehlgeschlagenen Start
// (z. B. Port belegt), ohne auf einen Timeout warten zu müssen. Endet der
// Aufrufer zuerst, läuft der Daemon weiter (das ist der Zweck).
// The return value is a channel that delivers the Wait result exactly once
// when the child exits; the caller uses it to detect a failed start (e.g.
// port in use) without waiting for a timeout. If the caller exits first,
// the daemon keeps running (that is the point).
func Spawn(exe, logPath string, env []string) (<-chan error, error) {
var logFile *os.File
if logPath == "" {
@@ -22,9 +23,9 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return nil, fmt.Errorf("Daemon-Log %s: %w", logPath, err)
return nil, fmt.Errorf("daemon log %s: %w", logPath, err)
}
defer logFile.Close() // das Kind hält sein eigenes Handle
defer logFile.Close() // the child holds its own handle
cmd := exec.Command(exe, "daemon", "run")
cmd.Env = env
@@ -33,7 +34,7 @@ func Spawn(exe, logPath string, env []string) (<-chan error, error) {
cmd.Stderr = logFile
detach(cmd)
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("Daemon starten (%s daemon run): %w", exe, err)
return nil, fmt.Errorf("starting daemon (%s daemon run): %w", exe, err)
}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
+1 -1
View File
@@ -4,5 +4,5 @@ package daemon
import "os/exec"
// detach ist auf dieser Plattform ein No-op (kein Setsid, keine Konsole).
// detach is a no-op on this platform (no Setsid, no console).
func detach(*exec.Cmd) {}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"syscall"
)
// detach löst das Kind vom Terminal: eigene Session (kein SIGHUP beim
// Schließen des Terminals, kein Ctrl-C der Shell).
// detach detaches the child from the terminal: own session (no SIGHUP when
// the terminal closes, no Ctrl-C from the shell).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
}
+4 -4
View File
@@ -7,12 +7,12 @@ import (
"syscall"
)
// detachedProcess ist das Win32-Flag DETACHED_PROCESS: kein Konsolenfenster,
// keine Bindung an die Konsole des Aufrufers.
// detachedProcess is the Win32 flag DETACHED_PROCESS: no console window, no
// binding to the caller's console.
const detachedProcess = 0x00000008
// detach löst das Kind von der Konsole: eigene Prozessgruppe (kein Ctrl-C
// der Shell) und keine Konsole (kein aufblitzendes Fenster).
// detach detaches the child from the console: own process group (no Ctrl-C
// from the shell) and no console (no flashing window).
func detach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | detachedProcess,