initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
// 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/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.
|
||||
DBPath string `json:"db_path"`
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
if c.DBPath == "" {
|
||||
c.DBPath = filepath.Join(defaultDataDir(), "maven.db")
|
||||
}
|
||||
if c.SocketPath == "" {
|
||||
c.SocketPath = filepath.Join(defaultRuntimeDir(), "mavend.sock")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "mavend.json")
|
||||
if err := writeFile(t, p, body); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
p := writeConfig(t, `{}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if time.Duration(c.TickInterval) != DefaultTickInterval {
|
||||
t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval)
|
||||
}
|
||||
if time.Duration(c.RepeatInterval) != DefaultRepeatInterval {
|
||||
t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval)
|
||||
}
|
||||
if c.DBPath == "" {
|
||||
t.Error("DBPath default not applied")
|
||||
}
|
||||
if c.SocketPath == "" {
|
||||
t.Error("SocketPath default not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDurationsParse(t *testing.T) {
|
||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if time.Duration(c.TickInterval) != 90*time.Second {
|
||||
t.Errorf("TickInterval = %v, want 90s", c.TickInterval)
|
||||
}
|
||||
if time.Duration(c.RepeatInterval) != 10*time.Minute {
|
||||
t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBadDurationRejected(t *testing.T) {
|
||||
p := writeConfig(t, `{"tick_interval":"not-a-duration"}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load succeeded for a bad duration; want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "nonexistent.json")
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load succeeded for a missing file; want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceEnabledRequiresBind(t *testing.T) {
|
||||
// enabled=true without bind is refused — the voice surface can't
|
||||
// default a bind (127.0.0.1 too relaxed for production, a wg addr is
|
||||
// the user's). surfacing the gap explicitly beats an idle listener.
|
||||
p := writeConfig(t, `{"voice":{"enabled":true}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load succeeded for voice.enabled=true with no bind; want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceEnabledWithBindOK(t *testing.T) {
|
||||
// voice surface fully configured — accepted (the daemon wires Stub tts +
|
||||
// voicesink; no models on disk required).
|
||||
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoicePresentDisabledOK(t *testing.T) {
|
||||
// voice block present but not enabled — acceptable (the listener stays
|
||||
// down; the routing table's ChannelVoice selections drop).
|
||||
p := writeConfig(t, `{"voice":{"enabled":false}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceSttTtsConfigParsed(t *testing.T) {
|
||||
// the stt/tts worker sub-blocks parse + remember socket/lang.
|
||||
p := writeConfig(t, `{
|
||||
"voice": {
|
||||
"enabled": true, "bind": "127.0.0.1:9100",
|
||||
"stt": {"socket": "/tmp/stt.sock", "lang": "ru"},
|
||||
"tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"}
|
||||
}
|
||||
}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" {
|
||||
t.Fatalf("Stt config not parsed: %+v", c.Voice)
|
||||
}
|
||||
if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" {
|
||||
t.Fatalf("Tts config not parsed: %+v", c.Voice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationRoundTrip(t *testing.T) {
|
||||
d := Duration(15 * time.Minute)
|
||||
b, err := d.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalJSON: %v", err)
|
||||
}
|
||||
if got, want := string(b), `"15m0s"`; got != want {
|
||||
t.Errorf("MarshalJSON = %s, want %s", got, want)
|
||||
}
|
||||
var d2 Duration
|
||||
if err := d2.UnmarshalJSON(b); err != nil {
|
||||
t.Fatalf("UnmarshalJSON: %v", err)
|
||||
}
|
||||
if d2 != d {
|
||||
t.Errorf("round-trip = %v, want %v", d2, d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeFile is a tiny helper used by the test files; inlined so config_test.go
|
||||
// stays self-contained without a shared helpers file.
|
||||
func writeFile(t *testing.T, path, body string) error {
|
||||
t.Helper()
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, []byte(body), 0o600)
|
||||
}
|
||||
Reference in New Issue
Block a user