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 }