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