69eda3ceee
WorkerConfig, TtsConfig, EmbedderConfig, WeatherConfig and ToolConfig. None of them is reachable except through a voice block, so they read better beside it.
780 lines
33 KiB
Go
780 lines
33 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"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
|
"github.com/kami/maven/internal/morning"
|
|
"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 acts on this block: it constructs no Updater and cannot
|
|
// update itself. Validate below is the one thing the daemon does with it, so
|
|
// a broken update config is caught at startup instead of on the night it is
|
|
// needed. That validation is also why internal/update is linked into mavend
|
|
// at all — linked, with no caller, which is the property that matters. The
|
|
// block lives here because cmd/mavupdate — a CLI the owner runs on the host,
|
|
// the only trigger there is — reads the same config file to find the socket
|
|
// it health-checks.
|
|
Update *update.Config `json:"update,omitempty"`
|
|
|
|
// Voice — the client↔core surface + the stt/tts modules the daemon
|
|
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
|
// down, the dispatcher's Voice slot stays nil (the routing table's
|
|
// ChannelVoice selections drop silently — same as pre-voice behaviour).
|
|
// To enable: voice.enabled = true AND voice.bind = an address inside
|
|
// the wg tunnel; the daemon binds the TCP listener there.
|
|
Voice *VoiceConfig `json:"voice,omitempty"`
|
|
|
|
// QuietHours — time-window schedule for quiet hours. When set, the
|
|
// loop's gatherer sets `quiet = true` in the State during the window,
|
|
// suppressing care nudges (sev1-2). The user can also toggle quiet
|
|
// hours by voice ("тихий режим") — that writes a `quiet_hours` config
|
|
// fact independently; both the schedule AND the toggle activate quiet.
|
|
// nil ⇒ quiet hours only activate via the voice toggle.
|
|
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
|
|
|
|
// DisabledRules — nudge rules that are not wired at all, by name
|
|
// ("service_down", "netdata_critical", "water", "meal", "break").
|
|
//
|
|
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
|
// this only subtracts. It exists because a rule can be right in principle
|
|
// and useless in practice. service_down was the case that forced it: it
|
|
// could not name the service it was nudging about, so being told "a service
|
|
// on homesrv is down" every fifteen minutes was noise with no action
|
|
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
|
// enabled again. The escape hatch stays.
|
|
//
|
|
// A disabled rule is never gathered for, never evaluated, and never
|
|
// delivered on any channel. Unknown names are ignored, so removing a rule
|
|
// from the code does not break a config that still lists it.
|
|
// Empty ⇒ every rule runs, which is the default.
|
|
DisabledRules []string `json:"disabled_rules,omitempty"`
|
|
|
|
// Digest — notification batching / digest mode. nil ⇒ digest disabled
|
|
// (every nudge is sent as it fires — legacy behaviour).
|
|
Digest *DigestConfig `json:"digest,omitempty"`
|
|
|
|
// Routines — scheduled behaviors maven performs on a cron schedule (a
|
|
// morning briefing, an evening wind-down), independent of any request or
|
|
// care predicate. Each fires its Body through the dispatcher on its Cron
|
|
// schedule. Empty ⇒ no routines. See internal/routine for the class
|
|
// distinction from reminders (user-stated) and care rules (world-state).
|
|
Routines []RoutineConfig `json:"routines,omitempty"`
|
|
|
|
// MorningRoutines — daily checklists (medicine, water, pets, ...) checked
|
|
// once near the end of a time window instead of firing one reminder per
|
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
|
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
|
|
|
// PatternProposals — whether a routine the digestion tick inferred on its
|
|
// own may be announced, and how often. nil / absent ⇒ silent detection
|
|
// only: proposals are written for /routines and never announced. See
|
|
// PatternProposalConfig.
|
|
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
|
|
|
|
// MemoryEval — background memory evaluation (internal/memeval). nil /
|
|
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
|
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
|
|
|
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
|
|
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
|
|
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
|
|
// live in the reader (cmd/mavmaild), never here.
|
|
Email *EmailConfig `json:"email,omitempty"`
|
|
|
|
// IntakeJournal — how many entries the unified intake journal keeps
|
|
// (Vikunja #283): one envelope per thing that arrived, whatever direction it
|
|
// came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the
|
|
// journal off entirely, and then there is no decorator on the intake path at
|
|
// all.
|
|
//
|
|
// Not gated behind an "off unless configured" block like feeds or telegram,
|
|
// and the distinction is the one CLAUDE.md draws: that rule exists for
|
|
// capabilities that reach OUT — a fetch, a send, a third party. This reaches
|
|
// nowhere. It is a bounded in-memory log of writes core already performed,
|
|
// it is read only by /events and the simulator, and nothing Maven says
|
|
// depends on it.
|
|
IntakeJournal int `json:"intake_journal,omitempty"`
|
|
|
|
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
|
// ever fetched: reading the outside world is off unless configured, like
|
|
// the weather and telegram. See FeedsConfig.
|
|
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
|
|
|
// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
|
|
// fetches a page: not on request, not on a schedule. See CrawlConfig.
|
|
Crawl *CrawlConfig `json:"crawl,omitempty"`
|
|
|
|
// Kiwix — the offline ZIM reader (Vikunja #122 neighbourhood). nil / absent
|
|
// / url empty ⇒ the query chain has no ZIM source. See KiwixConfig.
|
|
Kiwix *KiwixConfig `json:"kiwix,omitempty"`
|
|
|
|
// Search — the SearXNG metasearch instance. nil / absent / url empty ⇒ the
|
|
// query chain has no web-search source and Kiwix is the only encyclopedia.
|
|
// See SearchConfig.
|
|
Search *SearchConfig `json:"search,omitempty"`
|
|
|
|
// Workstation — the big model on the owner's desktop, preferred over the
|
|
// resident one when its GPU is free. nil / absent / url empty ⇒ homesrv
|
|
// behaves exactly as it does today. See WorkstationConfig.
|
|
Workstation *WorkstationConfig `json:"workstation,omitempty"`
|
|
|
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
|
// component reads another's store). nil ⇒ ecosystem integration disabled.
|
|
Praxis *PraxisConfig `json:"praxis,omitempty"`
|
|
|
|
// Nexus — the canonical identity service. When configured, maven resolves
|
|
// entity references (projects, services, devices, etc.) through Nexus
|
|
// before acting on them. nil ⇒ resolution disabled (maven uses raw text).
|
|
Nexus *NexusConfig `json:"nexus,omitempty"`
|
|
|
|
// Hexis — the capability execution service. When configured, maven
|
|
// discovers and executes capabilities through Hexis for ecosystem actions.
|
|
// nil ⇒ no capability-aware routing.
|
|
Hexis *HexisConfig `json:"hexis,omitempty"`
|
|
|
|
// Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot
|
|
// look at pictures at all: the intake refuses, and no vision server is
|
|
// contacted. See VisionConfig.
|
|
Vision *VisionConfig `json:"vision,omitempty"`
|
|
|
|
// Media — where images and captured audio are kept on disk, and for how
|
|
// long. nil / absent ⇒ no blob store is wired, which is what disables both
|
|
// vision intake and meeting capture regardless of their own blocks: nothing
|
|
// in this repo holds a recording only in memory. See MediaConfig.
|
|
Media *MediaConfig `json:"media,omitempty"`
|
|
|
|
// Capture — meeting recording and summarisation (Vikunja #253). nil /
|
|
// absent ⇒ the recorder does not exist: the start/stop methods are not
|
|
// served at all, so nothing on this box can begin a recording. This is the
|
|
// most invasive capability Maven has and it is the one most firmly off by
|
|
// default. See CaptureConfig.
|
|
Capture *CaptureConfig `json:"capture,omitempty"`
|
|
|
|
// Speaker — voice identification (Vikunja #255). nil / absent ⇒ no
|
|
// voiceprint is ever computed and nobody can be enrolled. Enabling it needs
|
|
// a speaker-embedding model, which is not on this box. See SpeakerConfig.
|
|
Speaker *SpeakerConfig `json:"speaker,omitempty"`
|
|
|
|
// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
|
|
// #251). nil / absent / no enabled server ⇒ no connection is made and no
|
|
// tool is discovered, like every other capability that reaches outside the
|
|
// box. She is a client here, never a server: nothing exposes her own
|
|
// capabilities to an outside caller. See MCPConfig.
|
|
MCP *MCPConfig `json:"mcp,omitempty"`
|
|
|
|
// SmartHome — the Home Assistant instance (Vikunja #256). nil / absent /
|
|
// disabled ⇒ Maven neither reads the house nor touches it, and no house row
|
|
// exists in the act allowlist. See SmartHomeConfig.
|
|
SmartHome *SmartHomeConfig `json:"smarthome,omitempty"`
|
|
|
|
// NetScan — the LAN scanner (Vikunja #257). nil / absent / disabled ⇒
|
|
// Maven never puts a packet on the network looking for hosts. See
|
|
// NetScanConfig.
|
|
NetScan *NetScanConfig `json:"netscan,omitempty"`
|
|
}
|
|
|
|
// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression
|
|
// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines
|
|
// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class
|
|
// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away
|
|
// channels.
|
|
type RoutineConfig struct {
|
|
Name string `json:"name"`
|
|
Cron string `json:"cron"`
|
|
Body string `json:"body"`
|
|
Severity int `json:"severity,omitempty"`
|
|
}
|
|
|
|
// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt
|
|
// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are
|
|
// 0=Sunday..6=Saturday; empty means every day (set two routines under
|
|
// different names for weekday/weekend variants).
|
|
type MorningRoutineConfig struct {
|
|
Name string `json:"name"`
|
|
Weekdays []int `json:"weekdays,omitempty"`
|
|
WindowStart string `json:"window_start"`
|
|
WindowEnd string `json:"window_end"`
|
|
NudgeAt string `json:"nudge_at,omitempty"`
|
|
Severity int `json:"severity,omitempty"`
|
|
Items []MorningRoutineItemConfig `json:"items"`
|
|
}
|
|
|
|
// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose
|
|
// presence within the window counts as completion evidence.
|
|
type MorningRoutineItemConfig struct {
|
|
Key string `json:"key"`
|
|
FactKey string `json:"fact_key"`
|
|
Label string `json:"label"`
|
|
// Optional — this one being skipped does not earn a nudge. Default false,
|
|
// so a routine written before 04-08-2026 keeps behaving as it did.
|
|
Optional bool `json:"optional,omitempty"`
|
|
}
|
|
|
|
// QuietHoursConfig — a recurring daily quiet-window. Times are local to the
|
|
// server's wall clock. A window crossing midnight (Start > End) is handled:
|
|
// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day.
|
|
type QuietHoursConfig struct {
|
|
Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00"
|
|
End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
|
|
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
|
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
|
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
|
//
|
|
// ModelPath is the only required field. The rest have sensible defaults:
|
|
// - BinPath defaults to "llama-server" (found via PATH at spawn time).
|
|
// - Listen defaults to "127.0.0.1:0" (random port, read from stderr).
|
|
// - NGpuLayers defaults to -1 (max, uses all available GPU layers).
|
|
// - NCtx defaults to 2048.
|
|
// - Timeout defaults to 30s per request.
|
|
type PhraserConfig struct {
|
|
ModelPath string `json:"model_path"`
|
|
BinPath string `json:"bin_path,omitempty"`
|
|
Listen string `json:"listen,omitempty"`
|
|
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
|
NCtx int `json:"n_ctx,omitempty"`
|
|
Timeout Duration `json:"timeout,omitempty"`
|
|
|
|
// CacheRAMMiB bounds llama-server's prompt cache. Omitted ⇒ 512 MiB, which
|
|
// is what keeps the resident model near 1 GB of RSS instead of the 7.9 GB
|
|
// measured on 2026-08-03. Set it to -1 to pass no flag at all and let the
|
|
// server apply its own 8 GiB default. See phraser.Config.CacheRAMMiB.
|
|
CacheRAMMiB int `json:"cache_ram_mib,omitempty"`
|
|
|
|
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
|
// worded from hand-written Russian templates now (the model broke the
|
|
// persona and invented units). Chat, query and reminder phrasing always go
|
|
// through the model regardless. See phraser.Config.LLMNudges.
|
|
LLMNudges bool `json:"llm_nudges,omitempty"`
|
|
|
|
// SwapModels — the gguf files the running daemon is allowed to swap to
|
|
// without a restart (Vikunja #250). Empty (the default) means the swap
|
|
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
|
|
// exactly like an unconfigured weather or telegram block.
|
|
//
|
|
// It is an allowlist and not a directory on purpose. The request carries a
|
|
// path, and llama-server is started with it as `-m`; anything short of an
|
|
// exact match against a list a human wrote in this file would make "swap the
|
|
// model" mean "load a file of your choosing off my disk". ModelPath is
|
|
// always swappable back to whether or not it is listed.
|
|
//
|
|
// Paths must be absolute — the daemon's working directory is not the
|
|
// operator's, and a relative path here would resolve somewhere surprising.
|
|
SwapModels []string `json:"swap_models,omitempty"`
|
|
}
|
|
|
|
// 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
|
|
// DefaultIntakeJournal — entries kept in the unified intake journal
|
|
// (Vikunja #283). A busy day is a few hundred intake writes, so this is
|
|
// roughly "today and yesterday" at a few hundred KB of memory.
|
|
DefaultIntakeJournal = 512
|
|
|
|
DefaultFactEnrichmentInterval = 30 * time.Second
|
|
|
|
// 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)
|
|
}
|
|
|
|
c.normaliseFeeds()
|
|
|
|
c.normaliseMCP()
|
|
|
|
c.normaliseSmartHome()
|
|
|
|
c.normaliseNetScan()
|
|
|
|
c.normaliseCrawl()
|
|
|
|
c.normaliseKiwix()
|
|
c.normaliseSearch()
|
|
|
|
c.normaliseWorkstation()
|
|
|
|
c.normaliseVoice()
|
|
|
|
// routines: default severity to care-class (1) — the safe floor: a
|
|
// misconfigured routine can't blast an away channel at 3am.
|
|
for i := range c.Routines {
|
|
if c.Routines[i].Severity == 0 {
|
|
c.Routines[i].Severity = 1
|
|
}
|
|
}
|
|
|
|
// morning routines: same safe-floor default as cron routines.
|
|
for i := range c.MorningRoutines {
|
|
if c.MorningRoutines[i].Severity == 0 {
|
|
c.MorningRoutines[i].Severity = 1
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.Phraser != nil {
|
|
if c.Phraser.ModelPath == "" {
|
|
return errors.New("phraser.model_path is required")
|
|
}
|
|
// 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 err := c.validateVoice(); err != nil {
|
|
return err
|
|
}
|
|
// routines: name + body required, cron must parse. A typo here should fail
|
|
// at startup, not silently never fire.
|
|
for _, r := range c.Routines {
|
|
if r.Name == "" {
|
|
return errors.New("routine: name is required")
|
|
}
|
|
if r.Body == "" {
|
|
return fmt.Errorf("routine %q: body is required", r.Name)
|
|
}
|
|
if _, err := cron.ParseStandard(r.Cron); err != nil {
|
|
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
|
}
|
|
}
|
|
if err := c.validateMCP(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateSmartHome(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateNetScan(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateMedia(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateVision(); err != nil {
|
|
return err
|
|
}
|
|
if err := c.validateCapture(); err != nil {
|
|
return err
|
|
}
|
|
if len(c.MorningRoutines) > 0 {
|
|
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// morningRoutinesFromConfig maps the config's morning-routine blocks to the
|
|
// engine type. Shared with the daemon so config validation and daemon wiring
|
|
// can never drift on the mapping.
|
|
func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
|
out := make([]morning.Routine, len(mc))
|
|
for i, r := range mc {
|
|
items := make([]morning.Item, len(r.Items))
|
|
for j, it := range r.Items {
|
|
items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional}
|
|
}
|
|
weekdays := make([]time.Weekday, len(r.Weekdays))
|
|
for j, w := range r.Weekdays {
|
|
weekdays[j] = time.Weekday(w)
|
|
}
|
|
out[i] = morning.Routine{
|
|
Name: r.Name,
|
|
Weekdays: weekdays,
|
|
WindowStart: r.WindowStart,
|
|
WindowEnd: r.WindowEnd,
|
|
NudgeAt: r.NudgeAt,
|
|
Severity: r.Severity,
|
|
Items: items,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MorningRoutinesFromConfig is the exported form daemon wiring uses.
|
|
func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine {
|
|
return morningRoutinesFromConfig(mc)
|
|
}
|
|
|
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
|
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
|
// never silently downgrade to plaintext).
|
|
func (c *Config) DBEncryptionKey() ([]byte, error) {
|
|
raw := c.DBKeyB64
|
|
if c.DBKeyEnv != "" {
|
|
raw = os.Getenv(c.DBKeyEnv)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv)
|
|
}
|
|
}
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: db key is not valid base64: %w", err)
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key))
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// DefaultWrappedKeyPath returns the conventional path for the wrapped
|
|
// encryption key blob — alongside the StateDir. This is the path checked
|
|
// automatically when --wrapped-key-file is not provided on the command line.
|
|
// The caller may always override via the flag.
|
|
func (c *Config) DefaultWrappedKeyPath() string {
|
|
return filepath.Join(c.StateDir, "db_key.wrapped")
|
|
}
|
|
|
|
func defaultDataDir() string {
|
|
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return filepath.Join(os.TempDir(), "maven")
|
|
}
|
|
return filepath.Join(home, ".local", "share", "maven")
|
|
}
|
|
|
|
func defaultRuntimeDir() string {
|
|
if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
// /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back
|
|
// to the data dir (still works; just not tmpfs-clearance-on-reboot clean).
|
|
return filepath.Join(defaultDataDir())
|
|
}
|