Files
Maven/internal/config/config.go
T
kami 76a251a20d Merge branch 'fix/g08' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:38:39 +04:00

1591 lines
69 KiB
Go

// Package config is maven's daemon configuration.
//
// The daemon reads a single JSON file at startup (path from the -config flag,
// default ~/.config/maven/mavend.json). Everything a module needs is wired
// from this file: the store path, the unix socket path, the tick cadence,
// and per-sink configs (ntfy/telegram). Credentials live in the file (or a
// systemd credential that the file points at) — never in the binary.
//
// This package is pure data + a loader. It imports the sink config structs
// so the daemon wires each `Sink` from a single, typed config tree without
// re-declaring their shapes (the sink constructors own validation).
package config
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"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"`
// 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"`
// 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:<name>", 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"`
}
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
// server's wall clock. A window crossing midnight (Start > End) is handled:
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
type QuietHoursConfig struct {
Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00"
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
}
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
// seams.
//
// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in
// production; "127.0.0.1:9100" for the local smoke). Lang is the default
// language hint passed to both stt and tts (per-call overrides later).
//
// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the
// in-process stt.Stub (the "no models on disk" floor — the loop is
// exercisable end-to-end with a deterministic no-model transcriber).
// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix
// socket (cmd/mavsttd serves the other end; production swaps in a
// faster-whisper handler in cmd/mavsttd, no daemon or stt-package
// change). Tts mirrors for tts.Remote + cmd/mavttsd.
//
// Embedder configures the router's sentence embedder. When all three
// paths are set, the daemon constructs an ONNX multilingual embedder
// (in-process); when nil, it falls back to the floor HashEmbedder stub
// (deterministic, no model files required — good for CI and smoke).
//
// The daemon refuses to start if Voice.Enabled but Bind is empty — the
// bind is the one operational config the surface can't default (127.0.0.1
// is too relaxed for production, a wg-tunnel address is the user's);
// surfacing the gap explicitly beats an idle listener the user thinks is
// wired but isn't reachable.
type VoiceConfig struct {
Enabled bool `json:"enabled,omitempty"`
Bind string `json:"bind,omitempty"`
Lang string `json:"lang,omitempty"`
Stt *WorkerConfig `json:"stt,omitempty"`
Tts *TtsConfig `json:"tts,omitempty"`
Embedder *EmbedderConfig `json:"embedder,omitempty"`
// RouterThreshold — the minimum confidence score for the intent classifier
// (stage 3 gate). Below this → clarify, don't guess. 0.0 means permissive
// (never clarify); 0.35 is a reasonable floor for the ONNX embedder.
// The HashEmbedder floor scores lexically and may need a lower value.
// Default 0.35 if unset.
RouterThreshold float64 `json:"router_threshold,omitempty"`
// LLMRouter — route with the resident model instead of the embedding
// classifier. On by default since Vikunja #320.
//
// Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of
// intents right against the classifier's 50.0%, and no route errors. It
// costs about 1s per turn instead of 30ms.
//
// It is safe to leave on. The model can refuse — it answers "unknown" when
// it cannot route, and the turn drops to the classifier and its clarify
// gate. Any LLM error does the same, so a turn never breaks on the model.
// Slot extraction runs on LLM decisions too, so acts get their Fn and
// reminders their Time.
//
// Set it false to go back to the classifier, e.g. on a box with no
// llama-server or when 1s a turn is too slow.
//
// It is a pointer so that "missing from the file" and "explicitly false"
// are different things: missing means on, false means off. Read it with
// UseLLMRouter(), not directly.
LLMRouter *bool `json:"llm_router,omitempty"`
// QueryMinScore — the note-recall confidence gate. Top cosine below this
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
// the HashEmbedder floor scores lexically and may never clear it. 0.55
// default if unset.
QueryMinScore float64 `json:"query_min_score,omitempty"`
// QueryMinMargin — the second half of the recall gate: the top hit must
// beat the runner-up by more than this. The absolute score above cannot do
// the job on its own, because the e5 embedder puts every cosine in one
// narrow high band, so a made-up question scores as high as a real one.
// The margin asks whether one note is clearly the best instead.
// Negative ⇒ off. 0 ⇒ the default below.
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
// request before she gives up and says she did not understand. Default 3.
ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`
// Persona — optional prompt prefix that tunes maven's character. Prepended
// to every LLM system prompt (nudge phrasing, note queries, general
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
// Russian self-reference). Example: "Be formal and answer in English only."
Persona string `json:"persona,omitempty"`
// OwnerName / City — optional facts about the owner, added to the shared
// context block (internal/persona). Empty is fine: the block still states
// who he is grammatically (a man, addressed as "ты") and the current time.
// Nothing about correct behaviour may depend on these being filled in.
OwnerName string `json:"owner_name,omitempty"`
City string `json:"city,omitempty"`
// Weather — the weather provider config. nil ⇒ the daemon wires
// the stub provider (returns ErrNotConfigured — "погода не настроена").
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
Weather *WeatherConfig `json:"weather,omitempty"`
// Tools — the enabled act allowlist. Each is a spoken verb → argv the
// executor runs (args from the utterance appended). Editing this set is the
// human-only "enable" act (per spec); maven can't add to it from a request.
// Empty ⇒ every act is refused (nothing enabled).
Tools []ToolConfig `json:"tools,omitempty"`
// ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s).
ToolTimeout Duration `json:"tool_timeout,omitempty"`
}
// 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:<name>" 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:<name>"
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:<name>"); 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"`
}
// CrawlWatchConfig — one page kept an eye on.
type CrawlWatchConfig struct {
Name string `json:"name"` // note source is "crawl:<name>"
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"`
// 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
}
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}
}
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())
}