// 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" "strings" "time" "github.com/kami/maven/internal/delivery/ntfysink" "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/netscan" "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/update" "github.com/kami/maven/internal/vision" "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"` // 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"` } // MCPConfig — the MCP client block. Servers are dark until one has // `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables // it on /tools, on the authed surface, exactly as he would a shell tool. The // voice path can never grant a capability to itself. type MCPConfig struct { // Servers — the configured servers. Each needs exactly one of command // (a subprocess on this box) or url (a streamable-HTTP endpoint). Servers []MCPServerConfig `json:"servers,omitempty"` // Timeout — per-call budget for every server that does not set its own. // 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a // spoken turn. Timeout Duration `json:"timeout,omitempty"` // AllowHosts / DenyHosts — the host lists for the shared webfetch door that // url servers go through. Deny wins. Private addresses are refused // unconditionally unless the individual server sets allow_private. AllowHosts []string `json:"allow_hosts,omitempty"` DenyHosts []string `json:"deny_hosts,omitempty"` // MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes. MaxBytes int64 `json:"max_bytes,omitempty"` // HostInterval — minimum spacing between two requests to one MCP server. // 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default. // That default was sized for a feed poll loop, and this path is in a spoken // turn: one dial is three requests (initialize, initialized, tools/list), // so a second of spacing is two seconds of pure sleeping per dial and up to // another second before every tools/call leaves the box. HostInterval Duration `json:"host_interval,omitempty"` } // DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a // runaway loop hammering a server, small enough not to be heard. const DefaultMCPHostInterval = 50 * time.Millisecond // SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until // `"enabled": true`, and even then a discovered device is only ever PROPOSED // into the act allowlist: Kami enables it on /tools, behind step-up, exactly as // he would a shell tool. Finding a switch on the network is not the same as // being allowed to flip it. type SmartHomeConfig struct { // Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are // not: Home Assistant already fronts them, and a broker client is a // dependency this vendored module tree cannot take on tonight. Provider string `json:"provider,omitempty"` // URL — the instance base, "http://192.168.1.50:8123". // // Plain http is accepted and is what the deploy block uses. That is a // deliberate choice, not an oversight: the instance is on the LAN behind // wireguard, and a self-signed cert on a home box buys a warning rather // than a guarantee. It does mean the long-lived token crosses the LAN in // cleartext on every refresh, so the LAN is part of the trust boundary. URL string `json:"url,omitempty"` // Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in // the gitignored env file, like the telegram credentials. Token string `json:"token,omitempty"` // Domains — entity domains to take. Empty ⇒ the controllable domains // EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor // for reads. A lock is only enumerated when it is named here, because a // front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B // half-remembers is a wrong act. Domains []string `json:"domains,omitempty"` // MaxEntities — cap on the proposal catalogue. 0 ⇒ 40. MaxEntities int `json:"max_entities,omitempty"` // Timeout — per-call budget. 0 ⇒ 10s. Timeout Duration `json:"timeout,omitempty"` // Refresh — how often the entity list is re-read and new devices proposed. // 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it: // "refresh": "1s" used to pass validation and enumerate the whole instance // every second. Discovery is idempotent, so this only ever adds rows. Refresh Duration `json:"refresh,omitempty"` // Enabled — false (the default) keeps a written block dark, so it can be // reviewed before the house is wired to a voice. Enabled bool `json:"enabled,omitempty"` } // SmartHomeClient maps the config block onto the smarthome package's own type. // Returns ok=false when nothing is configured or it is disabled, so validation // and daemon wiring cannot drift on the mapping. func (c *Config) SmartHomeClient() (smarthome.Config, bool) { if c.SmartHome == nil || !c.SmartHome.Enabled { return smarthome.Config{}, false } return smarthome.Config{ URL: c.SmartHome.URL, Token: c.SmartHome.Token, Domains: c.SmartHome.Domains, MaxEntities: c.SmartHome.MaxEntities, Timeout: time.Duration(c.SmartHome.Timeout), }, true } // NetScanConfig — the LAN scanner block (Vikunja #257). Dark until // `"enabled": true`. // // The important field is Subnets, and it is the ONLY source of a scan target. // Nothing an utterance, a router or a scanned host says can widen or move the // range: internal/netscan.Scanner.Scan takes no target argument at all. Each // subnet must be private and no larger than netscan.MaxPrefixHosts addresses // (a /22), enforced at config load rather than at the first spoken scan. type NetScanConfig struct { // Subnets — CIDRs to scan, "192.168.1.0/24". Subnets []string `json:"subnets,omitempty"` // Ports — TCP ports to try per host. Empty ⇒ 22, 80, 443, 8080. Ports []int `json:"ports,omitempty"` // Timeout — per-connection budget. 0 ⇒ 400ms. Timeout Duration `json:"timeout,omitempty"` // Rate — connections per second across the whole scan. 0 ⇒ 50. Low on // purpose: a scan should look like background traffic, not a portscan. Rate int `json:"rate,omitempty"` // MaxHosts — cap on addresses probed per scan. 0 ⇒ 256. MaxHosts int `json:"max_hosts,omitempty"` // Enabled — false (the default) keeps a written block dark. Enabled bool `json:"enabled,omitempty"` } // NetScanner maps the config block onto the netscan package's own type. // ok=false when absent or disabled, so validation and daemon wiring cannot // drift on the mapping. func (c *Config) NetScanner() (netscan.Config, bool) { if c.NetScan == nil || !c.NetScan.Enabled { return netscan.Config{}, false } return netscan.Config{ Subnets: c.NetScan.Subnets, Ports: c.NetScan.Ports, Timeout: time.Duration(c.NetScan.Timeout), Rate: c.NetScan.Rate, MaxHosts: c.NetScan.MaxHosts, }, true } // MCPServerConfig — one MCP server. type MCPServerConfig struct { // Name — the local handle. It prefixes every tool this server contributes // ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and // becomes the store scope "mcp:", so its provenance is readable on // /tools without opening the config. Name string `json:"name"` // Command / Args / Env / Dir — a stdio server: a child process of mavend, // on this box, under this user. argv, never a shell string. Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` Env []string `json:"env,omitempty"` Dir string `json:"dir,omitempty"` // URL — a streamable-HTTP endpoint. It is fetched through // internal/webfetch, so the SSRF guard, the redirect cap, the size cap and // the one-request-per-host-per-second limit all apply. URL string `json:"url,omitempty"` // AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja // server on homesrv is "http://localhost:9100/mcp", which is refused // without this flag. Understand what it means before setting it: a local // server is a DIFFERENT trust level from a public one. It is inside the // network, it usually needs no credential, and it can change things that // matter — so an argument the router got wrong lands somewhere real. Set it // only for a server you run yourself, and prefer allow_tools with it. AllowPrivate bool `json:"allow_private,omitempty"` // AllowTools — when set, the ONLY remote tool names taken from this server. // This is the knob that keeps the catalogue deliberate: the resident model // is a 1.7B with a 4096-token context, and a tool name it half-remembers is // a wrong act, so fewer and better-chosen beats complete. AllowTools []string `json:"allow_tools,omitempty"` // MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12). MaxTools int `json:"max_tools,omitempty"` // Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout. Timeout Duration `json:"timeout,omitempty"` // Headers — sent verbatim on every request to a url server. This is how a // bearer token reaches a real remote MCP server: {"Authorization": "Bearer // ${MCP_TOKEN}"}, with the value in the gitignored env file like the // telegram credentials. The Vikunja server on homesrv needs none only // because it is unauthenticated on loopback. Headers map[string]string `json:"headers,omitempty"` // Enabled — false (the default) keeps a configured server described but // dark, so a block can be written and reviewed before it is switched on. Enabled bool `json:"enabled,omitempty"` } // MCPServers maps the config blocks onto the mcp package's own type. It lives // here so config validation and daemon wiring cannot drift on the mapping. // Returns nil when nothing is configured or nothing is enabled. // // Disabled servers are dropped here, which is why validation does NOT use this // list — see allMCPServers. func (c *Config) MCPServers() []mcp.ServerConfig { return c.mcpServers(true) } // allMCPServers is every configured server, enabled or not, for validation. // // Validating only the enabled ones meant a block with both command and url, or // a bare hostname as the url, passed startup validation while it was dark. The // doc on Enabled says a block can be written and reviewed before it is switched // on; the review the config layer could give was the one thing skipped. Enabled // gates the dialing, not the shape check. func (c *Config) allMCPServers() []mcp.ServerConfig { return c.mcpServers(false) } func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig { if c.MCP == nil { return nil } out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers)) for _, s := range c.MCP.Servers { if onlyEnabled && !s.Enabled { continue } timeout := time.Duration(s.Timeout) if timeout <= 0 { timeout = time.Duration(c.MCP.Timeout) } out = append(out, mcp.ServerConfig{ Name: s.Name, Command: s.Command, Args: s.Args, Env: s.Env, Dir: s.Dir, URL: s.URL, AllowPrivate: s.AllowPrivate, AllowTools: s.AllowTools, MaxTools: s.MaxTools, Headers: s.Headers, Timeout: timeout, Enabled: s.Enabled, }) } if len(out) == 0 { return nil } return out } // 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"` // Token — the shared bearer token sent on every request. Empty ⇒ calls // go out unauthenticated, which is only appropriate on a loopback or // unix-socket transport. Supports ${VAR} expansion, so the secret lives // in deploy/telegram.env, not in the committed config. Token string `json:"token,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"` // Token — shared bearer token; see PraxisConfig.Token. Token string `json:"token,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"` // Token — shared bearer token; see PraxisConfig.Token. Token string `json:"token,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"` // Optional — this one being skipped does not earn a nudge. Default false, // so a routine written before 04-08-2026 keeps behaving as it did. Optional bool `json:"optional,omitempty"` } // 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 (docs/evals/2026-07-31-routing.md): 63.2% of // intents right against the classifier's 50.0%, and no route errors. It // costs about 1s per turn instead of 30ms. // // It is safe to leave on. The model can refuse — it answers "unknown" when // it cannot route, and the turn drops to the classifier and its clarify // gate. Any LLM error does the same, so a turn never breaks on the model. // Slot extraction runs on LLM decisions too, so acts get their Fn and // reminders their Time. // // Set it false to go back to the classifier, e.g. on a box with no // llama-server or when 1s a turn is too slow. // // It is a pointer so that "missing from the file" and "explicitly false" // are different things: missing means on, false means off. Read it with // UseLLMRouter(), not directly. LLMRouter *bool `json:"llm_router,omitempty"` // QueryMinScore — the note-recall confidence gate. Top cosine below this // ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55); // the HashEmbedder floor scores lexically and may never clear it. 0.55 // default if unset. QueryMinScore float64 `json:"query_min_score,omitempty"` // QueryMinMargin — the second half of the recall gate: the top hit must // beat the runner-up by more than this. The absolute score above cannot do // the job on its own, because the e5 embedder puts every cosine in one // narrow high band, so a made-up question scores as high as a real one. // The margin asks whether one note is clearly the best instead. // Negative ⇒ off. 0 ⇒ the default below. QueryMinMargin float64 `json:"query_min_margin,omitempty"` // ClarifyMaxAttempts — how many clarifying questions she may ask about one // request before she gives up and says she did not understand. Default 3. ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"` // Persona — optional prompt prefix that tunes maven's character. Prepended // to every LLM system prompt (nudge phrasing, note queries, general // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered // Russian self-reference). Example: "Be formal and answer in English only." Persona string `json:"persona,omitempty"` // OwnerName / City — optional facts about the owner, added to the shared // context block (internal/persona). Empty is fine: the block still states // who he is grammatically (a man, addressed as "ты") and the current time. // Nothing about correct behaviour may depend on these being filled in. OwnerName string `json:"owner_name,omitempty"` City string `json:"city,omitempty"` // Weather — the weather provider config. nil ⇒ the daemon wires // the stub provider (returns ErrNotConfigured — "погода не настроена"). // Set provider to "open-meteo" to use the keyless Open-Meteo API. Weather *WeatherConfig `json:"weather,omitempty"` // Tools — the enabled act allowlist. Each is a spoken verb → argv the // executor runs (args from the utterance appended). Editing this set is the // human-only "enable" act (per spec); maven can't add to it from a request. // Empty ⇒ every act is refused (nothing enabled). Tools []ToolConfig `json:"tools,omitempty"` // ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s). ToolTimeout Duration `json:"tool_timeout,omitempty"` } // MediaConfig — the on-disk blob store for images and captured audio // (internal/media). It is shared by all three senses: vision intake, meeting // capture, and speaker enrolment samples all write here. // // Absent ⇒ off, and off means Maven cannot accept an image or start a recording // at all. That default is deliberate: a capability that keeps photos and audio of // people on disk should require someone to have typed a path. type MediaConfig struct { // Dir — the blob store root, created 0700. Relative paths resolve against // StateDir. Required; an empty dir means the store is not wired. Dir string `json:"dir,omitempty"` // Retention — how long a blob is kept before the tick prunes it. 0 ⇒ // media.DefaultRetention (7 days). This is the knob that stops recordings // of people accumulating; raising it past a few weeks should need a reason. Retention Duration `json:"retention,omitempty"` // MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB). MaxBytes int64 `json:"max_bytes,omitempty"` // MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB). // The per-blob cap bounds one call; this one bounds the sum of them, which // is what actually decides whether the disk mavend's database lives on can // be filled from outside. MaxTotalBytes int64 `json:"max_total_bytes,omitempty"` } // StoreDir reports the configured blob directory, or "" when media is not // wired. Safe on a nil receiver. func (m *MediaConfig) StoreDir() string { if m == nil { return "" } return strings.TrimSpace(m.Dir) } // VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md). // // Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt // to look at an image answers that vision is not set up. There is no cloud // option in this block on purpose: Endpoint must be a loopback or private // address and internal/vision refuses anything else at startup, because // inference stays on the box and a photo of his flat is the last thing to make // an exception for. type VisionConfig struct { // Enabled — may she look at images. Default false. Enabled bool `json:"enabled,omitempty"` // Endpoint — base URL of a llama-server running a vision model with its // mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only. Endpoint string `json:"endpoint,omitempty"` // Model — model name sent in the request. llama-server ignores it. Model string `json:"model,omitempty"` // MaxDim — longest edge the image is scaled to before inference. 0 ⇒ // media.DefaultMaxDim (896). MaxDim int `json:"max_dim,omitempty"` // MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300). MaxTokens int `json:"max_tokens,omitempty"` // Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small // VLM on an iGPU is slow; a tight timeout here just means no answer ever. Timeout Duration `json:"timeout,omitempty"` // Prompt — the default question when he only sent a picture. Empty ⇒ // vision.DefaultPrompt (Russian, "опиши что на изображении"). Prompt string `json:"prompt,omitempty"` } // LooksAtImages reports whether vision is configured well enough to try. Safe on // a nil receiver, and false without an endpoint — enabled with nothing to talk // to is a misconfiguration, not a capability. func (v *VisionConfig) LooksAtImages() bool { return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != "" } // CaptureConfig — the meeting recorder (internal/capture, // docs/plans/08-hearing.md). // // Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods // return "unknown method", so no client can start a recording however it asks. // A media block is required too: audio is never held only in memory. // // There is deliberately no "auto", no keyword trigger and no duration default // long enough to be forgotten about. Recording other people is an explicit act // with a start, a stop, and a cap. type CaptureConfig struct { // Enabled — may she record a meeting when asked. Default false. Enabled bool `json:"enabled,omitempty"` // MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒ // capture.DefaultMaxDuration (120 minutes). MaxMinutes int `json:"max_minutes,omitempty"` // STTWindow — audio handed to whisper per call. 0 ⇒ // capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better // and block the STT worker for longer. STTWindow Duration `json:"stt_window,omitempty"` // ChunkRunes — transcript runes per summarisation prompt. 0 ⇒ // capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of // 4096. Raise this only if the resident model's context grows. ChunkRunes int `json:"chunk_runes,omitempty"` // MaxChunks — how many windows one meeting may be summarised in before the // transcript is truncated and the summary says so. 0 ⇒ // capture.DefaultMaxChunks (40). MaxChunks int `json:"max_chunks,omitempty"` // SaveTranscript — write the full transcript as a note alongside the // summary. Default false, and the cost is not disk: a note is embedded and // becomes recall corpus, so every later question can surface verbatim words // other people said in a room. That is the reason it takes a deliberate yes. // The audio blob is pruned by media.retention either way; the notes are not. // // A meeting with no summary writes its transcript regardless. The choice // here is transcript IN ADDITION to a summary, not whether the meeting is // remembered at all. SaveTranscript bool `json:"save_transcript,omitempty"` } // Records reports whether the recorder should be wired. Safe on a nil receiver. func (c *CaptureConfig) Records() bool { return c != nil && c.Enabled } // MaxDuration is the configured session cap as a duration, or 0 for the // package default. Safe on a nil receiver. func (c *CaptureConfig) MaxDuration() time.Duration { if c == nil || c.MaxMinutes <= 0 { return 0 } return time.Duration(c.MaxMinutes) * time.Minute } // SpeakerConfig — voice identification (internal/speaker, // docs/plans/10-speaker-recognition.md). // // Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the // enrolment methods do not exist, and nobody can be enrolled. A voiceprint is // biometric data about a person, so this one is off until someone typed a model // path on purpose. // // It cannot currently be turned on: there is no speaker-embedding model on this // box. See the plan document for what to download. type SpeakerConfig struct { // Enabled — may she work out who is speaking. Default false. Enabled bool `json:"enabled,omitempty"` // ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model. // Required; without it the recognizer runs disabled and says so once. ModelPath string `json:"model_path,omitempty"` // LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒ // the same default the embedder block uses. LibPath string `json:"lib_path,omitempty"` // Threshold — cosine similarity a match must beat. 0 ⇒ // speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by // his name, which is the expensive direction of this error. Threshold float64 `json:"threshold,omitempty"` // MinSeconds — least speech an identification will look at. 0 ⇒ // speaker.DefaultMinSeconds (2s). MinSeconds float64 `json:"min_seconds,omitempty"` } // Recognizes reports whether voice identification should be wired. Safe on a // nil receiver, and false without a model path — enabled with nothing to embed // with is a misconfiguration, not a capability. func (s *SpeakerConfig) Recognizes() bool { return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != "" } // 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 } // PatternProposalConfig — announcement policy for routines the digestion tick // inferred by itself (Vikunja #247, #43). // // Detection is always on and always silent by default: the tick writes a // proposed_routines row and the /routines page shows it. Notify is what turns // "she noticed" into "she said something", and it is OFF unless configured — // Maven is not a nag and not autonomous, so a behaviour that speaks without // being asked has to be switched on deliberately, like weather and telegram. // // When Notify is on, the announcement is still heavily restrained: // - at most one proposal per tick, however many were detected; // - at most one per Cooldown across all pairs (not per pair), so a batch of // freshly-detected patterns cannot turn into a queue of interruptions; // - through the ordinary care-class gate (quiet hours / away / snooze), at // sev1 — the lowest severity there is. A proposal is the least urgent // thing Maven can say. // // A pair is only ever announced once, because it is only ever proposed once: // proposed_routines is UNIQUE(action, object) and the row survives dismissal. type PatternProposalConfig struct { // Notify — announce newly inferred routines. Default false. Notify bool `json:"notify,omitempty"` // Cooldown — minimum spacing between two proposal announcements. 0 ⇒ // DefaultProposalCooldown (24h). Cooldown Duration `json:"cooldown,omitempty"` } // AnnounceProposals reports whether inferred routines may be announced. Safe // on a nil receiver — an absent config block means silent detection. func (p *PatternProposalConfig) AnnounceProposals() bool { return p != nil && p.Notify } // FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md). // // Absent ⇒ off, and off means no outbound request at all. Present with an empty // `sources` list is also off — a poller with nothing to poll is not wired. // // What a feed may NOT do here: speak. Items are written as notes with source // "rss:" and read back when he asks; nothing is dispatched, nudged or // announced on arrival. That is the "not a nag" constraint, and it is why there // is no severity or channel field in this block to reach for. type FeedsConfig struct { // Sources — the feeds to read. Empty ⇒ the reader stays down. Sources []FeedSourceConfig `json:"sources,omitempty"` // PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m). PollInterval Duration `json:"poll_interval,omitempty"` // MaxItems — most items kept from one feed in one poll. 0 ⇒ // rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob. MaxItems int `json:"max_items,omitempty"` // MaxAge — on a first poll (no saved mark), how far back to take items. // 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not // the archive. MaxAge Duration `json:"max_age,omitempty"` // AllowHosts — when set, the reader may only connect to these hosts (and // their subdomains). The feed URLs' own hosts are added automatically, so // this is only needed to be stricter than that. AllowHosts []string `json:"allow_hosts,omitempty"` // Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout. Timeout Duration `json:"timeout,omitempty"` // MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB). MaxBytes int64 `json:"max_bytes,omitempty"` } // FeedSourceConfig — one feed. type FeedSourceConfig struct { Name string `json:"name"` // note source is "rss:" URL string `json:"url"` // http(s) only Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval Include []string `json:"include,omitempty"` // keep only items containing one of these Exclude []string `json:"exclude,omitempty"` // drop items containing any of these } // CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md). // // Absent ⇒ off, and off means no page is ever fetched. Present with neither // `on_demand` nor a `watches` entry is also off: there would be nothing to do. // // The crawler is the LAST place an answer is looked for, behind the model, his // own memory and the local Kiwix ZIMs. That ordering lives in the query-source // chain (cmd/mavend/actions_query.go), not here, but it is the reason this block // is small: it is a fallback, not a search engine. // // Only the URL leaves the box. His notes, facts, persona block and history are // never part of a request — the crawler package cannot even read the store. type CrawlConfig struct { // OnDemand — may he ask her to read a page he names out loud // ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer // source stays off and only the watches below run. OnDemand bool `json:"on_demand,omitempty"` // Watches — pages re-read on a schedule. A page whose text changed is // written as a note (source "crawl:"); nothing is announced. Watches []CrawlWatchConfig `json:"watches,omitempty"` // Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h). Interval Duration `json:"interval,omitempty"` // AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains // included). Setting this is how "she may read the arch wiki and nothing // else" is expressed. // // A watched page's own host is reachable by the scheduled crawler whether // or not it is listed here, because configuring a watch is already saying // she may read it. That does NOT extend to on-demand reading: a watch is // not an allowlist entry for pages he pastes. AllowHosts []string `json:"allow_hosts,omitempty"` // DenyHosts — never reachable, checked first. Private addresses do not need // to be listed: they are refused unconditionally (see internal/webfetch). DenyHosts []string `json:"deny_hosts,omitempty"` // UserAgent — sent on every request AND matched against robots.txt groups. // Empty ⇒ webfetch.DefaultUserAgent. UserAgent string `json:"user_agent,omitempty"` // Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout. Timeout Duration `json:"timeout,omitempty"` // MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB). MaxBytes int64 `json:"max_bytes,omitempty"` // MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes // (4000), which is what fits a 4096-token context alongside a prompt. MaxRunes int `json:"max_runes,omitempty"` } // KiwixConfig — the offline encyclopedia. A kiwix-serve instance holding ZIM // archives (Wikipedia, ifixit, devdocs) on the LAN, searched before anything // touches the network. Dark until configured, same as every other reach. // // This is the "local sources first" rule in CLAUDE.md made concrete: a 1.7B // does not know enough to answer a world question, but it can read. A local // read costs nothing and leaves the box only as far as the LAN. // // Only the rewritten search query leaves this process. His notes, facts, // persona block and history are never part of a request. type KiwixConfig struct { // URL — base address of kiwix-serve, e.g. "http://kiwix:8080". Empty ⇒ the // whole block is normalised to nil and the source stays off. URL string `json:"url,omitempty"` // Book — the ZIM to search, by its catalog name, e.g. // "wikipedia_en_all_maxi_2026-02". Take it from the /content/… href in // /catalog/v2/entries; the display title is not the name. // // Required. kiwix-serve answers 400 to a search with an empty books.name, // so a block without one is normalised to nil rather than left to fail one // query at a time. Book string `json:"book,omitempty"` // MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults. // Only the top few reach the phraser regardless; the rest are context the // snippet ranking throws away. MaxResults int `json:"max_results,omitempty"` // SnippetRunes — how much of the joined snippets is handed to the phraser. // 0 ⇒ DefaultKiwixSnippetRunes. Sized against the 4096-token context, which // also holds the persona block and the prompt. SnippetRunes int `json:"snippet_runes,omitempty"` // Rewrite — turn the Russian question into English keywords with the // resident model before searching. The ZIMs are English and kiwix ranks by // keyword, not meaning, so a Russian sentence matches nothing. Costs one // short LLM call per query. Default true; set false only to measure the // difference or when the books are Russian. Rewrite *bool `json:"rewrite,omitempty"` } // RewriteEnabled — Rewrite with its default applied. Absent ⇒ on. func (k *KiwixConfig) RewriteEnabled() bool { return k.Rewrite == nil || *k.Rewrite } // Kiwix defaults, applied in Normalise. const ( DefaultKiwixResults = 5 DefaultKiwixSnippetRunes = 1500 ) // WorkstationConfig — the big model on the owner's desktop (workpc, a // 7900 GRE with 16GB), fronted by mavgpud. // // homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this // is the preferred model above it (owner's call, 2026-08-02, docs/offload.md). // The workstation is never assumed up: its card is often held by a CPT run and // the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as // it does today. // // Only the prompt crosses the LAN, and the workstation is not "the box". The // rules in CLAUDE.md about what may leave still apply. type WorkstationConfig struct { // URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒ // the whole block is normalised to nil and nothing probes anything. URL string `json:"url,omitempty"` // Health — the admission endpoint. Empty ⇒ URL + "/health", which is what // mavgpud serves. It answers 503 while the card is held, and that is the // signal, so it must be the supervisor's endpoint and not llama-server's. Health string `json:"health,omitempty"` // Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe. // Nothing on the hot path waits for it: the answer is cached and read // atomically, so this only sets how late Maven notices the card came back. Probe Duration `json:"probe,omitempty"` // Timeout — the per-request budget for a completion on the workstation. // 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than // the resident one, and a request that overruns falls back to the floor. Timeout Duration `json:"timeout,omitempty"` } // Workstation defaults, applied in Normalise. const ( DefaultWorkstationProbe = 15 * time.Second DefaultWorkstationTimeout = 90 * time.Second ) // SearchConfig — the self-hosted SearXNG instance she searches with. // // External search is allowed and off unless configured (CLAUDE.md). Configuring // it is the whole opt-in: no `search` block, no query ever leaves the LAN. // // It sits AHEAD of Kiwix in the query chain, and that is the owner's ruling of // 2026-08-02: a live search answers better than a frozen ZIM, and the ZIM is // what she falls back to when the line is down. Everything of HIS still comes // first — the personal boundary runs above both, so a question about him is // never searched. // // Only the query string leaves the box. Notes, facts, the persona block and the // history are never part of a request; internal/websearch cannot read the store. type SearchConfig struct { // URL — base address of the SearXNG instance, e.g. "http://searxng:9563". // Empty ⇒ the whole block is normalised to nil and the source stays off. // // The instance needs `search.formats` to include `json` in its settings.yml. // A stock install answers 403 to format=json, and then every search fails. URL string `json:"url,omitempty"` // MaxResults — how many hits are kept as evidence. 0 ⇒ DefaultSearchResults. // Small on purpose: the snippets share a 4096-token context with the persona // block and the prompt. MaxResults int `json:"max_results,omitempty"` // SnippetRunes — how much of the joined evidence reaches the phraser. // 0 ⇒ DefaultSearchSnippetRunes. SnippetRunes int `json:"snippet_runes,omitempty"` // Language — SearXNG's `language` parameter, e.g. "ru", "en" or "auto". // Empty ⇒ the instance default. He asks in Russian and in English, so // pinning one language here is usually the wrong call. Language string `json:"language,omitempty"` // Engines — comma-separated engine names to restrict the search to, e.g. // "duckduckgo,wikipedia". Empty ⇒ whatever the instance has enabled. Engines string `json:"engines,omitempty"` // Timeout — per-search budget. 0 ⇒ websearch.DefaultTimeout. SearXNG waits // on the slowest upstream engine, so this is the knob that decides how long // a voice turn can stall on a bad network. Timeout Duration `json:"timeout,omitempty"` } // Search defaults, applied in Normalise. const ( DefaultSearchResults = 4 DefaultSearchSnippetRunes = 1500 ) // CrawlWatchConfig — one page kept an eye on. type CrawlWatchConfig struct { Name string `json:"name"` // note source is "crawl:" URL string `json:"url"` Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval } // MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248). // Absent ⇒ off, like every other capability that costs something the owner did // not ask for. Each evaluation is a full LLM round-trip on the one resident // model, which is the same model answering him; running it hourly by default // would put a multi-second stall in front of an occasional voice turn for a // feature he may not want. // // The loop only ever writes notes (source infer:memory-eval, visible on // /dash). It cannot speak — see internal/memeval. type MemoryEvalConfig struct { // Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval. Interval Duration `json:"interval,omitempty"` // MaxItems — recent facts / notes / nudges fed into one evaluation. // 0 ⇒ memeval.DefaultMaxItems. MaxItems int `json:"max_items,omitempty"` // MinConfidence — observations the model scores below this are dropped. // 0 ⇒ memeval.DefaultMinConfidence. MinConfidence float64 `json:"min_confidence,omitempty"` } // EmailConfig — core's half of the email reader: how many task candidates one // message may produce, and how long the extraction call may take. // // There is deliberately nothing about a mailbox here. Core does not connect to // IMAP, does not know an account exists, and holds no mail credential — the // reader daemon does, the same split mavpoll uses for the zenmoney token. This // block only says "extraction is allowed, with these bounds". type EmailConfig struct { // MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3). MaxTasks int `json:"max_tasks,omitempty"` // Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is // a Thinking model reading a mail; nobody is waiting on the answer, but a // hung llama-server must not pin the reader's connection forever. Timeout Duration `json:"timeout,omitempty"` } // DefaultEmailTimeout — extraction budget per message. const DefaultEmailTimeout = 2 * time.Minute // DefaultSmartHomeRefresh — how often the house is re-enumerated for new // devices. Slow on purpose: discovery only adds proposals, and a flat does not // grow a new lamp every minute. const DefaultSmartHomeRefresh = 15 * time.Minute // MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating // every entity in the house is a full /api/states read; a misconfigured second // would hammer the instance for proposals that are idempotent anyway. const MinSmartHomeRefresh = time.Minute // 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"` // CacheRAMMiB bounds llama-server's prompt cache. Omitted ⇒ 512 MiB, which // is what keeps the resident model near 1 GB of RSS instead of the 7.9 GB // measured on 2026-08-03. Set it to -1 to pass no flag at all and let the // server apply its own 8 GiB default. See phraser.Config.CacheRAMMiB. CacheRAMMiB int `json:"cache_ram_mib,omitempty"` // LLMNudges — let the model word nudges again. Off by default: nudges are // worded from hand-written Russian templates now (the model broke the // persona and invented units). Chat, query and reminder phrasing always go // through the model regardless. See phraser.Config.LLMNudges. LLMNudges bool `json:"llm_nudges,omitempty"` // SwapModels — the gguf files the running daemon is allowed to swap to // without a restart (Vikunja #250). Empty (the default) means the swap // capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod, // exactly like an unconfigured weather or telegram block. // // It is an allowlist and not a directory on purpose. The request carries a // path, and llama-server is started with it as `-m`; anything short of an // exact match against a list a human wrote in this file would make "swap the // model" mean "load a file of your choosing off my disk". ModelPath is // always swappable back to whether or not it is listed. // // Paths must be absolute — the daemon's working directory is not the // operator's, and a relative path here would resolve somewhere surprising. SwapModels []string `json:"swap_models,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 // 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 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 // DefaultProposalCooldown — one inferred-routine announcement per day at // most. A proposal is never urgent; if two patterns surface in the same // hour, the second one waits, and the /routines page has it either way. DefaultProposalCooldown = 24 * time.Hour // DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory // evaluation loop, applied only when the block is present at all. DefaultMemoryEvalInterval = time.Hour ) // 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.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") } } 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 } // Absent block stays nil (⇒ silent detection). Present-but-partial gets the // cooldown default, so `{"notify": true}` is enough to switch it on. if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 { c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown) } // Same rule: absent stays nil (⇒ no evaluation loop), present gets defaults // so `{}` is a valid "on with the plan's cadence". if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 { c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval) } // Same rule again: absent stays nil (⇒ mail ingestion refused), present gets // the timeout default so `{}` is a valid "on with the defaults". if c.Email != nil && c.Email.Timeout <= 0 { c.Email.Timeout = Duration(DefaultEmailTimeout) } // A feeds block with no sources is the same as no block: nothing to poll, // nothing wired. Normalising it to nil keeps that "off" in one place. if c.Feeds != nil && len(c.Feeds.Sources) == 0 { c.Feeds = nil } // Same rule for MCP: a block with no server at all is the same as no block. // A block whose servers are all disabled is NOT normalised away, because // validate has to see their shape — a dark block with a typo in it should // fail at startup, which is the whole reason it can be written before it is // switched on. wireMCP builds nothing when nothing is enabled, so "off" // still holds. if c.MCP != nil && len(c.MCP.Servers) == 0 { c.MCP = nil } if c.MCP != nil && c.MCP.HostInterval <= 0 { c.MCP.HostInterval = Duration(DefaultMCPHostInterval) } // Same rule for the house: a block that is not enabled is the same as no // block at all, so "off" stays in one place. if c.SmartHome != nil && !c.SmartHome.Enabled { c.SmartHome = nil } if c.SmartHome != nil && c.SmartHome.Refresh <= 0 { c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh) } if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) { c.SmartHome.Refresh = Duration(MinSmartHomeRefresh) } // Same rule for the scanner. if c.NetScan != nil && !c.NetScan.Enabled { c.NetScan = nil } // Same rule for the crawler: a block that neither answers on demand nor // watches anything has nothing to do, so it is normalised to "off". if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 { c.Crawl = nil } // Same rule for the ZIM reader: no address or no book, nothing to search. if c.Kiwix != nil && (strings.TrimSpace(c.Kiwix.URL) == "" || strings.TrimSpace(c.Kiwix.Book) == "") { c.Kiwix = nil } if c.Kiwix != nil { if c.Kiwix.MaxResults <= 0 { c.Kiwix.MaxResults = DefaultKiwixResults } if c.Kiwix.SnippetRunes <= 0 { c.Kiwix.SnippetRunes = DefaultKiwixSnippetRunes } } // Same rule for the metasearch instance: no address, nothing to search. if c.Search != nil && strings.TrimSpace(c.Search.URL) == "" { c.Search = nil } if c.Search != nil { if c.Search.MaxResults <= 0 { c.Search.MaxResults = DefaultSearchResults } if c.Search.SnippetRunes <= 0 { c.Search.SnippetRunes = DefaultSearchSnippetRunes } } // No address, no preferred model. An unconfigured workstation is the // default deploy and must be indistinguishable from today. if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" { c.Workstation = nil } if c.Workstation != nil { w := c.Workstation if strings.TrimSpace(w.Health) == "" { w.Health = strings.TrimRight(w.URL, "/") + "/health" } if w.Probe <= 0 { w.Probe = Duration(DefaultWorkstationProbe) } if w.Timeout <= 0 { w.Timeout = Duration(DefaultWorkstationTimeout) } } 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") } // A relative entry in the swap allowlist would resolve against the // daemon's working directory, so the path a human reads in this file // would not be the path llama-server is handed. Fail at startup. for _, m := range c.Phraser.SwapModels { if !filepath.IsAbs(m) { return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m) } } } // 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 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) } } // An MCP block with a typo (no name, both command and url, a bare hostname // as the url) fails here, at startup, rather than at the first turn that // needed the tool. if err := mcp.Validate(c.allMCPServers()); err != nil { return err } // Same for the house: a missing token or a bare hostname fails at startup, // not at the first "выключи свет". if hc, ok := c.SmartHomeClient(); ok { if p := c.SmartHome.Provider; p != "" && p != "homeassistant" { return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p) } if err := smarthome.Validate(hc); err != nil { return err } } // A scanner pointed at the public internet, or at a /8, fails here rather // than after the packets have already left. if nc, ok := c.NetScanner(); ok { if err := netscan.Validate(nc); err != nil { return err } } // A media dir that cannot be created, or a vision endpoint that is a typo, // used to be logged at wiring time and the capability just stayed off. A // capability silently not existing is the hardest kind of misconfiguration // to notice, so both fail here instead. if c.Media != nil { if c.Media.StoreDir() == "" { return errors.New("media.dir is required when a media block is present") } if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 { return errors.New("media: max_bytes and max_total_bytes cannot be negative") } if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes { return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d", c.Media.MaxBytes, c.Media.MaxTotalBytes) } } if c.Vision != nil && c.Vision.Enabled { if strings.TrimSpace(c.Vision.Endpoint) == "" { return errors.New("vision.enabled set but vision.endpoint is empty") } if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil { return err } if c.Media.StoreDir() == "" { return errors.New("vision.enabled set but there is no media block to keep the bytes in") } } if c.Capture.Records() && c.Media.StoreDir() == "" { return errors.New("capture.enabled set but there is no media block to keep the audio in") } 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, Optional: it.Optional} } 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()) }