Files
Maven/internal/config/config.go
T
kami 2c27e2ce1f Give every prompt one shared context block
The "address him as ты" rule had only reached two of the five system
prompts. Instead of pasting it into the other three (five copies drift —
that is how this happened), there is now one block, in internal/persona,
prepended to all five: nudges, action replies, chat, note queries and
general knowledge.

The block says who he is and how to address him (a man, always "ты",
never "вы", never "он" about him; Maven stays feminine), plus the
current local date and time. It is rendered fresh each turn because the
time changes, and it is correct with an empty config — the address and
gender rules are defaults in code. Config only adds optional facts:
owner_name, city, and the existing free-text `persona` string, which is
now the static half of the block.

Russian even in front of the English prompts: the rules are Russian
grammar, so they read best stated in Russian, and there is one copy.

Vikunja #394.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 15:55:30 +04:00

691 lines
29 KiB
Go

// Package config is maven's daemon configuration.
//
// The daemon reads a single JSON file at startup (path from the -config flag,
// default ~/.config/maven/mavend.json). Everything a module needs is wired
// from this file: the store path, the unix socket path, the tick cadence,
// and per-sink configs (ntfy/telegram). Credentials live in the file (or a
// systemd credential that the file points at) — never in the binary.
//
// This package is pure data + a loader. It imports the sink config structs
// so the daemon wires each `Sink` from a single, typed config tree without
// re-declaring their shapes (the sink constructors own validation).
package config
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/morning"
"github.com/robfig/cron/v3"
)
// Config — the daemon's whole config tree. Loaded once at startup.
//
// Fields with omitempty are optional: a missing sink config = that channel
// not wired (the dispatcher's nil-sink path skips it silently, the same as a
// deliberately-unwired channel at scaffold time).
type Config struct {
// DBPath — sqlite database path. Default applied by Load if empty.
// When encryption is configured, the file at this path is ciphertext
// (AES-256-GCM); the daemon works on a tmpfs plaintext copy.
DBPath string `json:"db_path"`
// DBKeyB64 — base64 (std encoding) of a raw 32-byte AES-256 key. Empty ⇒
// the store is plaintext (dev/CI). Prefer DBKeyEnv over baking the key
// into the config file. Exactly one of DBKeyB64/DBKeyEnv should be set.
//
// ponytail: raw key, no KDF — stdlib has no argon2/scrypt and x/crypto
// isn't a dep. This is also the seam the L3 passkey cold-start key plugs
// into later: the passkey op produces the 32 bytes and calls
// store.OpenEncrypted directly, bypassing config.
DBKeyB64 string `json:"db_key_b64,omitempty"`
// DBKeyEnv — name of an env var holding the base64 32-byte key. Takes
// precedence over DBKeyB64. Lets systemd credentials / secrets managers
// inject the key without it touching the config file.
DBKeyEnv string `json:"db_key_env,omitempty"`
// DBTmpfs — plaintext working-copy path (RAM-backed). Empty ⇒ a stable
// per-db path under /dev/shm. Only used when encryption is configured.
DBTmpfs string `json:"db_tmpfs,omitempty"`
// SocketPath — the unix socket the IPC server listens on. Modules
// connect here; the dir is created 0700, the socket chmod'd 0600 by
// ipc.Listen. Default applied by Load if empty.
SocketPath string `json:"socket_path"`
// StateDir — base dir for db + socket if their paths aren't absolute.
// Default applied by Load if empty (XDG-style: ~/.local/share/maven for
// the db, /run/user/$UID/maven for the socket).
StateDir string `json:"state_dir,omitempty"`
// TickInterval — the proactive loop cadence. Default 60s. The loop is
// "dumb + deterministic": most ticks evaluate a few predicates and die
// for free; raising this saves nothing worth losing responsiveness over.
TickInterval Duration `json:"tick_interval,omitempty"`
// RepeatInterval — how often sev4 telegram sends re-fire until acked.
// Default 5m. A disk-fire alarm that repeats every tick (60s) is spam;
// one that repeats never is silent. The default tilts toward "loud."
RepeatInterval Duration `json:"repeat_interval,omitempty"`
// AutotuneInterval — how often the feedback auto-tuner runs: reads
// store.RecentOutcomes for each rule, calls loop.TuneCooldown, writes the
// tuned cooldown back as a `facts (kind=config, source=feedback)` row
// if it changed. Default 10m — slow enough to be cheap + not write every
// tick (append-only facts churn), fast enough that a weird-afternoon
// pattern shows up inside a day. 0 ⇒ autotune disabled (the gatherer
// falls back to the rule's static Base, matching pre-autotune behavior).
AutotuneInterval Duration `json:"autotune_interval,omitempty"`
// FactEnrichmentInterval — how often the fact-entity enrichment worker
// polls for facts with resolution_state='pending' and resolves their
// subject against Nexus. Default 30s. Only runs when Nexus is configured;
// no-ops (harmlessly) otherwise.
FactEnrichmentInterval Duration `json:"fact_enrichment_interval,omitempty"`
// Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired.
// sev3 (ops soft) away + sev4 (ops hard) present + reminders away all
// route here; not wiring ntfy means those routes drop silently.
Ntfy *ntfysink.Config `json:"ntfy,omitempty"`
// Telegram — the telegram push sink config. nil ⇒ telegram channel
// not wired. sev4 away routes here with repeat-til-ack; not wiring
// telegram means sev4-away alarms silently drop (a disk-fire alarm at
// 2am that no one sees — wire it).
Telegram *telegramsink.Config `json:"telegram,omitempty"`
// Phraser — the LLM-backed phraser config. nil ⇒ the daemon uses the
// template-based Stub (deterministic, no model required — good for CI).
// When configured, the daemon spawns llama-server as a subprocess and
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
Phraser *PhraserConfig `json:"phraser,omitempty"`
// Voice — the client↔core surface + the stt/tts modules the daemon
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
// down, the dispatcher's Voice slot stays nil (the routing table's
// ChannelVoice selections drop silently — same as pre-voice behaviour).
// To enable: voice.enabled = true AND voice.bind = an address inside
// the wg tunnel; the daemon binds the TCP listener there.
Voice *VoiceConfig `json:"voice,omitempty"`
// QuietHours — time-window schedule for quiet hours. When set, the
// loop's gatherer sets `quiet = true` in the State during the window,
// suppressing care nudges (sev1-2). The user can also toggle quiet
// hours by voice ("тихий режим") — that writes a `quiet_hours` config
// fact independently; both the schedule AND the toggle activate quiet.
// nil ⇒ quiet hours only activate via the voice toggle.
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
// Digest — notification batching / digest mode. nil ⇒ digest disabled
// (every nudge is sent as it fires — legacy behaviour).
Digest *DigestConfig `json:"digest,omitempty"`
// Routines — scheduled behaviors maven performs on a cron schedule (a
// morning briefing, an evening wind-down), independent of any request or
// care predicate. Each fires its Body through the dispatcher on its Cron
// schedule. Empty ⇒ no routines. See internal/routine for the class
// distinction from reminders (user-stated) and care rules (world-state).
Routines []RoutineConfig `json:"routines,omitempty"`
// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
// once near the end of a time window instead of firing one reminder per
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
// Praxis — the ecosystem attention-state service. When configured, maven
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
// Maven never touches Praxis's database directly (ecosystem invariant: no
// component reads another's store). nil ⇒ ecosystem integration disabled.
Praxis *PraxisConfig `json:"praxis,omitempty"`
// Nexus — the canonical identity service. When configured, maven resolves
// entity references (projects, services, devices, etc.) through Nexus
// before acting on them. nil ⇒ resolution disabled (maven uses raw text).
Nexus *NexusConfig `json:"nexus,omitempty"`
// Hexis — the capability execution service. When configured, maven
// discovers and executes capabilities through Hexis for ecosystem actions.
// nil ⇒ no capability-aware routing.
Hexis *HexisConfig `json:"hexis,omitempty"`
}
// PraxisConfig — maven's connection to the Praxis attention service.
type PraxisConfig struct {
// URL — the Praxis HTTP API base URL (e.g. "http://localhost:9742").
URL string `json:"url,omitempty"`
}
// NexusConfig — connection to the Nexus identity service.
type NexusConfig struct {
// URL — the Nexus HTTP API base URL (e.g. "http://localhost:9740").
URL string `json:"url,omitempty"`
}
// HexisConfig — connection to the Hexis capability execution service.
type HexisConfig struct {
// URL — the Hexis HTTP API base URL (e.g. "http://localhost:9741").
URL string `json:"url,omitempty"`
}
// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression
// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines
// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class
// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away
// channels.
type RoutineConfig struct {
Name string `json:"name"`
Cron string `json:"cron"`
Body string `json:"body"`
Severity int `json:"severity,omitempty"`
}
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
// 0=Sunday..6=Saturday; empty means every day (set two routines under
// different names for weekday/weekend variants).
type MorningRoutineConfig struct {
Name string `json:"name"`
Weekdays []int `json:"weekdays,omitempty"`
WindowStart string `json:"window_start"`
WindowEnd string `json:"window_end"`
NudgeAt string `json:"nudge_at,omitempty"`
Severity int `json:"severity,omitempty"`
Items []MorningRoutineItemConfig `json:"items"`
}
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
// presence within the window counts as completion evidence.
type MorningRoutineItemConfig struct {
Key string `json:"key"`
FactKey string `json:"fact_key"`
Label string `json:"label"`
}
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
// server's wall clock. A window crossing midnight (Start > End) is handled:
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
type QuietHoursConfig struct {
Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00"
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
}
// 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.0 means permissive
// (never clarify); 0.35 is a reasonable floor for the ONNX embedder.
// The HashEmbedder floor scores lexically and may need a lower value.
// Default 0.35 if unset.
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 (ROUTING-EVAL-31-07-2026.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"`
}
// 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).
type ToolConfig struct {
Name string `json:"name"`
Scope string `json:"scope,omitempty"`
Cmd []string `json:"cmd"`
Destructive bool `json:"destructive,omitempty"`
}
// DigestConfig — notification batching / digest mode. When enabled, eligible
// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent
// immediately. Every Window duration (or when MaxItems reached), the queue is
// flushed as a single digest notification. nil ⇒ digest disabled (legacy
// behaviour — every nudge is sent as it fires).
type DigestConfig struct {
Enabled bool `json:"enabled,omitempty"`
Window Duration `json:"window,omitempty"` // e.g. "30m"
MaxItems int `json:"max_items,omitempty"` // flush at this count
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
}
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
// as a managed subprocess and sends chat-completion requests to phrase nudge
// and reminder messages. nil ⇒ the template-based Stub is used instead.
//
// ModelPath is the only required field. The rest have sensible defaults:
// - BinPath defaults to "llama-server" (found via PATH at spawn time).
// - Listen defaults to "127.0.0.1:0" (random port, read from stderr).
// - NGpuLayers defaults to -1 (max, uses all available GPU layers).
// - NCtx defaults to 2048.
// - Timeout defaults to 30s per request.
type PhraserConfig struct {
ModelPath string `json:"model_path"`
BinPath string `json:"bin_path,omitempty"`
Listen string `json:"listen,omitempty"`
NGpuLayers int `json:"n_gpu_layers,omitempty"`
NCtx int `json:"n_ctx,omitempty"`
Timeout Duration `json:"timeout,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"`
}
// 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"`
}
// Duration — a time.Duration that round-trips through JSON as a string
// ("60s", "5m", "1h30m"). Plain time.Duration marshals as a nanosecond int,
// which is unreadable in a config file; this wrapper uses ParseDuration.
type Duration time.Duration
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
v, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("config: bad duration %q: %w", s, err)
}
*d = Duration(v)
return nil
}
// Defaults applied when the corresponding field is empty/zero.
const (
DefaultTickInterval = 60 * time.Second
DefaultRepeatInterval = 5 * time.Minute
DefaultAutotuneInterval = 10 * time.Minute
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
DefaultFactEnrichmentInterval = 30 * time.Second
)
// Load reads the JSON config at path and applies defaults. A missing file is
// an error — the daemon refuses to start without an explicit config (the
// default-less state is too permissive: empty db path, no sinks, an idle
// loop that silently does nothing, etc. — better to surface the gap than to
// run an idle daemon the user thinks is wired).
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
// Expand ${VAR} or $VAR patterns from environment variables. This lets
// secrets live in env (docker-compose env_file) rather than the config
// file committed to git.
expanded := os.ExpandEnv(string(b))
var c Config
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
c.applyDefaults()
if err := c.validate(); err != nil {
return nil, fmt.Errorf("config: %s: %w", path, err)
}
return &c, nil
}
func (c *Config) applyDefaults() {
if c.TickInterval == 0 {
c.TickInterval = Duration(DefaultTickInterval)
}
if c.RepeatInterval == 0 {
c.RepeatInterval = Duration(DefaultRepeatInterval)
}
if c.AutotuneInterval == 0 {
c.AutotuneInterval = Duration(DefaultAutotuneInterval)
}
if c.FactEnrichmentInterval == 0 {
c.FactEnrichmentInterval = Duration(DefaultFactEnrichmentInterval)
}
// StateDir — when set, use it as the base for both db and socket if their
// paths are still relative (empty). If StateDir is empty, fall back to the
// XDG-style defaults (data dir for db, runtime dir for socket).
if c.StateDir != "" {
if c.DBPath == "" {
c.DBPath = filepath.Join(c.StateDir, "maven.db")
}
if c.SocketPath == "" {
c.SocketPath = filepath.Join(c.StateDir, "mavend.sock")
}
} else {
if c.DBPath == "" {
c.DBPath = filepath.Join(defaultDataDir(), "maven.db")
}
if c.SocketPath == "" {
c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock")
}
}
if c.Digest == nil {
c.Digest = &DigestConfig{Enabled: false}
}
if c.Digest.Window == 0 {
c.Digest.Window = Duration(30 * time.Minute)
}
if c.Digest.MaxItems == 0 {
c.Digest.MaxItems = 5
}
if c.Digest.SeverityCeiling == 0 {
c.Digest.SeverityCeiling = 2
}
if c.Voice != nil {
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
}
}
// routines: default severity to care-class (1) — the safe floor: a
// misconfigured routine can't blast an away channel at 3am.
for i := range c.Routines {
if c.Routines[i].Severity == 0 {
c.Routines[i].Severity = 1
}
}
// morning routines: same safe-floor default as cron routines.
for i := range c.MorningRoutines {
if c.MorningRoutines[i].Severity == 0 {
c.MorningRoutines[i].Severity = 1
}
}
}
// 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
}
func (c *Config) validate() error {
if c.Phraser != nil {
if c.Phraser.ModelPath == "" {
return errors.New("phraser.model_path is required")
}
}
if c.Voice != nil && c.Voice.Enabled {
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 c.Voice.Embedder != nil {
partial := c.Voice.Embedder.ModelPath == "" || c.Voice.Embedder.TokenizerPath == "" || c.Voice.Embedder.LibPath == ""
if partial {
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")
}
}
}
// routines: name + body required, cron must parse. A typo here should fail
// at startup, not silently never fire.
for _, r := range c.Routines {
if r.Name == "" {
return errors.New("routine: name is required")
}
if r.Body == "" {
return fmt.Errorf("routine %q: body is required", r.Name)
}
if _, err := cron.ParseStandard(r.Cron); err != nil {
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
}
}
if len(c.MorningRoutines) > 0 {
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
return err
}
}
return nil
}
// morningRoutinesFromConfig maps the config's morning-routine blocks to the
// engine type. Shared with the daemon so config validation and daemon wiring
// can never drift on the mapping.
func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
out := make([]morning.Routine, len(mc))
for i, r := range mc {
items := make([]morning.Item, len(r.Items))
for j, it := range r.Items {
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label}
}
weekdays := make([]time.Weekday, len(r.Weekdays))
for j, w := range r.Weekdays {
weekdays[j] = time.Weekday(w)
}
out[i] = morning.Routine{
Name: r.Name,
Weekdays: weekdays,
WindowStart: r.WindowStart,
WindowEnd: r.WindowEnd,
NudgeAt: r.NudgeAt,
Severity: r.Severity,
Items: items,
}
}
return out
}
// MorningRoutinesFromConfig is the exported form daemon wiring uses.
func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
return morningRoutinesFromConfig(mc)
}
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
// a plaintext store. A configured-but-invalid key is an error (fail closed,
// never silently downgrade to plaintext).
func (c *Config) DBEncryptionKey() ([]byte, error) {
raw := c.DBKeyB64
if c.DBKeyEnv != "" {
raw = os.Getenv(c.DBKeyEnv)
if raw == "" {
return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv)
}
}
if raw == "" {
return nil, nil
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("config: db key is not valid base64: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key))
}
return key, nil
}
// DefaultWrappedKeyPath returns the conventional path for the wrapped
// encryption key blob — alongside the StateDir. This is the path checked
// automatically when --wrapped-key-file is not provided on the command line.
// The caller may always override via the flag.
func (c *Config) DefaultWrappedKeyPath() string {
return filepath.Join(c.StateDir, "db_key.wrapped")
}
func defaultDataDir() string {
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
return filepath.Join(x, "maven")
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(os.TempDir(), "maven")
}
return filepath.Join(home, ".local", "share", "maven")
}
func defaultRuntimeDir() string {
if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
return filepath.Join(x, "maven")
}
// /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back
// to the data dir (still works; just not tmpfs-clearance-on-reboot clean).
return filepath.Join(defaultDataDir())
}