Merge the config.go sweep (#222)

V-410. internal/config/config.go went from 1827 lines to 484 and is now the
loader and nothing else. Thirteen files each carry their struct, their Default
constants, a normaliseX and a validateX. applyDefaults and validate are ordered
lists of those calls.

The brief's premise did not hold and the agent said so. There are eight
accessors in the package, not forty, and they mean four different things. Three
are pointer-bool with absent meaning on, three are enabled-and-has-its-path, one
converts units. A generic accessor would collapse those and cost the ability to
grep the name. What repeated was the normalise-and-validate shape inlined forty
times, and that is what was extracted.

Five in-code comments had drifted from the code and are fixed. RouterThreshold
said 0.35 and said 0.0 meant permissive, where the default is 0.55 and anything
at or below zero is replaced. PhraserConfig documented three defaults that are
applied nowhere. KiwixConfig still said the ZIMs answer before the network.
NetScanConfig said 50 where the default is 100. Nothing in CLAUDE.md or docs
disagreed with the code.

Filed V-580 rather than fixing it: phraser.n_gpu_layers has no default, so an
omitted key reaches llama-server as CPU-only. deploy/mavend.json sets 99, which
is why nobody noticed. Applying the -1 would change behaviour on any box that
omitted the key, so that is the owner's call.

TestDeployConfigLoads is new. It parses the real deploy/mavend.json through the
Load the daemon calls, because every other test built its own JSON and a renamed
key would have gone unnoticed until startup.

No key removed. make build and go test -race both pass.
This commit is contained in:
2026-08-06 01:43:41 +04:00
14 changed files with 1675 additions and 1377 deletions
+69
View File
@@ -0,0 +1,69 @@
package config
import "time"
// Two loops that run behind the conversation and never speak. Both are absent
// by default, and both follow the same rule as every other cost the owner did
// not ask for: present-but-empty (`{}`) is a valid "on with the defaults".
// 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"`
}
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
// evaluation loop, applied only when the block is present at all.
const DefaultMemoryEvalInterval = time.Hour
// normaliseMemoryEval leaves an absent block nil (⇒ no evaluation loop) and
// gives a present one the plan's cadence.
func (c *Config) normaliseMemoryEval() {
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
}
}
// 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
// normaliseEmail leaves an absent block nil (⇒ mail ingestion refused) and
// gives a present one the timeout default.
func (c *Config) normaliseEmail() {
if c.Email != nil && c.Email.Timeout <= 0 {
c.Email.Timeout = Duration(DefaultEmailTimeout)
}
}
+37 -1377
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
package config
// The two scheduled outbound readers: RSS/Atom feeds and watched web pages.
// Both are off unless configured, both write notes and neither may speak —
// nothing they fetch 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
// either block to reach for.
//
// Only the URL leaves the box. His notes, facts, persona block and history are
// never part of a request; neither package can read the store.
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
//
// Absent ⇒ off. Present with an empty `sources` list is also off — a poller with
// nothing to poll is not wired, and normaliseFeeds folds that back to nil.
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
}
// normaliseFeeds folds a block with no sources back to nil: it is the same as
// no block, and keeping that "off" in one place is the point.
func (c *Config) normaliseFeeds() {
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
c.Feeds = nil
}
}
// 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, the live search 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.
type CrawlConfig struct {
// OnDemand — may he ask her to read a page he names out loud
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
// source stays off and only the watches below run.
OnDemand bool `json:"on_demand,omitempty"`
// Watches — pages re-read on a schedule. A page whose text changed is
// written as a note (source "crawl:<name>"); nothing is announced.
Watches []CrawlWatchConfig `json:"watches,omitempty"`
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
Interval Duration `json:"interval,omitempty"`
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
// included). Setting this is how "she may read the arch wiki and nothing
// else" is expressed.
//
// A watched page's own host is reachable by the scheduled crawler whether
// or not it is listed here, because configuring a watch is already saying
// she may read it. That does NOT extend to on-demand reading: a watch is
// not an allowlist entry for pages he pastes.
AllowHosts []string `json:"allow_hosts,omitempty"`
// DenyHosts — never reachable, checked first. Private addresses do not need
// to be listed: they are refused unconditionally (see internal/webfetch).
DenyHosts []string `json:"deny_hosts,omitempty"`
// UserAgent — sent on every request AND matched against robots.txt groups.
// Empty ⇒ webfetch.DefaultUserAgent.
UserAgent string `json:"user_agent,omitempty"`
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
Timeout Duration `json:"timeout,omitempty"`
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
// (4000), which is what fits a 4096-token context alongside a prompt.
MaxRunes int `json:"max_runes,omitempty"`
}
// CrawlWatchConfig — one page kept an eye on.
type CrawlWatchConfig struct {
Name string `json:"name"` // note source is "crawl:<name>"
URL string `json:"url"`
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
}
// normaliseCrawl folds a block that neither answers on demand nor watches
// anything back to nil: it has nothing to do.
func (c *Config) normaliseCrawl() {
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
c.Crawl = nil
}
}
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestDeployConfigLoads parses the file the box actually runs on.
//
// Every other test in this package builds its own JSON, so a key renamed in one
// place and not the other would go unnoticed until the daemon refused to start.
// This one reads deploy/mavend.json through the same Load the daemon calls, so
// a config change and a code change have to agree here or the suite is red.
//
// The ${VAR} expansions come from a gitignored deploy/telegram.env that is not
// present in CI. An unset var expands to the empty string, which is exactly the
// "not configured" state every block already has to handle, so the parse is
// still meaningful without the secrets.
func TestDeployConfigLoads(t *testing.T) {
path := filepath.Join("..", "..", "deploy", "mavend.json")
if _, err := os.Stat(path); err != nil {
t.Skipf("no deploy config at %s: %v", path, err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%s): %v", path, err)
}
// Spot-check the settings whose absence would be a silent behaviour change
// rather than a startup error.
if cfg.Phraser == nil {
t.Fatal("deploy config has no phraser block")
}
if cfg.Phraser.NGpuLayers == 0 {
t.Error("phraser.n_gpu_layers is 0 — llama-server would run CPU-only, " +
"because nothing in this package defaults that field")
}
if cfg.Voice == nil || !cfg.Voice.Enabled {
t.Fatal("deploy config does not enable voice")
}
if !cfg.Voice.UseLLMRouter() {
t.Error("deploy config turned the LLM router off")
}
if cfg.Voice.RouterThreshold <= 0 {
t.Error("router threshold did not get its default")
}
}
+41
View File
@@ -0,0 +1,41 @@
package config
// The ecosystem trio. Maven owns conversation and personal memory; it owns
// neither identity, nor operational state, nor execution. Each of these three
// is nil unless configured and each degrades on its own — an outage is a named
// gap in the answer, never a broken turn and never a guess. See docs/ecosystem.md.
//
// All three carry the same two fields, and they stay three types rather than one
// shared EndpointConfig on purpose: the block a reader greps for is the service
// they are debugging, and a shared type would put "url" in one place for three
// different trust levels.
// PraxisConfig — maven's connection to the Praxis attention service.
type PraxisConfig struct {
// URL — the Praxis HTTP API base URL (e.g. "http://localhost:9742").
URL string `json:"url,omitempty"`
// Token — the shared bearer token sent on every request. Empty ⇒ calls
// go out unauthenticated, which is only appropriate on a loopback or
// unix-socket transport. Supports ${VAR} expansion, so the secret lives
// in deploy/telegram.env, not in the committed config.
Token string `json:"token,omitempty"`
}
// NexusConfig — connection to the Nexus identity service.
type NexusConfig struct {
// URL — the Nexus HTTP API base URL (e.g. "http://localhost:9740").
URL string `json:"url,omitempty"`
// Token — shared bearer token; see PraxisConfig.Token.
Token string `json:"token,omitempty"`
}
// HexisConfig — connection to the Hexis capability execution service.
type HexisConfig struct {
// URL — the Hexis HTTP API base URL (e.g. "http://localhost:9741").
URL string `json:"url,omitempty"`
// Token — shared bearer token; see PraxisConfig.Token.
Token string `json:"token,omitempty"`
}
+176
View File
@@ -0,0 +1,176 @@
package config
import (
"fmt"
"time"
"github.com/kami/maven/internal/netscan"
"github.com/kami/maven/internal/smarthome"
)
// SmartHomeConfig — the Home Assistant block (Vikunja #256). Dark until
// `"enabled": true`, and even then a discovered device is only ever PROPOSED
// into the act allowlist: Kami enables it on /tools, behind step-up, exactly as
// he would a shell tool. Finding a switch on the network is not the same as
// being allowed to flip it.
type SmartHomeConfig struct {
// Provider — only "homeassistant" is implemented. MQTT / Zigbee2MQTT are
// not: Home Assistant already fronts them, and a broker client is a
// dependency this vendored module tree cannot take on tonight.
Provider string `json:"provider,omitempty"`
// URL — the instance base, "http://192.168.1.50:8123".
//
// Plain http is accepted and is what the deploy block uses. That is a
// deliberate choice, not an oversight: the instance is on the LAN behind
// wireguard, and a self-signed cert on a home box buys a warning rather
// than a guarantee. It does mean the long-lived token crosses the LAN in
// cleartext on every refresh, so the LAN is part of the trust boundary.
URL string `json:"url,omitempty"`
// Token — a long-lived access token. Use ${HA_TOKEN} and keep the value in
// the gitignored env file, like the telegram credentials.
Token string `json:"token,omitempty"`
// Domains — entity domains to take. Empty ⇒ the controllable domains
// EXCEPT lock (light, switch, fan, cover) plus sensor and binary_sensor
// for reads. A lock is only enumerated when it is named here, because a
// front door is not a lamp. Narrow it when the instance is large: a tool name the 1.7B
// half-remembers is a wrong act.
Domains []string `json:"domains,omitempty"`
// MaxEntities — cap on the proposal catalogue. 0 ⇒ 40.
MaxEntities int `json:"max_entities,omitempty"`
// Timeout — per-call budget. 0 ⇒ 10s.
Timeout Duration `json:"timeout,omitempty"`
// Refresh — how often the entity list is re-read and new devices proposed.
// 0 ⇒ 15m, and anything under MinSmartHomeRefresh is raised to it:
// "refresh": "1s" used to pass validation and enumerate the whole instance
// every second. Discovery is idempotent, so this only ever adds rows.
Refresh Duration `json:"refresh,omitempty"`
// Enabled — false (the default) keeps a written block dark, so it can be
// reviewed before the house is wired to a voice.
Enabled bool `json:"enabled,omitempty"`
}
// DefaultSmartHomeRefresh — how often the house is re-enumerated for new
// devices. Slow on purpose: discovery only adds proposals, and a flat does not
// grow a new lamp every minute.
const DefaultSmartHomeRefresh = 15 * time.Minute
// MinSmartHomeRefresh — the floor under SmartHomeConfig.Refresh. Enumerating
// every entity in the house is a full /api/states read; a misconfigured second
// would hammer the instance for proposals that are idempotent anyway.
const MinSmartHomeRefresh = time.Minute
// 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
}
// normaliseSmartHome applies the block's defaults. A block that is not enabled
// is the same as no block at all, so "off" stays in one place.
func (c *Config) normaliseSmartHome() {
if c.SmartHome != nil && !c.SmartHome.Enabled {
c.SmartHome = nil
}
if c.SmartHome != nil && c.SmartHome.Refresh <= 0 {
c.SmartHome.Refresh = Duration(DefaultSmartHomeRefresh)
}
if c.SmartHome != nil && c.SmartHome.Refresh < Duration(MinSmartHomeRefresh) {
c.SmartHome.Refresh = Duration(MinSmartHomeRefresh)
}
}
// validateSmartHome fails a missing token or a bare hostname at startup, not at
// the first "выключи свет".
func (c *Config) validateSmartHome() error {
hc, ok := c.SmartHomeClient()
if !ok {
return nil
}
if p := c.SmartHome.Provider; p != "" && p != "homeassistant" {
return fmt.Errorf("smarthome: provider %q: only \"homeassistant\" is implemented", p)
}
return smarthome.Validate(hc)
}
// 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 ⇒ netscan.DefaultPorts
// (22, 80, 443, 8080).
Ports []int `json:"ports,omitempty"`
// Timeout — per-connection budget. 0 ⇒ netscan.DefaultTimeout (400ms).
Timeout Duration `json:"timeout,omitempty"`
// Rate — connections per second across the whole scan. 0 ⇒
// netscan.DefaultRate (100). 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 ⇒ netscan.DefaultMaxHosts
// (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
}
// normaliseNetScan applies the block's defaults. Same rule as the house: not
// enabled is the same as no block at all.
func (c *Config) normaliseNetScan() {
if c.NetScan != nil && !c.NetScan.Enabled {
c.NetScan = nil
}
}
// validateNetScan fails a scanner pointed at the public internet, or at a /8,
// here rather than after the packets have already left.
func (c *Config) validateNetScan() error {
nc, ok := c.NetScanner()
if !ok {
return nil
}
return netscan.Validate(nc)
}
+173
View File
@@ -0,0 +1,173 @@
package config
import (
"time"
"github.com/kami/maven/internal/mcp"
)
// MCPConfig — the MCP client block. Servers are dark until one has
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
// it on /tools, on the authed surface, exactly as he would a shell tool. The
// voice path can never grant a capability to itself.
type MCPConfig struct {
// Servers — the configured servers. Each needs exactly one of command
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
Servers []MCPServerConfig `json:"servers,omitempty"`
// Timeout — per-call budget for every server that does not set its own.
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
// spoken turn.
Timeout Duration `json:"timeout,omitempty"`
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
// url servers go through. Deny wins. Private addresses are refused
// unconditionally unless the individual server sets allow_private.
AllowHosts []string `json:"allow_hosts,omitempty"`
DenyHosts []string `json:"deny_hosts,omitempty"`
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
MaxBytes int64 `json:"max_bytes,omitempty"`
// HostInterval — minimum spacing between two requests to one MCP server.
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
// That default was sized for a feed poll loop, and this path is in a spoken
// turn: one dial is three requests (initialize, initialized, tools/list),
// so a second of spacing is two seconds of pure sleeping per dial and up to
// another second before every tools/call leaves the box.
HostInterval Duration `json:"host_interval,omitempty"`
}
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
// runaway loop hammering a server, small enough not to be heard.
const DefaultMCPHostInterval = 50 * time.Millisecond
// normaliseMCP applies the block's defaults. A block with no server at all is
// the same as no block. A block whose servers are all disabled is NOT
// normalised away, because validate has to see their shape — a dark block with
// a typo in it should fail at startup, which is the whole reason it can be
// written before it is switched on. wireMCP builds nothing when nothing is
// enabled, so "off" still holds.
func (c *Config) normaliseMCP() {
if c.MCP != nil && len(c.MCP.Servers) == 0 {
c.MCP = nil
}
if c.MCP != nil && c.MCP.HostInterval <= 0 {
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
}
}
// MCPServerConfig — one MCP server.
type MCPServerConfig struct {
// Name — the local handle. It prefixes every tool this server contributes
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
// becomes the store scope "mcp:<name>", so its provenance is readable on
// /tools without opening the config.
Name string `json:"name"`
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
// on this box, under this user. argv, never a shell string.
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
Dir string `json:"dir,omitempty"`
// URL — a streamable-HTTP endpoint. It is fetched through
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
// the one-request-per-host-per-second limit all apply.
URL string `json:"url,omitempty"`
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
// server on homesrv is "http://localhost:9100/mcp", which is refused
// without this flag. Understand what it means before setting it: a local
// server is a DIFFERENT trust level from a public one. It is inside the
// network, it usually needs no credential, and it can change things that
// matter — so an argument the router got wrong lands somewhere real. Set it
// only for a server you run yourself, and prefer allow_tools with it.
AllowPrivate bool `json:"allow_private,omitempty"`
// AllowTools — when set, the ONLY remote tool names taken from this server.
// This is the knob that keeps the catalogue deliberate: the resident model
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
// a wrong act, so fewer and better-chosen beats complete.
AllowTools []string `json:"allow_tools,omitempty"`
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
MaxTools int `json:"max_tools,omitempty"`
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
Timeout Duration `json:"timeout,omitempty"`
// Headers — sent verbatim on every request to a url server. This is how a
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
// telegram credentials. The Vikunja server on homesrv needs none only
// because it is unauthenticated on loopback.
Headers map[string]string `json:"headers,omitempty"`
// Enabled — false (the default) keeps a configured server described but
// dark, so a block can be written and reviewed before it is switched on.
Enabled bool `json:"enabled,omitempty"`
}
// MCPServers maps the config blocks onto the mcp package's own type. It lives
// here so config validation and daemon wiring cannot drift on the mapping.
// Returns nil when nothing is configured or nothing is enabled.
//
// Disabled servers are dropped here, which is why validation does NOT use this
// list — see allMCPServers.
func (c *Config) MCPServers() []mcp.ServerConfig {
return c.mcpServers(true)
}
// allMCPServers is every configured server, enabled or not, for validation.
//
// Validating only the enabled ones meant a block with both command and url, or
// a bare hostname as the url, passed startup validation while it was dark. The
// doc on Enabled says a block can be written and reviewed before it is switched
// on; the review the config layer could give was the one thing skipped. Enabled
// gates the dialing, not the shape check.
func (c *Config) allMCPServers() []mcp.ServerConfig {
return c.mcpServers(false)
}
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
if c.MCP == nil {
return nil
}
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
for _, s := range c.MCP.Servers {
if onlyEnabled && !s.Enabled {
continue
}
timeout := time.Duration(s.Timeout)
if timeout <= 0 {
timeout = time.Duration(c.MCP.Timeout)
}
out = append(out, mcp.ServerConfig{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Env: s.Env,
Dir: s.Dir,
URL: s.URL,
AllowPrivate: s.AllowPrivate,
AllowTools: s.AllowTools,
MaxTools: s.MaxTools,
Headers: s.Headers,
Timeout: timeout,
Enabled: s.Enabled,
})
}
if len(out) == 0 {
return nil
}
return out
}
// validateMCP fails a block with a typo (no name, both command and url, a bare
// hostname as the url) at startup, rather than at the first turn that needed
// the tool.
func (c *Config) validateMCP() error {
return mcp.Validate(c.allMCPServers())
}
+91
View File
@@ -0,0 +1,91 @@
package config
import "time"
// The two blocks that shape what reaches him unasked: how nudges are batched,
// and whether a routine Maven inferred by herself may be announced at all.
// 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
}
// Digest defaults, applied in normaliseDigest.
const (
DefaultDigestWindow = 30 * time.Minute
DefaultDigestMaxItems = 5
DefaultDigestSeverityCeiling = 2
)
// normaliseDigest is the one block that does NOT fold an absent block to nil:
// it materialises a disabled one instead, because the dispatcher reads
// c.Digest.Enabled without a nil check.
func (c *Config) normaliseDigest() {
if c.Digest == nil {
c.Digest = &DigestConfig{Enabled: false}
}
if c.Digest.Window == 0 {
c.Digest.Window = Duration(DefaultDigestWindow)
}
if c.Digest.MaxItems == 0 {
c.Digest.MaxItems = DefaultDigestMaxItems
}
if c.Digest.SeverityCeiling == 0 {
c.Digest.SeverityCeiling = DefaultDigestSeverityCeiling
}
}
// 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"`
}
// 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.
const DefaultProposalCooldown = 24 * time.Hour
// 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
}
// normalisePatternProposals leaves an absent block nil, which means silent
// detection. A present-but-partial one gets the cooldown, so `{"notify": true}`
// is enough to switch announcements on.
func (c *Config) normalisePatternProposals() {
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
}
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"errors"
"fmt"
"path/filepath"
)
// 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. cmd/mavend fills BinPath
// ("llama-server", found via PATH at spawn time) and Listen ("127.0.0.1:0", a
// random port read back off stderr) when they are empty.
//
// Nothing in this package defaults the rest, and an omitted numeric field
// reaches llama-server as a zero. That matters most for NGpuLayers: see below.
type PhraserConfig struct {
ModelPath string `json:"model_path"`
BinPath string `json:"bin_path,omitempty"`
Listen string `json:"listen,omitempty"`
// NGpuLayers — layers offloaded to the GPU, passed straight through as
// `-ngl`. Omitted ⇒ 0, which is CPU-only inference.
//
// phraser.DefaultConfig says -1 (offload everything), but nothing calls it:
// cmd/mavend builds a phraser.Config literally and copies this field across.
// So the deploy config carries `"n_gpu_layers": 99` and must keep carrying
// it. Vikunja has the discrepancy; do not "fix" it by writing a default
// here, because that would change what a box without the key does.
NGpuLayers int `json:"n_gpu_layers,omitempty"`
// NCtx — context window, passed through as `-c`. Omitted ⇒ 0, which lets
// llama-server pick. The resident model is a Thinking variant and needs
// 4096; the deploy config sets it.
NCtx int `json:"n_ctx,omitempty"`
// Timeout — per-request budget. Omitted ⇒ phraser's own default.
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"`
}
// validatePhraser refuses a block with no model, and a swap allowlist entry
// that would resolve somewhere other than where a reader of this file expects.
func (c *Config) validatePhraser() error {
if c.Phraser == nil {
return 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)
}
}
return nil
}
+131
View File
@@ -0,0 +1,131 @@
package config
import (
"errors"
"fmt"
"time"
"github.com/kami/maven/internal/morning"
"github.com/robfig/cron/v3"
)
// The two scheduled things Maven says on her own, plus the window she does not
// say them in. Both routine kinds default to severity 1 — the care class, which
// quiet hours suppress and which never reaches an away channel. A routine that
// speaks at 3am has to be written that way on purpose.
// 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"
}
// normaliseRoutines defaults both routine kinds to severity 1, the care class:
// the safe floor, so a misconfigured routine cannot blast an away channel at
// 3am.
func (c *Config) normaliseRoutines() {
for i := range c.Routines {
if c.Routines[i].Severity == 0 {
c.Routines[i].Severity = 1
}
}
for i := range c.MorningRoutines {
if c.MorningRoutines[i].Severity == 0 {
c.MorningRoutines[i].Severity = 1
}
}
}
// validateRoutines fails a routine that could never fire. A typo in a cron
// expression or a missing body should stop the daemon at startup, not go
// unnoticed as silence at 08:00 every day.
func (c *Config) validateRoutines() error {
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 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)
}
+240
View File
@@ -0,0 +1,240 @@
package config
import (
"errors"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/vision"
)
// The senses: seeing, hearing, and knowing who spoke. All three are off unless
// someone typed a path on purpose, and all three depend on the media block —
// nothing in this repo holds an image or a recording only in memory.
// MediaConfig — the on-disk blob store for images and captured audio
// (internal/media). It is shared by all three senses: vision intake, meeting
// capture, and speaker enrolment samples all write here.
//
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
// at all. That default is deliberate: a capability that keeps photos and audio of
// people on disk should require someone to have typed a path.
type MediaConfig struct {
// Dir — the blob store root, created 0700. Relative paths resolve against
// StateDir. Required; an empty dir means the store is not wired.
Dir string `json:"dir,omitempty"`
// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
// media.DefaultRetention (7 days). This is the knob that stops recordings
// of people accumulating; raising it past a few weeks should need a reason.
Retention Duration `json:"retention,omitempty"`
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
// The per-blob cap bounds one call; this one bounds the sum of them, which
// is what actually decides whether the disk mavend's database lives on can
// be filled from outside.
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
}
// StoreDir reports the configured blob directory, or "" when media is not
// wired. Safe on a nil receiver.
func (m *MediaConfig) StoreDir() string {
if m == nil {
return ""
}
return strings.TrimSpace(m.Dir)
}
// validateMedia fails a media dir that cannot be created here rather than at
// wiring time. A capability silently not existing is the hardest kind of
// misconfiguration to notice.
func (c *Config) validateMedia() error {
if c.Media == nil {
return nil
}
if c.Media.StoreDir() == "" {
return errors.New("media.dir is required when a media block is present")
}
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
}
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
c.Media.MaxBytes, c.Media.MaxTotalBytes)
}
return nil
}
// 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) != ""
}
// validateVision fails an endpoint that is a typo, or a vision block with
// nowhere to keep the bytes, at startup.
func (c *Config) validateVision() error {
if c.Vision == nil || !c.Vision.Enabled {
return nil
}
if strings.TrimSpace(c.Vision.Endpoint) == "" {
return errors.New("vision.enabled set but vision.endpoint is empty")
}
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
return err
}
if c.Media.StoreDir() == "" {
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
}
return nil
}
// CaptureConfig — the meeting recorder (internal/capture,
// docs/plans/08-hearing.md).
//
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
// return "unknown method", so no client can start a recording however it asks.
// A media block is required too: audio is never held only in memory.
//
// There is deliberately no "auto", no keyword trigger and no duration default
// long enough to be forgotten about. Recording other people is an explicit act
// with a start, a stop, and a cap.
type CaptureConfig struct {
// Enabled — may she record a meeting when asked. Default false.
Enabled bool `json:"enabled,omitempty"`
// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
// capture.DefaultMaxDuration (120 minutes).
MaxMinutes int `json:"max_minutes,omitempty"`
// STTWindow — audio handed to whisper per call. 0 ⇒
// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
// and block the STT worker for longer.
STTWindow Duration `json:"stt_window,omitempty"`
// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
// 4096. Raise this only if the resident model's context grows.
ChunkRunes int `json:"chunk_runes,omitempty"`
// MaxChunks — how many windows one meeting may be summarised in before the
// transcript is truncated and the summary says so. 0 ⇒
// capture.DefaultMaxChunks (40).
MaxChunks int `json:"max_chunks,omitempty"`
// SaveTranscript — write the full transcript as a note alongside the
// summary. Default false, and the cost is not disk: a note is embedded and
// becomes recall corpus, so every later question can surface verbatim words
// other people said in a room. That is the reason it takes a deliberate yes.
// The audio blob is pruned by media.retention either way; the notes are not.
//
// A meeting with no summary writes its transcript regardless. The choice
// here is transcript IN ADDITION to a summary, not whether the meeting is
// remembered at all.
SaveTranscript bool `json:"save_transcript,omitempty"`
}
// Records reports whether the recorder should be wired. Safe on a nil receiver.
func (c *CaptureConfig) Records() bool {
return c != nil && c.Enabled
}
// MaxDuration is the configured session cap as a duration, or 0 for the
// package default. Safe on a nil receiver.
func (c *CaptureConfig) MaxDuration() time.Duration {
if c == nil || c.MaxMinutes <= 0 {
return 0
}
return time.Duration(c.MaxMinutes) * time.Minute
}
// validateCapture refuses a recorder with nowhere to keep the audio.
func (c *Config) validateCapture() error {
if c.Capture.Records() && c.Media.StoreDir() == "" {
return errors.New("capture.enabled set but there is no media block to keep the audio in")
}
return nil
}
// 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.
//
// Nothing reads it yet: cmd/mavend's newSpeakerEmbedder discards the whole
// block, because there is no speaker model on this box to load. It stays
// declared so the block a reader writes matches the plan document.
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) != ""
}
+240
View File
@@ -0,0 +1,240 @@
package config
import (
"errors"
"time"
)
// The blocks nested under voice: the two worker seams, the embedder the
// classifier scores with, the weather provider and the act allowlist. They live
// here because none of them is reachable except through a voice block.
// 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"`
}
// 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"`
}
// 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"`
}
// Voice defaults, applied in normaliseVoice.
const (
DefaultRouterThreshold = 0.55
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
)
// 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
}
// normaliseVoice applies the block's defaults. An absent block stays nil: the
// surface is off and there is nothing to tune.
func (c *Config) normaliseVoice() {
if c.Voice == nil {
return
}
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
}
}
// validateVoice refuses a surface that would listen nowhere, and an embedder
// block with only some of its three paths filled in.
func (c *Config) validateVoice() error {
if c.Voice == nil || !c.Voice.Enabled {
return nil
}
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 e := c.Voice.Embedder; e != nil {
if e.ModelPath == "" || e.TokenizerPath == "" || e.LibPath == "" {
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")
}
}
return nil
}
// 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 ⇒
// DefaultRouterThreshold (0.55), tuned for the ONNX embedder; the
// HashEmbedder floor scores lexically and may need a lower value.
//
// There is no way to ask for "permissive, never clarify" through this
// field: normaliseVoice replaces anything ≤ 0 with the default, so a
// written 0 is the default and a written negative is too.
RouterThreshold float64 `json:"router_threshold,omitempty"`
// LLMRouter — route with the resident model instead of the embedding
// classifier. On by default since Vikunja #320.
//
// Measured on the held-out fixture (docs/evals/2026-07-31-routing.md): 63.2% of
// intents right against the classifier's 50.0%, and no route errors. It
// costs about 1s per turn instead of 30ms.
//
// It is safe to leave on. The model can refuse — it answers "unknown" when
// it cannot route, and the turn drops to the classifier and its clarify
// gate. Any LLM error does the same, so a turn never breaks on the model.
// Slot extraction runs on LLM decisions too, so acts get their Fn and
// reminders their Time.
//
// Set it false to go back to the classifier, e.g. on a box with no
// llama-server or when 1s a turn is too slow.
//
// It is a pointer so that "missing from the file" and "explicitly false"
// are different things: missing means on, false means off. Read it with
// UseLLMRouter(), not directly.
LLMRouter *bool `json:"llm_router,omitempty"`
// QueryMinScore — the note-recall confidence gate. Top cosine below this
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
// the HashEmbedder floor scores lexically and may never clear it. 0.55
// default if unset.
QueryMinScore float64 `json:"query_min_score,omitempty"`
// QueryMinMargin — the second half of the recall gate: the top hit must
// beat the runner-up by more than this. The absolute score above cannot do
// the job on its own, because the e5 embedder puts every cosine in one
// narrow high band, so a made-up question scores as high as a real one.
// The margin asks whether one note is clearly the best instead.
// Negative ⇒ off. 0 ⇒ the default below.
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
// request before she gives up and says she did not understand. Default 3.
ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`
// Persona — optional prompt prefix that tunes maven's character. Prepended
// to every LLM system prompt (nudge phrasing, note queries, general
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
// Russian self-reference). Example: "Be formal and answer in English only."
Persona string `json:"persona,omitempty"`
// OwnerName / City — optional facts about the owner, added to the shared
// context block (internal/persona). Empty is fine: the block still states
// who he is grammatically (a man, addressed as "ты") and the current time.
// Nothing about correct behaviour may depend on these being filled in.
OwnerName string `json:"owner_name,omitempty"`
City string `json:"city,omitempty"`
// Weather — the weather provider config. nil ⇒ the daemon wires
// the stub provider (returns ErrNotConfigured — "погода не настроена").
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
Weather *WeatherConfig `json:"weather,omitempty"`
// Tools — the enabled act allowlist. Each is a spoken verb → argv the
// executor runs (args from the utterance appended). Editing this set is the
// human-only "enable" act (per spec); maven can't add to it from a request.
// Empty ⇒ every act is refused (nothing enabled).
Tools []ToolConfig `json:"tools,omitempty"`
// ToolTimeout bounds each tool invocation. Zero ⇒ executor default (30s).
ToolTimeout Duration `json:"tool_timeout,omitempty"`
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"strings"
"time"
)
// WorkstationConfig — the big model on the owner's desktop (workpc, a
// 7900 GRE with 16GB), fronted by mavgpud.
//
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
// is the preferred model above it (owner's call, 2026-08-02, docs/offload.md).
// The workstation is never assumed up: its card is often held by a CPT run and
// the machine sleeps. No block, or an empty URL, and homesrv behaves exactly as
// it does today.
//
// Only the prompt crosses the LAN, and the workstation is not "the box". The
// rules in CLAUDE.md about what may leave still apply.
type WorkstationConfig struct {
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
// the whole block is normalised to nil and nothing probes anything.
URL string `json:"url,omitempty"`
// Health — the admission endpoint. Empty ⇒ URL + "/health", which is what
// mavgpud serves. It answers 503 while the card is held, and that is the
// signal, so it must be the supervisor's endpoint and not llama-server's.
Health string `json:"health,omitempty"`
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
// Nothing on the hot path waits for it: the answer is cached and read
// atomically, so this only sets how late Maven notices the card came back.
Probe Duration `json:"probe,omitempty"`
// Timeout — the per-request budget for a completion on the workstation.
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
// the resident one, and a request that overruns falls back to the floor.
Timeout Duration `json:"timeout,omitempty"`
}
// Workstation defaults, applied in normaliseWorkstation.
const (
DefaultWorkstationProbe = 15 * time.Second
DefaultWorkstationTimeout = 90 * time.Second
)
// normaliseWorkstation applies the block's defaults. No address, no preferred
// model: an unconfigured workstation is the default deploy and must be
// indistinguishable from today.
func (c *Config) normaliseWorkstation() {
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
c.Workstation = nil
}
if c.Workstation == nil {
return
}
w := c.Workstation
if strings.TrimSpace(w.Health) == "" {
w.Health = strings.TrimRight(w.URL, "/") + "/health"
}
if w.Probe <= 0 {
w.Probe = Duration(DefaultWorkstationProbe)
}
if w.Timeout <= 0 {
w.Timeout = Duration(DefaultWorkstationTimeout)
}
}
+149
View File
@@ -0,0 +1,149 @@
package config
import "strings"
// The two world sources in the query chain, in the order they answer: a live
// SearXNG search first, the offline ZIMs behind it (owner's call, 2026-08-02).
// Everything of HIS still comes first — the personal boundary runs above both,
// so a question about him is never searched.
//
// Only the query string leaves the box in either case. Notes, facts, the
// persona block and the history are never part of a request; neither
// internal/websearch nor internal/kiwix can read the store.
// SearchConfig — the self-hosted SearXNG instance she searches with.
//
// External search is allowed and off unless configured (CLAUDE.md). Configuring
// it is the whole opt-in: no `search` block, no query ever leaves the LAN.
type SearchConfig struct {
// URL — base address of the SearXNG instance, e.g. "http://searxng:9563".
// Empty ⇒ the whole block is normalised to nil and the source stays off.
//
// The instance needs `search.formats` to include `json` in its settings.yml.
// A stock install answers 403 to format=json, and then every search fails.
URL string `json:"url,omitempty"`
// MaxResults — how many hits are kept as evidence. 0 ⇒ DefaultSearchResults.
// Small on purpose: the snippets share a 4096-token context with the persona
// block and the prompt.
MaxResults int `json:"max_results,omitempty"`
// SnippetRunes — how much of the joined evidence reaches the phraser.
// 0 ⇒ DefaultSearchSnippetRunes.
SnippetRunes int `json:"snippet_runes,omitempty"`
// Language — SearXNG's `language` parameter, e.g. "ru", "en" or "auto".
// Empty ⇒ the instance default. He asks in Russian and in English, so
// pinning one language here is usually the wrong call.
Language string `json:"language,omitempty"`
// Engines — comma-separated engine names to restrict the search to, e.g.
// "duckduckgo,wikipedia". Empty ⇒ whatever the instance has enabled.
Engines string `json:"engines,omitempty"`
// Timeout — per-search budget. 0 ⇒ websearch.DefaultTimeout. SearXNG waits
// on the slowest upstream engine, so this is the knob that decides how long
// a voice turn can stall on a bad network.
Timeout Duration `json:"timeout,omitempty"`
}
// Search defaults, applied in normaliseSearch.
const (
DefaultSearchResults = 4
DefaultSearchSnippetRunes = 1500
)
// normaliseSearch applies the block's defaults. No address, nothing to search.
func (c *Config) normaliseSearch() {
if c.Search != nil && strings.TrimSpace(c.Search.URL) == "" {
c.Search = nil
}
if c.Search == nil {
return
}
if c.Search.MaxResults <= 0 {
c.Search.MaxResults = DefaultSearchResults
}
if c.Search.SnippetRunes <= 0 {
c.Search.SnippetRunes = DefaultSearchSnippetRunes
}
}
// KiwixConfig — the offline encyclopedia. A kiwix-serve instance holding ZIM
// archives (Wikipedia, ifixit, devdocs) on the LAN, searched when the live
// search is empty, unreachable, or the line is down. Dark until configured,
// same as every other reach.
//
// This is the "local sources first" rule in CLAUDE.md made concrete: a 1.7B
// does not know enough to answer a world question, but it can read. A local
// read costs nothing and leaves the box only as far as the LAN.
type KiwixConfig struct {
// URL — base address of kiwix-serve, e.g. "http://kiwix:8080". Empty ⇒ the
// whole block is normalised to nil and the source stays off.
URL string `json:"url,omitempty"`
// Book — the ZIM to search, by its catalog name, e.g.
// "wikipedia_en_all_maxi_2026-02". Take it from the /content/… href in
// /catalog/v2/entries; the display title is not the name.
//
// Required. kiwix-serve answers 400 to a search with an empty books.name,
// so a block without one is normalised to nil rather than left to fail one
// query at a time.
Book string `json:"book,omitempty"`
// BookRU — the ZIM to search when the question is in Russian, by the same
// catalog name. Empty ⇒ every question goes to Book.
//
// It exists because the rewriter is a workaround, not a feature (V-508). An
// English ZIM cannot match a Russian sentence, so the resident model turns
// the question into English keywords first, and that costs a model call and
// loses whatever the keywords drop. A Russian ZIM matches the question as he
// asked it. So a Cyrillic question searches this book verbatim and skips the
// rewrite, and the English book keeps answering English ones.
BookRU string `json:"book_ru,omitempty"`
// MaxResults — how many hits are asked for. 0 ⇒ DefaultKiwixResults.
// Only the top few reach the phraser regardless; the rest are context the
// snippet ranking throws away.
MaxResults int `json:"max_results,omitempty"`
// SnippetRunes — how much of the joined snippets is handed to the phraser.
// 0 ⇒ DefaultKiwixSnippetRunes. Sized against the 4096-token context, which
// also holds the persona block and the prompt.
SnippetRunes int `json:"snippet_runes,omitempty"`
// Rewrite — turn the Russian question into English keywords with the
// resident model before searching. The ZIMs are English and kiwix ranks by
// keyword, not meaning, so a Russian sentence matches nothing. Costs one
// short LLM call per query. Default true; set false only to measure the
// difference or when the books are Russian.
Rewrite *bool `json:"rewrite,omitempty"`
}
// Kiwix defaults, applied in normaliseKiwix.
const (
DefaultKiwixResults = 5
DefaultKiwixSnippetRunes = 1500
)
// RewriteEnabled — Rewrite with its default applied. Absent ⇒ on.
func (k *KiwixConfig) RewriteEnabled() bool {
return k.Rewrite == nil || *k.Rewrite
}
// normaliseKiwix applies the block's defaults. No address or no book, nothing
// to search.
func (c *Config) normaliseKiwix() {
if c.Kiwix != nil && (strings.TrimSpace(c.Kiwix.URL) == "" || strings.TrimSpace(c.Kiwix.Book) == "") {
c.Kiwix = nil
}
if c.Kiwix == nil {
return
}
if c.Kiwix.MaxResults <= 0 {
c.Kiwix.MaxResults = DefaultKiwixResults
}
if c.Kiwix.SnippetRunes <= 0 {
c.Kiwix.SnippetRunes = DefaultKiwixSnippetRunes
}
}