package config import ( "errors" "time" ) // The blocks nested under voice: the two worker seams, the embedder the // classifier scores with, the weather provider and the act allowlist. They live // here because none of them is reachable except through a voice block. // WorkerConfig — a unix-socket worker module connection. Used by Stt and // (via TtsConfig embedding the same fields) by Tts. Socket is the unix // socket path the worker module listens on (e.g. // /run/user/$UID/maven/stt.sock). Lang overrides the surface default for // this module when the user wants different langs for stt vs tts (rare). type WorkerConfig struct { Socket string `json:"socket,omitempty"` Lang string `json:"lang,omitempty"` } // TtsConfig — the tts worker module connection + tts-specific Voice field // (a named voice when the worker supports multiple; "" ⇒ the worker's // configured default). type TtsConfig struct { Socket string `json:"socket,omitempty"` Lang string `json:"lang,omitempty"` Voice string `json:"voice,omitempty"` } // EmbedderConfig — paths for the ONNX multilingual embedder. The daemon // constructs an in-process ONNX embedder when all three paths are non-empty; // the router's classifier then uses real sentence embeddings instead of the // floor HashEmbedder stub. Model_path is the ONNX model file, tokenizer_path // is tokenizer.json (Unigram), lib_path is the ONNX Runtime shared library. type EmbedderConfig struct { ModelPath string `json:"model_path,omitempty"` TokenizerPath string `json:"tokenizer_path,omitempty"` LibPath string `json:"lib_path,omitempty"` } // WeatherConfig configures the weather provider for voice queries. type WeatherConfig struct { Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub DefaultLocation string `json:"default_location,omitempty"` // e.g. "Moscow" } // ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is // the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that // must not fire from the voice path (they need a confirm on an authed surface). // // Aliases are the spoken phrases that reach this tool, Russian included. They // are config data rather than a pattern in code, and they match as exact leading // tokens, so an imperative reaches the tool and the past tense of the same verb // does not. type ToolConfig struct { Name string `json:"name"` Scope string `json:"scope,omitempty"` Cmd []string `json:"cmd"` Destructive bool `json:"destructive,omitempty"` Aliases []string `json:"aliases,omitempty"` } // Voice defaults, applied in normaliseVoice. const ( DefaultRouterThreshold = 0.55 DefaultQueryMinScore = 0.55 // Read off the margin sweep in internal/memory/recalleval on the e5 // embedder: 0.008 answers 68% of real questions (down from 72%) and cuts // false recall from 5/5 to 1/5. Every larger delta costs real recall // without removing that last one until 0.020, which drops recall to 44%. DefaultQueryMinMargin = 0.008 // DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts. DefaultClarifyMaxAttempts = 3 DefaultToolTimeout = 30 * time.Second // DefaultLLMRouter — route with the resident model unless told otherwise. DefaultLLMRouter = true ) // UseLLMRouter reports whether to route with the resident model. Unset means // on; only an explicit false in the config turns it off. func (v *VoiceConfig) UseLLMRouter() bool { if v == nil || v.LLMRouter == nil { return DefaultLLMRouter } return *v.LLMRouter } // normaliseVoice applies the block's defaults. An absent block stays nil: the // surface is off and there is nothing to tune. func (c *Config) normaliseVoice() { if c.Voice == nil { return } if c.Voice.RouterThreshold <= 0 { c.Voice.RouterThreshold = DefaultRouterThreshold } if c.Voice.QueryMinScore <= 0 { c.Voice.QueryMinScore = DefaultQueryMinScore } // Unset ⇒ default. Negative is how you turn the margin off on purpose, // so it is clamped to 0 rather than replaced by the default. switch { case c.Voice.QueryMinMargin == 0: c.Voice.QueryMinMargin = DefaultQueryMinMargin case c.Voice.QueryMinMargin < 0: c.Voice.QueryMinMargin = 0 } if c.Voice.ClarifyMaxAttempts <= 0 { c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts } if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } if c.Voice.LLMRouter == nil { on := DefaultLLMRouter c.Voice.LLMRouter = &on } } // validateVoice refuses a surface that would listen nowhere, and an embedder // block with only some of its three paths filled in. func (c *Config) validateVoice() error { if c.Voice == nil || !c.Voice.Enabled { return nil } if c.Voice.Bind == "" { return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address") } if e := c.Voice.Embedder; e != nil { if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" { return errors.New("voice.embedder: all three of model_path, tokenizer_path, lib_path must be set, or remove embedder to use the floor stub") } } return nil } // VoiceConfig — the client↔core TCP surface + the stt/tts worker-module // seams. // // Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in // production; "127.0.0.1:9100" for the local smoke). Lang is the default // language hint passed to both stt and tts (per-call overrides later). // // Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the // in-process stt.Stub (the "no models on disk" floor — the loop is // exercisable end-to-end with a deterministic no-model transcriber). // non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix // socket (cmd/mavsttd serves the other end; production swaps in a // faster-whisper handler in cmd/mavsttd, no daemon or stt-package // change). Tts mirrors for tts.Remote + cmd/mavttsd. // // Embedder configures the router's sentence embedder. When all three // paths are set, the daemon constructs an ONNX multilingual embedder // (in-process); when nil, it falls back to the floor HashEmbedder stub // (deterministic, no model files required — good for CI and smoke). // // The daemon refuses to start if Voice.Enabled but Bind is empty — the // bind is the one operational config the surface can't default (127.0.0.1 // is too relaxed for production, a wg-tunnel address is the user's); // surfacing the gap explicitly beats an idle listener the user thinks is // wired but isn't reachable. type VoiceConfig struct { Enabled bool `json:"enabled,omitempty"` Bind string `json:"bind,omitempty"` Lang string `json:"lang,omitempty"` Stt *WorkerConfig `json:"stt,omitempty"` Tts *TtsConfig `json:"tts,omitempty"` Embedder *EmbedderConfig `json:"embedder,omitempty"` // RouterThreshold — the minimum confidence score for the intent classifier // (stage 3 gate). Below this → clarify, don't guess. 0 ⇒ // DefaultRouterThreshold (0.55), tuned for the ONNX embedder; the // HashEmbedder floor scores lexically and may need a lower value. // // There is no way to ask for "permissive, never clarify" through this // field: normaliseVoice replaces anything ≤ 0 with the default, so a // written 0 is the default and a written negative is too. RouterThreshold float64 `json:"router_threshold,omitempty"` // LLMRouter — route with the resident model instead of the embedding // classifier. On by default since Vikunja #320. // // Measured on the held-out fixture (docs/evals/2026-07-31-routing.md): 63.2% of // intents right against the classifier's 50.0%, and no route errors. It // costs about 1s per turn instead of 30ms. // // It is safe to leave on. The model can refuse — it answers "unknown" when // it cannot route, and the turn drops to the classifier and its clarify // gate. Any LLM error does the same, so a turn never breaks on the model. // Slot extraction runs on LLM decisions too, so acts get their Fn and // reminders their Time. // // Set it false to go back to the classifier, e.g. on a box with no // llama-server or when 1s a turn is too slow. // // It is a pointer so that "missing from the file" and "explicitly false" // are different things: missing means on, false means off. Read it with // UseLLMRouter(), not directly. LLMRouter *bool `json:"llm_router,omitempty"` // QueryMinScore — the note-recall confidence gate. Top cosine below this // ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55); // the HashEmbedder floor scores lexically and may never clear it. 0.55 // default if unset. QueryMinScore float64 `json:"query_min_score,omitempty"` // QueryMinMargin — the second half of the recall gate: the top hit must // beat the runner-up by more than this. The absolute score above cannot do // the job on its own, because the e5 embedder puts every cosine in one // narrow high band, so a made-up question scores as high as a real one. // The margin asks whether one note is clearly the best instead. // Negative ⇒ off. 0 ⇒ the default below. QueryMinMargin float64 `json:"query_min_margin,omitempty"` // ClarifyMaxAttempts — how many clarifying questions she may ask about one // request before she gives up and says she did not understand. Default 3. ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"` // Persona — optional prompt prefix that tunes maven's character. Prepended // to every LLM system prompt (nudge phrasing, note queries, general // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered // Russian self-reference). Example: "Be formal and answer in English only." Persona string `json:"persona,omitempty"` // OwnerName / City — optional facts about the owner, added to the shared // context block (internal/persona). Empty is fine: the block still states // who he is grammatically (a man, addressed as "ты") and the current time. // Nothing about correct behaviour may depend on these being filled in. OwnerName string `json:"owner_name,omitempty"` City string `json:"city,omitempty"` // Weather — the weather provider config. nil ⇒ the daemon wires // the stub provider (returns ErrNotConfigured — "погода не настроена"). // Set provider to "open-meteo" to use the keyless Open-Meteo API. Weather *WeatherConfig `json:"weather,omitempty"` // Tools — the enabled act allowlist. Each is a spoken verb → argv the // executor runs (args from the utterance appended). Editing this set is the // human-only "enable" act (per spec); maven can't add to it from a request. // Empty ⇒ every act is refused (nothing enabled). Tools []ToolConfig `json:"tools,omitempty"` // ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s). ToolTimeout Duration `json:"tool_timeout,omitempty"` }