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) != "" }