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

+94 -98
View File
@@ -10,33 +10,32 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene,
// schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich
// braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier
// Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei
// dokumentierten Vereinfachungen, die der Transport hergibt:
// MCP endpoint: Streamable HTTP on POST /mcp, a lean implementation of our
// own of the JSON-RPC 2.0 subset that MCP actually needs (deliberately NO
// SDK dependency; the protocol scope here is four methods plus ping).
// Semantics per MCP 2025-06-18, with two documented simplifications the
// transport allows:
//
// - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs
// (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung
// der Initialize-vor-allem-Regel — ein curl kann tools/call direkt
// rufen. MCP-Clients stört das nicht: Sie fahren den Handshake
// freiwillig.
// - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste
// ist statisch). GET /mcp antwortet daher 405 — die vom Streamable-
// HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream.
// - Stateless: every POST is independent; there are no session IDs
// (Mcp-Session-Id is ignored) and therefore no enforcement of the
// initialize-first rule; a curl can call tools/call directly. MCP
// clients do not mind: they run the handshake voluntarily.
// - No server-initiated streams: talkd never pushes (the tool list is
// static). GET /mcp therefore answers 405, the response the Streamable
// HTTP transport specifies for servers without an SSE stream.
//
// Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte
// ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202
// ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht
// mehr Teil von MCP → -32600.
// Responses: requests get 200 application/json (JSON-RPC error objects also
// 200; the HTTP part did succeed); notifications get 202 without a body
// (JSON-RPC 2.0). Batches (arrays) are no longer part of MCP since
// 2025-06-18 and yield -32600.
// mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der
// Client keine angibt (initialize ohne protocolVersion). Angefragte
// Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich
// implementiert ist der oben dokumentierte 2025-06-18-Umfang.
// mcpProtocolVersion is the version talkd itself speaks when the client
// names none (initialize without protocolVersion). Requested versions are
// accepted and echoed back; what is implemented is the 2025-06-18 scope
// documented above.
const mcpProtocolVersion = "2025-06-18"
// JSON-RPC-2.0-Fehlercodes (benutztes Subset).
// JSON-RPC 2.0 error codes (subset in use).
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
@@ -45,12 +44,11 @@ const (
rpcInternalError = -32603
)
// mcpServerName ist der serverInfo-Name im Initialize-Ergebnis.
// mcpServerName is the serverInfo name in the initialize result.
const mcpServerName = "talkd"
// jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als
// RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0
// bzw. id:string unterscheidbar.
// jsonRPCRequest is an incoming JSON-RPC message. ID as RawMessage keeps
// "field missing" (notification) distinguishable from id:0 or id:string.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -58,9 +56,9 @@ type jsonRPCRequest struct {
Params json.RawMessage `json:"params"`
}
// jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus
// Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern
// ohne bekannte ID null (JSON-RPC 2.0).
// jsonRPCResponse is an outgoing JSON-RPC message. Exactly one of
// Result/Error is set. ID is never omitted; null on parse errors without a
// known ID (JSON-RPC 2.0).
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -68,112 +66,112 @@ type jsonRPCResponse struct {
Error *rpcError `json:"error,omitempty"`
}
// rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort.
// rpcError is the error object of a JSON-RPC response.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// mcpInitializeParams sind die relevanten Teile der initialize-Parameter
// (Client-Capabilities interessieren talkd nicht — Tools genügen).
// mcpInitializeParams holds the relevant parts of the initialize params
// (client capabilities do not interest talkd; tools suffice).
type mcpInitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
}
// mcpServerInfo beschreibt den Server im Initialize-Ergebnis.
// mcpServerInfo describes the server in the initialize result.
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet:
// Tools (speak) — sonst nichts.
// mcpCapabilities reports in the initialize result what talkd offers:
// tools (speak), nothing else.
type mcpCapabilities struct {
Tools map[string]any `json:"tools"`
}
// mcpInitializeResult ist das Ergebnis von initialize: die (geechote)
// Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form.
// mcpInitializeResult is the result of initialize: the (echoed) protocol
// version, the capabilities and the server info, in 2025-06-18 form.
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities mcpCapabilities `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool
// (speak); ohne nextCursor (die Liste passt immer in eine Seite).
// mcpToolsListResult is the result of tools/list: exactly one tool (speak);
// no nextCursor (the list always fits on one page).
type mcpToolsListResult struct {
Tools []mcpTool `json:"tools"`
}
// mcpTool ist ein Tool-Eintrag in tools/list.
// mcpTool is a tool entry in tools/list.
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema mcpSchema `json:"inputSchema"`
}
// mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs.
// mcpSchema is the JSON Schema object of a tool input.
type mcpSchema struct {
Type string `json:"type"`
Properties map[string]mcpProperty `json:"properties"`
Required []string `json:"required"`
}
// mcpProperty ist eine einzelne Schema-Property.
// mcpProperty is a single schema property.
type mcpProperty struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// mcpToolCallParams sind die Parameter von tools/call.
// mcpToolCallParams are the parameters of tools/call.
type mcpToolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text).
// mcpContent is a content block of a tool result (here: text).
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
// mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen)
// auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein
// Protokollfehler (bewusste Entscheidung, siehe speakToolResult).
// mcpToolResult is the result of tools/call. isError:false (omitted) also
// for muted/discarded: that is a RESULT of admission, not a protocol error
// (deliberate decision, see speakToolResult).
type mcpToolResult struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request.
// serveMCP handles POST /mcp: one JSON-RPC message per request.
func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error())
writeRPCError(w, nil, rpcParseError, "reading body: "+err.Error())
return
}
if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") {
writeRPCError(w, nil, rpcInvalidRequest,
"Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST")
"batch requests are not supported (MCP 2025-06-18 removed batches); one message per POST")
return
}
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error())
writeRPCError(w, nil, rpcParseError, "invalid JSON: "+err.Error())
return
}
if req.JSONRPC != "2.0" {
writeRPCError(w, nil, rpcInvalidRequest,
`jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`)
`jsonrpc must be "2.0" (got `+strconv.Quote(req.JSONRPC)+`)`)
return
}
if isNotification(req.ID) {
// Notifications (notifications/initialized, …) bekommen nie eine
// JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte
// Notifications werden still ignoriert (JSON-RPC 2.0).
// Notifications (notifications/initialized, ...) never get a
// JSON-RPC response, only the transport's 202. Unknown
// notifications are silently ignored (JSON-RPC 2.0).
w.WriteHeader(http.StatusAccepted)
return
}
@@ -182,8 +180,8 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
case "initialize":
h.mcpInitialize(w, req)
case "notifications/initialized":
// Mit ID gesendet: kein regulärer Client — die korrekte Antwort
// ist ein leeres Ergebnis, kein Fehler.
// Sent with an ID: not a regular client; the correct answer is an
// empty result, not an error.
writeRPCResult(w, req.ID, struct{}{})
case "ping":
writeRPCResult(w, req.ID, struct{}{})
@@ -193,26 +191,26 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
h.mcpToolsCall(w, req)
default:
writeRPCError(w, req.ID, rpcMethodNotFound,
"Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)")
"method "+strconv.Quote(req.Method)+" is not implemented (available: initialize, notifications/initialized, ping, tools/list, tools/call)")
}
}
// mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine
// server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP-
// Antwort), Tools-Änderungen gibt es nicht.
// mcpGet handles GET /mcp: 405 with Allow. talkd offers no server-initiated
// streams (the Streamable HTTP response for that case); there are no tool
// changes.
func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
}
// mcpInitialize behandelt initialize: Die angefragte Protokollversion wird
// geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities:
// Tools; serverInfo: talkd + Daemon-Version.
// mcpInitialize handles initialize: the requested protocol version is
// echoed (accepted); empty means our own (2025-06-18). Capabilities: tools;
// serverInfo: talkd + daemon version.
func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpInitializeParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "initialize params: "+err.Error())
return
}
}
@@ -227,18 +225,18 @@ func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
})
}
// mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen
// Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive
// Mute und Gesetz (Rate-Limit/Ruhezeit).
// mcpToolsCall handles tools/call: exactly one tool (speak) whose arguments
// take the same admission path as POST /speak, including mute and the laws
// (rate limit/quiet hours).
func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call params: "+err.Error())
return
}
if params.Name != "speak" {
writeRPCError(w, req.ID, rpcInvalidParams,
"unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)")
"unknown tool "+strconv.Quote(params.Name)+" (available: speak)")
return
}
@@ -254,8 +252,8 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
adm := h.admitSpeak(speakReq)
if adm.status != http.StatusAccepted {
// Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als
// JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis.
// Validation/internal error (HTTP equivalent 400/503): report as a
// JSON-RPC error; this was not an admission result.
code := rpcInvalidParams
if adm.status != http.StatusBadRequest {
code = rpcInternalError
@@ -266,38 +264,37 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
writeRPCResult(w, req.ID, speakToolResult(adm.resp))
}
// speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem
// Task-06-Brief (text Pflicht; priority/provider/lang/now optional).
// speakTool is the tool description of speak: input schema with text
// required; priority/provider/lang/now optional.
func speakTool() mcpTool {
return mcpTool{
Name: "speak",
Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " +
"Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " +
"Queue, Rate-Limit, Mute und Ruhezeiten gelten.",
Description: "Speaks a short announcement (status, next step, what the user is needed for; " +
"never long answers). Goes through the daemon: queue, rate limit, mute and quiet hours apply.",
InputSchema: mcpSchema{
Type: "object",
Properties: map[string]mcpProperty{
"text": {
Type: "string",
Description: "Der Ansage-Text (1–2 Sätze Etikette)",
Description: "The announcement text (1 to 2 sentences by etiquette)",
},
"priority": {
Type: "string",
Enum: []string{"low", "normal", "high"},
Description: "Abarbeitungs-Priorität (Default normal)",
Description: "Processing priority (default normal)",
},
"provider": {
Type: "string",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); empty = default chain",
},
"lang": {
Type: "string",
Enum: []string{"de", "en"},
Description: "Sprache pin; leer = automatische Erkennung",
Description: "Language pin; empty = automatic detection",
},
"now": {
Type: "boolean",
Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus",
Description: "Jumps ahead of the queue and deliberately bypasses rate limit and quiet hours",
},
},
Required: []string{"text"},
@@ -305,27 +302,26 @@ func speakTool() mcpTool {
}
}
// speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool-
// Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind
// das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler)
// — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme:
// "queued: <id>".
// speakToolResult translates a /speak admission response into the MCP tool
// result. Mute and law discards are deliberately NOT isError: they are the
// correct, documented result of admission (no protocol error); the agent
// should read the reason, not crash. Successful admission: "queued: <id>".
func speakToolResult(resp SpeakResponse) mcpToolResult {
switch {
case resp.Muted:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "muted: Ansage verworfen (Mute ist aktiv)",
Text: "muted: announcement discarded (mute is on)",
}}}
case resp.Discarded == queue.DiscardRateLimit:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)",
Text: "discarded: rate_limit; announcement discarded (priority low over the window budget)",
}}}
case resp.Discarded == queue.DiscardQuietHours:
text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen"
text := "discarded: quiet_hours; quiet hours active, announcement discarded"
if resp.Toast {
text += " (durch Toast ersetzt, kein Audio)"
text += " (replaced by a toast, no audio)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
case resp.Discarded != "":
@@ -336,26 +332,26 @@ func speakToolResult(resp SpeakResponse) mcpToolResult {
default:
text := "queued: " + resp.ID
if resp.Truncated {
text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)"
text += " (text truncated to max_text; announcements are 1 to 2 sentences, not long answers)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
}
}
// isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt
// oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort).
// isNotification reports whether the ID marks a notification (field missing
// or explicitly null; both get no response per JSON-RPC 2.0).
func isNotification(id json.RawMessage) bool {
return len(id) == 0 || strings.TrimSpace(string(id)) == "null"
}
// writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200,
// writeRPCResult writes a successful JSON-RPC response (200,
// application/json).
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}
// writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die
// Anfrage-ID unbekannt ist (Parse-Fehler).
// writeRPCError writes a JSON-RPC error response; id null when the request
// ID is unknown (parse error).
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
writeRPC(w, jsonRPCResponse{
JSONRPC: "2.0",
@@ -364,7 +360,7 @@ func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg stri
})
}
// writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer.
// writeRPC writes the JSON-RPC response to the response writer.
func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)