a8fcb404be
internal/netscan/ discovers hosts on the network Maven is configured to look at:
a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read
of the kernel's ARP cache. Wired as a read-only query source, "network", so
"какие устройства в сети?" is answered by a scan instead of by whatever old note
happens to be nearest.
Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to
point somewhere it should not go, so the package is built around four bounds:
- Scan takes NO target argument. The range comes from the config block and
from nowhere else, so there is no exported way to scan an arbitrary prefix
and nothing an utterance, the router, or a scanned host says can retarget
it. That is asserted directly: the test watches every address handed to the
dialer and fails if one falls outside the configured prefix. The ARP cache —
the one input the network itself populates — is filtered to the configured
range for the same reason.
- Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no
larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused
at config load, not after the packets have left.
- Rate-limited to a configured connections-per-second across the whole scan,
so it looks like background traffic rather than a portscan.
- Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget
and the context; a canceled scan stops dialing immediately.
Off unless configured: dark without "enabled": true, and applyDefaults
normalises a disabled block to nil. deploy/mavend.json carries it disabled.
BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half
(internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not
here: bluetoothctl and hcitool are not installed, bluetoothd is not installed,
the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0
exists as a kernel device and nothing can talk to it. The docker deploy is
further away still — it would need host networking, the D-Bus system socket
passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does
not exist, against an output format nothing here can produce, would be a guess
dressed as a feature. It needs a decision about privileging the container before
any of it is worth writing.
Vikunja #257
1466 lines
63 KiB
Go
1466 lines
63 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/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 reads this block: the daemon does not import internal/update
|
|
// and cannot update itself. It 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"`
|
|
}
|
|
|
|
// 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".
|
|
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
|
|
// (light, switch, fan, cover, lock) plus sensor and binary_sensor for
|
|
// reads. 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. 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"`
|
|
|
|
// 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.
|
|
func (c *Config) MCPServers() []mcp.ServerConfig {
|
|
if c.MCP == nil {
|
|
return nil
|
|
}
|
|
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
|
|
for _, s := range c.MCP.Servers {
|
|
if !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,
|
|
Timeout: timeout,
|
|
Enabled: true,
|
|
})
|
|
}
|
|
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"`
|
|
}
|
|
|
|
// NexusConfig — connection to the Nexus identity service.
|
|
type NexusConfig struct {
|
|
// URL — the Nexus HTTP API base URL (e.g. "http://localhost:9740").
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// HexisConfig — connection to the Hexis capability execution service.
|
|
type HexisConfig struct {
|
|
// URL — the Hexis HTTP API base URL (e.g. "http://localhost:9741").
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression
|
|
// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines
|
|
// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class
|
|
// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away
|
|
// channels.
|
|
type RoutineConfig struct {
|
|
Name string `json:"name"`
|
|
Cron string `json:"cron"`
|
|
Body string `json:"body"`
|
|
Severity int `json:"severity,omitempty"`
|
|
}
|
|
|
|
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
|
|
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
|
|
// 0=Sunday..6=Saturday; empty means every day (set two routines under
|
|
// different names for weekday/weekend variants).
|
|
type MorningRoutineConfig struct {
|
|
Name string `json:"name"`
|
|
Weekdays []int `json:"weekdays,omitempty"`
|
|
WindowStart string `json:"window_start"`
|
|
WindowEnd string `json:"window_end"`
|
|
NudgeAt string `json:"nudge_at,omitempty"`
|
|
Severity int `json:"severity,omitempty"`
|
|
Items []MorningRoutineItemConfig `json:"items"`
|
|
}
|
|
|
|
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
|
|
// presence within the window counts as completion evidence.
|
|
type MorningRoutineItemConfig struct {
|
|
Key string `json:"key"`
|
|
FactKey string `json:"fact_key"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
|
// server's wall clock. A window crossing midnight (Start > End) is handled:
|
|
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
|
|
type QuietHoursConfig struct {
|
|
Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00"
|
|
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
|
|
}
|
|
|
|
// VoiceConfig — the client↔core TCP surface + the stt/tts worker-module
|
|
// seams.
|
|
//
|
|
// Enabled gates wiring; Bind is the TCP address (inside the wg tunnel in
|
|
// production; "127.0.0.1:9100" for the local smoke). Lang is the default
|
|
// language hint passed to both stt and tts (per-call overrides later).
|
|
//
|
|
// Stt and Tts are the worker-module seams. nil Stt ⇒ daemon wires the
|
|
// in-process stt.Stub (the "no models on disk" floor — the loop is
|
|
// exercisable end-to-end with a deterministic no-model transcriber).
|
|
// non-nil Stt with Socket ⇒ daemon wires stt.Remote dialing that unix
|
|
// socket (cmd/mavsttd serves the other end; production swaps in a
|
|
// faster-whisper handler in cmd/mavsttd, no daemon or stt-package
|
|
// change). Tts mirrors for tts.Remote + cmd/mavttsd.
|
|
//
|
|
// Embedder configures the router's sentence embedder. When all three
|
|
// paths are set, the daemon constructs an ONNX multilingual embedder
|
|
// (in-process); when nil, it falls back to the floor HashEmbedder stub
|
|
// (deterministic, no model files required — good for CI and smoke).
|
|
//
|
|
// The daemon refuses to start if Voice.Enabled but Bind is empty — the
|
|
// bind is the one operational config the surface can't default (127.0.0.1
|
|
// is too relaxed for production, a wg-tunnel address is the user's);
|
|
// surfacing the gap explicitly beats an idle listener the user thinks is
|
|
// wired but isn't reachable.
|
|
type VoiceConfig struct {
|
|
Enabled bool `json:"enabled,omitempty"`
|
|
Bind string `json:"bind,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
Stt *WorkerConfig `json:"stt,omitempty"`
|
|
Tts *TtsConfig `json:"tts,omitempty"`
|
|
Embedder *EmbedderConfig `json:"embedder,omitempty"`
|
|
|
|
// RouterThreshold — the minimum confidence score for the intent classifier
|
|
// (stage 3 gate). Below this → clarify, don't guess. 0.0 means permissive
|
|
// (never clarify); 0.35 is a reasonable floor for the ONNX embedder.
|
|
// The HashEmbedder floor scores lexically and may need a lower value.
|
|
// Default 0.35 if unset.
|
|
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
|
|
|
// LLMRouter — route with the resident model instead of the embedding
|
|
// classifier. On by default since Vikunja #320.
|
|
//
|
|
// Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of
|
|
// intents right against the classifier's 50.0%, and no route errors. It
|
|
// costs about 1s per turn instead of 30ms.
|
|
//
|
|
// It is safe to leave on. The model can refuse — it answers "unknown" when
|
|
// it cannot route, and the turn drops to the classifier and its clarify
|
|
// gate. Any LLM error does the same, so a turn never breaks on the model.
|
|
// Slot extraction runs on LLM decisions too, so acts get their Fn and
|
|
// reminders their Time.
|
|
//
|
|
// Set it false to go back to the classifier, e.g. on a box with no
|
|
// llama-server or when 1s a turn is too slow.
|
|
//
|
|
// It is a pointer so that "missing from the file" and "explicitly false"
|
|
// are different things: missing means on, false means off. Read it with
|
|
// UseLLMRouter(), not directly.
|
|
LLMRouter *bool `json:"llm_router,omitempty"`
|
|
|
|
// QueryMinScore — the note-recall confidence gate. Top cosine below this
|
|
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
|
|
// the HashEmbedder floor scores lexically and may never clear it. 0.55
|
|
// default if unset.
|
|
QueryMinScore float64 `json:"query_min_score,omitempty"`
|
|
|
|
// QueryMinMargin — the second half of the recall gate: the top hit must
|
|
// beat the runner-up by more than this. The absolute score above cannot do
|
|
// the job on its own, because the e5 embedder puts every cosine in one
|
|
// narrow high band, so a made-up question scores as high as a real one.
|
|
// The margin asks whether one note is clearly the best instead.
|
|
// Negative ⇒ off. 0 ⇒ the default below.
|
|
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
|
|
|
|
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
|
|
// request before she gives up and says she did not understand. Default 3.
|
|
ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`
|
|
|
|
// Persona — optional prompt prefix that tunes maven's character. Prepended
|
|
// to every LLM system prompt (nudge phrasing, note queries, general
|
|
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
|
|
// Russian self-reference). Example: "Be formal and answer in English only."
|
|
Persona string `json:"persona,omitempty"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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: a verbatim record of what other people said in a
|
|
// room is a heavier thing to keep than a four-line summary, so it takes a
|
|
// deliberate yes. The audio blob is pruned by media.retention either way.
|
|
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). Watched pages' own hosts are added automatically. Setting this
|
|
// is how "she may read the arch wiki and nothing else" is expressed.
|
|
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
|
|
|
|
// 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, or none enabled, is the same
|
|
// as no block at all. Normalising it to nil keeps "off" in one place.
|
|
if c.MCP != nil && len(c.MCPServers()) == 0 {
|
|
c.MCP = nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.MCPServers()); 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
|
|
}
|
|
}
|
|
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())
|
|
}
|