// 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" "fmt" "log" "os" "path/filepath" "time" "github.com/kami/maven/internal/delivery/ntfysink" "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/update" ) // 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"` // Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒ // the update capability does not exist, which is the state to leave it in // unless the operator has read internal/update's package comment. // // mavend never acts on this block: it constructs no Updater and cannot // update itself. Validate below is the one thing the daemon does with it, so // a broken update config is caught at startup instead of on the night it is // needed. That validation is also why internal/update is linked into mavend // at all — linked, with no caller, which is the property that matters. The // block lives here because cmd/mavupdate — a CLI the owner runs on the host, // the only trigger there is — reads the same config file to find the socket // it health-checks. Update *update.Config `json:"update,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"` // DisabledRules — nudge rules that are not wired at all, by name // ("service_down", "netdata_critical", "water", "meal", "break"). // // Rules are code, not config (see loop.DefaultRules), and that stays true: // this only subtracts. It exists because a rule can be right in principle // and useless in practice. service_down was the case that forced it: it // could not name the service it was nudging about, so being told "a service // on homesrv is down" every fifteen minutes was noise with no action // attached. That is fixed — one fact per kuma monitor — and the rule ships // enabled again. The escape hatch stays. // // A disabled rule is never gathered for, never evaluated, and never // delivered on any channel. Unknown names are ignored, so removing a rule // from the code does not break a config that still lists it. // Empty ⇒ every rule runs, which is the default. DisabledRules []string `json:"disabled_rules,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"` // PatternProposals — whether a routine the digestion tick inferred on its // own may be announced, and how often. nil / absent ⇒ silent detection // only: proposals are written for /routines and never announced. See // PatternProposalConfig. PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"` // MemoryEval — background memory evaluation (internal/memeval). nil / // absent ⇒ no evaluation loop at all. See MemoryEvalConfig. MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"` // Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses // ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a // mailbox by merely existing. See EmailConfig; the IMAP host and credential // live in the reader (cmd/mavmaild), never here. Email *EmailConfig `json:"email,omitempty"` // IntakeJournal — how many entries the unified intake journal keeps // (Vikunja #283): one envelope per thing that arrived, whatever direction it // came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the // journal off entirely, and then there is no decorator on the intake path at // all. // // Not gated behind an "off unless configured" block like feeds or telegram, // and the distinction is the one CLAUDE.md draws: that rule exists for // capabilities that reach OUT — a fetch, a send, a third party. This reaches // nowhere. It is a bounded in-memory log of writes core already performed, // it is read only by /events and the simulator, and nothing Maven says // depends on it. IntakeJournal int `json:"intake_journal,omitempty"` // Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is // ever fetched: reading the outside world is off unless configured, like // the weather and telegram. See FeedsConfig. Feeds *FeedsConfig `json:"feeds,omitempty"` // Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never // fetches a page: not on request, not on a schedule. See CrawlConfig. Crawl *CrawlConfig `json:"crawl,omitempty"` // Kiwix — the offline ZIM reader (Vikunja #122 neighbourhood). nil / absent // / url empty ⇒ the query chain has no ZIM source. See KiwixConfig. Kiwix *KiwixConfig `json:"kiwix,omitempty"` // Search — the SearXNG metasearch instance. nil / absent / url empty ⇒ the // query chain has no web-search source and Kiwix is the only encyclopedia. // See SearchConfig. Search *SearchConfig `json:"search,omitempty"` // Workstation — the big model on the owner's desktop, preferred over the // resident one when its GPU is free. nil / absent / url empty ⇒ homesrv // behaves exactly as it does today. See WorkstationConfig. Workstation *WorkstationConfig `json:"workstation,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"` // Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot // look at pictures at all: the intake refuses, and no vision server is // contacted. See VisionConfig. Vision *VisionConfig `json:"vision,omitempty"` // Media — where images and captured audio are kept on disk, and for how // long. nil / absent ⇒ no blob store is wired, which is what disables both // vision intake and meeting capture regardless of their own blocks: nothing // in this repo holds a recording only in memory. See MediaConfig. Media *MediaConfig `json:"media,omitempty"` // Capture — meeting recording and summarisation (Vikunja #253). nil / // absent ⇒ the recorder does not exist: the start/stop methods are not // served at all, so nothing on this box can begin a recording. This is the // most invasive capability Maven has and it is the one most firmly off by // default. See CaptureConfig. Capture *CaptureConfig `json:"capture,omitempty"` // Speaker — voice identification (Vikunja #255). nil / absent ⇒ no // voiceprint is ever computed and nobody can be enrolled. Enabling it needs // a speaker-embedding model, which is not on this box. See SpeakerConfig. Speaker *SpeakerConfig `json:"speaker,omitempty"` // MCP — Model Context Protocol servers Maven connects OUT to (Vikunja // #251). nil / absent / no enabled server ⇒ no connection is made and no // tool is discovered, like every other capability that reaches outside the // box. She is a client here, never a server: nothing exposes her own // capabilities to an outside caller. See MCPConfig. MCP *MCPConfig `json:"mcp,omitempty"` // SmartHome — the Home Assistant instance (Vikunja #256). nil / absent / // disabled ⇒ Maven neither reads the house nor touches it, and no house row // exists in the act allowlist. See SmartHomeConfig. SmartHome *SmartHomeConfig `json:"smarthome,omitempty"` // NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒ // Maven never puts a packet on the network looking for hosts. See // NetScanConfig. NetScan *NetScanConfig `json:"netscan,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 } // The core daemon's own defaults, applied when the field is empty or zero. // Every other block keeps its defaults in its own file, next to the struct and // the normalise that applies them. const ( DefaultTickInterval = 60 * time.Second DefaultRepeatInterval = 5 * time.Minute DefaultAutotuneInterval = 10 * time.Minute // DefaultIntakeJournal — entries kept in the unified intake journal // (Vikunja #283). A busy day is a few hundred intake writes, so this is // roughly "today and yesterday" at a few hundred KB of memory. DefaultIntakeJournal = 512 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, missing := expandEnv(string(b)) if len(missing) > 0 { // Expansion happens before typed validation. A disabled block may carry // empty placeholders; an enabled block that needs one of these values is // rejected below. Log the names too so a failed deploy says which secret // source was absent without ever printing a value. log.Printf("config: %s references unset environment variables %v — expanded them to empty; enabled integrations will reject missing credentials", path, missing) } 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 } // expandEnv is os.ExpandEnv plus the names it could not resolve, each reported // once and in the order the file mentions them. A variable set to the empty // string counts as set: the operator wrote it down, so he meant it. func expandEnv(s string) (string, []string) { var missing []string seen := map[string]bool{} out := os.Expand(s, func(name string) string { v, ok := os.LookupEnv(name) if !ok && !seen[name] { seen[name] = true missing = append(missing, name) } return v }) return out, missing } func (c *Config) applyDefaults() { if c.IntakeJournal == 0 { c.IntakeJournal = DefaultIntakeJournal } 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") } } // Per-block defaults. Each normalise lives beside the struct it fills in, // and none of them reads another block, so this is a list and not an order. c.normaliseVoice() c.normaliseRoutines() c.normaliseDigest() c.normalisePatternProposals() c.normaliseMemoryEval() c.normaliseEmail() c.normaliseMCP() c.normaliseSmartHome() c.normaliseNetScan() c.normaliseFeeds() c.normaliseCrawl() c.normaliseSearch() c.normaliseKiwix() c.normaliseWorkstation() } func (c *Config) validate() error { if err := c.validatePhraser(); err != nil { return err } // The update block is validated here even though mavend never acts on it: a // half-written update config that is only noticed by cmd/mavupdate is noticed // at the worst possible moment, halfway through deploying a new build. if c.Update != nil { if err := c.Update.Validate(); err != nil { return err } } if err := c.validateVoice(); err != nil { return err } if err := c.validateRoutines(); err != nil { return err } if err := c.validateMCP(); err != nil { return err } if err := c.validateSmartHome(); err != nil { return err } if err := c.validateNetScan(); err != nil { return err } if err := c.validateMedia(); err != nil { return err } if err := c.validateVision(); err != nil { return err } if err := c.validateCapture(); err != nil { return err } if err := c.validateTelegram(); err != nil { return err } if err := c.validateNtfy(); err != nil { return err } if err := c.validateWorkstation(); err != nil { return err } return nil } // validateTelegram refuses an intake half that cannot read the chat it is // pointed at. The push half accepts an @channelusername and the intake half // does not, so a box configured with both boots clean, keeps pushing, and // answers nothing — the failure is invisible from the chat. Same shape as // validateNetScan: fail the config rather than the turn. func (c *Config) validateTelegram() error { if c.Telegram == nil || c.Telegram.Disabled { return nil } if err := telegramsink.Validate(*c.Telegram); err != nil { return err } if c.Telegram.Intake { return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID) } return nil } func (c *Config) validateNtfy() error { if c.Ntfy == nil { return nil } return ntfysink.Validate(*c.Ntfy) } // 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()) }