Files
Maven/internal/config/config.go
T
kami 06aded6ab0 Merge commit 'd2be98e' into overnight-jul31
# Conflicts:
#	cmd/mavend/clarify.go
#	cmd/mavend/clarify_test.go
#	cmd/mavend/voice.go
#	internal/config/config.go
2026-07-31 12:34:00 +04:00

684 lines
28 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"`
// 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())
}