Compare commits
19 Commits
828e034c96
...
59b98d5c4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 59b98d5c4d | |||
| 59210cf07d | |||
| ceba69c4bb | |||
| 3bb82a90db | |||
| abbf0fe60d | |||
| 594bfc2bc3 | |||
| 795ecf67a5 | |||
| 69eda3ceee | |||
| 69a6eb0fb9 | |||
| d457c97355 | |||
| 1b6d51dc71 | |||
| 40152e3688 | |||
| 7f411656c2 | |||
| 9ea178be99 | |||
| 9ef6f286a9 | |||
| 7414ef4c39 | |||
| 231a4e00f6 | |||
| 2acfeb4453 | |||
| 1c20df70f8 |
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Two loops that run behind the conversation and never speak. Both are absent
|
||||
// by default, and both follow the same rule as every other cost the owner did
|
||||
// not ask for: present-but-empty (`{}`) is a valid "on with the defaults".
|
||||
|
||||
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||
// model, which is the same model answering him; running it hourly by default
|
||||
// would put a multi-second stall in front of an occasional voice turn for a
|
||||
// feature he may not want.
|
||||
//
|
||||
// The loop only ever writes notes (source infer:memory-eval, visible on
|
||||
// /dash). It cannot speak — see internal/memeval.
|
||||
type MemoryEvalConfig struct {
|
||||
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// MaxItems — recent facts / notes / nudges fed into one evaluation.
|
||||
// 0 ⇒ memeval.DefaultMaxItems.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MinConfidence — observations the model scores below this are dropped.
|
||||
// 0 ⇒ memeval.DefaultMinConfidence.
|
||||
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
|
||||
// evaluation loop, applied only when the block is present at all.
|
||||
const DefaultMemoryEvalInterval = time.Hour
|
||||
|
||||
// normaliseMemoryEval leaves an absent block nil (⇒ no evaluation loop) and
|
||||
// gives a present one the plan's cadence.
|
||||
func (c *Config) normaliseMemoryEval() {
|
||||
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
|
||||
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||
// message may produce, and how long the extraction call may take.
|
||||
//
|
||||
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||
// block only says "extraction is allowed, with these bounds".
|
||||
type EmailConfig struct {
|
||||
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||
MaxTasks int `json:"max_tasks,omitempty"`
|
||||
|
||||
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||
// hung llama-server must not pin the reader's connection forever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultEmailTimeout — extraction budget per message.
|
||||
const DefaultEmailTimeout = 2 * time.Minute
|
||||
|
||||
// normaliseEmail leaves an absent block nil (⇒ mail ingestion refused) and
|
||||
// gives a present one the timeout default.
|
||||
func (c *Config) normaliseEmail() {
|
||||
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||
}
|
||||
}
|
||||
+37
-1377
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
package config
|
||||
|
||||
// The two scheduled outbound readers: RSS/Atom feeds and watched web pages.
|
||||
// Both are off unless configured, both write notes and neither may speak —
|
||||
// nothing they fetch is dispatched, nudged or announced on arrival. That is the
|
||||
// "not a nag" constraint, and it is why there is no severity or channel field in
|
||||
// either block to reach for.
|
||||
//
|
||||
// Only the URL leaves the box. His notes, facts, persona block and history are
|
||||
// never part of a request; neither package can read the store.
|
||||
|
||||
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
|
||||
//
|
||||
// Absent ⇒ off. Present with an empty `sources` list is also off — a poller with
|
||||
// nothing to poll is not wired, and normaliseFeeds folds that back to nil.
|
||||
type FeedsConfig struct {
|
||||
// Sources — the feeds to read. Empty ⇒ the reader stays down.
|
||||
Sources []FeedSourceConfig `json:"sources,omitempty"`
|
||||
|
||||
// PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m).
|
||||
PollInterval Duration `json:"poll_interval,omitempty"`
|
||||
|
||||
// MaxItems — most items kept from one feed in one poll. 0 ⇒
|
||||
// rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MaxAge — on a first poll (no saved mark), how far back to take items.
|
||||
// 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not
|
||||
// the archive.
|
||||
MaxAge Duration `json:"max_age,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the reader may only connect to these hosts (and
|
||||
// their subdomains). The feed URLs' own hosts are added automatically, so
|
||||
// this is only needed to be stricter than that.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// FeedSourceConfig — one feed.
|
||||
type FeedSourceConfig struct {
|
||||
Name string `json:"name"` // note source is "rss:<name>"
|
||||
URL string `json:"url"` // http(s) only
|
||||
Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval
|
||||
Include []string `json:"include,omitempty"` // keep only items containing one of these
|
||||
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
||||
}
|
||||
|
||||
// normaliseFeeds folds a block with no sources back to nil: it is the same as
|
||||
// no block, and keeping that "off" in one place is the point.
|
||||
func (c *Config) normaliseFeeds() {
|
||||
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
|
||||
c.Feeds = nil
|
||||
}
|
||||
}
|
||||
|
||||
// CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md).
|
||||
//
|
||||
// Absent ⇒ off, and off means no page is ever fetched. Present with neither
|
||||
// `on_demand` nor a `watches` entry is also off: there would be nothing to do.
|
||||
//
|
||||
// The crawler is the LAST place an answer is looked for, behind the model, his
|
||||
// own memory, the live search and the local Kiwix ZIMs. That ordering lives in
|
||||
// the query-source chain (cmd/mavend/actions_query.go), not here, but it is the
|
||||
// reason this block is small: it is a fallback, not a search engine.
|
||||
type CrawlConfig struct {
|
||||
// OnDemand — may he ask her to read a page he names out loud
|
||||
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
|
||||
// source stays off and only the watches below run.
|
||||
OnDemand bool `json:"on_demand,omitempty"`
|
||||
|
||||
// Watches — pages re-read on a schedule. A page whose text changed is
|
||||
// written as a note (source "crawl:<name>"); nothing is announced.
|
||||
Watches []CrawlWatchConfig `json:"watches,omitempty"`
|
||||
|
||||
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
|
||||
// included). Setting this is how "she may read the arch wiki and nothing
|
||||
// else" is expressed.
|
||||
//
|
||||
// A watched page's own host is reachable by the scheduled crawler whether
|
||||
// or not it is listed here, because configuring a watch is already saying
|
||||
// she may read it. That does NOT extend to on-demand reading: a watch is
|
||||
// not an allowlist entry for pages he pastes.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
|
||||
// DenyHosts — never reachable, checked first. Private addresses do not need
|
||||
// to be listed: they are refused unconditionally (see internal/webfetch).
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// UserAgent — sent on every request AND matched against robots.txt groups.
|
||||
// Empty ⇒ webfetch.DefaultUserAgent.
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
|
||||
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
|
||||
// (4000), which is what fits a 4096-token context alongside a prompt.
|
||||
MaxRunes int `json:"max_runes,omitempty"`
|
||||
}
|
||||
|
||||
// CrawlWatchConfig — one page kept an eye on.
|
||||
type CrawlWatchConfig struct {
|
||||
Name string `json:"name"` // note source is "crawl:<name>"
|
||||
URL string `json:"url"`
|
||||
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
|
||||
}
|
||||
|
||||
// normaliseCrawl folds a block that neither answers on demand nor watches
|
||||
// anything back to nil: it has nothing to do.
|
||||
func (c *Config) normaliseCrawl() {
|
||||
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
|
||||
c.Crawl = nil
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
)
|
||||
|
||||
// MCPConfig — the MCP client block. Servers are dark until one has
|
||||
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
|
||||
// it on /tools, on the authed surface, exactly as he would a shell tool. The
|
||||
// voice path can never grant a capability to itself.
|
||||
type MCPConfig struct {
|
||||
// Servers — the configured servers. Each needs exactly one of command
|
||||
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
|
||||
Servers []MCPServerConfig `json:"servers,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for every server that does not set its own.
|
||||
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
|
||||
// spoken turn.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
|
||||
// url servers go through. Deny wins. Private addresses are refused
|
||||
// unconditionally unless the individual server sets allow_private.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// HostInterval — minimum spacing between two requests to one MCP server.
|
||||
// 0 ⇒ DefaultMCPHostInterval (50ms), NOT webfetch's own one-second default.
|
||||
// That default was sized for a feed poll loop, and this path is in a spoken
|
||||
// turn: one dial is three requests (initialize, initialized, tools/list),
|
||||
// so a second of spacing is two seconds of pure sleeping per dial and up to
|
||||
// another second before every tools/call leaves the box.
|
||||
HostInterval Duration `json:"host_interval,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMCPHostInterval — see MCPConfig.HostInterval. Enough to stop a
|
||||
// runaway loop hammering a server, small enough not to be heard.
|
||||
const DefaultMCPHostInterval = 50 * time.Millisecond
|
||||
|
||||
// normaliseMCP applies the block's defaults. A block with no server at all is
|
||||
// the same as no block. A block whose servers are all disabled is NOT
|
||||
// normalised away, because validate has to see their shape — a dark block with
|
||||
// a typo in it should fail at startup, which is the whole reason it can be
|
||||
// written before it is switched on. wireMCP builds nothing when nothing is
|
||||
// enabled, so "off" still holds.
|
||||
func (c *Config) normaliseMCP() {
|
||||
if c.MCP != nil && len(c.MCP.Servers) == 0 {
|
||||
c.MCP = nil
|
||||
}
|
||||
if c.MCP != nil && c.MCP.HostInterval <= 0 {
|
||||
c.MCP.HostInterval = Duration(DefaultMCPHostInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// MCPServerConfig — one MCP server.
|
||||
type MCPServerConfig struct {
|
||||
// Name — the local handle. It prefixes every tool this server contributes
|
||||
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
|
||||
// becomes the store scope "mcp:<name>", so its provenance is readable on
|
||||
// /tools without opening the config.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
|
||||
// on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// URL — a streamable-HTTP endpoint. It is fetched through
|
||||
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
|
||||
// the one-request-per-host-per-second limit all apply.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
|
||||
// server on homesrv is "http://localhost:9100/mcp", which is refused
|
||||
// without this flag. Understand what it means before setting it: a local
|
||||
// server is a DIFFERENT trust level from a public one. It is inside the
|
||||
// network, it usually needs no credential, and it can change things that
|
||||
// matter — so an argument the router got wrong lands somewhere real. Set it
|
||||
// only for a server you run yourself, and prefer allow_tools with it.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
|
||||
// AllowTools — when set, the ONLY remote tool names taken from this server.
|
||||
// This is the knob that keeps the catalogue deliberate: the resident model
|
||||
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
|
||||
// a wrong act, so fewer and better-chosen beats complete.
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
|
||||
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Headers — sent verbatim on every request to a url server. This is how a
|
||||
// bearer token reaches a real remote MCP server: {"Authorization": "Bearer
|
||||
// ${MCP_TOKEN}"}, with the value in the gitignored env file like the
|
||||
// telegram credentials. The Vikunja server on homesrv needs none only
|
||||
// because it is unauthenticated on loopback.
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a configured server described but
|
||||
// dark, so a block can be written and reviewed before it is switched on.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServers maps the config blocks onto the mcp package's own type. It lives
|
||||
// here so config validation and daemon wiring cannot drift on the mapping.
|
||||
// Returns nil when nothing is configured or nothing is enabled.
|
||||
//
|
||||
// Disabled servers are dropped here, which is why validation does NOT use this
|
||||
// list — see allMCPServers.
|
||||
func (c *Config) MCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(true)
|
||||
}
|
||||
|
||||
// allMCPServers is every configured server, enabled or not, for validation.
|
||||
//
|
||||
// Validating only the enabled ones meant a block with both command and url, or
|
||||
// a bare hostname as the url, passed startup validation while it was dark. The
|
||||
// doc on Enabled says a block can be written and reviewed before it is switched
|
||||
// on; the review the config layer could give was the one thing skipped. Enabled
|
||||
// gates the dialing, not the shape check.
|
||||
func (c *Config) allMCPServers() []mcp.ServerConfig {
|
||||
return c.mcpServers(false)
|
||||
}
|
||||
|
||||
func (c *Config) mcpServers(onlyEnabled bool) []mcp.ServerConfig {
|
||||
if c.MCP == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
|
||||
for _, s := range c.MCP.Servers {
|
||||
if onlyEnabled && !s.Enabled {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(s.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(c.MCP.Timeout)
|
||||
}
|
||||
out = append(out, mcp.ServerConfig{
|
||||
Name: s.Name,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
Dir: s.Dir,
|
||||
URL: s.URL,
|
||||
AllowPrivate: s.AllowPrivate,
|
||||
AllowTools: s.AllowTools,
|
||||
MaxTools: s.MaxTools,
|
||||
Headers: s.Headers,
|
||||
Timeout: timeout,
|
||||
Enabled: s.Enabled,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validateMCP fails a block with a typo (no name, both command and url, a bare
|
||||
// hostname as the url) at startup, rather than at the first turn that needed
|
||||
// the tool.
|
||||
func (c *Config) validateMCP() error {
|
||||
return mcp.Validate(c.allMCPServers())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user