047a813278
Two spine infra items (feature-ranking #1, part of the migration prereq): - migrations.go: PRAGMA user_version runner, empty (no-op) migration slice, one tx per step, fail-closed. Mechanism in place before any real schema change needs it. - crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on; re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib, CGO stays off. Fails closed on wrong key/tamper, never falls back to plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the passkey-derived L3 cold-start key plugs into the same seam later). Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project) and over the ncruces page-level VFS (swaps the driver project-wide); noted as the upgrade path in a ponytail: comment. Threat model is disk-at-rest only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
390 lines
16 KiB
Go
390 lines
16 KiB
Go
// Package config is maven's daemon configuration.
|
|
//
|
|
// The daemon reads a single JSON file at startup (path from the -config flag,
|
|
// default ~/.config/maven/mavend.json). Everything a module needs is wired
|
|
// from this file: the store path, the unix socket path, the tick cadence,
|
|
// and per-sink configs (ntfy/telegram). Credentials live in the file (or a
|
|
// systemd credential that the file points at) — never in the binary.
|
|
//
|
|
// This package is pure data + a loader. It imports the sink config structs
|
|
// so the daemon wires each `Sink` from a single, typed config tree without
|
|
// re-declaring their shapes (the sink constructors own validation).
|
|
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
|
)
|
|
|
|
// Config — the daemon's whole config tree. Loaded once at startup.
|
|
//
|
|
// Fields with omitempty are optional: a missing sink config = that channel
|
|
// not wired (the dispatcher's nil-sink path skips it silently, the same as a
|
|
// deliberately-unwired channel at scaffold time).
|
|
type Config struct {
|
|
// DBPath — sqlite database path. Default applied by Load if empty.
|
|
// When encryption is configured, the file at this path is ciphertext
|
|
// (AES-256-GCM); the daemon works on a tmpfs plaintext copy.
|
|
DBPath string `json:"db_path"`
|
|
|
|
// DBKeyB64 — base64 (std encoding) of a raw 32-byte AES-256 key. Empty ⇒
|
|
// the store is plaintext (dev/CI). Prefer DBKeyEnv over baking the key
|
|
// into the config file. Exactly one of DBKeyB64/DBKeyEnv should be set.
|
|
//
|
|
// ponytail: raw key, no KDF — stdlib has no argon2/scrypt and x/crypto
|
|
// isn't a dep. This is also the seam the L3 passkey cold-start key plugs
|
|
// into later: the passkey op produces the 32 bytes and calls
|
|
// store.OpenEncrypted directly, bypassing config.
|
|
DBKeyB64 string `json:"db_key_b64,omitempty"`
|
|
|
|
// DBKeyEnv — name of an env var holding the base64 32-byte key. Takes
|
|
// precedence over DBKeyB64. Lets systemd credentials / secrets managers
|
|
// inject the key without it touching the config file.
|
|
DBKeyEnv string `json:"db_key_env,omitempty"`
|
|
|
|
// DBTmpfs — plaintext working-copy path (RAM-backed). Empty ⇒ a stable
|
|
// per-db path under /dev/shm. Only used when encryption is configured.
|
|
DBTmpfs string `json:"db_tmpfs,omitempty"`
|
|
|
|
// SocketPath — the unix socket the IPC server listens on. Modules
|
|
// connect here; the dir is created 0700, the socket chmod'd 0600 by
|
|
// ipc.Listen. Default applied by Load if empty.
|
|
SocketPath string `json:"socket_path"`
|
|
|
|
// StateDir — base dir for db + socket if their paths aren't absolute.
|
|
// Default applied by Load if empty (XDG-style: ~/.local/share/maven for
|
|
// the db, /run/user/$UID/maven for the socket).
|
|
StateDir string `json:"state_dir,omitempty"`
|
|
|
|
// TickInterval — the proactive loop cadence. Default 60s. The loop is
|
|
// "dumb + deterministic": most ticks evaluate a few predicates and die
|
|
// for free; raising this saves nothing worth losing responsiveness over.
|
|
TickInterval Duration `json:"tick_interval,omitempty"`
|
|
|
|
// RepeatInterval — how often sev4 telegram sends re-fire until acked.
|
|
// Default 5m. A disk-fire alarm that repeats every tick (60s) is spam;
|
|
// one that repeats never is silent. The default tilts toward "loud."
|
|
RepeatInterval Duration `json:"repeat_interval,omitempty"`
|
|
|
|
// AutotuneInterval — how often the feedback auto-tuner runs: reads
|
|
// store.RecentOutcomes for each rule, calls loop.TuneCooldown, writes the
|
|
// tuned cooldown back as a `facts (kind=config, source=feedback)` row
|
|
// if it changed. Default 10m — slow enough to be cheap + not write every
|
|
// tick (append-only facts churn), fast enough that a weird-afternoon
|
|
// pattern shows up inside a day. 0 ⇒ autotune disabled (the gatherer
|
|
// falls back to the rule's static Base, matching pre-autotune behavior).
|
|
AutotuneInterval Duration `json:"autotune_interval,omitempty"`
|
|
|
|
// Ntfy — the ntfy push sink config. nil ⇒ ntfy channel not wired.
|
|
// sev3 (ops soft) away + sev4 (ops hard) present + reminders away all
|
|
// route here; not wiring ntfy means those routes drop silently.
|
|
Ntfy *ntfysink.Config `json:"ntfy,omitempty"`
|
|
|
|
// Telegram — the telegram push sink config. nil ⇒ telegram channel
|
|
// not wired. sev4 away routes here with repeat-til-ack; not wiring
|
|
// telegram means sev4-away alarms silently drop (a disk-fire alarm at
|
|
// 2am that no one sees — wire it).
|
|
Telegram *telegramsink.Config `json:"telegram,omitempty"`
|
|
|
|
// Phraser — the LLM-backed phraser config. nil ⇒ the daemon uses the
|
|
// template-based Stub (deterministic, no model required — good for CI).
|
|
// When configured, the daemon spawns llama-server as a subprocess and
|
|
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
|
Phraser *PhraserConfig `json:"phraser,omitempty"`
|
|
|
|
// Voice — the client↔core surface + the stt/tts modules the daemon
|
|
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
|
// down, the dispatcher's Voice slot stays nil (the routing table's
|
|
// ChannelVoice selections drop silently — same as pre-voice behaviour).
|
|
// To enable: voice.enabled = true AND voice.bind = an address inside
|
|
// the wg tunnel; the daemon binds the TCP listener there.
|
|
Voice *VoiceConfig `json:"voice,omitempty"`
|
|
|
|
// QuietHours — time-window schedule for quiet hours. When set, the
|
|
// loop's gatherer sets `quiet = true` in the State during the window,
|
|
// suppressing care nudges (sev1-2). The user can also toggle quiet
|
|
// hours by voice ("тихий режим") — that writes a `quiet_hours` config
|
|
// fact independently; both the schedule AND the toggle activate quiet.
|
|
// nil ⇒ quiet hours only activate via the voice toggle.
|
|
QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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"`
|
|
Cmd []string `json:"cmd"`
|
|
Destructive bool `json:"destructive,omitempty"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
|
// constructs an in-process ONNX embedder when all three paths are non-empty;
|
|
// the router's classifier then uses real sentence embeddings instead of the
|
|
// floor HashEmbedder stub. Model_path is the ONNX model file, tokenizer_path
|
|
// is tokenizer.json (Unigram), lib_path is the ONNX Runtime shared library.
|
|
type EmbedderConfig struct {
|
|
ModelPath string `json:"model_path,omitempty"`
|
|
TokenizerPath string `json:"tokenizer_path,omitempty"`
|
|
LibPath string `json:"lib_path,omitempty"`
|
|
}
|
|
|
|
// WorkerConfig — a unix-socket worker module connection. Used by Stt and
|
|
// (via TtsConfig embedding the same fields) by Tts. Socket is the unix
|
|
// socket path the worker module listens on (e.g.
|
|
// /run/user/$UID/maven/stt.sock). Lang overrides the surface default for
|
|
// this module when the user wants different langs for stt vs tts (rare).
|
|
type WorkerConfig struct {
|
|
Socket string `json:"socket,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
}
|
|
|
|
// TtsConfig — the tts worker module connection + tts-specific Voice field
|
|
// (a named voice when the worker supports multiple; "" ⇒ the worker's
|
|
// configured default).
|
|
type TtsConfig struct {
|
|
Socket string `json:"socket,omitempty"`
|
|
Lang string `json:"lang,omitempty"`
|
|
Voice string `json:"voice,omitempty"`
|
|
}
|
|
|
|
// Duration — a time.Duration that round-trips through JSON as a string
|
|
// ("60s", "5m", "1h30m"). Plain time.Duration marshals as a nanosecond int,
|
|
// which is unreadable in a config file; this wrapper uses ParseDuration.
|
|
type Duration time.Duration
|
|
|
|
func (d Duration) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(time.Duration(d).String())
|
|
}
|
|
|
|
func (d *Duration) UnmarshalJSON(b []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
v, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return fmt.Errorf("config: bad duration %q: %w", s, err)
|
|
}
|
|
*d = Duration(v)
|
|
return nil
|
|
}
|
|
|
|
// Defaults applied when the corresponding field is empty/zero.
|
|
const (
|
|
DefaultTickInterval = 60 * time.Second
|
|
DefaultRepeatInterval = 5 * time.Minute
|
|
DefaultAutotuneInterval = 10 * time.Minute
|
|
DefaultRouterThreshold = 0.35
|
|
DefaultToolTimeout = 30 * time.Second
|
|
)
|
|
|
|
// Load reads the JSON config at path and applies defaults. A missing file is
|
|
// an error — the daemon refuses to start without an explicit config (the
|
|
// default-less state is too permissive: empty db path, no sinks, an idle
|
|
// loop that silently does nothing, etc. — better to surface the gap than to
|
|
// run an idle daemon the user thinks is wired).
|
|
func Load(path string) (*Config, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
|
}
|
|
var c Config
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
|
}
|
|
c.applyDefaults()
|
|
if err := c.validate(); err != nil {
|
|
return nil, fmt.Errorf("config: %s: %w", path, err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (c *Config) applyDefaults() {
|
|
if c.TickInterval == 0 {
|
|
c.TickInterval = Duration(DefaultTickInterval)
|
|
}
|
|
if c.RepeatInterval == 0 {
|
|
c.RepeatInterval = Duration(DefaultRepeatInterval)
|
|
}
|
|
if c.AutotuneInterval == 0 {
|
|
c.AutotuneInterval = Duration(DefaultAutotuneInterval)
|
|
}
|
|
// StateDir — when set, use it as the base for both db and socket if their
|
|
// paths are still relative (empty). If StateDir is empty, fall back to the
|
|
// XDG-style defaults (data dir for db, runtime dir for socket).
|
|
if c.StateDir != "" {
|
|
if c.DBPath == "" {
|
|
c.DBPath = filepath.Join(c.StateDir, "maven.db")
|
|
}
|
|
if c.SocketPath == "" {
|
|
c.SocketPath = filepath.Join(c.StateDir, "mavend.sock")
|
|
}
|
|
} else {
|
|
if c.DBPath == "" {
|
|
c.DBPath = filepath.Join(defaultDataDir(), "maven.db")
|
|
}
|
|
if c.SocketPath == "" {
|
|
c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock")
|
|
}
|
|
}
|
|
|
|
if c.Voice != nil {
|
|
if c.Voice.RouterThreshold <= 0 {
|
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
|
}
|
|
if c.Voice.ToolTimeout <= 0 {
|
|
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.Phraser != nil {
|
|
if c.Phraser.ModelPath == "" {
|
|
return errors.New("phraser.model_path is required")
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
|
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
|
// never silently downgrade to plaintext).
|
|
func (c *Config) DBEncryptionKey() ([]byte, error) {
|
|
raw := c.DBKeyB64
|
|
if c.DBKeyEnv != "" {
|
|
raw = os.Getenv(c.DBKeyEnv)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("config: db_key_env %q is set but the env var is empty", c.DBKeyEnv)
|
|
}
|
|
}
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("config: db key is not valid base64: %w", err)
|
|
}
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("config: db key must decode to 32 bytes, got %d", len(key))
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func defaultDataDir() string {
|
|
if x := os.Getenv("XDG_DATA_HOME"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return filepath.Join(os.TempDir(), "maven")
|
|
}
|
|
return filepath.Join(home, ".local", "share", "maven")
|
|
}
|
|
|
|
func defaultRuntimeDir() string {
|
|
if x := os.Getenv("XDG_RUNTIME_DIR"); x != "" {
|
|
return filepath.Join(x, "maven")
|
|
}
|
|
// /run/user/$UID is the typical answer; without XDG_RUNTIME_DIR, fall back
|
|
// to the data dir (still works; just not tmpfs-clearance-on-reboot clean).
|
|
return filepath.Join(defaultDataDir())
|
|
} |