// 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"` // 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"` // 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 DefaultToolTimeout = 30 * time.Second 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 } if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } } // 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 } } } 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()) }