diff --git a/internal/config/config.go b/internal/config/config.go index 88d68bc..83d12fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,6 @@ import ( "github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/update" - "github.com/kami/maven/internal/vision" "github.com/robfig/cron/v3" ) @@ -436,84 +435,6 @@ type VoiceConfig struct { ToolTimeout Duration `json:"tool_timeout,omitempty"` } -// MediaConfig — the on-disk blob store for images and captured audio -// (internal/media). It is shared by all three senses: vision intake, meeting -// capture, and speaker enrolment samples all write here. -// -// Absent ⇒ off, and off means Maven cannot accept an image or start a recording -// at all. That default is deliberate: a capability that keeps photos and audio of -// people on disk should require someone to have typed a path. -type MediaConfig struct { - // Dir — the blob store root, created 0700. Relative paths resolve against - // StateDir. Required; an empty dir means the store is not wired. - Dir string `json:"dir,omitempty"` - - // Retention — how long a blob is kept before the tick prunes it. 0 ⇒ - // media.DefaultRetention (7 days). This is the knob that stops recordings - // of people accumulating; raising it past a few weeks should need a reason. - Retention Duration `json:"retention,omitempty"` - - // MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB). - MaxBytes int64 `json:"max_bytes,omitempty"` - - // MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB). - // The per-blob cap bounds one call; this one bounds the sum of them, which - // is what actually decides whether the disk mavend's database lives on can - // be filled from outside. - MaxTotalBytes int64 `json:"max_total_bytes,omitempty"` -} - -// StoreDir reports the configured blob directory, or "" when media is not -// wired. Safe on a nil receiver. -func (m *MediaConfig) StoreDir() string { - if m == nil { - return "" - } - return strings.TrimSpace(m.Dir) -} - -// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md). -// -// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt -// to look at an image answers that vision is not set up. There is no cloud -// option in this block on purpose: Endpoint must be a loopback or private -// address and internal/vision refuses anything else at startup, because -// inference stays on the box and a photo of his flat is the last thing to make -// an exception for. -type VisionConfig struct { - // Enabled — may she look at images. Default false. - Enabled bool `json:"enabled,omitempty"` - - // Endpoint — base URL of a llama-server running a vision model with its - // mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only. - Endpoint string `json:"endpoint,omitempty"` - - // Model — model name sent in the request. llama-server ignores it. - Model string `json:"model,omitempty"` - - // MaxDim — longest edge the image is scaled to before inference. 0 ⇒ - // media.DefaultMaxDim (896). - MaxDim int `json:"max_dim,omitempty"` - - // MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300). - MaxTokens int `json:"max_tokens,omitempty"` - - // Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small - // VLM on an iGPU is slow; a tight timeout here just means no answer ever. - Timeout Duration `json:"timeout,omitempty"` - - // Prompt — the default question when he only sent a picture. Empty ⇒ - // vision.DefaultPrompt (Russian, "опиши что на изображении"). - Prompt string `json:"prompt,omitempty"` -} - -// LooksAtImages reports whether vision is configured well enough to try. Safe on -// a nil receiver, and false without an endpoint — enabled with nothing to talk -// to is a misconfiguration, not a capability. -func (v *VisionConfig) LooksAtImages() bool { - return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != "" -} - // CaptureConfig — the meeting recorder (internal/capture, // docs/plans/08-hearing.md). // @@ -1061,32 +982,11 @@ func (c *Config) validate() error { if err := c.validateNetScan(); err != nil { return err } - // A media dir that cannot be created, or a vision endpoint that is a typo, - // used to be logged at wiring time and the capability just stayed off. A - // capability silently not existing is the hardest kind of misconfiguration - // to notice, so both fail here instead. - if c.Media != nil { - if c.Media.StoreDir() == "" { - return errors.New("media.dir is required when a media block is present") - } - if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 { - return errors.New("media: max_bytes and max_total_bytes cannot be negative") - } - if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes { - return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d", - c.Media.MaxBytes, c.Media.MaxTotalBytes) - } + if err := c.validateMedia(); err != nil { + return err } - if c.Vision != nil && c.Vision.Enabled { - if strings.TrimSpace(c.Vision.Endpoint) == "" { - return errors.New("vision.enabled set but vision.endpoint is empty") - } - if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil { - return err - } - if c.Media.StoreDir() == "" { - return errors.New("vision.enabled set but there is no media block to keep the bytes in") - } + if err := c.validateVision(); err != nil { + return err } if c.Capture.Records() && c.Media.StoreDir() == "" { return errors.New("capture.enabled set but there is no media block to keep the audio in") diff --git a/internal/config/senses.go b/internal/config/senses.go new file mode 100644 index 0000000..47736da --- /dev/null +++ b/internal/config/senses.go @@ -0,0 +1,129 @@ +package config + +import ( + "errors" + "fmt" + "strings" + + "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 +}